Spaces:
Running
Running
Download backend/services/vector_store.py from HarryDataScience/documind-ai: direct link, hf CLI and curl.
- Browser
- Download file 11.3 kB
-
https://huggingface.co/spaces/HarryDataScience/documind-ai/resolve/main/backend/services/vector_store.py
- Command line
-
hf download hf://spaces/HarryDataScience/documind-ai/backend/services/vector_store.py
-
curl -L -o vector_store.py https://huggingface.co/spaces/HarryDataScience/documind-ai/resolve/main/backend/services/vector_store.py
11.3 kB
| """Persistent vector storage: ChromaDB (text + metadata) with FAISS or Chroma similarity search.""" | |
| from typing import Dict, List, Optional | |
| import time | |
| import numpy as np | |
| import chromadb | |
| from chromadb.config import Settings as ChromaSettings | |
| from backend.core import settings, get_logger | |
| from backend.services.embeddings import get_embedding_service | |
| from backend.services.pdf_processor import ProcessedDocument | |
| logger = get_logger(__name__) | |
| class VectorStoreService: | |
| # Class-level defaults keep partially constructed instances (e.g. in tests) on the Chroma path. | |
| backend = "chroma" | |
| _faiss = None | |
| def __init__(self): | |
| self.client = chromadb.PersistentClient(path=settings.vectorstore_path, | |
| settings=ChromaSettings(anonymized_telemetry=False)) | |
| self.embedding_service = None | |
| # embedding_function=None: we always pass our own vectors. Without this, Chroma | |
| # would silently download a second (ONNX) embedding model when none is supplied. | |
| self.collection = self.client.get_or_create_collection( | |
| name="documents", metadata={"hnsw:space": "cosine"}, embedding_function=None) | |
| self.registry = self.client.get_or_create_collection( | |
| name="document_registry", metadata={"description": "Document metadata"}, | |
| embedding_function=None) | |
| self._documents_cache = (0.0, []) | |
| if settings.vector_backend == "faiss": | |
| try: | |
| from backend.services.faiss_index import FaissIndex | |
| self._faiss = FaissIndex() | |
| self.backend = "faiss" | |
| except ImportError: | |
| logger.warning("VECTOR_BACKEND=faiss but faiss-cpu is not installed; " | |
| "falling back to ChromaDB search. Fix: pip install faiss-cpu") | |
| logger.info(f"Vector search backend: {self.backend}") | |
| # ββ FAISS <-> Chroma synchronisation βββββββββββββββββββββββββββββββββββββ | |
| def _ensure_faiss(self) -> None: | |
| """Chroma is the source of truth: rebuild FAISS whenever the vector counts differ.""" | |
| if self._faiss is not None and self._faiss.ntotal != self.collection.count(): | |
| self._rebuild_faiss() | |
| def _rebuild_faiss(self, page_size: int = 2000) -> None: | |
| started = time.perf_counter() | |
| self._faiss.clear() | |
| offset = 0 | |
| while True: | |
| page = self.collection.get(limit=page_size, offset=offset, | |
| include=["embeddings", "metadatas"]) | |
| ids = page["ids"] | |
| if len(ids) == 0: | |
| break | |
| metas = page["metadatas"] | |
| self._faiss.add(ids, np.asarray(page["embeddings"], dtype="float32"), | |
| [(m or {}).get("doc_id", "") for m in metas], | |
| [(m or {}).get("owner_id", "") for m in metas]) | |
| offset += len(ids) | |
| logger.info(f"FAISS index rebuilt from ChromaDB | vectors={self._faiss.ntotal} " | |
| f"| {(time.perf_counter() - started) * 1000:.0f} ms") | |
| def index_document(self, processed_doc: ProcessedDocument, owner_id: str = "") -> bool: | |
| if not processed_doc.chunks: | |
| return False | |
| chunks = processed_doc.chunks | |
| try: | |
| if self.embedding_service is None: | |
| self.embedding_service = get_embedding_service() | |
| vectors = self.embedding_service.embed_texts([c.text for c in chunks]) | |
| if self._faiss is not None: | |
| self._ensure_faiss() # bring FAISS level with Chroma before adding to both | |
| self.collection.upsert(ids=[c.chunk_id for c in chunks], | |
| embeddings=vectors.tolist(), documents=[c.text for c in chunks], | |
| metadatas=[{"doc_id": c.doc_id, "doc_name": c.doc_name, | |
| "page_number": c.page_number, "chunk_index": c.chunk_index, | |
| "char_count": c.char_count, "owner_id": owner_id} for c in chunks]) | |
| if self._faiss is not None: | |
| self._faiss.add([c.chunk_id for c in chunks], vectors, | |
| [c.doc_id for c in chunks], [owner_id] * len(chunks)) | |
| # Registry is the commit marker; write it only after every vector succeeds. | |
| # The registry only stores metadata; a constant 1-d vector satisfies Chroma. | |
| self.registry.upsert(ids=[processed_doc.doc_id], embeddings=[[0.0]], | |
| documents=[processed_doc.filename], | |
| metadatas=[{"doc_id": processed_doc.doc_id, "filename": processed_doc.filename, | |
| "total_pages": processed_doc.total_pages, "total_chunks": len(chunks), | |
| "file_size_kb": processed_doc.file_size_kb, "owner_id": owner_id}]) | |
| self.invalidate_documents_cache() | |
| return True | |
| except Exception: | |
| self.collection.delete(where={"doc_id": processed_doc.doc_id}) | |
| if self._faiss is not None: | |
| self._faiss.remove_doc(processed_doc.doc_id) | |
| raise | |
| def list_documents(self, owner_id: Optional[str] = None) -> List[Dict]: | |
| try: | |
| cached_at, cached_docs = self._documents_cache | |
| if owner_id is None and time.monotonic() - cached_at < 5: | |
| return list(cached_docs) | |
| result = self.registry.get(include=["metadatas"]) | |
| docs = [] | |
| for doc_id, meta in zip(result.get("ids", []), result.get("metadatas", [])): | |
| meta = meta or {} | |
| if owner_id is not None and meta.get("owner_id", "") != owner_id: | |
| continue | |
| docs.append({k: meta.get(k, default) for k, default in { | |
| "doc_id": doc_id, "filename": "", "total_pages": 0, | |
| "total_chunks": 0, "file_size_kb": 0, "owner_id": ""}.items()}) | |
| if owner_id is None: | |
| self._documents_cache = (time.monotonic(), docs) | |
| return docs | |
| except Exception: | |
| # Do not hide storage failures: returning [] would look like "no documents". | |
| logger.exception("Failed to list documents") | |
| raise | |
| def search(self, query: str, doc_ids: Optional[List[str]] = None, | |
| top_k: int = None, owner_id: Optional[str] = None) -> List[Dict]: | |
| top_k = top_k or settings.top_k_results | |
| docs = self.list_documents(owner_id) | |
| selected = [d["doc_id"] for d in docs if doc_ids is None or d["doc_id"] in doc_ids] | |
| count = self.collection.count() | |
| if not selected or top_k <= 0 or count == 0: | |
| return [] | |
| if self.embedding_service is None: | |
| self.embedding_service = get_embedding_service() | |
| n_candidates = min(max(top_k, 20 if settings.hybrid_search or settings.enable_rerank else top_k), count) | |
| query_vector = self.embedding_service.embed_query(query) | |
| if self._faiss is not None: | |
| rows = self._faiss_candidates(query_vector, n_candidates, selected, owner_id) | |
| else: | |
| rows = self._chroma_candidates(query_vector, n_candidates, selected, owner_id) | |
| if settings.hybrid_search: | |
| from rank_bm25 import BM25Okapi | |
| corpus = [r["text"].lower().split() for r in rows] | |
| if corpus: | |
| scores = BM25Okapi(corpus).get_scores(query.lower().split()) | |
| lexical = sorted(range(len(rows)), key=lambda i: scores[i], reverse=True) | |
| vec = sorted(range(len(rows)), key=lambda i: rows[i]["similarity"], reverse=True) | |
| rrf = {} | |
| for ranking in (vec, lexical): | |
| for rank, ix in enumerate(ranking, 1): rrf[ix] = rrf.get(ix, 0) + 1/(60+rank) | |
| rows = [rows[i] for i in sorted(rrf, key=rrf.get, reverse=True)] | |
| if settings.enable_rerank and rows: | |
| from sentence_transformers import CrossEncoder | |
| if not hasattr(self, "reranker"): | |
| self.reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2") | |
| scores = self.reranker.predict([(query, row["text"]) for row in rows]) | |
| rows = [row for _, row in sorted(zip(scores, rows), reverse=True, key=lambda p: p[0])] | |
| return rows[:top_k] | |
| def _chroma_candidates(self, query_vector, n: int, selected: List[str], | |
| owner_id: Optional[str]) -> List[Dict]: | |
| filters = {"doc_id": {"$in": selected}} | |
| if owner_id is not None: | |
| filters = {"$and": [filters, {"owner_id": owner_id}]} | |
| result = self.collection.query(query_embeddings=[query_vector.tolist()], n_results=n, | |
| where=filters, include=["documents", "metadatas", "distances"]) | |
| rows = [] | |
| for text, meta, distance in zip(result["documents"][0], result["metadatas"][0], result["distances"][0]): | |
| if owner_id is not None and meta.get("owner_id", "") != owner_id: | |
| continue | |
| rows.append({"text": text, "metadata": meta, | |
| "similarity": round(max(0.0, min(1.0, 1.0 - float(distance))), 4), | |
| "doc_name": meta.get("doc_name", ""), "page_number": meta.get("page_number", 0)}) | |
| return rows | |
| def _faiss_candidates(self, query_vector, n: int, selected: List[str], | |
| owner_id: Optional[str]) -> List[Dict]: | |
| """FAISS finds the nearest chunk ids; Chroma supplies their text and metadata.""" | |
| self._ensure_faiss() | |
| hits = self._faiss.search(query_vector, n, doc_ids=set(selected), owner_id=owner_id) | |
| if not hits: | |
| return [] | |
| got = self.collection.get(ids=[chunk_id for chunk_id, _ in hits], include=["documents", "metadatas"]) | |
| by_id = dict(zip(got["ids"], zip(got["documents"], got["metadatas"]))) | |
| rows = [] | |
| for chunk_id, score in hits: | |
| if chunk_id not in by_id: # removed between the FAISS search and the Chroma lookup | |
| continue | |
| text, meta = by_id[chunk_id] | |
| rows.append({"text": text, "metadata": meta, | |
| "similarity": round(max(0.0, min(1.0, score)), 4), | |
| "doc_name": meta.get("doc_name", ""), "page_number": meta.get("page_number", 0)}) | |
| return rows | |
| def delete_document(self, doc_id: str, owner_id: Optional[str] = None) -> bool: | |
| docs = self.list_documents(owner_id) | |
| if not any(d["doc_id"] == doc_id for d in docs): return False | |
| self.collection.delete(where={"doc_id": doc_id}) | |
| if self._faiss is not None: | |
| self._faiss.remove_doc(doc_id) | |
| self.registry.delete(ids=[doc_id]) | |
| self.invalidate_documents_cache() | |
| return True | |
| def invalidate_documents_cache(self) -> None: | |
| self._documents_cache = (0.0, []) | |
| def document_exists(self, doc_id: str, owner_id: Optional[str] = None) -> bool: | |
| return any(d["doc_id"] == doc_id for d in self.list_documents(owner_id)) | |
| _vector_store_instance = None | |
| def get_vector_store() -> VectorStoreService: | |
| global _vector_store_instance | |
| if _vector_store_instance is None: _vector_store_instance = VectorStoreService() | |
| return _vector_store_instance | |