| """ChromaDB vector store wrapper with DB25 hybrid search. |
| |
| DB25 = Dense (Chroma cosine ANN) + BM25 keyword scoring, |
| fused via Reciprocal Rank Fusion (RRF, k=60). |
| """ |
| from __future__ import annotations |
| import sys, os, uuid |
|
|
| sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) |
| import config |
| from pipeline.security import encrypt_data, decrypt_data |
|
|
| |
| import chromadb |
| from chromadb.config import Settings |
|
|
| |
| from rank_bm25 import BM25Okapi |
|
|
| _client: chromadb.PersistentClient | None = None |
| _collection: chromadb.Collection | None = None |
|
|
| |
| _text_cache: str | None = None |
| _text_cache_valid: bool = False |
| _bm25_cache: tuple | None = None |
|
|
|
|
| def _validate_and_get_collection() -> chromadb.Collection: |
| """Get or create collection, auto-purging if embedding dims are mismatched.""" |
| global _client |
| persist_dir = config.CHROMA_PERSIST_DIR |
| os.makedirs(persist_dir, exist_ok=True) |
| |
| _client = chromadb.PersistentClient( |
| path=persist_dir, |
| settings=Settings(anonymized_telemetry=False), |
| ) |
|
|
| try: |
| col = _client.get_collection(name=config.CHROMA_COLLECTION) |
| count = col.count() |
|
|
| |
| if count > 0: |
| |
| sample = col.get(limit=1, include=["embeddings"]) |
| if sample.get("embeddings") and sample["embeddings"]: |
| existing_dim = len(sample["embeddings"][0]) |
| try: |
| from pipeline import embedder |
| test_embedding = embedder.embed_query("test") |
| expected_dim = len(test_embedding) |
|
|
| if existing_dim != expected_dim: |
| import logging |
| log_obj = logging.getLogger("vector_store") |
| log_obj.warning( |
| "[VectorStore] Dimension mismatch: collection=%d-dim, " |
| "embedder=%d-dim. Auto-purging and recreating...", |
| existing_dim, expected_dim |
| ) |
| |
| _client.delete_collection(config.CHROMA_COLLECTION) |
| col = _client.get_or_create_collection( |
| name=config.CHROMA_COLLECTION, |
| metadata={"hnsw:space": "cosine"}, |
| ) |
| count = 0 |
| print( |
| f"[VectorStore] Collection recreated with {expected_dim}-dim " |
| f"at {persist_dir}" |
| ) |
| except Exception: |
| pass |
|
|
| print( |
| f"[VectorStore] ChromaDB collection '{config.CHROMA_COLLECTION}' ready " |
| f"({count} docs) at {persist_dir}" |
| ) |
| return col |
| except Exception: |
| |
| col = _client.get_or_create_collection( |
| name=config.CHROMA_COLLECTION, |
| metadata={"hnsw:space": "cosine"}, |
| ) |
| print( |
| f"[VectorStore] Created new ChromaDB collection at {persist_dir}" |
| ) |
| return col |
|
|
|
|
| def _get_collection() -> chromadb.Collection: |
| global _client, _collection |
| if _collection is None: |
| _collection = _validate_and_get_collection() |
| return _collection |
|
|
|
|
| |
|
|
| def _db25_fuse( |
| dense_results: dict, |
| candidate_texts: list[str], |
| query_text: str, |
| top_k: int, |
| rrf_k: int = 60, |
| ) -> list[dict]: |
| """Fuse Chroma dense results with BM25 scores via Reciprocal Rank Fusion. |
| |
| Args: |
| dense_results: raw chromadb query result dict (ids, documents, metadatas, distances). |
| candidate_texts: plain-text (decrypted) strings corresponding to each candidate. |
| query_text: the raw user query string for BM25. |
| top_k: number of results to return. |
| rrf_k: RRF constant (default 60 per the original RRF paper). |
| |
| Returns: |
| List of result dicts: {text, metadata, score}. |
| """ |
| ids = dense_results["ids"][0] |
| metadatas = dense_results["metadatas"][0] |
| distances = dense_results["distances"][0] |
| n = len(ids) |
|
|
| if n == 0: |
| return [] |
|
|
| |
| dense_rank = {doc_id: rank for rank, doc_id in enumerate(ids)} |
|
|
| |
| |
| |
| global _bm25_cache |
| col_count = len(ids) |
| if _bm25_cache is None or _bm25_cache[0] != col_count: |
| tokenized = [t.lower().split() for t in candidate_texts] |
| _bm25_cache = (col_count, BM25Okapi(tokenized)) |
| bm25 = _bm25_cache[1] |
| bm25_scores = bm25.get_scores(query_text.lower().split()) |
| |
| bm25_order = sorted(range(n), key=lambda i: bm25_scores[i], reverse=True) |
| bm25_rank = {bm25_order[rank]: rank for rank in range(n)} |
|
|
| |
| fused = [] |
| for i, doc_id in enumerate(ids): |
| rrf_score = 1.0 / (rrf_k + dense_rank[doc_id]) + 1.0 / (rrf_k + bm25_rank[i]) |
| |
| cosine_sim = max(0.0, 1.0 - distances[i]) |
| fused.append({ |
| "_idx": i, |
| "_id": doc_id, |
| "rrf_score": rrf_score, |
| "score": cosine_sim, |
| "metadata": metadatas[i], |
| "text": candidate_texts[i], |
| }) |
|
|
| fused.sort(key=lambda x: x["rrf_score"], reverse=True) |
|
|
| return [ |
| { |
| "text": r["text"], |
| "metadata": { |
| "source": r["metadata"].get("source"), |
| "file_type": r["metadata"].get("file_type"), |
| "tier": r["metadata"].get("tier"), |
| }, |
| "score": r["score"], |
| } |
| for r in fused[:top_k] |
| ] |
|
|
|
|
| |
|
|
| def add_chunks( |
| chunks: list[dict], |
| embeddings: list[list[float]], |
| doc_id: str, |
| tier: str = "extended", |
| session_token: str = "admin", |
| ) -> int: |
| """Store chunks with their embeddings and knowledge tier. Returns number of items added.""" |
| col = _get_collection() |
|
|
| ids, docs, metadatas, vecs = [], [], [], [] |
| for i, (chunk, vector) in enumerate(zip(chunks, embeddings)): |
| text = chunk.get("text", "") |
| enc_text = encrypt_data(text) |
| chunk_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"{doc_id}_{i}")) |
|
|
| ids.append(chunk_id) |
| docs.append(enc_text) |
| metadatas.append({ |
| "source": chunk.get("source", "unknown"), |
| "file_type": chunk.get("file_type", "?"), |
| "tier": tier, |
| "session_token": session_token, |
| }) |
| vecs.append(vector) |
|
|
| |
| try: |
| col.upsert(ids=ids, documents=docs, metadatas=metadatas, embeddings=vecs) |
| except Exception as e: |
| error_msg = str(e) |
| |
| if "dimension" in error_msg.lower() and ("expecting" in error_msg.lower() or "got" in error_msg.lower()): |
| raise ValueError( |
| f"Embedding dimension mismatch: {error_msg}\n" |
| f"This occurs when embedding models are changed (e.g., bge-small→bge-m3). " |
| f"The ChromaDB collection schema no longer matches the new embedder output.\n" |
| f"SOLUTION: Call purge() to clear the collection, or delete data/chroma_db/ manually:\n" |
| f" python -c \"from pipeline import vector_store; vector_store.purge()\"\n" |
| f"or:\n" |
| f" rm -rf data/chroma_db/\n" |
| f"Then restart the application to recreate the collection with correct dimensions." |
| ) from e |
| raise |
|
|
| |
| global _text_cache_valid, _bm25_cache |
| _text_cache_valid = False |
| _bm25_cache = None |
|
|
| return len(chunks) |
|
|
|
|
| def query( |
| query_embedding: list[float], |
| top_k: int | None = None, |
| keyword: str | None = None, |
| session_token: str = "admin", |
| ) -> list[dict]: |
| """Return top_k most similar chunks using DB25 hybrid search. |
| |
| DB25 = Dense (ChromaDB cosine ANN) + BM25, fused via RRF. |
| Falls back to pure dense search when keyword is None. |
| """ |
| k = top_k or config.TOP_K_VECTOR |
| col = _get_collection() |
|
|
| |
| if session_token == "admin": |
| where_filter = None |
| else: |
| where_filter = { |
| "$or": [ |
| {"tier": {"$eq": "foundation"}}, |
| {"session_token": {"$eq": session_token}}, |
| ] |
| } |
|
|
| |
| fetch_k = max(k * 4, 20) if keyword else k |
|
|
| query_kwargs: dict = dict( |
| query_embeddings=[query_embedding], |
| n_results=min(fetch_k, max(col.count(), 1)), |
| include=["documents", "metadatas", "distances"], |
| ) |
| if where_filter: |
| query_kwargs["where"] = where_filter |
|
|
| raw = col.query(**query_kwargs) |
|
|
| |
| enc_texts = raw["documents"][0] if raw["documents"] else [] |
| plain_texts = [decrypt_data(enc) for enc in enc_texts] |
|
|
| if keyword and plain_texts: |
| |
| return _db25_fuse(raw, plain_texts, keyword, top_k=k) |
|
|
| def query_dense( |
| query_embedding: list[float], |
| top_k: int | None = None, |
| session_token: str = "admin", |
| ) -> list[dict]: |
| """Return top_k most similar chunks using pure Vector (cosine) search.""" |
| k = top_k or config.TOP_K_VECTOR |
| col = _get_collection() |
|
|
| if session_token == "admin": |
| where_filter = None |
| else: |
| where_filter = { |
| "$or": [ |
| {"tier": {"$eq": "foundation"}}, |
| {"session_token": {"$eq": session_token}}, |
| ] |
| } |
|
|
| query_kwargs: dict = dict( |
| query_embeddings=[query_embedding], |
| n_results=min(k, max(col.count(), 1)), |
| include=["documents", "metadatas", "distances"], |
| ) |
| if where_filter: |
| query_kwargs["where"] = where_filter |
|
|
| raw = col.query(**query_kwargs) |
|
|
| enc_texts = raw["documents"][0] if raw["documents"] else [] |
| plain_texts = [decrypt_data(enc) for enc in enc_texts] |
|
|
| results = [] |
| ids = raw["ids"][0] if raw["ids"] else [] |
| metadatas = raw["metadatas"][0] if raw["metadatas"] else [] |
| distances = raw["distances"][0] if raw["distances"] else [] |
| for text, meta, dist in zip(plain_texts, metadatas, distances): |
| results.append({ |
| "text": text, |
| "metadata": { |
| "source": meta.get("source"), |
| "file_type": meta.get("file_type"), |
| "tier": meta.get("tier"), |
| }, |
| "score": max(0.0, 1.0 - dist), |
| }) |
| return results[:k] |
|
|
|
|
| def query_bm25( |
| keyword: str, |
| top_k: int | None = None, |
| session_token: str = "admin", |
| ) -> list[dict]: |
| """Return top_k most similar chunks using pure BM25 keyword search.""" |
| k = top_k or config.TOP_K_VECTOR |
| col = _get_collection() |
| |
| if col.count() == 0: |
| return [] |
|
|
| |
| |
| if session_token == "admin": |
| where_filter = None |
| else: |
| where_filter = { |
| "$or": [ |
| {"tier": {"$eq": "foundation"}}, |
| {"session_token": {"$eq": session_token}}, |
| ] |
| } |
| |
| all_data = col.get(where=where_filter, include=["documents", "metadatas"]) if where_filter else col.get(include=["documents", "metadatas"]) |
| enc_docs = all_data.get("documents") or [] |
| metadatas = all_data.get("metadatas") or [] |
| ids = all_data.get("ids") or [] |
|
|
| if not enc_docs: |
| return [] |
|
|
| plain_texts = [decrypt_data(enc) for enc in enc_docs] |
| |
| |
| tokenized = [t.lower().split() for t in plain_texts] |
| bm25 = BM25Okapi(tokenized) |
| bm25_scores = bm25.get_scores(keyword.lower().split()) |
| |
| |
| n = len(plain_texts) |
| bm25_order = sorted(range(n), key=lambda i: bm25_scores[i], reverse=True) |
| |
| results = [] |
| for rank, idx in enumerate(bm25_order): |
| if rank >= k: |
| break |
| if bm25_scores[idx] <= 0: |
| break |
| |
| results.append({ |
| "text": plain_texts[idx], |
| "metadata": { |
| "source": metadatas[idx].get("source"), |
| "file_type": metadatas[idx].get("file_type"), |
| "tier": metadatas[idx].get("tier"), |
| }, |
| "score": bm25_scores[idx], |
| }) |
| return results |
|
|
|
|
| def list_documents(session_token: str = "admin") -> list[dict]: |
| """Return unique source documents stored in the collection.""" |
| col = _get_collection() |
|
|
| |
| all_meta = col.get(include=["metadatas"])["metadatas"] or [] |
|
|
| seen, docs = set(), [] |
| for meta in all_meta: |
| tier = meta.get("tier", "extended") |
| tok = meta.get("session_token", "") |
| if session_token != "admin" and tier != "foundation" and tok != session_token: |
| continue |
| src = meta.get("source", "unknown") |
| if src not in seen: |
| seen.add(src) |
| docs.append({ |
| "source": src, |
| "file_type": meta.get("file_type", "?"), |
| "tier": tier, |
| }) |
| return docs |
|
|
|
|
| def get_all_text(session_token: str = "admin") -> str: |
| """Return all document text in the knowledge base, concatenated. |
| |
| Performance: caches the admin result and returns it immediately on |
| subsequent calls until cache is invalidated by add/delete/purge. |
| """ |
| global _text_cache, _text_cache_valid |
|
|
| |
| if session_token == "admin" and _text_cache_valid and _text_cache is not None: |
| return _text_cache |
|
|
| col = _get_collection() |
|
|
| all_data = col.get(include=["documents", "metadatas"]) |
| enc_docs = all_data.get("documents") or [] |
| metadatas = all_data.get("metadatas") or [] |
|
|
| texts = [] |
| for enc_text, meta in zip(enc_docs, metadatas): |
| tier = meta.get("tier", "extended") |
| tok = meta.get("session_token", "") |
| if session_token != "admin" and tier != "foundation" and tok != session_token: |
| continue |
| text = decrypt_data(enc_text) |
| if text: |
| texts.append(text) |
|
|
| result = "\n\n".join(texts) |
|
|
| |
| if session_token == "admin": |
| _text_cache = result |
| _text_cache_valid = True |
|
|
| return result |
|
|
|
|
| def delete_document(source_name: str, session_token: str = "admin") -> int: |
| """Delete all chunks belonging to a source document. Returns deleted count.""" |
| col = _get_collection() |
|
|
| if session_token == "admin": |
| where_filter = {"source": {"$eq": source_name}} |
| else: |
| where_filter = { |
| "$and": [ |
| {"source": {"$eq": source_name}}, |
| {"session_token": {"$eq": session_token}}, |
| ] |
| } |
|
|
| |
| result = col.get(where=where_filter, include=[]) |
| ids = result.get("ids") or [] |
| if ids: |
| col.delete(ids=ids) |
|
|
| |
| global _text_cache_valid, _bm25_cache |
| _text_cache_valid = False |
| _bm25_cache = None |
|
|
| return len(ids) |
|
|
|
|
| def delete_by_session(session_token: str) -> int: |
| """Delete all chunks belonging to a specific session token.""" |
| if session_token in ("admin", "anonymous", ""): |
| return 0 |
| col = _get_collection() |
| result = col.get( |
| where={"session_token": {"$eq": session_token}}, |
| include=[], |
| ) |
| ids = result.get("ids") or [] |
| if ids: |
| col.delete(ids=ids) |
| return len(ids) |
|
|
|
|
| def count() -> int: |
| """Return total chunk count in the collection.""" |
| return _get_collection().count() |
|
|
|
|
| def get_embedding_info() -> dict: |
| """Return information about collection embedding dimensions. |
| |
| Returns: |
| { |
| "collection_exists": bool, |
| "doc_count": int, |
| "embedding_dim": int | None, |
| "embedding_model": str # from config |
| } |
| """ |
| col = _get_collection() |
| count = col.count() |
| embed_dim = None |
| |
| if count > 0: |
| sample = col.get(limit=1) |
| if sample.get("embeddings"): |
| embed_dim = len(sample["embeddings"][0]) |
| |
| return { |
| "collection_exists": True, |
| "doc_count": count, |
| "embedding_dim": embed_dim, |
| "embedding_model": config.EMBEDDING_MODEL, |
| } |
|
|
|
|
| def purge() -> None: |
| """Wipe the entire ChromaDB collection and reset the in-memory client. |
| |
| This deletes the Chroma collection (all stored vectors) and clears every |
| in-process cache so that the next call to _get_collection() rebuilds from |
| scratch with the correct embedding dimensions. |
| """ |
| global _client, _collection, _text_cache, _text_cache_valid, _bm25_cache |
| import logging, shutil |
| log_obj = logging.getLogger("vector_store") |
|
|
| if _client is not None: |
| try: |
| _client.delete_collection(config.CHROMA_COLLECTION) |
| log_obj.info("[VectorStore] ChromaDB collection '%s' dropped.", config.CHROMA_COLLECTION) |
| except Exception as e: |
| log_obj.warning("[VectorStore] delete_collection failed (may already be absent): %s", e) |
|
|
| |
| |
| |
|
|
| |
| _client = None |
| _collection = None |
| _text_cache = None |
| _text_cache_valid = False |
| _bm25_cache = None |
|
|
|
|
| if __name__ == "__main__": |
| """CLI utility to inspect and manage the vector store.""" |
| import sys |
| import json |
| |
| if len(sys.argv) > 1 and sys.argv[1] == "--purge": |
| print("Purging ChromaDB collection...") |
| purge() |
| print("✓ Collection purged. Will be recreated on next ingest.") |
| elif len(sys.argv) > 1 and sys.argv[1] == "--info": |
| info = get_embedding_info() |
| print(json.dumps(info, indent=2)) |
| else: |
| print("Vector Store Management") |
| print("-" * 50) |
| info = get_embedding_info() |
| print(f"Model : {info['embedding_model']}") |
| print(f"Docs : {info['doc_count']}") |
| if info['embedding_dim']: |
| print(f"Dimension : {info['embedding_dim']}-dim") |
| else: |
| print(f"Dimension : (empty collection)") |
| print() |
| print("Usage:") |
| print(" python -m pipeline.vector_store # show info") |
| print(" python -m pipeline.vector_store --info # JSON output") |
| print(" python -m pipeline.vector_store --purge # clear collection") |
|
|