Spaces:
Sleeping
Sleeping
| import hashlib | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Optional | |
| import chromadb | |
| from chromadb.errors import NotFoundError | |
| from app.observability import retrieval as _obs_retrieval | |
| from app.observability.contracts import RetrievalOutcome | |
| from app.observability.operation import observe_operation | |
| from app.rag.embeddings import Embedder | |
| from app.rag.pipeline_version import get_pipeline_version | |
| _FILE_INDEX_COLLECTION = "file-index" | |
| _COLLECTION_ROLES = { | |
| "paper_evidence": "paper_evidence", | |
| "project_memory": "project_memory", | |
| } | |
| def _collection_role(collection: str) -> str: | |
| """Return the finite role name safe to attach to retrieval spans.""" | |
| return _COLLECTION_ROLES.get(collection, "other") | |
| def _safe_count(col: Any) -> Optional[int]: | |
| """Read a collection's size without ever raising into product code.""" | |
| try: | |
| return col.count() | |
| except Exception: | |
| return None | |
| class ChromaDBClient: | |
| """In-memory ChromaDB wrapper for the web demo deployment. | |
| State is persistent across process restarts. Files are deduplicated by | |
| SHA-256 content hash. | |
| """ | |
| def __init__( | |
| self, | |
| embedder: Optional[Embedder] = None, | |
| *, | |
| path: str | Path | None = None, | |
| client: Any | None = None, | |
| ) -> None: | |
| import os | |
| if client is not None: | |
| self._client = client | |
| else: | |
| db_path = Path(path).expanduser() if path is not None else Path(os.path.expanduser("~/.studybuddy/chroma")) | |
| db_path.mkdir(parents=True, exist_ok=True) | |
| self._client = chromadb.PersistentClient(path=str(db_path)) | |
| self.embedder = embedder or Embedder() | |
| # ------------------------------------------------------------------ # | |
| # Internal # | |
| # ------------------------------------------------------------------ # | |
| def _get_or_create(self, name: str): | |
| return self._client.get_or_create_collection(name) | |
| # ------------------------------------------------------------------ # | |
| # File dedup # | |
| # ------------------------------------------------------------------ # | |
| def file_hash(content: bytes) -> str: | |
| return hashlib.sha256(content).hexdigest() | |
| def is_indexed(self, content_hash: str) -> bool: | |
| try: | |
| col = self._client.get_or_create_collection(_FILE_INDEX_COLLECTION) | |
| r = col.get(ids=[content_hash]) | |
| return len(r["ids"]) > 0 | |
| except Exception: | |
| return False | |
| def mark_indexed(self, content_hash: str, filename: str) -> None: | |
| col = self._client.get_or_create_collection(_FILE_INDEX_COLLECTION) | |
| col.upsert( | |
| ids=[content_hash], | |
| documents=[filename], | |
| metadatas=[{"filename": filename}], | |
| ) | |
| # ------------------------------------------------------------------ # | |
| # Public API # | |
| # ------------------------------------------------------------------ # | |
| def upsert( | |
| self, | |
| collection: str, | |
| documents: List[str], | |
| metadatas: List[Dict[str, Any]], | |
| ids: List[str], | |
| ) -> None: | |
| col = self._get_or_create(collection) | |
| with observe_operation("embedding.batch", subsystem="embedding") as op: | |
| op.add_count("embedding_batch_size", len(documents)) | |
| embeddings = self.embedder.embed(documents) | |
| with observe_operation("chroma.upsert", subsystem="chroma") as op: | |
| before = _safe_count(col) | |
| if before is not None: | |
| op.set("collection_count_before", before) | |
| col.upsert(documents=documents, embeddings=embeddings, metadatas=metadatas, ids=ids) | |
| after = _safe_count(col) | |
| if after is not None: | |
| op.set("collection_count_after", after) | |
| def query_observed( | |
| self, | |
| collection: str, | |
| query_embedding: List[float], | |
| n_results: int = 5, | |
| where: Optional[Dict[str, Any]] = None, | |
| *, | |
| consumer: Optional[str] = None, | |
| pipeline_version: Optional[str] = None, | |
| ) -> RetrievalOutcome: | |
| """Vector search that preserves the *cause* of an empty result. | |
| Behaviourally identical to :meth:`query` (same clamping, same | |
| fallbacks), but returns a :class:`RetrievalOutcome` so a caller can | |
| tell a genuinely-empty successful search (``success_empty``) apart from | |
| a collection-lookup / vector-search failure that fell back to ``[]`` | |
| (``error_fallback``). :meth:`query` delegates here and unwraps | |
| ``.candidates`` so every existing caller stays byte-for-byte compatible. | |
| ``pipeline_version`` (default ``None``) gates the over-fetch + dedup + | |
| truncate mechanism -- see ``app.rag.pipeline_version``. ``None`` (every | |
| ordinary product call site) and the explicit ``"rag-naive-v1"`` both | |
| resolve to the exact same frozen, unmodified behavior this method has | |
| always had: request exactly ``n_results``, no over-fetch, no dedup. | |
| Only an explicit ``"rag-dedup-v2"`` (driven by the benchmark runner) | |
| changes anything. | |
| """ | |
| pv = get_pipeline_version(pipeline_version) | |
| requested_n = n_results # what the caller actually wants back, unchanged by over-fetch | |
| with observe_operation("chroma.collection_lookup", subsystem="chroma", consumer=consumer) as op: | |
| try: | |
| col = self._client.get_collection(collection) | |
| except Exception as exc: | |
| op.mark_error(_obs_retrieval.COLLECTION_UNAVAILABLE) | |
| return RetrievalOutcome.failure(_obs_retrieval.COLLECTION_UNAVAILABLE) | |
| # Over-fetch a larger raw pool when dedup is enabled, so removing | |
| # near-duplicates below can still backfill up to `requested_n` from | |
| # alternate candidates instead of only ever shrinking the naive | |
| # top-k in place. `fetch_n == requested_n` whenever dedup is | |
| # disabled (v1's overfetch_factor is pinned to 1), which is what | |
| # keeps v1 byte-identical to its pre-Task-8 behavior. | |
| fetch_n = requested_n * pv.overfetch_factor | |
| # Clamp fetch_n to actual collection size (a count failure is | |
| # non-fatal here, exactly as before). `requested_top_k` is always the | |
| # caller's original ask, never the inflated over-fetch amount, so | |
| # this attribute means the same thing across both pipeline versions. | |
| with observe_operation("chroma.collection_count", subsystem="chroma", consumer=consumer) as op: | |
| try: | |
| actual = col.count() | |
| op.set("collection_size", actual) | |
| op.set("requested_top_k", requested_n) | |
| fetch_n = min(fetch_n, actual) if actual > 0 else 1 | |
| except Exception: | |
| pass | |
| kwargs: Dict[str, Any] = { | |
| "query_embeddings": [query_embedding], | |
| "n_results": fetch_n, | |
| } | |
| if where: | |
| kwargs["where"] = where | |
| with observe_operation("chroma.vector_search", subsystem="chroma", consumer=consumer) as op: | |
| try: | |
| results = col.query(**kwargs) | |
| except Exception as exc: | |
| op.mark_error(_obs_retrieval.VECTOR_SEARCH_FAILED) | |
| return RetrievalOutcome.failure(_obs_retrieval.VECTOR_SEARCH_FAILED) | |
| with observe_operation("rag.result_prepare", subsystem="retrieval", consumer=consumer) as op: | |
| out = [] | |
| for doc, meta in zip(results["documents"][0], results["metadatas"][0]): | |
| out.append({"text": doc, **meta}) | |
| # Pool-level removed count, captured *before* truncation to | |
| # `requested_n` -- this is direct evidence of what the dedup | |
| # mechanism actually did to the raw over-fetched pool. It is | |
| # deliberately distinct from `duplicate_candidate_ratio` below, | |
| # which is computed on the final, already-deduplicated/truncated | |
| # list `candidate_stats()` receives: a post-dedup set is ~0.0 by | |
| # construction whether or not anything was removed, so it alone | |
| # cannot tell "removed nothing" apart from "removed something and | |
| # truncated it away". `duplicates_removed_from_pool` closes that | |
| # gap. Always 0 for v1 (dedup disabled -- there is no pool step to | |
| # remove anything from), which is itself the correct, honest value. | |
| duplicates_removed_from_pool = 0 | |
| if pv.dedup_enabled: | |
| dup_indices = _obs_retrieval.find_duplicate_indices(out) | |
| duplicates_removed_from_pool = len(dup_indices) | |
| out = [c for i, c in enumerate(out) if i not in dup_indices] | |
| out = out[:requested_n] | |
| op.set("duplicates_removed_from_pool", duplicates_removed_from_pool) | |
| for key, value in _obs_retrieval.candidate_stats(out).items(): | |
| op.set(key, value) | |
| outcome = RetrievalOutcome.success(out) | |
| return outcome | |
| def query( | |
| self, | |
| collection: str, | |
| query_embedding: List[float], | |
| n_results: int = 5, | |
| where: Optional[Dict[str, Any]] = None, | |
| *, | |
| pipeline_version: Optional[str] = None, | |
| ) -> List[Dict[str, Any]]: | |
| return self.query_observed( | |
| collection, | |
| query_embedding, | |
| n_results=n_results, | |
| where=where, | |
| pipeline_version=pipeline_version, | |
| ).candidates | |
| def query_raw( | |
| self, | |
| collection: str, | |
| query_embedding: List[float], | |
| n_results: int = 20, | |
| where: Optional[Dict[str, Any]] = None, | |
| *, | |
| consumer: Optional[str] = None, | |
| raise_on_failure: bool = False, | |
| ) -> List[Dict[str, Any]]: | |
| """Return stable IDs and distances for repository-level retrieval. | |
| Product consumers should use ``PaperEvidenceService`` or the project | |
| memory repository. This low-level method exists so rank fusion retains | |
| Chroma's identity and score instead of flattening results into text. | |
| ``raise_on_failure`` lets the structured source-retrieval boundary | |
| distinguish an unavailable dense index from a genuinely empty one; | |
| legacy callers retain the established empty-list fallback. | |
| """ | |
| collection_role = _collection_role(collection) | |
| with observe_operation( | |
| "chroma.collection_lookup", | |
| subsystem="chroma", | |
| consumer=consumer, | |
| attributes={"collection_role": collection_role}, | |
| ) as op: | |
| try: | |
| col = self._client.get_collection(collection) | |
| except NotFoundError: | |
| return [] | |
| except Exception: | |
| op.mark_error(_obs_retrieval.COLLECTION_UNAVAILABLE) | |
| if raise_on_failure: | |
| raise | |
| return [] | |
| try: | |
| actual = col.count() | |
| except Exception: | |
| actual = n_results | |
| if actual <= 0: | |
| return [] | |
| kwargs: Dict[str, Any] = { | |
| "query_embeddings": [query_embedding], | |
| "n_results": min(max(1, n_results), actual), | |
| "include": ["documents", "metadatas", "distances"], | |
| } | |
| if where: | |
| kwargs["where"] = where | |
| with observe_operation( | |
| "chroma.vector_search", | |
| subsystem="chroma", | |
| consumer=consumer, | |
| attributes={"collection_role": collection_role}, | |
| ) as op: | |
| try: | |
| result = col.query(**kwargs) | |
| except Exception: | |
| op.mark_error(_obs_retrieval.VECTOR_SEARCH_FAILED) | |
| if raise_on_failure: | |
| raise | |
| return [] | |
| ids = (result.get("ids") or [[]])[0] | |
| documents = (result.get("documents") or [[]])[0] | |
| metadatas = (result.get("metadatas") or [[]])[0] | |
| distances = (result.get("distances") or [[]])[0] | |
| rows: List[Dict[str, Any]] = [] | |
| for index, item_id in enumerate(ids): | |
| rows.append( | |
| { | |
| "id": item_id, | |
| "text": documents[index] if index < len(documents) else "", | |
| "metadata": metadatas[index] if index < len(metadatas) else {}, | |
| "distance": distances[index] if index < len(distances) else None, | |
| } | |
| ) | |
| return rows | |
| def delete_where(self, collection: str, where: Dict[str, Any]) -> None: | |
| """Delete matching rows, treating only an absent collection as empty. | |
| Derived-state deletion is a correctness operation. Authentication, | |
| storage, or filter failures must reach the caller so source files are | |
| not removed while stale vectors remain searchable. | |
| """ | |
| try: | |
| col = self._client.get_collection(collection) | |
| except NotFoundError: | |
| return | |
| col.delete(where=where) | |
| def get_where( | |
| self, | |
| collection: str, | |
| where: Dict[str, Any], | |
| *, | |
| include: List[str] | None = None, | |
| ) -> Dict[str, Any]: | |
| try: | |
| col = self._client.get_collection(collection) | |
| except NotFoundError: | |
| return {"ids": [], "metadatas": [], "documents": []} | |
| return col.get(where=where, include=include or ["metadatas"]) | |
| def delete_ids(self, collection: str, ids: List[str]) -> None: | |
| if not ids: | |
| return | |
| try: | |
| col = self._client.get_collection(collection) | |
| except NotFoundError: | |
| return | |
| col.delete(ids=ids) | |
| def collection_count(self, collection: str) -> int: | |
| try: | |
| return self._client.get_collection(collection).count() | |
| except Exception: | |
| return 0 | |
| def count_where(self, collection: str, where: Dict[str, Any]) -> int: | |
| try: | |
| result = self._client.get_collection(collection).get(where=where, include=[]) | |
| return len(result.get("ids") or []) | |
| except Exception: | |
| return 0 | |
| def delete_collection(self, name: str) -> None: | |
| try: | |
| self._client.delete_collection(name) | |
| except Exception: | |
| pass | |