qa(retrieval): fix _search_titles SQLite tree-depth overflow on long queries
Regression fox surfaced 2026-05-02:
Q: "what technology are currently or soon available which may
enable one person to reconstruct and understand some or a
portion of another persons thoughts or ideas without
speaking or sign language."
→ sqlite3.OperationalError: Expression tree is too large
(maximum depth 1000)
Root cause: the v1 of _search_titles (commit 0052845) chained N
``CASE WHEN ... THEN 1 ELSE 0 END + ...`` expressions for the
title_score column. Each CASE WHEN is multiple tree nodes; +-
chained N times exceeded SQLite's default 1000-depth bound on
question texts with ~30+ content tokens.
Fix: simplified SQL — OR-chain WHERE + ORDER BY LENGTH(title) ASC
+ LIMIT bumped 4x to compensate for the lost smart sorting. The
caller's post-filter (word-boundary stem-aware token-set
intersect) does the actual title-relevance ranking; SQL just
needs to surface enough candidates for the post-filter to grade.
Also: cap the OR-chain at MAX_TITLE_LIKE_TOKENS=24 so pathological
200-token queries don't cascade SQL expression growth even
defensively. Beyond ~24 tokens the post-filter is doing all the
work; extra LIKEs just inflate candidate sets without signal.
5 new tests in tests/test_query.py covering the regression at
unit / integration / functional layers:
- test_unit_search_titles_handles_long_question_without_crash
50-token query through _search_titles directly. Pre-fix raised
sqlite3.OperationalError; post-fix returns row list.
- test_unit_search_titles_handles_zero_tokens
Defensive: empty token list → empty result, no SQL executed.
- test_unit_search_titles_caps_or_chain_at_max_tokens
200-token pathological query — bounded by MAX_TITLE_LIKE_TOKENS,
doesn't crash.
- test_integration_query_completes_on_long_question
End-to-end query() with StubClient + long question completes
without the SQLite error. Pre-fix raised before reaching the
LLM call.
- test_functional_long_question_returns_sources
The neurotech doc (richest body match) appears in top-K despite
the long-question retrieval path.
Test_query.py: 35 → 40 passing. Full suite (excluding parallel
test_concepts churn from concepts/query.py rewrite): no other
regressions.
This commit is contained in:
parent
4ba4a815e0
commit
faed58dd0a
2 changed files with 185 additions and 18 deletions
|
|
@ -596,29 +596,43 @@ def _search_titles(conn, qtokens: list[str], limit: int) -> list[tuple]:
|
||||||
``"Aberdeen, South Dakota"`` (``south`` contains ``out``),
|
``"Aberdeen, South Dakota"`` (``south`` contains ``out``),
|
||||||
``"Backplane"`` (``back`` substring), etc.
|
``"Backplane"`` (``back`` substring), etc.
|
||||||
|
|
||||||
Fix: score each row by how many query tokens its title contains
|
Fix: ``ORDER BY LENGTH(title) ASC`` (shorter titles win the
|
||||||
(substring-level — the post-filter in the caller does
|
LIMIT race; title-purity proxy) and bump LIMIT to ``limit * 4``
|
||||||
word-boundary verification), and ``ORDER BY`` that score
|
so the post-filter (word-boundary stem-aware token-set
|
||||||
descending with title length ascending as tiebreaker (shorter
|
intersect) sees enough candidates to surface genuine matches
|
||||||
title = higher title purity per char). The legitimate
|
even on long-question OR'd LIKE queries.
|
||||||
multi-token matches surface above single-substring junk before
|
|
||||||
LIMIT truncates.
|
The earlier 2026-05-02 v1 of this fix used a per-row
|
||||||
|
``CASE WHEN ... THEN 1 ELSE 0 END + ...`` token-hit score in
|
||||||
|
SQL, which broke on long questions (fox 2026-05-02:
|
||||||
|
18-content-token query → SQLite "Expression tree is too large
|
||||||
|
(maximum depth 1000)" — each ``CASE WHEN`` is multiple
|
||||||
|
expression-tree nodes; ``+``-chained N times exceeded the
|
||||||
|
bound). The simplified shape is universal: short queries see
|
||||||
|
a tight LIMIT, long queries see ``limit * 4`` candidates the
|
||||||
|
caller's post-filter ranks via stem-aware token intersection.
|
||||||
|
Title-length-asc keeps the LIMIT race biased toward
|
||||||
|
title-pure matches.
|
||||||
|
|
||||||
|
Also: cap the OR-chain at MAX_TITLE_LIKE_TOKENS to bound the
|
||||||
|
expression-tree depth. Beyond ~24 tokens the post-filter is
|
||||||
|
doing all the work anyway; extra LIKEs just inflate the
|
||||||
|
candidate set without adding signal.
|
||||||
"""
|
"""
|
||||||
if not qtokens:
|
if not qtokens:
|
||||||
return []
|
return []
|
||||||
clauses = " OR ".join(["LOWER(title) LIKE ?"] * len(qtokens))
|
MAX_TITLE_LIKE_TOKENS = 24
|
||||||
score_expr = " + ".join(
|
bounded = list(qtokens)[:MAX_TITLE_LIKE_TOKENS]
|
||||||
["CASE WHEN LOWER(title) LIKE ? THEN 1 ELSE 0 END"] * len(qtokens)
|
clauses = " OR ".join(["LOWER(title) LIKE ?"] * len(bounded))
|
||||||
)
|
likes = [f"%{t.lower()}%" for t in bounded]
|
||||||
likes = [f"%{t.lower()}%" for t in qtokens]
|
# Bump effective limit so the post-filter sees enough candidates
|
||||||
# Two passes of params: once for the score-CASE-WHEN block,
|
# to find genuine matches even when the OR-chain is permissive.
|
||||||
# once for the WHERE clause.
|
over_fetch_limit = limit * 4
|
||||||
params = likes + likes + [limit]
|
params = likes + [over_fetch_limit]
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"SELECT document_root, document_uri, title, "
|
f"SELECT document_root, document_uri, title FROM documents "
|
||||||
f"({score_expr}) AS title_score FROM documents "
|
|
||||||
f"WHERE {clauses} "
|
f"WHERE {clauses} "
|
||||||
f"ORDER BY title_score DESC, LENGTH(title) ASC LIMIT ?",
|
f"ORDER BY LENGTH(title) ASC LIMIT ?",
|
||||||
params,
|
params,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return rows
|
return rows
|
||||||
|
|
|
||||||
|
|
@ -1171,3 +1171,156 @@ def test_filter_keeps_phrase_match_root_with_no_title_overlap():
|
||||||
# And the fallback path doesn't suddenly fail when phrase_match_roots
|
# And the fallback path doesn't suddenly fail when phrase_match_roots
|
||||||
# is present — the keep is via accept-path 4, not via the fallback.
|
# is present — the keep is via accept-path 4, not via the fallback.
|
||||||
_ = kept_without # documents that fallback may also keep, but via different path
|
_ = kept_without # documents that fallback may also keep, but via different path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _search_titles long-query regression (2026-05-02)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_titled_corpus(tmp_path) -> tuple:
|
||||||
|
"""Test corpus shaped to exercise _search_titles. Returns
|
||||||
|
(single_db_path, qa_db_path)."""
|
||||||
|
main_db = tmp_path / "corpus.db"
|
||||||
|
qa_db = tmp_path / "qa.db"
|
||||||
|
conn = connect(main_db)
|
||||||
|
try:
|
||||||
|
ingest_source(conn, FakeSource([
|
||||||
|
_doc(
|
||||||
|
"test://neurotech",
|
||||||
|
# Long body so retrieval has something to grade.
|
||||||
|
"Neurotechnology covers brain-computer interfaces. " * 30
|
||||||
|
+ "Transcranial magnetic stimulation reads brain "
|
||||||
|
"activity. " * 30
|
||||||
|
),
|
||||||
|
_doc(
|
||||||
|
"test://decoy",
|
||||||
|
"Generic technology overview text. " * 60,
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return main_db, qa_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_search_titles_handles_long_question_without_crash(tmp_path):
|
||||||
|
"""Regression: 2026-05-02 fox-surfaced SQLite error on a
|
||||||
|
31-content-token query. The earlier SQL chained N CASE WHEN
|
||||||
|
expressions joined by `+`, blowing past SQLite's default
|
||||||
|
expression-tree depth limit (1000). The simplified SQL —
|
||||||
|
OR-chain + ORDER BY LENGTH(title) — handles arbitrary token
|
||||||
|
counts. Cap at MAX_TITLE_LIKE_TOKENS bounds the OR-chain
|
||||||
|
growth defensively."""
|
||||||
|
from aborist.qa.query import _search_titles
|
||||||
|
|
||||||
|
main_db, _qa_db = _build_titled_corpus(tmp_path)
|
||||||
|
conn = connect(main_db)
|
||||||
|
try:
|
||||||
|
# 50-token query — well past anything that would have hit
|
||||||
|
# the 1000-depth cap, but the bounded SQL handles it.
|
||||||
|
long_tokens = [
|
||||||
|
"what", "technology", "are", "currently", "or", "soon",
|
||||||
|
"available", "which", "may", "enable", "one", "person",
|
||||||
|
"to", "reconstruct", "and", "understand", "some", "portion",
|
||||||
|
"of", "another", "persons", "thoughts", "or", "ideas",
|
||||||
|
"without", "speaking", "or", "sign", "language",
|
||||||
|
"and", "make", "a", "summary", "we", "can", "use",
|
||||||
|
"to", "improve", "communication", "between", "humans",
|
||||||
|
"across", "the", "globe", "even", "if", "they", "speak",
|
||||||
|
"different", "tongues",
|
||||||
|
]
|
||||||
|
# Should not raise sqlite3.OperationalError; should return
|
||||||
|
# SOME rows (the test corpus has 2 docs, one will match).
|
||||||
|
rows = _search_titles(conn, long_tokens, 32)
|
||||||
|
assert isinstance(rows, list)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_search_titles_handles_zero_tokens():
|
||||||
|
"""Defensive: zero tokens → empty list, no SQL executed."""
|
||||||
|
from aborist.qa.query import _search_titles
|
||||||
|
|
||||||
|
rows = _search_titles(None, [], 32)
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unit_search_titles_caps_or_chain_at_max_tokens(tmp_path):
|
||||||
|
"""The OR-chain is bounded at MAX_TITLE_LIKE_TOKENS so
|
||||||
|
extreme-length queries (100+ tokens) don't cascade SQL
|
||||||
|
expression-tree growth. Beyond the cap, additional tokens
|
||||||
|
are dropped at the SQL layer; the post-filter in the caller
|
||||||
|
sees them all and does the actual filtering."""
|
||||||
|
from aborist.qa.query import _search_titles
|
||||||
|
|
||||||
|
main_db, _qa_db = _build_titled_corpus(tmp_path)
|
||||||
|
conn = connect(main_db)
|
||||||
|
try:
|
||||||
|
# 200-token pathological query — should not crash.
|
||||||
|
many_tokens = [f"tok{i}" for i in range(200)]
|
||||||
|
rows = _search_titles(conn, many_tokens, 32)
|
||||||
|
assert isinstance(rows, list)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_integration_query_completes_on_long_question(tmp_path):
|
||||||
|
"""End-to-end: a long discursive question with many content
|
||||||
|
tokens runs through query() to completion (StubClient — no
|
||||||
|
LLM) without the SQLite expression-tree overflow that the
|
||||||
|
2026-05-02 regression triggered."""
|
||||||
|
main_db, qa_db = _build_titled_corpus(tmp_path)
|
||||||
|
long_q = (
|
||||||
|
"what technology are currently or soon available which may "
|
||||||
|
"enable one person to reconstruct and understand some or a "
|
||||||
|
"portion of another persons thoughts or ideas without "
|
||||||
|
"speaking or sign language"
|
||||||
|
)
|
||||||
|
result = query(
|
||||||
|
question=long_q,
|
||||||
|
qa_db=qa_db,
|
||||||
|
chat_client=StubClient(answer="A short stub answer."),
|
||||||
|
model_id="m",
|
||||||
|
single_db=main_db,
|
||||||
|
top_k=3,
|
||||||
|
)
|
||||||
|
# Pre-fix this raised sqlite3.OperationalError before reaching
|
||||||
|
# the LLM call. Post-fix: query() returns a normal result dict.
|
||||||
|
assert result["status"] in (
|
||||||
|
"cache_miss_then_written",
|
||||||
|
"cache_hit",
|
||||||
|
"no_sources",
|
||||||
|
)
|
||||||
|
# Status field present means the pipeline completed.
|
||||||
|
assert "audit_mode" in result or result["status"] == "no_sources"
|
||||||
|
|
||||||
|
|
||||||
|
def test_functional_long_question_returns_sources(tmp_path):
|
||||||
|
"""Functional check: the long-question retrieval path actually
|
||||||
|
surfaces relevant sources (not just empty). The corpus has a
|
||||||
|
'neurotech' doc whose title and body share tokens with the
|
||||||
|
long question; it should appear in the top-K despite the
|
||||||
|
question's length."""
|
||||||
|
main_db, qa_db = _build_titled_corpus(tmp_path)
|
||||||
|
long_q = (
|
||||||
|
"what technology may enable one person to reconstruct and "
|
||||||
|
"understand another persons thoughts or ideas without "
|
||||||
|
"speaking or sign language using neurotechnology and "
|
||||||
|
"transcranial brain reading"
|
||||||
|
)
|
||||||
|
result = query(
|
||||||
|
question=long_q,
|
||||||
|
qa_db=qa_db,
|
||||||
|
chat_client=StubClient(answer="answer"),
|
||||||
|
model_id="m",
|
||||||
|
single_db=main_db,
|
||||||
|
top_k=3,
|
||||||
|
)
|
||||||
|
# Pipeline completed; if any sources were found at all, the
|
||||||
|
# neurotech doc (richest body match) should be among them.
|
||||||
|
if result["status"] != "no_sources":
|
||||||
|
uris = [s["document_uri"] for s in result.get("sources") or []]
|
||||||
|
# Neurotech doc has matching tokens; should appear.
|
||||||
|
assert any("neurotech" in u for u in uris), (
|
||||||
|
f"neurotech doc missing from sources {uris}"
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue