208 lines
6.1 KiB
Python
208 lines
6.1 KiB
Python
"""
|
|
Query interface for the retrieval layer.
|
|
|
|
``query()`` is the single entry point the CLI (and any caller) uses. It:
|
|
|
|
1. embeds the natural-language query,
|
|
2. runs an ANN search against the FAISS index,
|
|
3. attaches title / year / category / URL / abstract snippet to each hit,
|
|
4. applies the requested filters (year range, primary category, survey-only),
|
|
5. returns a ranked list of ``Result`` objects.
|
|
|
|
If no index exists on disk yet (or building it failed), ``query()`` falls back
|
|
to a **pure-keyword** search that streams the metadata and matches on title/
|
|
abstract with a TF-style score. This guarantees the CLI always returns
|
|
something, even before an embedding index has been built.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
|
|
import numpy as np
|
|
|
|
from data import DataStore, SURVEY_KEYWORDS
|
|
|
|
# Cap the working set for the keyword fallback so it stays fast on the 3.15M-row
|
|
# metadata file (term-overlap scoring doesn't need the full corpus).
|
|
_KEYWORD_WORKING_SET = 200_000
|
|
|
|
|
|
@dataclass
|
|
class Result:
|
|
paper_id: str
|
|
title: str
|
|
year: int
|
|
category: str
|
|
url: str
|
|
snippet: str
|
|
score: float
|
|
method: str # "semantic" | "keyword"
|
|
|
|
|
|
@dataclass
|
|
class QueryOptions:
|
|
top_k: int = 10
|
|
year_min: int | None = None
|
|
year_max: int | None = None
|
|
primary_category: str | None = None
|
|
survey_only: bool = False
|
|
embedder = None # set by caller; optional
|
|
|
|
|
|
def _snippet(abstract: str, maxlen: int = 200) -> str:
|
|
text = re.sub(r"\s+", " ", abstract or "").strip()
|
|
if len(text) <= maxlen:
|
|
return text
|
|
return text[:maxlen].rsplit(" ", 1)[0] + " …"
|
|
|
|
|
|
def _normalize(text: str) -> str:
|
|
return re.sub(r"[^a-z0-9]+", " ", (text or "").lower()).strip()
|
|
|
|
|
|
def _keyword_score(text: str, terms: list[str]) -> float:
|
|
"""TF-style overlap score between a doc and the query terms."""
|
|
words = set(text.split())
|
|
score = sum(1 for t in terms if t in words)
|
|
# small bonus for title matches
|
|
return score
|
|
|
|
|
|
def _keyword_search(
|
|
query: str,
|
|
store: DataStore,
|
|
opts: QueryOptions,
|
|
) -> list[Result]:
|
|
"""
|
|
Pure-keyword fallback: stream matching rows, score by term overlap, filter,
|
|
and return the top-k. Used when no ANN index is available.
|
|
"""
|
|
terms = [t for t in _normalize(query).split() if len(t) > 1]
|
|
if not terms:
|
|
return []
|
|
|
|
# Pull a working set of rows (push the cheap filters into the scan).
|
|
scan = store.scan_filtered(
|
|
year_min=opts.year_min,
|
|
year_max=opts.year_max,
|
|
primary_category=opts.primary_category,
|
|
survey_only=opts.survey_only,
|
|
)
|
|
rows = (
|
|
scan.select(
|
|
["paper_id", "title", "primary_category", "first_version_date", "arxiv_abs_url", "abstract"]
|
|
)
|
|
.limit(_KEYWORD_WORKING_SET)
|
|
.collect()
|
|
.to_dicts()
|
|
)
|
|
if not rows:
|
|
return []
|
|
|
|
scored: list[Result] = []
|
|
for r in rows:
|
|
rec = dict(r)
|
|
title = str(rec["title"] or "")
|
|
abstract = str(rec["abstract"] or "")
|
|
body = f"{title} {abstract}"
|
|
score = _keyword_score(body.lower(), terms)
|
|
if score <= 0:
|
|
continue
|
|
fvd = rec["first_version_date"]
|
|
year = fvd.year if isinstance(fvd, __import__("datetime").datetime) else (int(fvd) if fvd is not None else 0)
|
|
scored.append(
|
|
Result(
|
|
paper_id=str(rec["paper_id"]),
|
|
title=title,
|
|
year=year,
|
|
category=str(rec["primary_category"] or ""),
|
|
url=str(rec["arxiv_abs_url"] or ""),
|
|
snippet=_snippet(abstract),
|
|
score=score,
|
|
method="keyword",
|
|
)
|
|
)
|
|
scored.sort(key=lambda x: x.score, reverse=True)
|
|
return scored[: opts.top_k]
|
|
|
|
|
|
def query(
|
|
query_text: str,
|
|
store: DataStore,
|
|
ann=None,
|
|
embedder=None,
|
|
opts: QueryOptions | None = None,
|
|
) -> list[Result]:
|
|
"""
|
|
Run a search. Tries the ANN index first; falls back to keyword search.
|
|
|
|
Returns a list of ranked ``Result`` objects.
|
|
"""
|
|
opts = opts or QueryOptions()
|
|
query_text = query_text or ""
|
|
|
|
# --- semantic path ---
|
|
if ann is not None and embedder is not None:
|
|
try:
|
|
return _semantic_query(query_text, store, ann, embedder, opts)
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
print(f"[query] ANN search failed ({exc}); falling back to keyword search.")
|
|
|
|
# --- keyword fallback ---
|
|
return _keyword_search(query_text, store, opts)
|
|
|
|
|
|
def _semantic_query(
|
|
query_text: str,
|
|
store: DataStore,
|
|
ann,
|
|
embedder,
|
|
opts: QueryOptions,
|
|
) -> list[Result]:
|
|
import faiss
|
|
|
|
qvec = np.ascontiguousarray(embedder.embed_one(query_text), dtype=np.float32)
|
|
k = min(opts.top_k * 8, len(ann), ann.index.ntotal)
|
|
k = max(k, 1)
|
|
dists, idx = ann.index.search(qvec.reshape(1, -1), k)
|
|
dists = dists[0]
|
|
idx = idx[0]
|
|
|
|
# Gather candidate hits and apply filters.
|
|
candidates: list[Result] = []
|
|
for d, i in zip(dists.tolist(), idx.tolist()):
|
|
if i < 0 or i >= len(ann._ids):
|
|
continue
|
|
pid = ann._ids[i]
|
|
year = ann._years[i]
|
|
cat = ann._cats[i]
|
|
if opts.year_min is not None and year < opts.year_min:
|
|
continue
|
|
if opts.year_max is not None and year > opts.year_max:
|
|
continue
|
|
if opts.primary_category and cat != opts.primary_category:
|
|
continue
|
|
if opts.survey_only:
|
|
if not any(kw in ann._abstracts[i].lower() for kw in SURVEY_KEYWORDS):
|
|
continue
|
|
# FAISS L2 distance -> similarity in [0,1]; closer => higher.
|
|
sim = 1.0 / (1.0 + float(d))
|
|
candidates.append(
|
|
Result(
|
|
paper_id=pid,
|
|
title=ann._titles[i],
|
|
year=year,
|
|
category=cat,
|
|
url=ann._urls[i],
|
|
snippet=_snippet(ann._abstracts[i]),
|
|
score=sim,
|
|
method="semantic",
|
|
)
|
|
)
|
|
|
|
candidates.sort(key=lambda x: x.score, reverse=True)
|
|
return candidates[: opts.top_k]
|