Source code for axiom.retrieval.lexical
"""Lexical (BM25) retrieval arm.
The semantic arm (sentence-transformer embeddings) is great at meaning but can
miss an *exact* token the player typed — a proper noun ("Kael"), an item name,
a coined word — because such tokens carry little distributed meaning. BM25 is
the classic lexical complement: it rewards rare exact-term overlap. We fuse the
two arms with Reciprocal Rank Fusion (see ``fusion.py``).
Graceful degradation: if ``rank_bm25`` is unavailable the lexical arm is simply
absent and search falls back to the semantic arm alone (no crash), mirroring the
``VectorMemory._disabled`` philosophy for the embedding runtime.
Algorithm reference: Hindsight (MIT) runs BM25 in Postgres (``tsvector``); that
SQL is not portable, so we reimplement the *idea* with the pure-Python
``rank_bm25`` library over the in-memory corpus of a save's chunks.
"""
from __future__ import annotations
import re
try: # heavy/optional dependency — degrade gracefully if missing
from rank_bm25 import BM25Okapi
except Exception: # pragma: no cover - exercised only when the lib is absent
BM25Okapi = None # type: ignore[assignment]
_TOKEN_RE = re.compile(r"[^\W_]+", flags=re.UNICODE)
[docs]
def is_available() -> bool:
"""True when the BM25 backend can be used."""
return BM25Okapi is not None
[docs]
def tokenize(text: str) -> list[str]:
"""Deterministic, Unicode-aware word tokenizer (lowercased).
Splits on anything that is not a word character, so punctuation and
underscores act as separators. Keeps accented letters and non-Latin scripts
(the game ships 10 languages), so a name like ``"Kaël"`` stays one token.
"""
return _TOKEN_RE.findall(text.lower())
[docs]
def build_bm25(corpus_texts: list[str]):
"""Build a BM25 index over ``corpus_texts`` (or ``None`` if unavailable).
Split out from :func:`rank_by_bm25` so callers can *cache* the index when the
corpus is unchanged (building it tokenises the whole corpus and computes IDF
— the expensive part), then score many queries against it cheaply.
"""
if BM25Okapi is None or not corpus_texts:
return None
tokenized_corpus = [tokenize(text) for text in corpus_texts]
# BM25Okapi requires every document to have at least one token; substitute a
# neutral placeholder for empty docs so indices stay aligned with corpus_ids.
tokenized_corpus = [toks if toks else [""] for toks in tokenized_corpus]
return BM25Okapi(tokenized_corpus)
[docs]
def rank_with_bm25(bm25, query_text: str, corpus_ids: list[str]) -> list[str]:
"""Rank ``corpus_ids`` against a pre-built ``bm25`` index, best first.
``corpus_ids`` must be aligned 1:1 with the texts the index was built from.
Returns an empty list when the index is ``None`` or the query has no usable
tokens; drops zero-score docs (no lexical signal). Ties keep input order.
"""
if bm25 is None or not corpus_ids:
return []
query_tokens = tokenize(query_text)
if not query_tokens:
return []
scores = bm25.get_scores(query_tokens)
ranked = sorted(
zip(corpus_ids, scores),
key=lambda pair: pair[1],
reverse=True,
)
return [doc_id for doc_id, score in ranked if score > 0.0]
[docs]
def rank_by_bm25(query_text: str, corpus_ids: list[str], corpus_texts: list[str]) -> list[str]:
"""Rank ``corpus_ids`` by BM25 relevance to ``query_text``, best first.
Convenience wrapper that builds a one-shot index then scores. Hot paths that
reuse a stable corpus should cache :func:`build_bm25` and call
:func:`rank_with_bm25` instead.
Args:
query_text: The raw query string.
corpus_ids: Stable ids, aligned 1:1 with ``corpus_texts``.
corpus_texts: The chunk texts to score against the query.
Returns:
``corpus_ids`` reordered by descending BM25 score, dropping documents
whose score is zero (no query-term overlap → no lexical signal). Returns
an empty list when BM25 is unavailable or the query has no usable tokens.
Ties keep the input order (deterministic).
"""
if not corpus_ids:
return []
return rank_with_bm25(build_bm25(corpus_texts), query_text, corpus_ids)