arborist/aborist/qa/query.py
russell@unturf.com d25c0fe66f
storage cheats + TF-IDF retrieval fix
Three cheats stack to drop on-disk store from ~21 KB to ~6.7 KB per doc on
the 2003 enwiki cur corpus (-67% measured, apples-to-apples reingest with
identical document/edge counts; Merkle proofs round-trip 30/30):

1. zstd-compressed chunks.content (level 3). Magic-byte detection on read
   means legacy plaintext rows pass through unchanged. Cores stay plaintext
   so qa.query._docs_with_core_keyword_match's SQL LOWER+LIKE keeps working.

2. edges WITHOUT ROWID. The composite PK (src_root, edge_type, dst_root,
   dst_uri, anchor) covers every column, so a default rowid-based table
   near-doubles row data in the PK index. WITHOUT ROWID makes the table
   itself the B-tree. Drops idx_edges_dst_uri too — the only query that
   filters on dst_uri alone is gravity_top_inbound, a one-shot analytic.

3. contentless FTS5 (content='', contentless_delete=1) eliminates the
   28 MB / 1000 docs of duplicated chunk text the old chunks_fts stored.
   chunks gets an explicit chunk_id INTEGER PRIMARY KEY so the FTS5
   rowid maps back to chunks.chunk_id at search time. Snippets are
   built in Python (search/fts5.py:_build_snippet) since SQL snippet()
   returns empty in contentless mode.

TF-IDF retrieval also fixed: the prior LIKE '%intel%' substring match
let "intelligence", "intellectual", "intellivision" drown real hits like
Pentium_4 (whose TF-IDF core has "intel" as an exact keyword). Now uses
word-boundary `LIKE '%, intel, %'` patterns plus a match_count over the
distinct query tokens — multi-token coverage outranks single-token title
boosts. Pentium_4 surfaces #1 for "what is the fastest intel CPU?" with
the canonical 2003 answer (Pentium 4 3.20 GHz) instead of an empty
"insufficient sources" reply.

Schema-level changes affect new DBs only; existing v9.8 DBs keep
working at the old layout. Cross-shard UNION views explicitly list the
intersection of columns so a mixed cluster (legacy + new schema shards
in one --shards-dir) still unions cleanly.
2026-04-27 17:24:51 -04:00

717 lines
25 KiB
Python

"""Multi-source corpus Q&A.
Pose a question, the tree finds related cached docs, assembles them as context,
asks Hermes, caches the answer.
The flow:
1. FTS5 search across all shards (chunks_fts can't be UNION'd in views,
so we query each shard's index independently and merge by score).
2. Title-boost rerank: hits whose title contains query tokens get a
score bump. Title is a strong topical signal that BM25 alone misses
(BM25 favors short docs with rare body tokens — Tell_(poker) outranks
Back_to_the_Future without a title boost).
3. Pick top-K distinct documents within a character budget.
4. Compute `context_root` = Merkle root over the sorted source
document_roots — that's the "source" dimension of v9.8's 8-dim
cache_key for this multi-source answer.
5. Cache lookup; hit returns STRICT immediately.
6. Miss calls Hermes via the OpenAI-compatible client.
7. Persist record with merkle_proof = {context_root, sources: [...]}
so a verifier can recompute context_root and check each source
document_root against its shard.
Per-source proofs are not bundled here (the source roots themselves are
already content-addressed). A verifier asks the shards for any specific
chunk's proof on demand.
"""
from __future__ import annotations
import json
import re
import time
from dataclasses import dataclass
from pathlib import Path
from aborist import (
CANONICALIZATION_VERSION,
CHUNKING_VERSION,
SCHEMA_VERSION,
)
from aborist.compress import unpack_chunk
from aborist.merkle import MerkleTree
from aborist.qa.client import ChatClient
from aborist.qa.concepts import (
has_compare_phrasing,
rivalry_excluded,
synonym_expand,
)
from aborist.qa.keys import (
cache_key,
conversation_hash,
governance_policy_hash,
model_profile_hash,
question_hash,
)
from aborist.search import FTS5Backend
from aborist.store import (
append_audit,
connect,
discover_shards,
transaction,
)
_TITLE_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*")
# Kept in sync with FTS5 stopwords in aborist.search.fts5 — both filter
# the same set of question-shaping words. "tell" leaking into title-LIKE
# search caused "tell me about permacomputer" to pull Tell_(poker), the
# Tell-Tale_Heart movie, Tell_City Indiana, etc.
_TITLE_STOPWORDS = frozenset(
"""
the a an is are was were be been being of to in on at for with by from
as about into through during and or but not no nor so yet too very also just
what who where when why how which this that these those such i you he she
it we they me him her us them do does did have has had can could should
would will may might
tell show describe explain summarize say give list find make please
""".split()
)
def _title_query_tokens(s: str) -> set[str]:
return {
t.lower()
for t in _TITLE_TOKEN_RE.findall(s)
if t.lower() not in _TITLE_STOPWORDS and len(t) > 1
}
def _rerank_by_title(
hits: list,
question: str,
boost: float = 10.0,
) -> list:
"""Boost hits whose title overlaps query tokens. Pure ordering aid."""
qtokens = _title_query_tokens(question)
if not qtokens:
return hits
for h in hits:
if not h.title:
continue
ttokens = _title_query_tokens(h.title.replace("_", " "))
overlap = len(qtokens & ttokens)
if overlap:
h.score += overlap * boost
hits.sort(key=lambda h: -h.score)
return hits
def _filter_by_title_relevance(
hits: list,
question: str,
*,
core_match_roots: set[str] | None = None,
body_density_check: callable | None = None,
fallback_top_n: int = 5,
) -> list:
"""Concept-aware relevance filter with three accept paths:
1. Title-token overlap (after synonym expansion). Strongest signal.
2. TF-IDF core keyword overlap — `core_match_roots` is a precomputed
set of source document_roots whose TF-IDF cores contain query
tokens. Closes the gap for neologisms like "permacomputer" that
never appear in titles but are distinctive enough to be TF-IDF
keywords of conversation bodies.
3. Body density — docs mentioning the query token >= N times pass
even without title or core match. Cheap proxy for "actually about
the topic." `body_density_check(hit)` returns bool.
Rivalry exclusion (Intel-titled docs in AMD queries) still applies on
every accept path.
If all three accept paths together produce nothing, fall back to the
top `fallback_top_n` body-BM25 hits — the LLM gets enough context to
say "I don't know" rather than fabricating from a single tangential
source.
"""
qtokens = _title_query_tokens(question)
if not qtokens:
return hits
accept = synonym_expand(qtokens)
exclude = rivalry_excluded(qtokens, compare_phrasing=has_compare_phrasing(question))
core_roots = core_match_roots or set()
kept: list = []
for h in hits:
ttokens = _title_query_tokens(h.title.replace("_", " ")) if h.title else set()
if exclude & ttokens:
continue # rivalry: opposing-side title, drop it
if accept & ttokens:
kept.append(h)
continue
if h.document_root in core_roots:
kept.append(h)
continue
if body_density_check is not None and body_density_check(h):
kept.append(h)
continue
if not kept:
return hits[: max(1, fallback_top_n)] if hits else []
return kept
DEFAULT_QUERY_POLICY = {
"system_prompt": (
"You are answering a question using ONLY the sources provided below. "
"Each source is delimited by '=== Source: <URI> ===' headers.\n\n"
"ATTRIBUTION RULES:\n"
"1. A single source may discuss MULTIPLE products, companies, or "
"competitors. Read carefully and only attribute facts to the entity "
"the source explicitly names for that fact.\n"
"2. If the question asks about ONE specific entity (e.g., 'fastest "
"AMD CPU'), do NOT include facts about competitors (Intel, Pentium) "
"as if they were facts about the asked entity. They are different "
"products even when discussed in the same article.\n"
"3. When referencing model numbers like 'Athlon XP 3200+', remember "
"that AMD's PR-rating numbers (3200+, 2500+) are NOT the clock speed "
"in MHz. Quote the source's own wording for clock speeds rather than "
"interpreting model numbers.\n"
"4. Cite which source supports each claim by quoting it inline.\n"
"5. If the sources do not contain the answer, say 'I don't know "
"based on the provided sources.' Do not speculate beyond what the "
"sources say."
),
"temperature": 0.1,
"top_p": 1.0,
"max_tokens": 768,
}
@dataclass
class _Hit:
document_root: str
document_uri: str
title: str | None
score: float
shard_path: str
chunk_idx: int
def _search_titles(conn, qtokens: list[str], limit: int) -> list[tuple]:
"""SQL LIKE over documents.title — finds the HTTP article that FTS5
misses because list-pages with many URLs have higher 'http' term
frequency than the actual protocol article. Returns rows shaped
to match the FTS5 hit tuple."""
if not qtokens:
return []
clauses = " OR ".join(["LOWER(title) LIKE ?"] * len(qtokens))
params = [f"%{t.lower()}%" for t in qtokens]
params.append(limit)
rows = conn.execute(
f"SELECT document_root, document_uri, title FROM documents "
f"WHERE {clauses} LIMIT ?",
params,
).fetchall()
return rows
def _docs_with_core_keyword_match(
conn, qtokens: list[str], limit: int
) -> list[tuple]:
"""Find SOURCE docs whose TF-IDF core keywords contain a query token.
TF-IDF cores act as enriched titles: a doc's distinctive low-frequency
terms get distilled into the core's content as comma-separated keywords.
A query for "permacomputer" (a neologism that never makes it into a
title) can match the TF-IDF core of a Grok conversation that mentioned
it, because permacomputer is a rare term that ranks high under TF-IDF.
Returns rows of (document_root, document_uri, title) for the SOURCE docs
(not the cores) — the source is what gets fed to the LLM as context.
"""
if not qtokens:
return []
# Word-boundary match against the comma-separated TF-IDF keyword list.
# Prepending/appending ", " lets one LIKE pattern (`%, token, %`) check
# for the token regardless of its position in the keyword string.
# Without this, naive `LIKE '%intel%'` would match "intelligence",
# "intellectual", "intellivision" — drowning real hits like Pentium_4
# (whose TF-IDF core has "intel" as an exact keyword) in noise.
#
# Per-row `match_count` tallies how many distinct query tokens hit
# this doc's TF-IDF core. Multi-token coverage is a strong relevance
# signal — a doc whose core has "intel" + "cpu" + "faster" beats a
# doc whose only signal is "intel" appearing in its TITLE. The
# caller boosts the score by match_count.
case_clauses = " + ".join(
[
"(CASE WHEN LOWER(', ' || c.content || ', ') LIKE ? THEN 1 ELSE 0 END)"
]
* len(qtokens)
)
where_clauses = " OR ".join(
["LOWER(', ' || c.content || ', ') LIKE ?"] * len(qtokens)
)
patterns = [f"%, {t.lower()}, %" for t in qtokens]
params = patterns + patterns + [limit]
rows = conn.execute(
f"""
SELECT
src.document_root,
src.document_uri,
src.title,
MAX({case_clauses}) AS match_count
FROM chunks c
JOIN documents core ON core.document_root = c.document_root
JOIN derivations der ON der.core_root = core.document_root
JOIN documents src ON src.document_root = der.src_root
WHERE core.source_type LIKE 'core:tfidf-%'
AND ({where_clauses})
GROUP BY src.document_root, src.document_uri, src.title
ORDER BY match_count DESC
LIMIT ?
""",
params,
).fetchall()
return rows
def _body_density_passes(
conn, document_root: str, qtokens: set[str], min_mentions: int = 3
) -> bool:
"""Body-token relevance: docs that mention a query token >= N times pass.
Cheap proxy for "this doc is actually about the topic" when title
doesn't help. Counts case-insensitive substring matches in concatenated
chunk content. min_mentions=3 skips docs that mention the term in
passing without being topical.
"""
if not qtokens:
return False
rows = conn.execute(
"SELECT content FROM chunks WHERE document_root = ? AND content IS NOT NULL",
(document_root,),
).fetchall()
if not rows:
return False
body = " ".join(unpack_chunk(r["content"]) or "" for r in rows).lower()
total_mentions = 0
for t in qtokens:
total_mentions += body.count(t.lower())
if total_mentions >= min_mentions:
return True
return False
def _search_corpus(
shards_dir: Path | None,
single_db: Path | None,
question: str,
over_fetch: int,
) -> list[_Hit]:
"""Two parallel searches across shards, merged:
- FTS5 body search (BM25 over chunk content).
- SQL title-LIKE search (catches articles whose body is short on the
query terms but whose title is the literal topic — e.g., the HTTP
protocol article doesn't out-frequency-score URL-heavy list pages
but it IS the topic).
Dedupe by document_root. Title hits get a baseline score that
out-ranks FTS5 body hits so the actual topic article rises to the top.
"""
qtokens = _title_query_tokens(question)
# Synonym expansion: a query for "athlon" also fetches AMD-titled docs.
accept_tokens = synonym_expand(qtokens)
paths: list[Path]
if shards_dir is not None:
paths = discover_shards(shards_dir)
elif single_db is not None:
paths = [Path(single_db)]
else:
paths = []
raw: list[tuple] = []
# Roots whose TF-IDF cores contain a query token — collected across
# shards. Used downstream by _filter_by_title_relevance as accept-path 2.
core_match_roots: set[str] = set()
# Per-shard mapping of doc root -> shard path, for body-density lookups
# in accept-path 3. Lets us reach back to the source shard cheaply.
root_to_shard: dict[str, str] = {}
for p in paths:
conn = connect(p)
try:
backend = FTS5Backend(conn)
for h in backend.search(question, limit=over_fetch):
raw.append(
(
h.score,
h.document_root,
h.document_uri,
h.title,
h.chunk_idx,
str(p.resolve()),
)
)
root_to_shard[h.document_root] = str(p.resolve())
# Parallel title search using synonym-expanded tokens.
for r in _search_titles(conn, list(accept_tokens), over_fetch):
title_lower = (r["title"] or "").lower().replace("_", " ")
overlap = sum(1 for t in accept_tokens if t in title_lower)
if overlap == 0:
continue
title_score = 50.0 + overlap * 10.0
raw.append(
(
title_score,
r["document_root"],
r["document_uri"],
r["title"],
0,
str(p.resolve()),
)
)
root_to_shard[r["document_root"]] = str(p.resolve())
# Core-keyword search: docs whose TF-IDF core keywords match.
# The query token doesn't need to be in title or even in body —
# being a TF-IDF keyword of the doc's core is enough signal.
for r in _docs_with_core_keyword_match(
conn, list(accept_tokens), over_fetch
):
core_match_roots.add(r["document_root"])
# Score scales with how many query tokens hit this doc's
# TF-IDF core. A 3-token coverage (e.g. Pentium_4's core
# carries "intel", "cpu", "faster" for the query "fastest
# intel CPU?") beats single-token title boosts (~80) that
# otherwise saturate the top with Intel_80486DX,
# Intel_8086, etc. — articles that share *one* word with
# the query but aren't the topical answer.
match_count = r["match_count"] or 1
kw_score = 40.0 + 25.0 * match_count
raw.append(
(
kw_score,
r["document_root"],
r["document_uri"],
r["title"],
0,
str(p.resolve()),
)
)
root_to_shard[r["document_root"]] = str(p.resolve())
finally:
conn.close()
raw.sort(key=lambda r: -r[0])
seen: set[str] = set()
out: list[_Hit] = []
for score, root, uri, title, idx, sp in raw:
if root in seen:
continue
seen.add(root)
out.append(
_Hit(
document_root=root,
document_uri=uri,
title=title,
score=score,
shard_path=sp,
chunk_idx=idx,
)
)
return out
def _rerank(
hits: list[_Hit],
question: str,
*,
core_match_roots: set[str] | None = None,
body_density_check: callable | None = None,
) -> list[_Hit]:
"""Filter out off-topic, then boost title-token matches by overlap count."""
hits = _filter_by_title_relevance(
hits,
question,
core_match_roots=core_match_roots,
body_density_check=body_density_check,
)
return _rerank_by_title(hits, question)
def _load_doc_text(shard_path: str, document_root: str) -> str | None:
"""Concatenate all hot chunks of a document. Returns None if cold or missing."""
conn = connect(shard_path)
try:
rows = conn.execute(
"SELECT content FROM chunks "
"WHERE document_root = ? AND content IS NOT NULL "
"ORDER BY idx ASC",
(document_root,),
).fetchall()
finally:
conn.close()
if not rows:
return None
return "\n\n".join(unpack_chunk(r["content"]) or "" for r in rows)
def _context_root(source_roots: list[str]) -> str:
"""Merkle root over sorted source document_roots — the v9.8 'source' dim
for multi-source answers. Sorting makes the root deterministic regardless
of search ranking order."""
if not source_roots:
return "00" * 32
sorted_roots = sorted(source_roots)
if len(sorted_roots) == 1:
return sorted_roots[0]
leaves = [bytes.fromhex(r) for r in sorted_roots]
return MerkleTree.build(leaves).root.hex()
def query(
*,
question: str,
qa_db: Path,
chat_client: ChatClient,
model_id: str,
revision: str = "",
quantization: str = "",
shards_dir: Path | None = None,
single_db: Path | None = None,
top_k: int = 8,
over_fetch: int = 32,
max_context_chars: int = 60000,
policy: dict | None = None,
chain: str = "private",
) -> dict:
"""Answer `question` using the corpus. Cache to qa_db. Returns a result dict."""
policy = policy or DEFAULT_QUERY_POLICY
t_start = time.monotonic()
# 1. Search.
t_phase = time.monotonic()
hits = _search_corpus(shards_dir, single_db, question, over_fetch)
if not hits:
return {
"status": "no_sources",
"msg": "FTS5 search returned no hits",
"timings": {
"search_ms": _ms_since(t_phase),
"total_ms": _ms_since(t_start),
},
}
core_match_roots = getattr(hits, "_core_match_roots", set())
root_to_shard = getattr(hits, "_root_to_shard", {})
qtokens_lower = {t.lower() for t in _title_query_tokens(question)}
def _body_density_check(h) -> bool:
# Lazy per-hit check: open the shard, count token mentions in this doc.
sp = root_to_shard.get(h.document_root) or h.shard_path
if not sp:
return False
c = connect(sp)
try:
return _body_density_passes(c, h.document_root, qtokens_lower)
finally:
c.close()
hits = _rerank(
hits,
question,
core_match_roots=core_match_roots,
body_density_check=_body_density_check,
)
search_ms = _ms_since(t_phase)
# 2. Pull doc texts within budget.
t_phase = time.monotonic()
chosen: list[_Hit] = []
context_parts: list[str] = []
char_budget = max_context_chars
for h in hits[:top_k]:
text = _load_doc_text(h.shard_path, h.document_root)
if not text:
continue
if len(text) > char_budget:
text = text[:char_budget]
context_parts.append(
f"=== Source: {h.document_uri} ===\n{text}"
)
chosen.append(h)
char_budget -= len(text)
if char_budget <= 0:
break
if not chosen:
return {
"status": "no_sources",
"msg": "top-k hits had cold or empty content",
"timings": {
"search_ms": search_ms,
"context_ms": _ms_since(t_phase),
"total_ms": _ms_since(t_start),
},
}
context = "\n\n".join(context_parts)
context_ms = _ms_since(t_phase)
# 3. Build messages + hashes.
messages = [
{"role": "system", "content": policy["system_prompt"]},
{
"role": "user",
"content": f"Sources:\n\n{context}\n\n---\n\nQuestion: {question}",
},
]
context_root = _context_root([h.document_root for h in chosen])
qhash = question_hash(question)
mhash = model_profile_hash(model_id, revision, quantization)
chash = conversation_hash(messages)
ghash = governance_policy_hash(policy)
ckey = cache_key(
context_root,
qhash,
mhash,
chash,
ghash,
SCHEMA_VERSION,
CANONICALIZATION_VERSION,
CHUNKING_VERSION,
)
qa_conn = connect(qa_db)
try:
# 4. Cache lookup.
t_phase = time.monotonic()
cached = qa_conn.execute(
"SELECT * FROM providence_cache "
"WHERE cache_key = ? AND falsification_state = 'live'",
(ckey,),
).fetchone()
cache_lookup_ms = _ms_since(t_phase)
if cached is not None:
now = int(time.time())
with transaction(qa_conn):
qa_conn.execute(
"UPDATE providence_cache "
"SET hit_count = hit_count + 1, last_hit_at = ? "
"WHERE cache_key = ?",
(now, ckey),
)
return {
"status": "cache_hit",
"audit_mode": "STRICT",
"cache_key": ckey,
"context_root": context_root,
"answer_text": cached["answer_text"],
"sources": json.loads(cached["merkle_proof"])["sources"],
"timings": {
"search_ms": search_ms,
"context_ms": context_ms,
"cache_lookup_ms": cache_lookup_ms,
"llm_ms": None,
"persist_ms": None,
"total_ms": _ms_since(t_start),
},
}
# 5. Cache miss — call LLM.
t_phase = time.monotonic()
answer_text = chat_client.chat_completion(
messages,
model=model_id,
temperature=policy["temperature"],
max_tokens=policy["max_tokens"],
top_p=policy.get("top_p", 1.0),
)
llm_ms = _ms_since(t_phase)
# 6. Persist record + audit event.
t_phase = time.monotonic()
proof_obj = {
"context_root": context_root,
"sources": [
{
"document_root": h.document_root,
"document_uri": h.document_uri,
"title": h.title,
"score": h.score,
"chunk_idx": h.chunk_idx,
"shard": Path(h.shard_path).name,
}
for h in chosen
],
}
proof_blob = json.dumps(proof_obj, separators=(",", ":"))
now = int(time.time())
with transaction(qa_conn):
event_hash = append_audit(
qa_conn,
event_type="providence_query",
subject_root=ckey,
body={
"context_root": context_root,
"n_sources": len(chosen),
"model_id": model_id,
"revision": revision,
"quantization": quantization,
"answer_chars": len(answer_text),
"context_chars": len(context),
},
ts=now,
)
qa_conn.execute(
"INSERT INTO providence_cache "
"(cache_key, source_root, document_uri, question_hash, question_text, "
" answer_text, merkle_proof, model_profile_hash, conversation_hash, "
" governance_policy_hash, schema_version, canonicalization_version, "
" chunking_version, falsification_state, chain, audit_event_hash, "
" created_at, hit_count) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'live', ?, ?, ?, 0)",
(
ckey,
context_root,
"corpus://multi-source",
qhash,
question,
answer_text,
proof_blob,
mhash,
chash,
ghash,
SCHEMA_VERSION,
CANONICALIZATION_VERSION,
CHUNKING_VERSION,
chain,
event_hash,
now,
),
)
finally:
qa_conn.close()
persist_ms = _ms_since(t_phase)
return {
"status": "cache_miss_then_written",
"audit_mode": "STRICT",
"cache_key": ckey,
"context_root": context_root,
"answer_text": answer_text,
"sources": proof_obj["sources"],
"timings": {
"search_ms": search_ms,
"context_ms": context_ms,
"cache_lookup_ms": cache_lookup_ms,
"llm_ms": llm_ms,
"persist_ms": persist_ms,
"total_ms": _ms_since(t_start),
},
}
def _ms_since(t: float) -> float:
"""Wall-time elapsed in milliseconds, rounded to 1 decimal."""
return round((time.monotonic() - t) * 1000, 1)