Spaces:
Sleeping
Sleeping
File size: 12,468 Bytes
3bfe897 56fa10c 6e92226 56fa10c 3bfe897 56fa10c 3bfe897 56fa10c 3bfe897 56fa10c 3bfe897 56fa10c 3bfe897 56fa10c 3bfe897 56fa10c 3bfe897 56fa10c 3bfe897 56fa10c 3bfe897 56fa10c 6e92226 3bfe897 6e92226 3bfe897 6e92226 3bfe897 6e92226 3bfe897 56fa10c 3bfe897 56fa10c | 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 361 362 363 | """ChromaDB query interface with RRF merge and cross-encoder reranking."""
from __future__ import annotations
import math
import re
import chromadb
from config import (
CHROMA_ENTITY_N_RESULTS,
CHROMA_N_RESULTS,
CROSS_ENCODER_TOP_N,
RETRIEVAL_ENTITY_N,
RETRIEVAL_ENTITY_QUERY_CAP,
RETRIEVAL_SEMANTIC_N,
RRF_K,
RRF_TOP_N,
)
from logging_config import get_logger
_logger = get_logger("rag.retriever")
def search(
collection: chromadb.Collection,
query_text: str,
n_results: int = RETRIEVAL_SEMANTIC_N,
) -> list[dict]:
"""
Semantic search — returns pure similarity-ranked results (no citation weighting).
Over-fetches 2× then deduplicates to one chunk per paper.
Citation boost is applied downstream after cross-encoder reranking.
"""
n_fetch = min(n_results * 2, collection.count())
if n_fetch == 0:
return []
raw = collection.query(
query_texts=[query_text],
n_results=n_fetch,
include=["documents", "metadatas", "distances"],
)
results = _parse_raw(raw)
results = _dedup_by_pmid(results)
return results[:n_results]
def search_by_entities(
collection: chromadb.Collection,
entity_names: list[str],
n_results: int = RETRIEVAL_ENTITY_N,
) -> list[dict]:
"""
Run one ChromaDB query per entity name, merge and deduplicate by PMID.
Caps at RETRIEVAL_ENTITY_QUERY_CAP (12) entity queries to bound latency.
"""
if not entity_names or collection.count() == 0:
return []
seen: dict[str, dict] = {}
for entity in entity_names[:RETRIEVAL_ENTITY_QUERY_CAP]:
raw = collection.query(
query_texts=[entity],
n_results=min(10, collection.count()),
include=["documents", "metadatas", "distances"],
)
for r in _parse_raw(raw):
pmid = r["pmid"]
if pmid not in seen or r["similarity"] > seen[pmid]["similarity"]:
seen[pmid] = r
merged = _dedup_by_pmid(list(seen.values()))
return merged[:n_results]
def _term_variants(term: str) -> list[str]:
"""
Generate letter/digit boundary variants of a compound identifier so that
"SPG302", "SPG 302", and "SPG-302" all resolve to the same papers.
"SPG302" → ["SPG302", "SPG 302", "SPG-302"]
"""
# Collapse any existing space/hyphen at letter–digit boundaries → canonical form
canonical = re.sub(r'(?<=[A-Za-z])[\s\-](?=\d)|(?<=\d)[\s\-](?=[A-Za-z])', '', term)
spaced = re.sub(r'([A-Za-z])(\d)', r'\1 \2', canonical)
hyphenated = re.sub(r'([A-Za-z])(\d)', r'\1-\2', canonical)
return list(dict.fromkeys([term, canonical, spaced, hyphenated]))
def term_matches_text(text: str, term: str) -> bool:
"""True if any spacing/hyphen variant of `term` appears in `text` (case-insensitive)."""
if not text or not term.strip():
return False
low = text.lower()
return any(v.lower() in low for v in _term_variants(term) if v.strip())
def paper_texts_for_pmids(
collection: chromadb.Collection,
pmids: list[str],
) -> dict[str, dict[str, str]]:
"""
Fetch ALL chunks for each PMID in one call and return, per PMID:
{"abstract": <chunk_index 0 doc>, "full": <all chunk docs concatenated>}.
Used to distinguish "the paper is actually about this compound" (present in the
abstract) from an incidental full-text-only mention (present somewhere in the
body, e.g. a drug-pipeline table, but not the abstract). Fetching every chunk is
required because the retrieved representative chunk is often NOT the one holding
the compound name.
"""
pmids = [p for p in dict.fromkeys(pmids) if p]
if not pmids:
return {}
try:
res = collection.get(
where={"pmid": {"$in": pmids}},
include=["documents", "metadatas"],
)
except Exception:
return {}
out: dict[str, dict[str, str]] = {p: {"abstract": "", "full": ""} for p in pmids}
parts: dict[str, list[str]] = {p: [] for p in pmids}
for meta, doc in zip(res.get("metadatas", []), res.get("documents", [])):
pmid = meta.get("pmid", "")
if pmid not in out:
continue
doc = doc or ""
parts[pmid].append(doc)
if meta.get("chunk_index") == 0:
out[pmid]["abstract"] = doc
for pmid in out:
out[pmid]["full"] = "\n".join(parts[pmid])
return out
def is_grounded_in_abstract(collection: chromadb.Collection, term: str) -> bool:
"""
True if any variant of `term` appears in an abstract chunk (chunk_index == 0).
Signals that at least one paper is genuinely *about* the term, as opposed to
only naming it in a full-text pipeline/landscape table.
"""
if not term.strip() or collection.count() == 0:
return False
for variant in _term_variants(term):
if not variant.strip():
continue
try:
raw = collection.get(
where={"chunk_index": {"$eq": 0}},
where_document={"$contains": variant},
limit=1,
include=["metadatas"],
)
except Exception:
continue
if raw.get("ids"):
return True
return False
def is_grounded_in_corpus(collection: chromadb.Collection, term: str) -> bool:
"""
True if any spacing/hyphen variant of `term` appears literally in a paper
(ChromaDB $contains). This is exact-substring presence — the reliable signal
for "is this named entity actually written in the corpus", as opposed to
semantic search which always returns nearest neighbors regardless of relevance.
"""
if not term.strip() or collection.count() == 0:
return False
for variant in _term_variants(term):
if not variant.strip():
continue
try:
raw = collection.query(
query_texts=[variant],
where_document={"$contains": variant},
n_results=1,
include=["metadatas"],
)
except Exception:
continue
if raw.get("ids", [[]])[0]:
return True
return False
def search_by_keyword(
collection: chromadb.Collection,
terms: list[str],
n_results: int = 10,
) -> list[dict]:
"""
Exact-substring search using ChromaDB's where_document $contains filter.
Searches all spacing/hyphen variants of each term so "SPG302", "SPG 302",
and "SPG-302" all resolve to the same papers. Catches proper nouns (drug
codes, gene IDs) whose embeddings are meaningless to the model.
"""
if not terms or collection.count() == 0:
return []
seen: dict[str, dict] = {}
for term in terms:
if not term.strip():
continue
for variant in _term_variants(term):
if not variant.strip():
continue
try:
raw = collection.query(
query_texts=[variant],
where_document={"$contains": variant},
n_results=min(n_results, collection.count()),
include=["documents", "metadatas", "distances"],
)
except Exception:
continue
for r in _parse_raw(raw):
pmid = r["pmid"]
if pmid not in seen or r["similarity"] > seen[pmid]["similarity"]:
seen[pmid] = r
return _dedup_by_pmid(list(seen.values()))
def rrf_merge(
ranked_lists: list[list[dict]],
k: int = RRF_K,
top_n: int = RRF_TOP_N,
) -> list[dict]:
"""
Reciprocal Rank Fusion — combines N ranked lists into one.
score(pmid) = Σ 1 / (k + rank_in_list_i + 1)
Preserves the best-scoring dict per PMID from all input lists.
"""
rrf_scores: dict[str, float] = {}
best: dict[str, dict] = {}
for ranked in ranked_lists:
for rank, result in enumerate(ranked):
pmid = result["pmid"]
rrf_scores[pmid] = rrf_scores.get(pmid, 0.0) + 1.0 / (k + rank + 1)
if pmid not in best or result["similarity"] > best[pmid]["similarity"]:
best[pmid] = result
sorted_pmids = sorted(rrf_scores, key=lambda p: rrf_scores[p], reverse=True)
merged = []
for pmid in sorted_pmids[:top_n]:
r = best[pmid].copy()
r["rrf_score"] = round(rrf_scores[pmid], 6)
merged.append(r)
return merged
def cross_encoder_rerank(
model,
query: str,
candidates: list[dict],
top_n: int = CROSS_ENCODER_TOP_N,
) -> list[dict]:
"""
Cross-encoder reranking — scores (query, document) pairs jointly.
Truncates document text to 1800 chars (~450 tokens) so query+doc fits
within the ms-marco model's 512-token limit.
"""
if not candidates:
return []
pairs = [(query, r["document"][:1800]) for r in candidates]
ce_scores = model.predict(pairs, show_progress_bar=False)
for r, score in zip(candidates, ce_scores):
r["ce_score"] = float(score)
candidates.sort(key=lambda x: x["ce_score"], reverse=True)
return candidates[:top_n]
_RECENCY_BASE_YEAR = 1990
_RECENCY_CURRENT_YEAR = 2025
_RECENCY_MAX_BOOST = 0.5 # most recent papers get 1.5× vs oldest at 1.0×
def apply_citation_boost(results: list[dict]) -> list[dict]:
"""
Final score = ce_score × log(citation_count + 2) × recency_factor.
Citation factor: log-scaled so each order-of-magnitude in citations adds
roughly equal weight. log(2) ≈ 0.69 floor for uncited papers.
Recency factor: linear 1.0 → 1.5 from 1990 to 2025. A 2024 paper scores
50% higher than a 1990 paper at equal citation count and relevance, reflecting
that recent evidence is more likely to reflect current understanding.
"""
for r in results:
base = r.get("ce_score", r.get("similarity", 0.0))
citation_factor = math.log(r["citation_count"] + 2)
year = r.get("year") or _RECENCY_BASE_YEAR
recency_factor = 1.0 + _RECENCY_MAX_BOOST * (
max(0, year - _RECENCY_BASE_YEAR)
/ (_RECENCY_CURRENT_YEAR - _RECENCY_BASE_YEAR)
)
r["score"] = base * citation_factor * recency_factor
results.sort(key=lambda x: x["score"], reverse=True)
return results
def get_paper(collection: chromadb.Collection, pmid: str) -> dict | None:
"""Retrieve a specific paper's abstract chunk by PMID."""
result = collection.get(
where={"$and": [{"pmid": {"$eq": pmid}}, {"chunk_index": {"$eq": 0}}]},
include=["documents", "metadatas"],
)
ids = result.get("ids", [])
if not ids:
return None
meta = result["metadatas"][0]
return {
"pmid": pmid,
"title": meta.get("title", ""),
"year": meta.get("year", 0),
"doi": meta.get("doi", ""),
"citation_count": meta.get("citation_count", 0),
"document": result["documents"][0],
}
def _parse_raw(raw: dict) -> list[dict]:
"""Flatten a ChromaDB query response into a list of result dicts."""
ids = raw.get("ids", [[]])[0]
docs = raw.get("documents", [[]])[0]
metas = raw.get("metadatas", [[]])[0]
distances = raw.get("distances", [[]])[0]
results = []
for chunk_id, doc, meta, dist in zip(ids, docs, metas, distances):
similarity = max(0.0, 1.0 - dist)
citation_count = int(meta.get("citation_count", 0))
results.append({
"chunk_id": chunk_id,
"pmid": meta.get("pmid", ""),
"title": meta.get("title", ""),
"year": int(meta.get("year", 0)),
"doi": meta.get("doi", ""),
"section": meta.get("section", "abstract"),
"citation_count": citation_count,
"entity_names": [e for e in meta.get("entity_names", "").split(",") if e],
"has_full_text": bool(meta.get("has_full_text", 0)),
"document": doc,
"similarity": similarity,
"score": similarity,
})
return results
def _dedup_by_pmid(results: list[dict]) -> list[dict]:
"""Keep best-similarity chunk per paper, sorted by similarity descending."""
results.sort(key=lambda x: x["similarity"], reverse=True)
seen: dict[str, dict] = {}
for r in results:
pmid = r["pmid"]
if pmid not in seen:
seen[pmid] = r
return list(seen.values())
|