nitdaa / pipeline /vector_store.py
Sam-max1's picture
Upload folder using huggingface_hub
af8ac78 verified
Raw
History Blame Contribute Delete
20.8 kB
"""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
# ── ChromaDB client ───────────────────────────────────────────────────────────
import chromadb
from chromadb.config import Settings
# ── BM25 (for DB25 hybrid search) ────────────────────────────────────────────
from rank_bm25 import BM25Okapi
_client: chromadb.PersistentClient | None = None
_collection: chromadb.Collection | None = None
# ── Performance: in-memory caches (invalidated on every write/delete/purge) ────
_text_cache: str | None = None # result of get_all_text("admin")
_text_cache_valid: bool = False # invalidated on every add/delete/purge
_bm25_cache: tuple | None = None # (count, BM25Okapi) – rebuilt on count change
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)
# Assign to the global _client so purge() and subsequent calls share the same instance
_client = chromadb.PersistentClient(
path=persist_dir,
settings=Settings(anonymized_telemetry=False),
)
try:
col = _client.get_collection(name=config.CHROMA_COLLECTION)
count = col.count()
# If collection has data, validate embedding dimensions
if count > 0:
# include=["embeddings"] is required to actually fetch embedding vectors
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
)
# Delete the stale collection and recreate it fresh
_client.delete_collection(config.CHROMA_COLLECTION)
col = _client.get_or_create_collection(
name=config.CHROMA_COLLECTION,
metadata={"hnsw:space": "cosine"},
)
count = 0 # reset reported count after purge
print(
f"[VectorStore] Collection recreated with {expected_dim}-dim "
f"at {persist_dir}"
)
except Exception:
pass # If validation fails, proceed with existing collection
print(
f"[VectorStore] ChromaDB collection '{config.CHROMA_COLLECTION}' ready "
f"({count} docs) at {persist_dir}"
)
return col
except Exception:
# Collection doesn't exist yet — create it fresh
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
# ── DB25 Hybrid Search Helper ─────────────────────────────────────────────────
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] # cosine distance (0=identical, 1=orthogonal)
n = len(ids)
if n == 0:
return []
# Dense rank: Chroma returns nearest first (lowest distance = rank 0)
dense_rank = {doc_id: rank for rank, doc_id in enumerate(ids)}
# BM25 rank over decrypted candidate texts
# Performance: cache BM25 index keyed on collection size.
# The index only changes when chunks are added or deleted.
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())
# Rank descending by BM25 score (highest score = rank 0)
bm25_order = sorted(range(n), key=lambda i: bm25_scores[i], reverse=True)
bm25_rank = {bm25_order[rank]: rank for rank in range(n)}
# RRF fusion
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])
# Convert cosine distance → similarity score (0–1)
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]
]
# ── Public API ────────────────────────────────────────────────────────────────
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) # stored document = encrypted text
metadatas.append({
"source": chunk.get("source", "unknown"),
"file_type": chunk.get("file_type", "?"),
"tier": tier,
"session_token": session_token,
})
vecs.append(vector)
# ChromaDB batch upsert
try:
col.upsert(ids=ids, documents=docs, metadatas=metadatas, embeddings=vecs)
except Exception as e:
error_msg = str(e)
# Detect embedding dimension mismatch (happens when switching embedding models)
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
# Invalidate caches so next read reflects the new data
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()
# RBAC where-clause: foundation docs are globally readable; session docs only by owner/admin
if session_token == "admin":
where_filter = None # admin sees everything
else:
where_filter = {
"$or": [
{"tier": {"$eq": "foundation"}},
{"session_token": {"$eq": session_token}},
]
}
# Oversample for BM25 re-ranking (4× oversample, min 20)
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)
# Decrypt texts for BM25 and output
enc_texts = raw["documents"][0] if raw["documents"] else []
plain_texts = [decrypt_data(enc) for enc in enc_texts]
if keyword and plain_texts:
# DB25: dense + BM25 fusion
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 []
# Fetch all chunks (filtered by RBAC) to rank them
# For a real DB this should be indexed, but BM25Okapi works in memory.
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]
# BM25 rank over all decrypted candidate texts
tokenized = [t.lower().split() for t in plain_texts]
bm25 = BM25Okapi(tokenized)
bm25_scores = bm25.get_scores(keyword.lower().split())
# Sort by BM25 score descending
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: # No keyword match
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()
# Fetch all metadata (no embeddings needed)
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
# Fast path: return cached result for admin (most common caller)
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)
# Populate cache for admin queries
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}},
]
}
# Get IDs matching filter then delete
result = col.get(where=where_filter, include=[])
ids = result.get("ids") or []
if ids:
col.delete(ids=ids)
# Invalidate caches
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)
# Note: we do NOT shutil.rmtree the sqlite directory here, as that causes
# 'attempt to write a readonly database' errors on the active PersistentClient.
# _client.delete_collection is sufficient to completely wipe the vectors.
# Reset all in-memory state
_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")