KevinIsInCoding commited on
Commit
d4b056d
·
unverified ·
2 Parent(s): cbc4a983bfe897

Merge pull request #7 from KevinIsInCoding/feat/rrf-cross-encoder-retrieval

Browse files
Files changed (4) hide show
  1. agents/research_agent.py +53 -17
  2. app.py +15 -2
  3. config.py +14 -3
  4. rag/retriever.py +91 -26
agents/research_agent.py CHANGED
@@ -7,8 +7,17 @@ from collections.abc import Generator
7
  import anthropic
8
  import chromadb
9
  import networkx as nx
10
-
11
- from config import SYNTHESIS_MODEL
 
 
 
 
 
 
 
 
 
12
  from graph import query as kg_query
13
  from llm import cached_system, cached_tools
14
  from logging_config import get_logger
@@ -18,6 +27,9 @@ from tools import RESEARCH_TOOLS
18
 
19
  _logger = get_logger("agents.research_agent")
20
 
 
 
 
21
 
22
  def stream_research_agent(
23
  client: anthropic.Anthropic,
@@ -63,7 +75,7 @@ def stream_research_agent(
63
  "name": event.content_block.name,
64
  }
65
  current_input_json = ""
66
- yield ("status", "Searching ALS research knowledge base...")
67
 
68
  elif event.type == "content_block_delta":
69
  if event.delta.type == "text_delta":
@@ -132,38 +144,62 @@ def _handle_search(
132
  else:
133
  expanded_entities = query_entities
134
 
135
- # Step 2: RAG semantic search on query text
136
- semantic_results = rag_retriever.search(collection, query_text, n_results=10)
137
 
138
- # Step 3: Entity-targeted RAG using expanded entity set
139
- entity_results = rag_retriever.search_by_entities(collection, expanded_entities, n_results=15)
 
 
140
 
141
- # Merge by PMID keep best score per paper
142
- seen: dict[str, dict] = {}
143
- for r in semantic_results + entity_results:
144
- pmid = r["pmid"]
145
- if pmid not in seen or r["score"] > seen[pmid]["score"]:
146
- seen[pmid] = r
147
 
148
- top_papers = sorted(seen.values(), key=lambda x: x["score"], reverse=True)[:15]
 
 
 
 
 
 
149
 
150
  _logger.info(
151
- "KG+RAG search",
152
  extra={"data": {
153
  "query_entities": query_entities,
154
  "expanded_entities": len(expanded_entities),
155
  "semantic_hits": len(semantic_results),
156
  "entity_hits": len(entity_results),
157
- "merged": len(top_papers),
 
158
  "kg_active": graph is not None,
159
  }},
160
  )
161
 
162
- # Step 4: Trial matching — prefer KG-linked trials, fall back to text match
163
  related_trials: list[dict] = []
164
  if graph and query_entities:
165
  related_trials = kg_query.find_trials_for_entities(graph, expanded_entities, max_trials=10)
166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  if not related_trials and query_entities:
168
  entities_lower = [e.lower() for e in expanded_entities]
169
  for trial in trials:
 
7
  import anthropic
8
  import chromadb
9
  import networkx as nx
10
+ from sentence_transformers import CrossEncoder
11
+
12
+ from config import (
13
+ CROSS_ENCODER_MODEL,
14
+ CROSS_ENCODER_TOP_N,
15
+ RETRIEVAL_ENTITY_N,
16
+ RETRIEVAL_SEMANTIC_N,
17
+ RRF_K,
18
+ RRF_TOP_N,
19
+ SYNTHESIS_MODEL,
20
+ )
21
  from graph import query as kg_query
22
  from llm import cached_system, cached_tools
23
  from logging_config import get_logger
 
27
 
28
  _logger = get_logger("agents.research_agent")
29
 
30
+ # Loaded once at startup — ~80MB model, ~80ms/pair on CPU
31
+ _cross_encoder = CrossEncoder(CROSS_ENCODER_MODEL)
32
+
33
 
34
  def stream_research_agent(
35
  client: anthropic.Anthropic,
 
75
  "name": event.content_block.name,
76
  }
77
  current_input_json = ""
78
+ yield ("status", "Searching knowledge base and re-ranking results for precision...")
79
 
80
  elif event.type == "content_block_delta":
81
  if event.delta.type == "text_delta":
 
144
  else:
145
  expanded_entities = query_entities
146
 
147
+ # Step 2: Semantic search up to 30 papers (pure similarity, no citation weight yet)
148
+ semantic_results = rag_retriever.search(collection, query_text, n_results=RETRIEVAL_SEMANTIC_N)
149
 
150
+ # Step 3: Entity-targeted search up to 30 papers (one query per expanded entity)
151
+ entity_results = rag_retriever.search_by_entities(
152
+ collection, expanded_entities, n_results=RETRIEVAL_ENTITY_N
153
+ )
154
 
155
+ # Step 4: RRF merge top 20 papers
156
+ merged = rag_retriever.rrf_merge(
157
+ [semantic_results, entity_results], k=RRF_K, top_n=RRF_TOP_N
158
+ )
 
 
159
 
160
+ # Step 5: Cross-encoder rerank top 15 papers
161
+ reranked = rag_retriever.cross_encoder_rerank(
162
+ _cross_encoder, query_text, merged, top_n=CROSS_ENCODER_TOP_N
163
+ )
164
+
165
+ # Step 6: Citation boost — final score = ce_score × log(citation_count + 2)
166
+ top_papers = rag_retriever.apply_citation_boost(reranked)
167
 
168
  _logger.info(
169
+ "KG+RAG+CE search",
170
  extra={"data": {
171
  "query_entities": query_entities,
172
  "expanded_entities": len(expanded_entities),
173
  "semantic_hits": len(semantic_results),
174
  "entity_hits": len(entity_results),
175
+ "rrf_merged": len(merged),
176
+ "after_cross_encoder": len(top_papers),
177
  "kg_active": graph is not None,
178
  }},
179
  )
180
 
181
+ # Step 7: Trial matching — prefer KG-linked trials, fall back to text match
182
  related_trials: list[dict] = []
183
  if graph and query_entities:
184
  related_trials = kg_query.find_trials_for_entities(graph, expanded_entities, max_trials=10)
185
 
186
+ if not related_trials:
187
+ # Exact NCT ID match first — handles "NCT06351592" style queries
188
+ nct_ids_in_query = {
189
+ w.upper() for w in query_text.split() if w.upper().startswith("NCT")
190
+ }
191
+ trial_by_nct = {t.get("nct_id", "").upper(): t for t in trials}
192
+ for nct_id in nct_ids_in_query:
193
+ if nct_id in trial_by_nct:
194
+ t = trial_by_nct[nct_id]
195
+ related_trials.append({
196
+ "nct_id": t.get("nct_id", ""),
197
+ "title": t.get("title", ""),
198
+ "phase": t.get("phase", ""),
199
+ "status": t.get("status", ""),
200
+ "url": t.get("url", ""),
201
+ })
202
+
203
  if not related_trials and query_entities:
204
  entities_lower = [e.lower() for e in expanded_entities]
205
  for trial in trials:
app.py CHANGED
@@ -37,12 +37,19 @@ def _load_trials() -> list[dict]:
37
  return [json.loads(line) for line in f if line.strip()]
38
 
39
 
40
- _collection = load_collection(CHROMA_DIR, CHROMA_COLLECTION)
 
 
 
 
 
 
 
41
  _graph = _load_graph()
42
  _trials = _load_trials()
43
  _client = anthropic.Anthropic()
44
 
45
- _n_chunks = _collection.count()
46
  _n_trials = len(_trials)
47
  _kg_nodes = _graph.number_of_nodes() if _graph else 0
48
 
@@ -64,6 +71,12 @@ def respond(message: str, history: list[dict]):
64
  yield history, gr.update(value="", interactive=True)
65
  return
66
 
 
 
 
 
 
 
67
  history = history + [{"role": "user", "content": message}]
68
  history = history + [{"role": "assistant", "content": ""}]
69
  yield history, gr.update(value="", interactive=False)
 
37
  return [json.loads(line) for line in f if line.strip()]
38
 
39
 
40
+ def _load_collection():
41
+ try:
42
+ return load_collection(CHROMA_DIR, CHROMA_COLLECTION)
43
+ except Exception:
44
+ _logger.warning("ChromaDB collection not found — running in demo mode (no data)")
45
+ return None
46
+
47
+ _collection = _load_collection()
48
  _graph = _load_graph()
49
  _trials = _load_trials()
50
  _client = anthropic.Anthropic()
51
 
52
+ _n_chunks = _collection.count() if _collection else 0
53
  _n_trials = len(_trials)
54
  _kg_nodes = _graph.number_of_nodes() if _graph else 0
55
 
 
71
  yield history, gr.update(value="", interactive=True)
72
  return
73
 
74
+ if _collection is None:
75
+ history = history + [{"role": "user", "content": message}]
76
+ history = history + [{"role": "assistant", "content": "⚠️ The knowledge base has not been loaded yet. The pipeline data (ChromaDB index, knowledge graph, papers) needs to be uploaded to this Space. Please contact the Space administrator."}]
77
+ yield history, gr.update(value="", interactive=True)
78
+ return
79
+
80
  history = history + [{"role": "user", "content": message}]
81
  history = history + [{"role": "assistant", "content": ""}]
82
  yield history, gr.update(value="", interactive=False)
config.py CHANGED
@@ -34,9 +34,20 @@ PUBMED_BATCH_SIZE = 200 # PMIDs per Entrez efetch call
34
  # Entity extraction
35
  EXTRACTION_BATCH_SIZE = 10 # papers per Claude call
36
 
37
- # RAG
38
- CHROMA_N_RESULTS = 10
39
- CHROMA_ENTITY_N_RESULTS = 15
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  # Knowledge graph
42
  KG_EXPANSION_HOPS = 1 # hops for query entity expansion
 
34
  # Entity extraction
35
  EXTRACTION_BATCH_SIZE = 10 # papers per Claude call
36
 
37
+ # RAG — retrieval counts per stage
38
+ CHROMA_N_RESULTS = 10 # legacy default (kept for backward compat)
39
+ CHROMA_ENTITY_N_RESULTS = 15 # legacy default (kept for backward compat)
40
+ RETRIEVAL_SEMANTIC_N = 30 # semantic search candidate pool
41
+ RETRIEVAL_ENTITY_N = 30 # entity search candidate pool
42
+ RETRIEVAL_ENTITY_QUERY_CAP = 12 # max entity names to query individually
43
+
44
+ # RRF merge
45
+ RRF_K = 10 # lower k → stronger rank differentiation (k=60 is too flat for 30-item lists)
46
+ RRF_TOP_N = 20 # candidates passed to cross-encoder
47
+
48
+ # Cross-encoder reranking
49
+ CROSS_ENCODER_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
50
+ CROSS_ENCODER_TOP_N = 15 # final papers sent to Claude for synthesis
51
 
52
  # Knowledge graph
53
  KG_EXPANSION_HOPS = 1 # hops for query entity expansion
rag/retriever.py CHANGED
@@ -1,11 +1,20 @@
1
- """ChromaDB query interface with citation-weighted re-ranking."""
2
  from __future__ import annotations
3
 
4
  import math
5
 
6
  import chromadb
7
 
8
- from config import CHROMA_ENTITY_N_RESULTS, CHROMA_N_RESULTS
 
 
 
 
 
 
 
 
 
9
  from logging_config import get_logger
10
 
11
  _logger = get_logger("rag.retriever")
@@ -14,12 +23,12 @@ _logger = get_logger("rag.retriever")
14
  def search(
15
  collection: chromadb.Collection,
16
  query_text: str,
17
- n_results: int = CHROMA_N_RESULTS,
18
  ) -> list[dict]:
19
  """
20
- Semantic search with citation-weighted re-ranking.
21
- Over-fetches 2× then re-ranks by: similarity * log(citation_count + 2).
22
- Deduplicates to one chunk per paper (best-scoring chunk wins).
23
  """
24
  n_fetch = min(n_results * 2, collection.count())
25
  if n_fetch == 0:
@@ -31,24 +40,24 @@ def search(
31
  include=["documents", "metadatas", "distances"],
32
  )
33
  results = _parse_raw(raw)
34
- results = _rerank(results)
35
  return results[:n_results]
36
 
37
 
38
  def search_by_entities(
39
  collection: chromadb.Collection,
40
  entity_names: list[str],
41
- n_results: int = CHROMA_ENTITY_N_RESULTS,
42
  ) -> list[dict]:
43
  """
44
- Run one query per entity, merge and deduplicate by PMID.
45
- Caps at 8 entity queries to avoid excessive API calls.
46
  """
47
  if not entity_names or collection.count() == 0:
48
  return []
49
 
50
  seen: dict[str, dict] = {}
51
- for entity in entity_names[:8]:
52
  raw = collection.query(
53
  query_texts=[entity],
54
  n_results=min(10, collection.count()),
@@ -56,13 +65,79 @@ def search_by_entities(
56
  )
57
  for r in _parse_raw(raw):
58
  pmid = r["pmid"]
59
- if pmid not in seen or r["score"] > seen[pmid]["score"]:
60
  seen[pmid] = r
61
 
62
- merged = _rerank(list(seen.values()))
63
  return merged[:n_results]
64
 
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  def get_paper(collection: chromadb.Collection, pmid: str) -> dict | None:
67
  """Retrieve a specific paper's abstract chunk by PMID."""
68
  result = collection.get(
@@ -111,19 +186,9 @@ def _parse_raw(raw: dict) -> list[dict]:
111
  return results
112
 
113
 
114
- def _rerank(results: list[dict]) -> list[dict]:
115
- """
116
- Apply citation weighting and deduplicate to one chunk per PMID.
117
- score = similarity * log(citation_count + 2)
118
- log(2) ≈ 0.69 is the floor for uncited papers, so they're still ranked
119
- but deprioritized relative to highly-cited work.
120
- """
121
- for r in results:
122
- r["score"] = r["similarity"] * math.log(r["citation_count"] + 2)
123
-
124
- results.sort(key=lambda x: x["score"], reverse=True)
125
-
126
- # Keep best chunk per paper
127
  seen: dict[str, dict] = {}
128
  for r in results:
129
  pmid = r["pmid"]
 
1
+ """ChromaDB query interface with RRF merge and cross-encoder reranking."""
2
  from __future__ import annotations
3
 
4
  import math
5
 
6
  import chromadb
7
 
8
+ from config import (
9
+ CHROMA_ENTITY_N_RESULTS,
10
+ CHROMA_N_RESULTS,
11
+ CROSS_ENCODER_TOP_N,
12
+ RETRIEVAL_ENTITY_N,
13
+ RETRIEVAL_ENTITY_QUERY_CAP,
14
+ RETRIEVAL_SEMANTIC_N,
15
+ RRF_K,
16
+ RRF_TOP_N,
17
+ )
18
  from logging_config import get_logger
19
 
20
  _logger = get_logger("rag.retriever")
 
23
  def search(
24
  collection: chromadb.Collection,
25
  query_text: str,
26
+ n_results: int = RETRIEVAL_SEMANTIC_N,
27
  ) -> list[dict]:
28
  """
29
+ Semantic search returns pure similarity-ranked results (no citation weighting).
30
+ Over-fetches 2× then deduplicates to one chunk per paper.
31
+ Citation boost is applied downstream after cross-encoder reranking.
32
  """
33
  n_fetch = min(n_results * 2, collection.count())
34
  if n_fetch == 0:
 
40
  include=["documents", "metadatas", "distances"],
41
  )
42
  results = _parse_raw(raw)
43
+ results = _dedup_by_pmid(results)
44
  return results[:n_results]
45
 
46
 
47
  def search_by_entities(
48
  collection: chromadb.Collection,
49
  entity_names: list[str],
50
+ n_results: int = RETRIEVAL_ENTITY_N,
51
  ) -> list[dict]:
52
  """
53
+ Run one ChromaDB query per entity name, merge and deduplicate by PMID.
54
+ Caps at RETRIEVAL_ENTITY_QUERY_CAP (12) entity queries to bound latency.
55
  """
56
  if not entity_names or collection.count() == 0:
57
  return []
58
 
59
  seen: dict[str, dict] = {}
60
+ for entity in entity_names[:RETRIEVAL_ENTITY_QUERY_CAP]:
61
  raw = collection.query(
62
  query_texts=[entity],
63
  n_results=min(10, collection.count()),
 
65
  )
66
  for r in _parse_raw(raw):
67
  pmid = r["pmid"]
68
+ if pmid not in seen or r["similarity"] > seen[pmid]["similarity"]:
69
  seen[pmid] = r
70
 
71
+ merged = _dedup_by_pmid(list(seen.values()))
72
  return merged[:n_results]
73
 
74
 
75
+ def rrf_merge(
76
+ ranked_lists: list[list[dict]],
77
+ k: int = RRF_K,
78
+ top_n: int = RRF_TOP_N,
79
+ ) -> list[dict]:
80
+ """
81
+ Reciprocal Rank Fusion — combines N ranked lists into one.
82
+ score(pmid) = Σ 1 / (k + rank_in_list_i + 1)
83
+ Preserves the best-scoring dict per PMID from all input lists.
84
+ """
85
+ rrf_scores: dict[str, float] = {}
86
+ best: dict[str, dict] = {}
87
+
88
+ for ranked in ranked_lists:
89
+ for rank, result in enumerate(ranked):
90
+ pmid = result["pmid"]
91
+ rrf_scores[pmid] = rrf_scores.get(pmid, 0.0) + 1.0 / (k + rank + 1)
92
+ if pmid not in best or result["similarity"] > best[pmid]["similarity"]:
93
+ best[pmid] = result
94
+
95
+ sorted_pmids = sorted(rrf_scores, key=lambda p: rrf_scores[p], reverse=True)
96
+ merged = []
97
+ for pmid in sorted_pmids[:top_n]:
98
+ r = best[pmid].copy()
99
+ r["rrf_score"] = round(rrf_scores[pmid], 6)
100
+ merged.append(r)
101
+ return merged
102
+
103
+
104
+ def cross_encoder_rerank(
105
+ model,
106
+ query: str,
107
+ candidates: list[dict],
108
+ top_n: int = CROSS_ENCODER_TOP_N,
109
+ ) -> list[dict]:
110
+ """
111
+ Cross-encoder reranking — scores (query, document) pairs jointly.
112
+ Truncates document text to 1800 chars (~450 tokens) so query+doc fits
113
+ within the ms-marco model's 512-token limit.
114
+ """
115
+ if not candidates:
116
+ return []
117
+
118
+ pairs = [(query, r["document"][:1800]) for r in candidates]
119
+ ce_scores = model.predict(pairs, show_progress_bar=False)
120
+
121
+ for r, score in zip(candidates, ce_scores):
122
+ r["ce_score"] = float(score)
123
+
124
+ candidates.sort(key=lambda x: x["ce_score"], reverse=True)
125
+ return candidates[:top_n]
126
+
127
+
128
+ def apply_citation_boost(results: list[dict]) -> list[dict]:
129
+ """
130
+ Final score = cross_encoder_score × log(citation_count + 2).
131
+ Applied after cross-encoder so citation quality amplifies — not corrupts — relevance.
132
+ log(2) ≈ 0.69 is the floor for uncited papers.
133
+ """
134
+ for r in results:
135
+ base = r.get("ce_score", r.get("similarity", 0.0))
136
+ r["score"] = base * math.log(r["citation_count"] + 2)
137
+ results.sort(key=lambda x: x["score"], reverse=True)
138
+ return results
139
+
140
+
141
  def get_paper(collection: chromadb.Collection, pmid: str) -> dict | None:
142
  """Retrieve a specific paper's abstract chunk by PMID."""
143
  result = collection.get(
 
186
  return results
187
 
188
 
189
+ def _dedup_by_pmid(results: list[dict]) -> list[dict]:
190
+ """Keep best-similarity chunk per paper, sorted by similarity descending."""
191
+ results.sort(key=lambda x: x["similarity"], reverse=True)
 
 
 
 
 
 
 
 
 
 
192
  seen: dict[str, dict] = {}
193
  for r in results:
194
  pmid = r["pmid"]