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
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
53176def 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
99256def _recency_score (year : int | None , current_year : int ) -> float :
0 commit comments