Spaces:
Sleeping
Sleeping
File size: 14,535 Bytes
2e818da | 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 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 | 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 #
# ------------------------------------------------------------------ #
@staticmethod
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
|