foto / retrieval.py
htohfa's picture
Upload 4 files
c51da3c verified
Raw
History Blame Contribute Delete
7.41 kB
"""Figure retrieval over the caption index.
Supports three things beyond plain nearest-neighbour search:
multi-query fusion - search with several phrasings of the same request and
combine the ranked lists with reciprocal rank fusion
author filter - restrict the search to papers by named authors, applied
inside FAISS as an exact ID constraint, not a post-hoc
trim, so the top k is the top k among that author's
figures rather than whatever survived a global search
author boost - softly promote papers by named authors without excluding
anything else (off unless you pass boost_authors)
Author names are matched loosely: "McQuinn", "M. McQuinn" and "Matthew McQuinn"
all match the same person. A name that matches nobody in the corpus contributes
nothing, so a wrong or invented name degrades to a no-op rather than corrupting
the result.
"""
import json
import re
import unicodedata
from collections import defaultdict
from pathlib import Path
import numpy as np
import pyarrow.parquet as pq
RRF_K = 60
BOOST_WEIGHT = 0.25
def normalize_name(name: str) -> str:
"""Lowercase, strip accents and punctuation, and put the family name last.
Handles both "Given Family" (as Semantic Scholar returns) and
"Family, Given" (as people often type), so the two normalize alike.
"""
name = unicodedata.normalize("NFKD", name)
name = "".join(c for c in name if not unicodedata.combining(c))
name = name.lower()
if "," in name:
family, _, given = name.partition(",")
name = f"{given} {family}"
name = re.sub(r"['\u2019\-]", "", name)
name = re.sub(r"[^a-z0-9 ]", " ", name)
return re.sub(r"\s+", " ", name).strip()
def last_name(name: str) -> str:
parts = normalize_name(name).split()
return parts[-1] if parts else ""
class FigureIndex:
def __init__(self, index_dir, authors_path=None):
import faiss
index_dir = Path(index_dir)
self.index = faiss.read_index(str(index_dir / "index.faiss"))
self.meta = pq.read_table(index_dir / "meta.parquet").to_pylist()
info_path = index_dir / "info.json"
self.info = (json.load(info_path.open()) if info_path.exists()
else {"backend": "openai", "model": "text-embedding-3-small",
"dim": self.index.d})
self._embedder = None
self.rows_by_paper = defaultdict(list)
for row, m in enumerate(self.meta):
self.rows_by_paper[m["arxiv_id"]].append(row)
self.papers_by_lastname = defaultdict(set)
self.authors_by_paper = {}
if authors_path and Path(authors_path).exists():
table = pq.read_table(authors_path).to_pylist()
for entry in table:
names = entry["authors"] or []
if not names:
continue
self.authors_by_paper[entry["arxiv_id"]] = names
for n in names:
self.papers_by_lastname[last_name(n)].add(entry["arxiv_id"])
@property
def embedder(self):
if self._embedder is None:
from embedders import embedder_from_info
self._embedder = embedder_from_info(self.info)
return self._embedder
def papers_for_authors(self, queries: list[str]) -> set:
"""arXiv IDs whose author list matches any of the given names."""
matched = set()
for q in queries:
qn = normalize_name(q)
if not qn:
continue
candidates = self.papers_by_lastname.get(last_name(q), set())
for arxiv_id in candidates:
for full in self.authors_by_paper.get(arxiv_id, []):
fn = normalize_name(full)
if qn == fn or qn == last_name(full):
matched.add(arxiv_id)
break
q_parts, f_parts = qn.split(), fn.split()
if (len(q_parts) > 1 and q_parts[-1] == f_parts[-1]
and q_parts[0][0] == f_parts[0][0]):
matched.add(arxiv_id)
break
return matched
def _row_selector(self, papers: set):
import faiss
rows = []
for arxiv_id in papers:
rows.extend(self.rows_by_paper.get(arxiv_id, []))
if not rows:
return None, 0
ids = np.array(sorted(rows), dtype="int64")
return faiss.SearchParameters(sel=faiss.IDSelectorBatch(ids)), len(ids)
def search(self, query: str, k: int = 20, variants=None,
filter_authors=None, boost_authors=None, depth=None):
"""Return up to k figure matches, best first.
variants: extra phrasings of the same query, fused with RRF
filter_authors: hard restriction to papers by these authors
boost_authors: soft promotion of papers by these authors
depth: per-query retrieval depth before fusion (default 5k)
"""
texts = [query] + list(variants or [])
depth = depth or max(k * 5, 100)
params = None
if filter_authors:
papers = self.papers_for_authors(filter_authors)
params, n_rows = self._row_selector(papers)
if params is None:
return []
depth = min(depth, n_rows)
Q = self.embedder.embed(texts, is_query=True)
if params is not None:
sims, ids = self.index.search(Q, depth, params=params)
else:
sims, ids = self.index.search(Q, depth)
scores = defaultdict(float)
best_sim = {}
for qi in range(len(texts)):
for rank, row in enumerate(ids[qi]):
if row < 0:
continue
row = int(row)
scores[row] += 1.0 / (RRF_K + rank + 1)
sim = float(sims[qi][rank])
if sim > best_sim.get(row, -1e9):
best_sim[row] = sim
if boost_authors:
boosted = self.papers_for_authors(boost_authors)
if boosted:
bonus = BOOST_WEIGHT / RRF_K
for row in list(scores):
if self.meta[row]["arxiv_id"] in boosted:
scores[row] += bonus
ranked = sorted(scores.items(), key=lambda kv: -kv[1])[:k]
out = []
for row, score in ranked:
m = self.meta[row]
out.append({
"arxiv_id": m["arxiv_id"],
"fig_idx": m["fig_idx"],
"caption": m.get("caption", ""),
"authors": self.authors_by_paper.get(m["arxiv_id"], []),
"fusion_score": score,
"similarity": best_sim.get(row, 0.0),
})
return out
def search_rows(self, query: str, k: int, variants=None,
filter_authors=None, boost_authors=None, depth=None):
"""Same as search() but returns raw meta row indices, for evaluation."""
hits = self.search(query, k=k, variants=variants,
filter_authors=filter_authors,
boost_authors=boost_authors, depth=depth)
return [(h["arxiv_id"], h["fig_idx"]) for h in hits]