arborist/aborist/concepts/query.py
russell@unturf.com b7f087f26c
qa(concepts): per-token IDF for cap-time ranking — replaces alphabetical truncation
Phase 1 of the holistic-tuning pair fox requested 2026-05-02. Replaces
the dumb alphabetical-truncation heuristic in synonym_expand's cap
path with corpus-derived IDF ranking — rare topical synonyms win the
truncation, common-corpus noise drops.

(1) New per-shard table: concept_token_idf (token, doc_freq, total_docs,
    derived_at). Indexed on doc_freq for ORDER BY ranking. Folds into
    the cross-shard UNION view via _SHARDABLE_TABLES.

(2) Extractor: backfill_token_idf (registered as evidence_kind
    "token_idf"). Reads chunk-frequency from FTS5's fts5vocab virtual
    table, bounded by the union of token + target columns in
    concept_relations (only synonym tokens get an IDF row, not the 6M
    full corpus vocabulary). Idempotent INSERT OR REPLACE.

(3) Query layer: _load_token_idf sums per-token doc_freq across all
    shards (cross-shard ranking). _get_indices return tuple grew from
    (manual, derived, rivalry) → (manual, derived, rivalry, idf). Cache
    LRU keyed on shards_dir mtime so the IDF lookup is per-process-once.

(4) synonym_expand cap-time truncation: when expanded set exceeds
    MAX_TOTAL_TOKENS, sort neighbors by (doc_freq ASC, token ASC) so
    rare tokens come first. Tokens missing from concept_token_idf get
    a sentinel high-rarity score (treats unknown as hapax — fail-safe
    when the IDF backfill hasn't run yet).

Live verified on the brain-tech 19-token query:
  Pre-IDF expansion (alphabetical 50): bumper, buster, channel,
    convention, dave, duck, esp, field, glen, mind, minimum, ...
  Post-IDF expansion (rare-first 50): neuroimaging, neurons,
    neuroscience, neurotechnology, psychokinesis, telepathic,
    telepathy, thinking, tms, transcranial — all the brain-tech
    terms that lost the alphabetical race in pre-IDF now win.

Backfill cost: ~50s wall-clock across 4 wiki shards (~22k tokens
indexed per shard). Fts5vocab over chunks_fts is the right
infrastructure — no full corpus scan needed since we filter by the
concept_relations token set. Storage: ~1 MB per shard for the IDF
table. Stays well inside the ~90 MB/shard concept-layer budget fox
set earlier.

Tests: 641 passed (no regression). Live fixtures (boss baby,
amazon river/rainforest) all pass — ranking doesn't break gating.
2026-05-02 06:53:55 -04:00

300 lines
12 KiB
Python

"""Retrieval-time concept lookup. Cross-shard, read-only.
Public API matches the legacy ``aborist.qa.concepts`` shape so existing
call sites in ``query.py`` keep working unchanged. Behavior changes:
- Backed by the ``concept_relations`` SQLite table instead of in-Python
frozensets.
- Walks every shard in ``shards_dir`` (same UNION pattern as cross-shard
FTS5 search). Concept relations from shard 003 are visible to a query
routed at shard 000 — exactly what we want for a 3.47M-doc corpus
split across many shards.
- Token comparison is case-insensitive at the SQL layer (NOCASE on
LOWER()). Stored capitalization is preserved.
Cache: a per-process LRU keyed on ``shards_dir`` mtime. Lookups in a
hot loop don't re-walk shards. Cache invalidates when any shard file's
mtime changes (e.g. after `aborist concepts derive` writes new rows).
"""
from __future__ import annotations
import re
import time
from pathlib import Path
from aborist.store import connect_query
# Tokens that mean "user wants both sides of any rivalry shown" —
# kept here (not in DB) because compare-phrasing detection is a
# query-classification task, not a corpus-derived signal.
COMPARE_WORDS: frozenset[str] = frozenset({
"vs", "versus", "compare", "compared", "comparison", "compares",
"between", "difference", "differences", "or", "either",
})
def has_compare_phrasing(question: str) -> bool:
"""True if the question contains comparison language."""
lower = (question or "").lower()
words = set(re.findall(r"[a-z]+", lower))
return bool(words & COMPARE_WORDS)
# ---------------------------------------------------------------------------
# Cross-shard lookup with mtime-keyed cache
# ---------------------------------------------------------------------------
# Cache shape: { shards_dir_str: (mtime_sig, manual_index, derived_index, rivalry_pairs, token_idf) }
# - manual_index: curated synonym edges; always expanded
# - derived_index: corpus-derived synonym edges; expansion subject to per-token cap
# - rivalry_pairs: list of (set_a, set_b) — frozensets of lowercase tokens
# - token_idf: { token_lower: doc_freq } summed across shards, used for cap-time ranking
_CACHE: dict[str, tuple[tuple, dict, dict, list, dict]] = {}
def _shards_mtime_signature(shards_dir: Path) -> tuple:
"""Return a tuple of (path, mtime_ns) for every *.db in shards_dir.
Stable across runs as long as no shard's mtime changes."""
if not shards_dir.is_dir():
return ()
return tuple(
(str(p.resolve()), p.stat().st_mtime_ns)
for p in sorted(shards_dir.glob("*.db"))
)
def _load_token_idf(shards_dir: Path) -> dict[str, int]:
"""Sum per-token doc_freq across all shards.
The concept_token_idf table is per-shard (the `chunks_fts` index
it derives from is per-shard), so cross-shard ranking aggregates
via SUM over the UNION view exposed by connect_query. Tokens not
in any shard's table get NO entry; callers default to "treat as
rare" via dict.get() with a high-rarity default.
"""
conn = connect_query(shards_dir=shards_dir)
rows = conn.execute(
"SELECT token, SUM(doc_freq) AS df FROM concept_token_idf GROUP BY token"
).fetchall()
conn.close()
return {(r["token"] or "").lower(): int(r["df"] or 0) for r in rows}
def _load_indices(shards_dir: Path) -> tuple[dict, dict, list]:
"""Walk all shards, materialize the synonym & rivalry indices.
Synonyms map every token to its **direct neighbors only**, NOT the
transitive closure across reciprocal-link chains. Why: union-find
over the full Wikipedia reciprocal-link graph collapses everything
into one giant connected component (54k+ tokens for any seed in
a 4-shard corpus). Direct-neighbor expansion preserves the legacy
frozenset semantics (every member of a group expanded to the others
in that group, but the groups didn't chain).
Two synonym indices are built, one per evidence class:
- ``manual_index`` — manual_legacy + manual rows. Curated; always
expanded regardless of per-token degree.
Captures the brain-tech / AMD-family / etc.
seed groups whose anchor token has many
deliberate members (e.g. telepathy → 29
members of the brain-tech group).
- ``derived_index`` — link_reciprocity & other corpus-derived
extractors. Subject to per-token degree
cap because the Wikipedia link graph
carries topic-adjacency noise on generic
tokens (person, thoughts, language).
Rivalry pairs use the union of both indices for closure.
"""
conn = connect_query(shards_dir=shards_dir)
rows = conn.execute(
"SELECT relation_kind, evidence_kind, token, target FROM concept_relations"
).fetchall()
conn.close()
manual_index: dict[str, set[str]] = {}
derived_index: dict[str, set[str]] = {}
rivalry_rows: list[tuple[str, str]] = []
# Curated evidence kinds — never capped at expansion time.
MANUAL_KINDS = {"manual", "manual_legacy"}
for r in rows:
kind = r["relation_kind"]
evidence_kind = r["evidence_kind"]
a = (r["token"] or "").lower()
b = (r["target"] or "").lower()
if not a or not b or a == b:
continue
if kind == "synonym":
target_index = (
manual_index if evidence_kind in MANUAL_KINDS else derived_index
)
target_index.setdefault(a, set()).add(b)
target_index.setdefault(b, set()).add(a)
elif kind == "rivalry":
rivalry_rows.append((a, b))
# Rivalry pairs use union of manual + derived neighborhoods.
rivalry_pairs: list[tuple[frozenset[str], frozenset[str]]] = []
for a, b in rivalry_rows:
ga = frozenset(
manual_index.get(a, set()) | derived_index.get(a, set()) | {a}
)
gb = frozenset(
manual_index.get(b, set()) | derived_index.get(b, set()) | {b}
)
rivalry_pairs.append((ga, gb))
return manual_index, derived_index, rivalry_pairs
def _get_indices(shards_dir: Path | str | None) -> tuple[dict, dict, list, dict]:
"""Return (manual_index, derived_index, rivalry_pairs, token_idf),
using cached values when the shard mtime signature is unchanged."""
if shards_dir is None:
return {}, {}, [], {}
p = Path(shards_dir)
key = str(p.resolve())
sig = _shards_mtime_signature(p)
cached = _CACHE.get(key)
if cached is not None and cached[0] == sig:
return cached[1], cached[2], cached[3], cached[4]
manual, derived, riv = _load_indices(p)
idf = _load_token_idf(p)
_CACHE[key] = (sig, manual, derived, riv, idf)
return manual, derived, riv, idf
def invalidate_cache() -> None:
"""Drop all cached indices. Call after a writer commits new rows
(the mtime check would catch this on next read, but invalidating
explicitly is faster on the same-process write+read pattern)."""
_CACHE.clear()
# ---------------------------------------------------------------------------
# Public API — matches the legacy ``aborist.qa.concepts`` shape
# ---------------------------------------------------------------------------
# Caps that match the legacy (frozenset) expansion size. The corpus-
# derived synonym graph is noisier than hand-curated frozensets:
# generic tokens like "person" / "thoughts" / "language" have ~20+
# reciprocal-link neighbors each, most of which are topic-adjacency
# noise rather than actual synonyms. Without these caps a 19-token
# query expands to ~400 accept tokens, and the title-LIKE search
# multiplies that by the document count to a many-minute hang.
#
# MAX_NEIGHBORS_PER_TOKEN: any token with more than this many
# direct neighbors is treated as "too generic to expand" — we add
# only the token itself, not its neighbors. Mirrors how the legacy
# frozensets covered named entities (athlon, pentium) but not common
# words (person, thoughts).
#
# MAX_TOTAL_TOKENS: overall cap on the expanded set. Original query
# tokens are always preserved; once total exceeds the cap, neighbors
# are sorted alphabetically & truncated. Bound on title-LIKE clause
# count keeps the SQL tractable on a 3.47M-doc corpus.
MAX_NEIGHBORS_PER_TOKEN = 8
MAX_TOTAL_TOKENS = 50
def synonym_expand(
tokens: set[str],
*,
shards_dir: Path | str | None = None,
max_neighbors_per_token: int = MAX_NEIGHBORS_PER_TOKEN,
max_total: int = MAX_TOTAL_TOKENS,
) -> set[str]:
"""Add direct synonym neighbors for any input token whose degree
is bounded enough that its neighbors are likely topical, not
topic-adjacency noise.
Two caps protect retrieval performance & quality:
1. ``max_neighbors_per_token`` — tokens with more direct neighbors
than this contribute NO expansion. Generic tokens ("person",
"thoughts") have huge degree in the Wikipedia reciprocal-link
graph; expanding them dumps random topical-cluster noise.
Specific named entities ("athlon", "telepathy") have small
focused neighborhoods that pass the cap.
2. ``max_total`` — overall cap on expanded set size. Bounds the
SQL clause count downstream. Original query tokens are always
preserved; if total > cap, neighbors are sorted alphabetically
& truncated.
"""
if not tokens:
return set()
if shards_dir is None:
return set(tokens)
manual_index, derived_index, _, token_idf = _get_indices(shards_dir)
qlower = {t.lower() for t in tokens}
expanded: set[str] = set(qlower)
# Manual (curated) synonyms always expand: brain-tech / AMD-family /
# etc. seed groups have legitimately many members per anchor & we
# trust the curation.
for t in qlower:
if t in manual_index:
expanded |= manual_index[t]
# Derived (corpus-extracted) synonyms cap on per-token degree.
# Generic tokens like "person" / "thoughts" / "language" have wide
# noisy neighborhoods in the reciprocal-link graph — skip those.
# Specific tokens with bounded degree expand cleanly.
for t in qlower:
if t not in derived_index:
continue
neighbors = derived_index[t]
if len(neighbors) > max_neighbors_per_token:
continue
expanded |= neighbors
if len(expanded) > max_total:
# IDF-rank the neighbors (rarer = more topical = keep first).
# Tokens absent from concept_token_idf get a sentinel high-
# rarity score so a hapax doesn't lose to a known-common
# token at the truncation boundary. When the IDF table is
# empty (backfill not run yet), ranking degenerates to
# alphabetical (fallback compatibility).
neighbors_only = expanded - qlower
# Lower doc_freq → rarer → higher rank → kept first.
# `total_docs + 1` sentinel pushes "missing" tokens to the
# rarest-bucket so they tie-break before the most common.
SENTINEL_HIGH_RARITY = 0
ranked = sorted(
neighbors_only,
key=lambda t: (token_idf.get(t, SENTINEL_HIGH_RARITY), t),
)
budget = max(0, max_total - len(qlower))
expanded = qlower | set(ranked[:budget])
return expanded
def rivalry_excluded(
tokens: set[str],
*,
shards_dir: Path | str | None = None,
compare_phrasing: bool = False,
) -> set[str]:
"""Tokens whose presence in a doc title means EXCLUDE that doc.
For each rivalry pair (A, B): if exactly ONE side appears in the
query AND no comparison language was used, exclude the OTHER side's
tokens. If both sides appear, or if the user asked for a comparison,
no exclusion (they wanted both).
"""
if compare_phrasing or not tokens or shards_dir is None:
return set()
_, _, rivalry_pairs, _ = _get_indices(shards_dir)
qlower = {t.lower() for t in tokens}
excluded: set[str] = set()
for a, b in rivalry_pairs:
a_in = bool(qlower & a)
b_in = bool(qlower & b)
if a_in and not b_in:
excluded |= b
elif b_in and not a_in:
excluded |= a
return excluded