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
233 lines
8.5 KiB
Python
233 lines
8.5 KiB
Python
"""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()
|