Spaces:
Sleeping
Sleeping
File size: 7,887 Bytes
67be617 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 | """FAISS-backed vector store for the T.I.A. RAG engine.
Persists a FAISS index plus a JSONL sidecar of chunk metadata so the index
can be rebuilt offline (via ``data/master_harvest/ingest.py``) and loaded by
the API at startup.
Designed to fail soft: if ``faiss`` or ``sentence-transformers`` are not
installed, ``FaissVectorStore.is_available()`` returns ``False`` and callers
should fall back to the in-memory TF-IDF index in :mod:`core.rag_engine`.
"""
from __future__ import annotations
import json
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
DEFAULT_INDEX_DIR = Path("data") / "master_harvest" / ".index"
DEFAULT_INDEX_FILE = "harvest.faiss"
DEFAULT_META_FILE = "harvest.meta.jsonl"
DEFAULT_EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
def _try_import_faiss():
try:
import faiss # type: ignore
return faiss
except Exception:
return None
def _try_import_st():
try:
from sentence_transformers import SentenceTransformer # type: ignore
return SentenceTransformer
except Exception:
return None
def _try_import_numpy():
try:
import numpy as np # type: ignore
return np
except Exception:
return None
@dataclass
class VectorHit:
source: str
index: int
text: str
score: float
def to_dict(self) -> dict:
return {
"source": self.source,
"index": self.index,
"text": self.text,
"score": round(float(self.score), 4),
}
class FaissVectorStore:
"""Thin wrapper around a FAISS ``IndexFlatIP`` over normalized embeddings.
Cosine similarity is computed by L2-normalizing inputs and using inner
product, which is the standard and most-portable FAISS recipe.
"""
def __init__(
self,
index_dir: str | Path = DEFAULT_INDEX_DIR,
embed_model: str = DEFAULT_EMBED_MODEL,
) -> None:
self.index_dir = Path(index_dir)
self.embed_model_name = embed_model
self._index = None # type: ignore[assignment]
self._meta: list[dict] = []
self._encoder = None # type: ignore[assignment]
self._lock = threading.Lock()
# -- availability ----------------------------------------------------
@staticmethod
def is_available() -> bool:
return (
_try_import_faiss() is not None
and _try_import_st() is not None
and _try_import_numpy() is not None
)
# -- encoder lazy-init ----------------------------------------------
def _get_encoder(self):
if self._encoder is not None:
return self._encoder
SentenceTransformer = _try_import_st()
if SentenceTransformer is None:
raise RuntimeError(
"sentence-transformers is required for embeddings; "
"install with `pip install sentence-transformers`."
)
self._encoder = SentenceTransformer(self.embed_model_name)
return self._encoder
def embed(self, texts: list[str]):
np = _try_import_numpy()
if np is None:
raise RuntimeError("numpy is required for embeddings")
encoder = self._get_encoder()
vectors = encoder.encode(
texts,
convert_to_numpy=True,
normalize_embeddings=True,
show_progress_bar=False,
)
return vectors.astype(np.float32, copy=False)
# -- build / persist -------------------------------------------------
def build(self, chunks: Iterable[dict]) -> dict:
"""Build the FAISS index from an iterable of ``{source, index, text}`` dicts.
Persists the index + JSONL metadata to ``index_dir``.
"""
faiss = _try_import_faiss()
np = _try_import_numpy()
if faiss is None or np is None:
raise RuntimeError(
"faiss and numpy are required to build a FaissVectorStore."
)
chunk_list = [c for c in chunks if c.get("text", "").strip()]
if not chunk_list:
with self._lock:
self._index = None
self._meta = []
return {"chunks_indexed": 0, "dimension": 0, "index_dir": str(self.index_dir)}
texts = [c["text"] for c in chunk_list]
vectors = self.embed(texts)
dimension = int(vectors.shape[1])
index = faiss.IndexFlatIP(dimension)
index.add(vectors)
with self._lock:
self._index = index
self._meta = chunk_list
self._persist()
return {
"chunks_indexed": len(chunk_list),
"dimension": dimension,
"index_dir": str(self.index_dir),
}
def _persist(self) -> None:
faiss = _try_import_faiss()
if faiss is None:
return
self.index_dir.mkdir(parents=True, exist_ok=True)
index_path = self.index_dir / DEFAULT_INDEX_FILE
meta_path = self.index_dir / DEFAULT_META_FILE
if self._index is not None:
faiss.write_index(self._index, str(index_path))
with meta_path.open("w", encoding="utf-8") as fh:
for record in self._meta:
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
# -- load ------------------------------------------------------------
def load(self) -> bool:
"""Load a previously-persisted index. Returns True on success."""
faiss = _try_import_faiss()
if faiss is None:
return False
index_path = self.index_dir / DEFAULT_INDEX_FILE
meta_path = self.index_dir / DEFAULT_META_FILE
if not index_path.exists() or not meta_path.exists():
return False
try:
index = faiss.read_index(str(index_path))
meta: list[dict] = []
with meta_path.open("r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if line:
meta.append(json.loads(line))
except Exception:
return False
with self._lock:
self._index = index
self._meta = meta
return True
# -- search ----------------------------------------------------------
def search(self, query: str, top_k: int = 5) -> list[VectorHit]:
if top_k <= 0 or not query.strip():
return []
with self._lock:
index = self._index
meta = list(self._meta)
if index is None or not meta:
return []
query_vec = self.embed([query])
scores, ids = index.search(query_vec, min(top_k, len(meta)))
hits: list[VectorHit] = []
for score, idx in zip(scores[0].tolist(), ids[0].tolist()):
if idx < 0 or idx >= len(meta):
continue
record = meta[idx]
hits.append(
VectorHit(
source=record.get("source", ""),
index=int(record.get("index", 0)),
text=record.get("text", ""),
score=float(score),
)
)
return hits
# -- introspection ---------------------------------------------------
def stats(self) -> dict:
with self._lock:
count = len(self._meta)
loaded = self._index is not None
return {
"loaded": loaded,
"chunks_indexed": count,
"embed_model": self.embed_model_name,
"index_dir": str(self.index_dir),
}
|