Skip to content

Commit d950c14

Browse files
committed
Strengthen search relevance ranking (stemming, phrase, synonyms, CJK)
Relevance was exact lowercase-token overlap, so "transformer" missed "transformers", multi-word queries got no adjacency credit, acronyms never matched their expansions, and CJK queries got no signal at all. - conservative English stemming, min-stem >= 4 guards over-stripping - adjacency bonus so a query's phrase outranks scattered terms - small acronym <-> expansion synonym map (llm, rag, gnn, ...), expanding documents not the query so the relevance denominator stays honest - CJK character bigrams so Chinese / Japanese / Korean queries rank too Adds unit tests plus a golden-query regression set that pins relevance dominance over a 180k-citation off-topic paper.
1 parent f0c8471 commit d950c14

2 files changed

Lines changed: 298 additions & 16 deletions

File tree

tests/test_dedup_ranking.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
import pytest
6+
57
from thesisagents.core.dedup import dedupe
68
from thesisagents.core.models import Paper
79
from thesisagents.core.ranking import rank
@@ -193,3 +195,126 @@ def test_rank_short_query_terms_are_dropped():
193195
miss = _paper(source_id="miss", title="A study of birds", year=2024)
194196
ordered = rank([miss, hit], keywords="a llm of", current_year=2025)
195197
assert ordered[0].source_id == "hit"
198+
199+
200+
def test_rank_stemming_matches_plural():
201+
"""A singular query term matches a plural/inflected title — same concept.
202+
203+
Without stemming, "transformer" would miss "Transformers" entirely and the
204+
on-topic paper would sort below an unrelated one on recency/citation alone.
205+
"""
206+
hit = _paper(source_id="hit", title="Transformers in Vision", year=2024)
207+
miss = _paper(source_id="miss", title="Graph Theory Basics", year=2024)
208+
ordered = rank([miss, hit], keywords="transformer", current_year=2025)
209+
assert ordered[0].source_id == "hit"
210+
211+
212+
def test_rank_phrase_adjacency_bonus():
213+
"""Two titles share all query words, but the one where they appear ADJACENT
214+
(a real phrase) ranks above the one where they are scattered."""
215+
phrase = _paper(
216+
source_id="phrase", title="Retrieval-Augmented Generation", year=2024,
217+
)
218+
scattered = _paper(
219+
source_id="scattered", title="Generation Augmented by Retrieval", year=2024,
220+
)
221+
ordered = rank(
222+
[scattered, phrase],
223+
keywords="retrieval augmented generation", current_year=2025,
224+
)
225+
assert ordered[0].source_id == "phrase"
226+
227+
228+
def test_rank_synonym_acronym_expands():
229+
"""A query for an acronym matches a title that only writes the long form."""
230+
hit = _paper(
231+
source_id="hit", title="A Survey of Large Language Models", year=2024,
232+
)
233+
miss = _paper(source_id="miss", title="Image Segmentation Methods", year=2024)
234+
ordered = rank([miss, hit], keywords="llm", current_year=2025)
235+
assert ordered[0].source_id == "hit"
236+
237+
238+
def test_rank_synonym_does_not_overmatch_shared_word():
239+
"""A lone shared word ("language") must NOT inject an unrelated acronym:
240+
a query for "nlp" must not match a pure "language model" title."""
241+
lang_only = _paper(
242+
source_id="lang", title="Sign Language Recognition", year=2024,
243+
)
244+
nlp_paper = _paper(
245+
source_id="nlp", title="Natural Language Processing Advances", year=2024,
246+
)
247+
ordered = rank([lang_only, nlp_paper], keywords="nlp", current_year=2025)
248+
assert ordered[0].source_id == "nlp"
249+
250+
251+
def test_rank_cjk_query_has_relevance():
252+
"""A Chinese-keyword search gets a real relevance signal (CJK bigrams), so an
253+
on-topic Chinese title beats an off-topic one rather than tying on recency."""
254+
hit = _paper(source_id="hit", title="視覺注意力機制研究", year=2024)
255+
miss = _paper(source_id="miss", title="區塊鏈技術概論", year=2024)
256+
ordered = rank([miss, hit], keywords="注意力機制", current_year=2025)
257+
assert ordered[0].source_id == "hit"
258+
259+
260+
def test_rank_stemming_does_not_overstrip():
261+
"""The min-stem-length guard keeps short words intact: "ring" must stay
262+
"ring" (not collapse to "r"), so it still matches a "Ring ..." title and
263+
"Rendering" correctly stems to "render" without colliding."""
264+
hit = _paper(source_id="hit", title="Ring Signatures", year=2024)
265+
miss = _paper(source_id="miss", title="Rendering Pipelines", year=2024)
266+
ordered = rank([miss, hit], keywords="ring", current_year=2025)
267+
assert ordered[0].source_id == "hit"
268+
269+
270+
# --- Golden-query regression set -------------------------------------------
271+
# A realistic candidate pool ranked end-to-end (relevance + recency + citation
272+
# together), so a future change to the weights / stemmer / synonyms that quietly
273+
# degrades real-world ranking is caught — not just the isolated-axis unit tests
274+
# above. The invariant each case pins: the on-topic paper takes the #1 slot even
275+
# though the pool contains a 180k-citation OFF-topic paper (resnet) — i.e.
276+
# relevance still dominates raw citation count. (Below #1, the off-topic paper
277+
# may legitimately fill a slot on a query where only one paper is on-topic; that
278+
# is correct recency+citation behaviour, so the test pins #1, not the whole top
279+
# 3.) The gaps are wide enough to survive small weight retunes; if one breaks,
280+
# the ranking changed materially and the expectation should be re-justified.
281+
def _golden_pool() -> list[Paper]:
282+
return [
283+
_paper(source_id="attn", title="Attention Is All You Need",
284+
year=2017, citation_count=90000),
285+
_paper(source_id="xformer_survey", title="A Survey of Transformer Architectures",
286+
year=2024, citation_count=120),
287+
_paper(source_id="resnet", title="Deep Residual Learning for Image Recognition",
288+
year=2016, citation_count=180000),
289+
_paper(source_id="rag",
290+
title="Retrieval-Augmented Generation for Knowledge-Intensive NLP",
291+
year=2020, citation_count=5000),
292+
_paper(source_id="gpt3", title="Large Language Models are Few-Shot Learners",
293+
year=2020, citation_count=30000),
294+
_paper(source_id="gnn", title="Graph Neural Networks: A Comprehensive Review",
295+
year=2021, citation_count=800),
296+
]
297+
298+
299+
@pytest.mark.parametrize(
300+
("query", "expected_top"),
301+
[
302+
# Plural/inflected title match (stemming): "transformer" -> "Transformer".
303+
("transformer", "xformer_survey"),
304+
# Exact multi-word phrase wins on adjacency + full overlap.
305+
("retrieval augmented generation", "rag"),
306+
# Acronym synonym: "llm" surfaces the long-form "Large Language Models".
307+
("llm", "gpt3"),
308+
# Plural in title ("Networks") + phrase; on-topic beats the 180k-cite resnet.
309+
("graph neural network", "gnn"),
310+
],
311+
)
312+
def test_rank_golden_queries(query, expected_top):
313+
ordered = rank(_golden_pool(), keywords=query, current_year=2026)
314+
top_ids = [p.source_id for p in ordered]
315+
assert top_ids[0] == expected_top, f"{query!r} -> {top_ids[:3]}"
316+
# Relevance dominance: the on-topic paper outranks the 180k-citation
317+
# off-topic paper, even with a ~400x citation disadvantage in some cases.
318+
assert top_ids.index(expected_top) < top_ids.index("resnet"), (
319+
f"off-topic resnet outranked {expected_top!r} for {query!r}: {top_ids}"
320+
)

thesisagents/core/ranking.py

Lines changed: 173 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,23 @@
1313
* **relevance** (dominant) — overlap between the query keywords and the paper's
1414
title (weighted heavily) + abstract (weighted lightly). Research starts from
1515
"is this on my topic?", so an on-topic paper should beat an off-topic one even
16-
when the off-topic one is older-and-more-cited.
16+
when the off-topic one is older-and-more-cited. The relevance axis goes beyond
17+
exact word matching in four ways so it does not silently miss on-topic papers:
18+
19+
1. **Light stemming** — a query for ``transformer`` matches a ``Transformers``
20+
title (a plural/inflection difference is the same concept). Stemming is
21+
deliberately conservative: only a small whitelist of suffixes is stripped,
22+
and only when the remaining stem stays ``>= _MIN_STEM_LEN`` so short words
23+
(``bias``, ``ring``, ``gas``) are never mangled into a false match.
24+
2. **Phrase adjacency bonus** — for a multi-word query, a title where the
25+
query words appear *adjacent* (``Retrieval-Augmented Generation``) scores
26+
above one where they are merely *scattered* across the title.
27+
3. **Acronym synonyms** — a query for ``llm`` matches a title that only ever
28+
writes ``large language model`` (and vice-versa), via a small curated map.
29+
4. **CJK support** — Chinese/Japanese/Korean runs are tokenised into character
30+
bigrams, so a Chinese-keyword search gets a real relevance signal instead
31+
of falling back to recency+citation only (the prior ``[a-z0-9]+`` tokenizer
32+
dropped every CJK character).
1733
* **recency** — exponential decay over paper age (~5-year scale).
1834
* **citation** — ``log10`` of the citation count (diminishing returns), then
1935
damped by ``_CITATION_WEIGHT`` so a huge citation count is a strong tie-break
@@ -40,14 +56,121 @@
4056
# (citation term ≈ _CITATION_WEIGHT * log10(1e5) = 0.4 * 5 = 2.0 < 3.0).
4157
_TITLE_WEIGHT = 3.0
4258
_ABSTRACT_WEIGHT = 0.6
59+
# Bonus when the query's adjacent word pairs (bigrams) also appear adjacent in
60+
# the title. Smaller than _TITLE_WEIGHT so it only *re-orders* papers that
61+
# already share the query words — a phrase match is a tie-break in favour of the
62+
# more on-topic title, not a signal that can outweigh actual word overlap.
63+
_PHRASE_WEIGHT = 1.0
4364
# Damping on the log10 citation term. Keeps "most-cited" a meaningful tie-break
4465
# among similarly-relevant papers without letting it swamp the topic signal.
4566
_CITATION_WEIGHT = 0.4
4667
# Query/title tokens shorter than this are dropped as stop-word-ish noise
4768
# ("a", "of", "is", "the"). 3 keeps useful short terms like "llm", "rag", "gan".
4869
_MIN_TERM_LEN = 3
70+
# A suffix is only stripped when the remaining stem stays at least this long.
71+
# Guards against over-stripping short words into spurious collisions, e.g.
72+
# "ring" -> "r", "bias" -> "bia", "gas" -> "ga". 4 keeps stemming useful for
73+
# real content words ("transformers" -> "transformer", "learning" -> "learn")
74+
# while leaving every short word untouched.
75+
_MIN_STEM_LEN = 4
76+
77+
# One regex, two alternatives: an ASCII alphanumeric run, OR a run of CJK
78+
# characters (CJK unified incl. Ext-A, hiragana, katakana, hangul syllables,
79+
# CJK compatibility ideographs). Matching both in one pass keeps token order so
80+
# adjacency (the phrase bonus) is computed across the original sequence.
81+
_TOKEN_RE = re.compile(
82+
r"[a-z0-9]+"
83+
r"|[぀-ヿ㐀-鿿가-힯豈-﫿]+"
84+
)
85+
86+
# Conservative English suffix rules, longest/most-specific first so "ies" wins
87+
# over "s" ("studies" -> "study", not "studie"). Each maps a suffix to its
88+
# replacement. Only applied when the resulting stem stays >= _MIN_STEM_LEN.
89+
_SUFFIX_RULES: tuple[tuple[str, str], ...] = (
90+
("ies", "y"),
91+
("es", ""),
92+
("ed", ""),
93+
("ing", ""),
94+
("s", ""),
95+
)
96+
97+
# Acronym <-> expansion synonyms. Each entry lets a search for the short form
98+
# surface papers that only write the long form, and vice-versa.
99+
# Why: without this the relevance axis misses a whole class of on-topic papers
100+
# (a "llm" search never matching a "Large Language Models: A Survey" title).
101+
# Only acronyms of length >= _MIN_TERM_LEN are listed — shorter ones ("rl",
102+
# "ml") would be dropped by the stop-word floor before they could be matched.
103+
_SYNONYM_GROUPS: tuple[tuple[str, str], ...] = (
104+
("llm", "large language model"),
105+
("rag", "retrieval augmented generation"),
106+
("gnn", "graph neural network"),
107+
("cnn", "convolutional neural network"),
108+
("rnn", "recurrent neural network"),
109+
("nlp", "natural language processing"),
110+
("vlm", "vision language model"),
111+
("gan", "generative adversarial network"),
112+
)
113+
114+
115+
def _stem(token: str) -> str:
116+
"""Conservatively normalise one token's English inflection.
117+
118+
CJK bigrams, digits, and mixed alphanumerics pass through unchanged — only
119+
pure ASCII alphabetic tokens are stemmed, and only when the stem stays
120+
``>= _MIN_STEM_LEN``. A trailing "ss" (``process``, ``address``) is left
121+
alone so the plural "s" rule does not bite into a doubled consonant.
122+
123+
Example: ``_stem("transformers") == "transformer"``;
124+
``_stem("ring") == "ring"`` (stripping "ing" -> "r" fails the length guard).
125+
"""
126+
if not token.isascii() or not token.isalpha():
127+
return token
128+
for suffix, repl in _SUFFIX_RULES:
129+
if suffix == "s" and token.endswith("ss"):
130+
continue
131+
if token.endswith(suffix):
132+
stem = token[: len(token) - len(suffix)] + repl
133+
return stem if len(stem) >= _MIN_STEM_LEN else token
134+
return token
49135

50-
_WORD_RE = re.compile(r"[a-z0-9]+")
136+
137+
def _ordered_tokens(text: str) -> list[str]:
138+
"""Position-ordered, stemmed, stop-word-filtered tokens of ``text``.
139+
140+
ASCII runs become stemmed words kept only when ``len >= _MIN_TERM_LEN``; CJK
141+
runs become character bigrams (each length 2, always kept). Order is
142+
preserved across scripts so the bigram (phrase) pass sees real adjacency.
143+
"""
144+
out: list[str] = []
145+
for match in _TOKEN_RE.finditer(text.lower()):
146+
chunk = match.group()
147+
if chunk[0].isascii():
148+
stem = _stem(chunk)
149+
if len(stem) >= _MIN_TERM_LEN:
150+
out.append(stem)
151+
elif len(chunk) == 1:
152+
out.append(chunk)
153+
else:
154+
out.extend(chunk[i : i + 2] for i in range(len(chunk) - 1))
155+
return out
156+
157+
158+
# Acronym -> stemmed long-form tokens. One direction only: an acronym is
159+
# specific, so seeing "llm" in a document safely implies "large language model".
160+
# The REVERSE (long form -> acronym) deliberately is NOT a per-token map — a lone
161+
# shared word like "language" must not inject "nlp"/"vlm"; it requires the whole
162+
# long form and is handled by _SYNONYM_LONG_TO_SHORT below.
163+
_SYNONYM_EXPAND: dict[str, tuple[str, ...]] = {
164+
short: tuple(_ordered_tokens(long_form))
165+
for short, long_form in _SYNONYM_GROUPS
166+
}
167+
# Whole stemmed long forms -> acronym: the acronym is added to a document's term
168+
# set only when every token of the long form is present (so "Large Language
169+
# Models" gains "llm", but "language models" alone does not).
170+
_SYNONYM_LONG_TO_SHORT: tuple[tuple[frozenset[str], str], ...] = tuple(
171+
(frozenset(_ordered_tokens(long_form)), short)
172+
for short, long_form in _SYNONYM_GROUPS
173+
)
51174

52175

53176
def rank(
@@ -62,38 +185,72 @@ def rank(
62185
axis (single-paper / query-less callers).
63186
"""
64187
year_base = current_year or _CURRENT_YEAR_FALLBACK
65-
terms = _terms(keywords) if keywords else frozenset()
188+
terms = frozenset(_ordered_tokens(keywords)) if keywords else frozenset()
189+
bigrams = _bigrams(keywords) if keywords else frozenset()
66190
return sorted(
67191
papers,
68-
key=lambda paper: _score(paper, year_base, terms),
192+
key=lambda paper: _score(paper, year_base, terms, bigrams),
69193
reverse=True,
70194
)
71195

72196

73-
def _score(paper: Paper, current_year: int, terms: frozenset[str]) -> float:
197+
def _score(
198+
paper: Paper,
199+
current_year: int,
200+
terms: frozenset[str],
201+
query_bigrams: frozenset[tuple[str, str]],
202+
) -> float:
74203
return (
75-
_relevance_score(paper, terms)
204+
_relevance_score(paper, terms, query_bigrams)
76205
+ _recency_score(paper.year, current_year)
77206
+ _citation_score(paper.citation_count)
78207
)
79208

80209

81-
def _terms(text: str) -> frozenset[str]:
82-
"""Lowercase alphanumeric tokens of length >= ``_MIN_TERM_LEN``."""
83-
return frozenset(w for w in _WORD_RE.findall(text.lower()) if len(w) >= _MIN_TERM_LEN)
210+
def _bigrams(text: str) -> frozenset[tuple[str, str]]:
211+
"""Adjacent token pairs of ``text`` (empty when fewer than two tokens)."""
212+
tokens = _ordered_tokens(text)
213+
return frozenset(zip(tokens, tokens[1:], strict=False))
214+
215+
216+
def _term_set(text: str) -> frozenset[str]:
217+
"""Synonym-expanded term set of a document field (title / abstract).
218+
219+
Documents — not the query — are expanded, so the relevance denominator stays
220+
the user's actual query size (expanding the query would dilute the fraction).
221+
"""
222+
base = set(_ordered_tokens(text))
223+
expanded = set(base)
224+
for token in base:
225+
expanded.update(_SYNONYM_EXPAND.get(token, ()))
226+
for long_tokens, short in _SYNONYM_LONG_TO_SHORT:
227+
if long_tokens <= base:
228+
expanded.add(short)
229+
return frozenset(expanded)
84230

85231

86-
def _relevance_score(paper: Paper, terms: frozenset[str]) -> float:
87-
"""Fraction of query terms appearing in title / abstract, title-weighted.
232+
def _relevance_score(
233+
paper: Paper,
234+
terms: frozenset[str],
235+
query_bigrams: frozenset[tuple[str, str]],
236+
) -> float:
237+
"""Title/abstract overlap with the query, title-weighted, plus a phrase bonus.
88238
89-
Range ``[0, _TITLE_WEIGHT + _ABSTRACT_WEIGHT]``. ``0.0`` when no query terms
90-
were supplied, so the score reduces to recency + citation.
239+
Range ``[0, _TITLE_WEIGHT + _ABSTRACT_WEIGHT + _PHRASE_WEIGHT]``. ``0.0``
240+
when no query terms were supplied, so the score reduces to recency +
241+
citation.
91242
"""
92243
if not terms:
93244
return 0.0
94-
title_hit = len(terms & _terms(paper.title)) / len(terms)
95-
abstract_hit = len(terms & _terms(paper.abstract)) / len(terms)
96-
return title_hit * _TITLE_WEIGHT + abstract_hit * _ABSTRACT_WEIGHT
245+
title_terms = _term_set(paper.title)
246+
abstract_terms = _term_set(paper.abstract)
247+
title_hit = len(terms & title_terms) / len(terms)
248+
abstract_hit = len(terms & abstract_terms) / len(terms)
249+
score = title_hit * _TITLE_WEIGHT + abstract_hit * _ABSTRACT_WEIGHT
250+
if query_bigrams:
251+
matched = len(query_bigrams & _bigrams(paper.title))
252+
score += _PHRASE_WEIGHT * (matched / len(query_bigrams))
253+
return score
97254

98255

99256
def _recency_score(year: int | None, current_year: int) -> float:

0 commit comments

Comments
 (0)