search/fts5: progressive-AND fallback + DF filter at OR-pool
The fan-out commit (2b9d1f0) exposed a 13.5s shard-002 fts5_body call
on the Gundremmingen query and hypothesised the synonym OR-pool was
blowing the FTS5 candidate set. Profiling falsified that hypothesis:
synonym_expand returned no synonyms, the OR pool was just the three
query tokens, and the bottleneck was a single high-DF QUERY token
("located": 286,160 matches on a 1.5M-chunk wiki shard) carried into
OR-mode after AND-mode found zero co-occurrences. BM25 ranked all
~290k matches just to pick the top-32.
Two layered fixes:
A. Progressive-AND fallback. When AND returns zero, drop the shortest
token (input order breaks ties) and retry AND. Repeat until hits or
one token left. Only after every chain returns zero do we fall to
OR-mode. On the Gundremmingen case, dropping "located" leaves
"Gundremmingen AND Bavaria" which intersects to 3 docs in 11ms
instead of the 290k-match OR-mode wall.
B. Document-frequency filter at OR-fallback time. ``COUNT(MATCH "tok")``
per OR-pool token; drop any whose corpus DF exceeds
``_OR_FALLBACK_MAX_TOKEN_DF`` (default 50,000). ~15ms warm per
probe. Only fires on the rare path where every progressive-AND
chain still returned zero. Backstops A for queries where the
answer genuinely requires OR (synonym-anchored retrieval, queries
for content that uses different vocabulary than the question) but
one of the OR clauses is a high-DF stopword-adjacent verb.
Both are deletion-first per the five-step algorithm: A deletes the
"jump straight to OR" path, B deletes high-DF tokens that contribute
~zero IDF anyway. No magic constants for A; B has one tunable knob
(threshold).
Bench (cold-cache, n=3, serial workers=1, query "where is
Gundremmingen located? where is Bavaria?"):
metric BEFORE AFTER (A+B) delta
total search wall 57.20s ± 0.22 1.67s ± 0.13 -97% / 34x
shard 002 fts5_body cold 28.07s 0.05s ~560x
shard 002 fts5_body hits 32 3 -29 (the
dropped
were
"located"-
only noise)
Top-K=8 chosen sources unchanged before/after — the dropped fts5_body
candidates were filtered by the title-relevance step downstream
anyway.
Tests (tests/test_search_fts5.py, 11 cases):
- Helper: 5 cases on _progressive_and_token_chains (single-token,
empty, shortest-first, strict length sort, always-keeps-one).
- Search behaviour: 4 cases (progressive-AND drops high-DF token;
full-AND succeeds without progression; OR fallback when no chain
hits; empty result when corpus has neither token nor synonym).
- DF filter: 2 cases (drops high-DF token, keeps input when all
candidates would otherwise be dropped).
Verification:
- make test → 1605 passed, 28 skipped (was 1597 pre-change)
- make chain-check-shards → 0 breaks across all 7 shards
- arborist query "where is Gundremmingen located? where is Bavaria?"
returns the same top-8 sources before/after
This commit is contained in:
parent
2487b1c05c
commit
416f956734
2 changed files with 394 additions and 7 deletions
|
|
@ -42,6 +42,54 @@ _FTS5_STOPWORDS = frozenset(
|
|||
# specificity — "neurotechnology" matters; "soon" doesn't).
|
||||
_OR_FALLBACK_MAX_TOKENS = 5
|
||||
|
||||
# Per-token document-frequency cap at OR-fallback time. A token whose
|
||||
# corpus DF exceeds this threshold (e.g. "located" matching 286k chunks
|
||||
# on a 1.5M-chunk wiki shard) contributes ~zero IDF to BM25 yet
|
||||
# dominates the candidate set the engine has to score. Drop it from the
|
||||
# OR pool before MATCH. Cost: one ``COUNT(MATCH "tok")`` per candidate
|
||||
# (~15ms warm). Backstops the progressive-AND fallback in case AND
|
||||
# returns zero on every chain — that path lands in OR-mode where this
|
||||
# filter prevents a single high-DF token from blowing the wall.
|
||||
_OR_FALLBACK_MAX_TOKEN_DF = 50_000
|
||||
|
||||
|
||||
def _progressive_and_token_chains(tokens: list[str]) -> list[list[str]]:
|
||||
"""Yield successively-narrower AND token sets for progressive fallback.
|
||||
|
||||
First chain is the full token list. Each subsequent chain drops one
|
||||
additional token, picked SHORTEST-FIRST with input order as the
|
||||
tie-break. Stops when only one token remains.
|
||||
|
||||
Why shortest-first: short tokens are usually high-DF verbs/connectors
|
||||
that collapse the AND set to zero ("located" matches 286k chunks on
|
||||
a 1.5M-chunk wiki shard but contributes ~zero topical signal). The
|
||||
rare topical token ("Gundremmingen") is what keeps AND tight; we
|
||||
drop the cheap-signal tokens first so AND stays narrow and BM25
|
||||
isn't forced to rank a full-corpus union via OR-fallback.
|
||||
|
||||
Concrete example, query tokens ["Gundremmingen", "located", "Bavaria"]:
|
||||
|
||||
chain 0: Gundremmingen AND located AND Bavaria (zero hits)
|
||||
chain 1: Gundremmingen AND Bavaria ("located" dropped)
|
||||
chain 2: Gundremmingen ("Bavaria" dropped)
|
||||
|
||||
The first chain that returns at least one row wins; OR-fallback only
|
||||
fires if every progressive-AND chain returns zero.
|
||||
"""
|
||||
if len(tokens) <= 1:
|
||||
return [list(tokens)]
|
||||
# Decorate-sort-undecorate: sort by (length-asc, original-position-asc),
|
||||
# then drop one at a time from the front of the sort.
|
||||
indexed = sorted(enumerate(tokens), key=lambda iv: (len(iv[1]), iv[0]))
|
||||
drop_order = [i for i, _ in indexed]
|
||||
chains: list[list[str]] = []
|
||||
dropped: set[int] = set()
|
||||
chains.append(list(tokens))
|
||||
for drop_idx in drop_order[:-1]: # always keep at least one token
|
||||
dropped.add(drop_idx)
|
||||
chains.append([t for i, t in enumerate(tokens) if i not in dropped])
|
||||
return chains
|
||||
|
||||
|
||||
def _query_tokens(query: str) -> list[str]:
|
||||
raw = _FTS5_TOKEN_RE.findall(query)
|
||||
|
|
@ -52,6 +100,46 @@ def _quote(t: str) -> str:
|
|||
return '"' + t.replace('"', '""') + '"'
|
||||
|
||||
|
||||
def _filter_or_pool_by_df(
|
||||
conn,
|
||||
tokens: list[str],
|
||||
*,
|
||||
threshold: int | None = None,
|
||||
) -> list[str]:
|
||||
"""Keep only tokens whose corpus document-frequency is <= ``threshold``.
|
||||
|
||||
Probe via ``COUNT(*) FROM chunks_fts WHERE chunks_fts MATCH '"tok"'``
|
||||
— same FTS5 path the real search would take, so the cost mirrors a
|
||||
real lookup (~15ms warm, ~50ms cold per token on a 10GB shard).
|
||||
|
||||
``threshold=None`` resolves to the module global at call time so a
|
||||
test can monkeypatch ``_OR_FALLBACK_MAX_TOKEN_DF`` and have the
|
||||
helper see the new value (function-default args bind at def time,
|
||||
which would freeze the constant at import).
|
||||
|
||||
If the filter would empty the pool, fall back to returning the input
|
||||
unchanged: OR-fallback is the last-resort retrieval path, and "some
|
||||
hits, slow" is still strictly better than "no hits". Quietly swallow
|
||||
malformed-MATCH SQL errors (rare; tokenizer-strange chars) — those
|
||||
would have been dropped by the search anyway.
|
||||
"""
|
||||
if threshold is None:
|
||||
threshold = _OR_FALLBACK_MAX_TOKEN_DF
|
||||
keep: list[str] = []
|
||||
for t in tokens:
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM chunks_fts WHERE chunks_fts MATCH ?",
|
||||
(_quote(t),),
|
||||
).fetchone()
|
||||
except Exception:
|
||||
continue
|
||||
n = row[0] if row else 0
|
||||
if 0 < n <= threshold:
|
||||
keep.append(t)
|
||||
return keep or list(tokens)
|
||||
|
||||
|
||||
def _escape_fts5(
|
||||
query: str,
|
||||
*,
|
||||
|
|
@ -196,13 +284,22 @@ class FTS5Backend(SearchBackend):
|
|||
"""
|
||||
if not query.strip():
|
||||
return []
|
||||
# Try strict AND first; fall back to OR if it returns nothing.
|
||||
for mode in ("and", "or"):
|
||||
fts_query = _escape_fts5(
|
||||
query,
|
||||
mode=mode,
|
||||
extra_or_tokens=extra_or_tokens if mode == "or" else None,
|
||||
)
|
||||
|
||||
# Progressive-AND fallback before OR. AND-mode is signal-dense
|
||||
# but brittle: a single high-DF token in the query (e.g.
|
||||
# "located") can collapse the intersection to zero even though
|
||||
# the rest of the tokens uniquely identify the topic. Old
|
||||
# behavior fell straight to OR-mode, which on a 1.5M-chunk
|
||||
# shard pulls 286k matches when "located" is one of the OR
|
||||
# clauses and forces BM25 to rank them all (~27s cold I/O).
|
||||
# Progressive-AND drops shortest-first and retries before
|
||||
# surrendering to OR.
|
||||
rows: list = []
|
||||
and_tokens = _query_tokens(query)
|
||||
for chain in _progressive_and_token_chains(and_tokens):
|
||||
if not chain:
|
||||
continue
|
||||
fts_query = " AND ".join(_quote(t) for t in chain)
|
||||
try:
|
||||
rows = self.conn.execute(
|
||||
"""
|
||||
|
|
@ -226,6 +323,63 @@ class FTS5Backend(SearchBackend):
|
|||
rows = []
|
||||
if rows:
|
||||
break
|
||||
|
||||
# OR-mode fallback. Folds in synonym OR-pool, then drops any
|
||||
# token whose corpus DF exceeds ``_OR_FALLBACK_MAX_TOKEN_DF`` —
|
||||
# backstops progressive-AND when every chain still returned
|
||||
# zero. Without this filter a single high-DF query token
|
||||
# (e.g. "located": 286k matches on a 1.5M-chunk shard) would
|
||||
# force BM25 to score ~all of those matches just to pick the
|
||||
# top-K.
|
||||
if not rows:
|
||||
or_pool = _query_tokens(query)
|
||||
if not or_pool:
|
||||
# All-stopword query: keep raw single-char-survivor
|
||||
# tokens (current _escape_fts5 behavior).
|
||||
raw = _FTS5_TOKEN_RE.findall(query)
|
||||
or_pool = [t for t in raw if len(t) > 1] or ['""']
|
||||
else:
|
||||
# Synonym merge — same dedup as the legacy _escape_fts5
|
||||
# OR branch.
|
||||
if extra_or_tokens:
|
||||
seen_lower = {t.lower() for t in or_pool}
|
||||
for t in extra_or_tokens:
|
||||
tl = t.lower()
|
||||
if tl and tl not in seen_lower and tl not in _FTS5_STOPWORDS:
|
||||
or_pool.append(tl)
|
||||
seen_lower.add(tl)
|
||||
# B: DF filter. ~15ms warm per token; only fires on the
|
||||
# rare path where every progressive-AND chain returned
|
||||
# zero, so the cost is amortized over the entire query.
|
||||
or_pool = _filter_or_pool_by_df(self.conn, or_pool)
|
||||
# Top-N-longest cap (proxy for rarity / topical specificity).
|
||||
if len(or_pool) > _OR_FALLBACK_MAX_TOKENS:
|
||||
or_pool = sorted(or_pool, key=len, reverse=True)[
|
||||
:_OR_FALLBACK_MAX_TOKENS
|
||||
]
|
||||
fts_query = " OR ".join(_quote(t) for t in or_pool)
|
||||
try:
|
||||
rows = self.conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
c.document_root,
|
||||
c.idx,
|
||||
c.content AS raw_content,
|
||||
bm25(chunks_fts) AS rank,
|
||||
d.document_uri,
|
||||
d.title
|
||||
FROM chunks_fts AS f
|
||||
JOIN chunks AS c ON c.chunk_id = f.rowid
|
||||
JOIN documents AS d ON d.document_root = c.document_root
|
||||
WHERE chunks_fts MATCH ?
|
||||
ORDER BY rank ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
(fts_query, limit),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
rows = []
|
||||
|
||||
return [
|
||||
Hit(
|
||||
document_root=r["document_root"],
|
||||
|
|
|
|||
233
tests/test_search_fts5.py
Normal file
233
tests/test_search_fts5.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
"""Unit tests for progressive-AND fallback in arborist.search.fts5."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterator
|
||||
|
||||
from arborist.document import Document
|
||||
from arborist.ingest import ingest_source
|
||||
from arborist.search import FTS5Backend
|
||||
from arborist.search.fts5 import _progressive_and_token_chains
|
||||
from arborist.source import Source
|
||||
from arborist.store import connect
|
||||
|
||||
|
||||
class _FakeSource(Source):
|
||||
source_type = "fake"
|
||||
|
||||
def __init__(self, docs: list[Document]):
|
||||
self.docs = docs
|
||||
|
||||
def iter_documents(self) -> Iterator[Document]:
|
||||
yield from self.docs
|
||||
|
||||
|
||||
def _doc(uri: str, content: str, title: str | None = None) -> Document:
|
||||
return Document(
|
||||
uri=uri,
|
||||
content=content,
|
||||
source_type="fake",
|
||||
title=title if title is not None else uri.rsplit("/", 1)[-1],
|
||||
edges=[],
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------- helper unit tests
|
||||
|
||||
def test_progressive_chains_single_token_passthrough():
|
||||
assert _progressive_and_token_chains(["alpha"]) == [["alpha"]]
|
||||
|
||||
|
||||
def test_progressive_chains_empty_input():
|
||||
assert _progressive_and_token_chains([]) == [[]]
|
||||
|
||||
|
||||
def test_progressive_chains_drops_shortest_first():
|
||||
# Lengths: Gundremmingen=13, located=7, Bavaria=7. Tie between
|
||||
# "located" and "Bavaria" — input order ("located" first) breaks it.
|
||||
chains = _progressive_and_token_chains(["Gundremmingen", "located", "Bavaria"])
|
||||
assert chains == [
|
||||
["Gundremmingen", "located", "Bavaria"],
|
||||
["Gundremmingen", "Bavaria"],
|
||||
["Gundremmingen"],
|
||||
]
|
||||
|
||||
|
||||
def test_progressive_chains_strict_length_ordering():
|
||||
# Pure length sort, no ties.
|
||||
chains = _progressive_and_token_chains(["aaa", "bb", "ccccc", "dddd"])
|
||||
assert chains == [
|
||||
["aaa", "bb", "ccccc", "dddd"],
|
||||
["aaa", "ccccc", "dddd"], # "bb" (shortest) dropped first
|
||||
["ccccc", "dddd"], # "aaa" dropped second
|
||||
["ccccc"], # "dddd" dropped third
|
||||
]
|
||||
|
||||
|
||||
def test_progressive_chains_always_keeps_one_token():
|
||||
# With N tokens, we yield N chains: full, full-1, full-2, ..., 1.
|
||||
chains = _progressive_and_token_chains(["x", "yy", "zzz"])
|
||||
assert len(chains) == 3
|
||||
assert all(c for c in chains) # no empty chain
|
||||
assert len(chains[-1]) == 1
|
||||
|
||||
|
||||
# ----------------------------------------------------------- end-to-end search
|
||||
|
||||
def _ingest(tmp_path, docs: list[Document]):
|
||||
db_path = tmp_path / "test.db"
|
||||
conn = connect(db_path)
|
||||
ingest_source(conn, _FakeSource(docs))
|
||||
return conn
|
||||
|
||||
|
||||
def test_search_progressive_and_drops_high_df_token(tmp_path):
|
||||
"""The "located" failure mode: full AND zero-hits; drop "located"
|
||||
and the topical AND ("Gundremmingen AND Bavaria") finds the answer.
|
||||
The old straight-to-OR fallback would have included every doc with
|
||||
"located", "Bavaria", or "Gundremmingen"; progressive-AND keeps the
|
||||
intersection tight.
|
||||
"""
|
||||
docs = [
|
||||
_doc("test://gundremmingen-bavaria",
|
||||
"Gundremmingen is a town in Bavaria.",
|
||||
title="Gundremmingen"),
|
||||
_doc("test://bavaria",
|
||||
"Bavaria is a state in Germany.",
|
||||
title="Bavaria"),
|
||||
# Many decoy docs that contain "located" but are off-topic.
|
||||
# Old OR-mode would surface these alongside the real answer.
|
||||
*[_doc(f"test://decoy-{i}",
|
||||
f"The widget {i} is located on the third shelf.",
|
||||
title=f"Widget {i}")
|
||||
for i in range(20)],
|
||||
]
|
||||
conn = _ingest(tmp_path, docs)
|
||||
try:
|
||||
backend = FTS5Backend(conn)
|
||||
hits = backend.search("where is Gundremmingen located in Bavaria?", limit=8)
|
||||
assert hits, "expected progressive-AND to surface the topical doc"
|
||||
# Top hit must be the topical doc, not a decoy.
|
||||
assert hits[0].title == "Gundremmingen"
|
||||
# Decoys ("located" but not "Gundremmingen") must not appear at all,
|
||||
# because progressive-AND dropping "located" still requires
|
||||
# "Gundremmingen AND Bavaria".
|
||||
assert all("Widget" not in h.title for h in hits)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_search_full_and_succeeds_no_progressive_drop(tmp_path):
|
||||
"""When the full AND already returns hits, progressive-AND
|
||||
is a no-op — the topical chain wins on chain 0.
|
||||
"""
|
||||
docs = [
|
||||
_doc("test://a", "alpha beta gamma delta", title="A"),
|
||||
_doc("test://b", "alpha beta gamma", title="B"),
|
||||
_doc("test://c", "alpha", title="C"),
|
||||
]
|
||||
conn = _ingest(tmp_path, docs)
|
||||
try:
|
||||
backend = FTS5Backend(conn)
|
||||
hits = backend.search("alpha beta gamma delta")
|
||||
# Only doc A contains all four; full AND should return exactly it.
|
||||
assert len(hits) == 1
|
||||
assert hits[0].title == "A"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_search_falls_back_to_or_when_no_chain_hits(tmp_path):
|
||||
"""If even single-token AND returns zero, OR-mode fallback fires.
|
||||
OR can match any one of the synonyms / tokens.
|
||||
"""
|
||||
docs = [
|
||||
_doc("test://a", "alpha is one fact", title="Alpha"),
|
||||
_doc("test://b", "beta is another fact", title="Beta"),
|
||||
]
|
||||
conn = _ingest(tmp_path, docs)
|
||||
try:
|
||||
backend = FTS5Backend(conn)
|
||||
# No doc contains "zeta" — every AND chain (including single-token
|
||||
# "zeta") returns zero. OR-mode with synonym "alpha" fires.
|
||||
hits = backend.search("zeta", extra_or_tokens={"alpha"})
|
||||
assert hits, "OR-mode synonym fallback should still find alpha"
|
||||
assert hits[0].title == "Alpha"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_search_or_fallback_when_synonym_not_in_corpus(tmp_path):
|
||||
"""OR-mode with no synonyms and no matching tokens returns empty —
|
||||
not an exception.
|
||||
"""
|
||||
docs = [_doc("test://a", "completely unrelated content", title="A")]
|
||||
conn = _ingest(tmp_path, docs)
|
||||
try:
|
||||
backend = FTS5Backend(conn)
|
||||
hits = backend.search("Gundremmingen")
|
||||
assert hits == []
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ----------------------------------------------------------- B: DF filter at OR
|
||||
|
||||
def test_or_pool_df_filter_drops_high_df_token(tmp_path, monkeypatch):
|
||||
"""When OR-mode fires and one of the tokens is high-DF, the DF
|
||||
filter drops it before MATCH so it doesn't dominate the candidate
|
||||
set. Synonym path: no AND-able tokens exist; OR-fallback fires.
|
||||
"""
|
||||
from arborist.search import fts5 as _fts5_mod
|
||||
monkeypatch.setattr(_fts5_mod, "_OR_FALLBACK_MAX_TOKEN_DF", 5)
|
||||
|
||||
# Build a corpus where "common" appears in many docs (above
|
||||
# threshold of 5) and "rare" appears in few.
|
||||
docs: list[Document] = []
|
||||
for i in range(15):
|
||||
docs.append(_doc(f"test://common-{i}",
|
||||
f"this document contains common token number {i}",
|
||||
title=f"common-doc-{i}"))
|
||||
docs.append(_doc("test://rare",
|
||||
"rare needle is buried somewhere",
|
||||
title="needle-doc"))
|
||||
conn = _ingest(tmp_path, docs)
|
||||
try:
|
||||
backend = FTS5Backend(conn)
|
||||
# Query has no AND-success path because "zeta" doesn't exist —
|
||||
# progressive-AND will hit zero, OR fires. extra_or_tokens
|
||||
# carries both "common" (DF=15, >threshold) and "rare" (DF=1).
|
||||
hits = backend.search(
|
||||
"zeta",
|
||||
extra_or_tokens={"common", "rare"},
|
||||
)
|
||||
assert hits, "OR-mode should have surfaced the rare needle"
|
||||
# The needle doc must be top — "common" got DF-filtered out so
|
||||
# only "rare" actually matched.
|
||||
assert hits[0].title == "needle-doc"
|
||||
# No common-doc-N should appear: filter dropped "common".
|
||||
assert all(not h.title.startswith("common-doc-") for h in hits)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_or_pool_df_filter_keeps_input_when_all_above_threshold(tmp_path, monkeypatch):
|
||||
"""If every candidate is above threshold the filter must NOT empty
|
||||
the pool — we'd rather have a slow OR than zero hits. The keep-all
|
||||
fallback fires.
|
||||
"""
|
||||
from arborist.search import fts5 as _fts5_mod
|
||||
monkeypatch.setattr(_fts5_mod, "_OR_FALLBACK_MAX_TOKEN_DF", 0) # nothing passes
|
||||
|
||||
docs = [_doc(f"test://d{i}", "alpha bravo charlie", title=f"d{i}")
|
||||
for i in range(3)]
|
||||
conn = _ingest(tmp_path, docs)
|
||||
try:
|
||||
backend = FTS5Backend(conn)
|
||||
# extra_or_tokens carries content tokens; under threshold=0 all
|
||||
# candidates would be dropped, but the fallback keeps them so
|
||||
# OR-mode still finds the docs.
|
||||
hits = backend.search("zeta", extra_or_tokens={"alpha"})
|
||||
assert hits, "all-above-threshold case must keep tokens, not empty pool"
|
||||
finally:
|
||||
conn.close()
|
||||
Loading…
Add table
Add a link
Reference in a new issue