arborist/tests/test_search_fts5.py
russell@unturf.com 416f956734
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
2026-05-09 18:31:24 -04:00

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()