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.
300 lines
12 KiB
Python
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
|