MukulRay commited on
Commit
22e9366
Β·
1 Parent(s): 2a79143

Phase 3: retriever utils, hybrid scoring, DDG+S2 wrappers, disk cache

Browse files
data/cache/058352a518feab93e76651fe9a922736.json ADDED
@@ -0,0 +1 @@
 
 
1
+ [{"url": "https://arxiv.org/html/2310.07240v6", "snippet": "name :KVCacheCompressionand Streaming for FastLargeLanguageModelServing ...largelanguagemodels(LLMs) are ubiquitously used (Anastasiya, ...", "title": "\\name: KV Cache Compression and Streaming for Fast Large", "inferred_year": null, "hybrid_score": 0.0, "source": "duckduckgo"}, {"url": "https://arxiv.org/html/2410.00161v2", "snippet": "We propose query-group-compression, a simple yet effective method tocompresstheKVcacheof GQAmodelswithout repeating it into the dimension of ...", "title": "KV-Compress: Paged KV-Cache Compression with Variable", "inferred_year": null, "hybrid_score": 0.0, "source": "duckduckgo"}, {"url": "https://arxiv.org/html/2509.05165v2", "snippet": "Largelanguagemodels(LLMs) rely on key-value (KV)cachesfor efficient autoregressive decoding; however,cachesize grows linearly with context ...", "title": "KVCompose: Efficient Structured KV Cache Compression with", "inferred_year": null, "hybrid_score": 0.0, "source": "duckduckgo"}]
requirements.txt CHANGED
@@ -4,7 +4,7 @@ langchain-groq
4
  langchain-huggingface
5
  sentence-transformers
6
  semanticscholar
7
- duckduckgo-search
8
  tavily-python
9
  ragas
10
  datasets
 
4
  langchain-huggingface
5
  sentence-transformers
6
  semanticscholar
7
+ ddgs
8
  tavily-python
9
  ragas
10
  datasets
src/retriever_utils.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ import math
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ from datetime import datetime
8
+ from typing import Optional
9
+
10
+ from dotenv import load_dotenv
11
+ from sentence_transformers import SentenceTransformer
12
+ from sklearn.metrics.pairwise import cosine_similarity
13
+ import numpy as np
14
+
15
+ from src.state import Paper, WebResult
16
+
17
+ load_dotenv()
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Embedding model β€” loaded once at module level (CPU, fast)
23
+ # ---------------------------------------------------------------------------
24
+ _embedder: Optional[SentenceTransformer] = None
25
+
26
+ def get_embedder() -> SentenceTransformer:
27
+ global _embedder
28
+ if _embedder is None:
29
+ _embedder = SentenceTransformer("all-MiniLM-L6-v2")
30
+ return _embedder
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Disk cache β€” prevents re-fetching on eval loop crashes
35
+ # ---------------------------------------------------------------------------
36
+ _CACHE_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "cache")
37
+ os.makedirs(_CACHE_DIR, exist_ok=True)
38
+
39
+ def _cache_key(text: str) -> str:
40
+ return hashlib.md5(text.encode()).hexdigest()
41
+
42
+ def _cache_get(key: str) -> Optional[list]:
43
+ path = os.path.join(_CACHE_DIR, f"{key}.json")
44
+ if os.path.exists(path):
45
+ with open(path) as f:
46
+ return json.load(f)
47
+ return None
48
+
49
+ def _cache_set(key: str, data: list) -> None:
50
+ path = os.path.join(_CACHE_DIR, f"{key}.json")
51
+ with open(path, "w") as f:
52
+ json.dump(data, f)
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Recency scoring β€” three formulas for ablation study
57
+ # ---------------------------------------------------------------------------
58
+ CURRENT_YEAR = datetime.now().year
59
+
60
+ def recency_score(year: int, decay_config: str = "linear") -> float:
61
+ """
62
+ Returns a 0–1 recency score for a paper given its publication year.
63
+ decay_config: "none" | "linear" | "log"
64
+ """
65
+ if year is None or year == 0:
66
+ return 0.0
67
+ age = max(0, CURRENT_YEAR - year)
68
+
69
+ if decay_config == "none":
70
+ return 1.0
71
+ elif decay_config == "linear":
72
+ return max(0.0, 1.0 - (age / 20.0))
73
+ elif decay_config == "log":
74
+ return max(0.0, 1.0 - math.log1p(age) / math.log1p(20))
75
+ else:
76
+ return max(0.0, 1.0 - (age / 20.0)) # default to linear
77
+
78
+
79
+ def authority_score(citation_count: int) -> float:
80
+ """Normalize citation count to 0–1 using log scale."""
81
+ if citation_count <= 0:
82
+ return 0.0
83
+ return min(1.0, math.log1p(citation_count) / math.log1p(10000))
84
+
85
+
86
+ def hybrid_score(
87
+ semantic_sim: float,
88
+ year: int,
89
+ citation_count: int,
90
+ decay_config: str = "linear",
91
+ ) -> float:
92
+ """
93
+ final_score = semantic_sim Γ— 0.5 + recency Γ— 0.3 + authority Γ— 0.2
94
+ Weights chosen by ablation study (see eval/).
95
+ """
96
+ r = recency_score(year, decay_config)
97
+ a = authority_score(citation_count)
98
+ return round(semantic_sim * 0.5 + r * 0.3 + a * 0.2, 4)
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Semantic Scholar search
103
+ # ---------------------------------------------------------------------------
104
+ def search_semantic_scholar(
105
+ query: str,
106
+ limit: int = 5,
107
+ decay_config: str = "linear",
108
+ use_cache: bool = True,
109
+ ) -> list[Paper]:
110
+ """
111
+ Search Semantic Scholar for papers matching the query.
112
+ Returns a list of Paper objects sorted by hybrid_score descending.
113
+ Rate limit: 100 req/5 min (with key), ~10/min (without).
114
+ sleep(3) is applied on every call to stay safe.
115
+ """
116
+ cache_key = _cache_key(f"s2_{query}_{limit}")
117
+ if use_cache:
118
+ cached = _cache_get(cache_key)
119
+ if cached:
120
+ logger.info(f"S2 cache hit: {query[:50]}")
121
+ return [Paper(**p) for p in cached]
122
+
123
+ s2_key = os.getenv("S2_API_KEY")
124
+
125
+ try:
126
+ import semanticscholar as ss
127
+ if s2_key:
128
+ sch = ss.SemanticScholar(api_key=s2_key)
129
+ else:
130
+ sch = ss.SemanticScholar()
131
+
132
+ time.sleep(3) # rate limit guard β€” always
133
+
134
+ results = sch.search_paper(
135
+ query,
136
+ limit=limit,
137
+ fields=["title", "abstract", "year", "citationCount",
138
+ "authors", "references", "paperId"],
139
+ )
140
+
141
+ except Exception as e:
142
+ logger.warning(f"S2 search failed for '{query}': {e}")
143
+ return []
144
+
145
+ if not results:
146
+ return []
147
+
148
+ # Embed query once, then score all abstracts
149
+ embedder = get_embedder()
150
+ query_vec = embedder.encode([query])
151
+
152
+ papers = []
153
+ for r in results:
154
+ if not r.abstract:
155
+ continue
156
+
157
+ abstract_vec = embedder.encode([r.abstract])
158
+ sim = float(cosine_similarity(query_vec, abstract_vec)[0][0])
159
+
160
+ year = r.year or 0
161
+ citations = r.citationCount or 0
162
+ authors = [a["name"] for a in (r.authors or [])]
163
+ references = [ref["paperId"] for ref in (r.references or [])
164
+ if ref.get("paperId")]
165
+
166
+ paper = Paper(
167
+ title=r.title or "Untitled",
168
+ abstract=r.abstract,
169
+ year=year,
170
+ citation_count=citations,
171
+ paper_id=r.paperId or "",
172
+ authors=authors,
173
+ references=references,
174
+ hybrid_score=hybrid_score(sim, year, citations, decay_config),
175
+ source="semantic_scholar",
176
+ )
177
+ papers.append(paper)
178
+
179
+ papers.sort(key=lambda p: p.hybrid_score, reverse=True)
180
+
181
+ if use_cache:
182
+ _cache_set(cache_key, [p.__dict__ for p in papers])
183
+
184
+ return papers
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # DuckDuckGo web search (with Tavily fallback)
189
+ # ---------------------------------------------------------------------------
190
+ def search_web(
191
+ query: str,
192
+ limit: int = 5,
193
+ use_cache: bool = True,
194
+ ) -> list[WebResult]:
195
+ """
196
+ Search the web via DuckDuckGo. Falls back to Tavily if DDG fails.
197
+ Returns a list of WebResult objects.
198
+ """
199
+ cache_key = _cache_key(f"web_{query}_{limit}")
200
+ if use_cache:
201
+ cached = _cache_get(cache_key)
202
+ if cached:
203
+ logger.info(f"Web cache hit: {query[:50]}")
204
+ return [WebResult(**r) for r in cached]
205
+
206
+ results = _ddg_search(query, limit)
207
+
208
+ if not results:
209
+ logger.warning(f"DDG failed for '{query}', trying Tavily fallback")
210
+ results = _tavily_search(query, limit)
211
+
212
+ if use_cache and results:
213
+ _cache_set(cache_key, [r.__dict__ for r in results])
214
+
215
+ return results
216
+
217
+
218
+ def _ddg_search(query: str, limit: int) -> list[WebResult]:
219
+ try:
220
+ from ddgs import DDGS
221
+ time.sleep(1)
222
+ # Force English results, safesearch off, recent results
223
+ search_query = f"{query} research paper arxiv"
224
+ with DDGS() as ddgs:
225
+ raw = list(ddgs.text(
226
+ search_query,
227
+ max_results=limit,
228
+ region="wt-wt", # worldwide β€” avoids regional override
229
+ safesearch="off",
230
+ ))
231
+ results = []
232
+ for r in raw:
233
+ year = _infer_year(r.get("body", ""))
234
+ results.append(WebResult(
235
+ url=r.get("href", ""),
236
+ snippet=r.get("body", "")[:500],
237
+ title=r.get("title", ""),
238
+ inferred_year=year,
239
+ source="duckduckgo",
240
+ ))
241
+ return results
242
+ except Exception as e:
243
+ logger.warning(f"DDG error: {e}")
244
+ return []
245
+
246
+
247
+ def _tavily_search(query: str, limit: int) -> list[WebResult]:
248
+ tavily_key = os.getenv("TAVILY_API_KEY")
249
+ if not tavily_key:
250
+ return []
251
+ try:
252
+ from tavily import TavilyClient
253
+ client = TavilyClient(api_key=tavily_key)
254
+ response = client.search(query, max_results=limit)
255
+ results = []
256
+ for r in response.get("results", []):
257
+ year = _infer_year(r.get("content", ""))
258
+ results.append(WebResult(
259
+ url=r.get("url", ""),
260
+ snippet=r.get("content", "")[:500],
261
+ title=r.get("title", ""),
262
+ inferred_year=year,
263
+ source="tavily",
264
+ ))
265
+ return results
266
+ except Exception as e:
267
+ logger.warning(f"Tavily error: {e}")
268
+ return []
269
+
270
+
271
+ def _infer_year(text: str) -> Optional[int]:
272
+ """Try to extract a 4-digit year (2000–2026) from a text snippet."""
273
+ import re
274
+ matches = re.findall(r"\b(20[0-2][0-9])\b", text)
275
+ if matches:
276
+ years = [int(y) for y in matches]
277
+ return max(years)
278
+ return None
279
+
280
+
281
+ # ---------------------------------------------------------------------------
282
+ # Citation graph builder
283
+ # ---------------------------------------------------------------------------
284
+ def build_citation_graph(papers: list[Paper]) -> dict:
285
+ """
286
+ Build a citation graph from retrieved papers.
287
+ Returns {paper_id: [list of referenced paper_ids that are also in our set]}
288
+ Only includes edges where both source and target are in our retrieved set.
289
+ """
290
+ paper_ids = {p.paper_id for p in papers}
291
+ graph = {}
292
+ for p in papers:
293
+ graph[p.paper_id] = [
294
+ ref for ref in p.references
295
+ if ref in paper_ids
296
+ ]
297
+ return graph
test_phase3.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, logging
2
+ sys.path.insert(0, ".")
3
+ logging.basicConfig(level=logging.WARNING)
4
+
5
+ from src.retriever_utils import (
6
+ search_semantic_scholar,
7
+ search_web,
8
+ build_citation_graph,
9
+ hybrid_score,
10
+ recency_score,
11
+ )
12
+
13
+ print("=== Phase 3: Retriever Utils ===\n")
14
+
15
+ # Test 1: Scoring functions
16
+ print("--- Scoring functions ---")
17
+ print(f"βœ“ recency linear year=2024: {recency_score(2024, 'linear'):.3f}")
18
+ print(f"βœ“ recency linear year=2019: {recency_score(2019, 'linear'):.3f}")
19
+ print(f"βœ“ recency log year=2019: {recency_score(2019, 'log'):.3f}")
20
+ print(f"βœ“ recency none year=2019: {recency_score(2019, 'none'):.3f}")
21
+ print(f"βœ“ hybrid score (sim=0.8, year=2023, citations=500): {hybrid_score(0.8, 2023, 500):.4f}")
22
+
23
+ # Test 2: Semantic Scholar
24
+ print("\n--- Semantic Scholar ---")
25
+ papers = search_semantic_scholar("KV cache compression LLM", limit=3)
26
+ if papers:
27
+ for p in papers:
28
+ print(f" βœ“ [{p.hybrid_score:.3f}] {p.title[:60]} ({p.year})")
29
+ else:
30
+ print(" ⚠ No results (S2 key may not be active yet β€” expected)")
31
+
32
+ # Test 3: Web search
33
+ print("\n--- DuckDuckGo ---")
34
+ results = search_web("KV cache compression large language models 2024", limit=3)
35
+ if results:
36
+ for r in results:
37
+ print(f" βœ“ [{r.source}] {r.title[:60]}")
38
+ else:
39
+ print(" ⚠ No results from DDG or Tavily")
40
+
41
+ # Test 4: Citation graph
42
+ print("\n--- Citation graph ---")
43
+ if papers:
44
+ graph = build_citation_graph(papers)
45
+ print(f" βœ“ Graph nodes: {len(graph)}")
46
+ edges = sum(len(v) for v in graph.values())
47
+ print(f" βœ“ Internal edges: {edges}")
48
+ else:
49
+ print(" ⚠ Skipped (no papers retrieved)")
50
+
51
+ print("\nβœ… Phase 3 complete")