Phase 2 of the holistic-tuning pair. Replaces the un-indexable LOWER(title) LIKE '%tok%' title-search with an FTS5 MATCH-based lookup. The structural fix that was deferred when the >5-token bypass landed (commit1d70c4f). (1) Schema: new `documents_fts` virtual table over the title column. Contentless mode (same trick as chunks_fts) — stores only the inverted index, not a copy of the title. Joins back to documents via rowid for the post-filter caller. (2) Extractor: backfill_documents_fts (registered as evidence_kind "documents_fts"). Reset-and-rebuild the index from documents in one INSERT...SELECT. Idempotent. Cost: ~2.5s per 870k-doc shard. (3) `_search_titles` rewritten: try FTS5 MATCH first, fall back to LOWER(title) LIKE only on shards lacking documents_fts data (legacy ingest pre-this-commit). MATCH expression OR-joins quoted query tokens; falls through to LIKE form on tokenizer edge cases. (4) Removed the `>5 accept_tokens` bypass in `_search_corpus` that commit1d70c4fadded as a workaround for the LIKE full-scan cost. With FTS5 the title search is sub-second regardless of token count, so synonym-expanded title search is affordable at any query length. Live verified on the 19-token brain-tech query with 50 accept_tokens (post-IDF expansion): Pre-FTS5: ~50s (LIKE '%tok%' × 870k docs × 4 shards) Post-FTS5: ~0.04s ← 1250× speedup End-to-end query: 10.4s (was 11.6-13s; the saved title-search time partially absorbed by Hermes inference variance). Backfill cost: 10.5s wall-clock across 4 wiki shards + 1 crawl shard. Storage: ~30 MB per wiki shard for documents_fts (well inside the 90 MB/shard concept-layer budget). Re-running is idempotent (DELETE FROM ... INSERT INTO ...). Tests: 641 passed (no regression).
288 lines
11 KiB
Python
288 lines
11 KiB
Python
"""Extractors: derive concept relations from the corpus that's already
|
||
in the SQLite shards. None of these crawl anything — the crawler &
|
||
HTML/wikitext parsers already populated ``edges`` and ``documents``;
|
||
extractors just read those rows & emit concept_relations.
|
||
|
||
Each extractor has a stable ``evidence_kind`` string that lets an
|
||
operator purge its output cleanly via
|
||
``aborist concepts purge --evidence-kind X``.
|
||
|
||
Built-in extractors:
|
||
|
||
- ``link_reciprocity_synonym`` — for any pair of documents A & B where
|
||
``edges`` has BOTH A→B and B→A, emit a synonym edge between their
|
||
title-tokens. Bidirectional linking is the strongest topical-cluster
|
||
signal a link graph carries; it works for Wikipedia (See-also +
|
||
cross-references), HTML site internal links (russell.ballestrini.net
|
||
pattern), or any other document graph the corpus already holds.
|
||
|
||
Evidence kind: ``link_reciprocity``.
|
||
|
||
Adding a new extractor: implement a callable
|
||
``(conn, *, derived_from) -> dict[str, int]`` that walks the shard
|
||
& calls ``add_concept_relation`` for each finding. Register it under
|
||
a stable evidence_kind string. ``aborist concepts derive`` reads from
|
||
EXTRACTORS to dispatch.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import sqlite3
|
||
import time
|
||
from typing import Callable
|
||
|
||
from aborist.concepts.store import add_concept_relation
|
||
|
||
# Tokens too generic to use as anchor for a synonym edge. A reciprocal
|
||
# link between two pages whose titles only share "the", "of", "and"
|
||
# etc. is not topical evidence — it would generate noise.
|
||
_TITLE_STOPWORDS = frozenset({
|
||
"the", "a", "an", "of", "and", "or", "in", "on", "at", "to",
|
||
"for", "with", "by", "from", "is", "as", "was", "are", "be",
|
||
})
|
||
|
||
# Title-token extractor: lowercase alpha runs ≥4 chars, stopword-stripped.
|
||
# Conservative on purpose — false positives in synonym edges hurt
|
||
# retrieval more than false negatives (a missed synonym is recovered
|
||
# later by a different extractor; a wrong synonym poisons every query).
|
||
_TITLE_TOKEN_RE = re.compile(r"[a-z][a-z0-9'\-]+")
|
||
|
||
|
||
def _title_tokens(title: str) -> set[str]:
|
||
if not title:
|
||
return set()
|
||
raw = _TITLE_TOKEN_RE.findall(title.lower().replace("_", " "))
|
||
return {t for t in raw if len(t) >= 4 and t not in _TITLE_STOPWORDS}
|
||
|
||
|
||
def link_reciprocity_synonym(
|
||
conn: sqlite3.Connection,
|
||
*,
|
||
derived_from: str | None = None,
|
||
) -> dict[str, int]:
|
||
"""For every pair of docs (A, B) with reciprocal edges (A→B AND B→A),
|
||
emit synonym edges between every (title_token_a, title_token_b) pair.
|
||
|
||
Idempotent: each (source_root, kind, token, target, evidence_kind)
|
||
is UNIQUE so re-running adds nothing if no new reciprocal pairs
|
||
have appeared in the corpus since last derivation.
|
||
|
||
Returns ``{"reciprocal_pairs": N, "synonyms_inserted": M,
|
||
"synonyms_skipped": K}``.
|
||
"""
|
||
# Reciprocal pairs: rows where (A,B) AND (B,A) both exist with
|
||
# resolved dst_root. Using a self-join filtered to A < B so each
|
||
# pair appears once.
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT e1.src_root AS a, e1.dst_root AS b
|
||
FROM edges e1
|
||
JOIN edges e2
|
||
ON e2.src_root = e1.dst_root
|
||
AND e2.dst_root = e1.src_root
|
||
WHERE e1.dst_root <> ''
|
||
AND e2.dst_root <> ''
|
||
AND e1.src_root < e1.dst_root
|
||
GROUP BY e1.src_root, e1.dst_root
|
||
"""
|
||
).fetchall()
|
||
|
||
if not rows:
|
||
return {"reciprocal_pairs": 0, "synonyms_inserted": 0, "synonyms_skipped": 0}
|
||
|
||
# Resolve doc titles in a single pass.
|
||
roots = {r["a"] for r in rows} | {r["b"] for r in rows}
|
||
title_rows = conn.execute(
|
||
f"SELECT document_root, title FROM documents "
|
||
f"WHERE document_root IN ({','.join('?' * len(roots))})",
|
||
tuple(roots),
|
||
).fetchall()
|
||
titles: dict[str, str] = {r["document_root"]: r["title"] or "" for r in title_rows}
|
||
|
||
derived_at = int(time.time())
|
||
derived_from = derived_from or "extract.link_reciprocity_synonym"
|
||
syn_ins = syn_skip = 0
|
||
pair_count = 0
|
||
|
||
for r in rows:
|
||
a_root, b_root = r["a"], r["b"]
|
||
a_tokens = _title_tokens(titles.get(a_root, ""))
|
||
b_tokens = _title_tokens(titles.get(b_root, ""))
|
||
if not a_tokens or not b_tokens:
|
||
continue
|
||
# Skip the self-overlap (token appears in both titles): that's
|
||
# not a synonym, it's the same word.
|
||
cross = {(a, b) for a in a_tokens for b in b_tokens if a != b}
|
||
if not cross:
|
||
continue
|
||
pair_count += 1
|
||
# Use the first doc's root as source_root — the relation is
|
||
# provenanced to the side whose links we observed first.
|
||
for a_tok, b_tok in cross:
|
||
inserted = add_concept_relation(
|
||
conn,
|
||
source_root=a_root,
|
||
relation_kind="synonym",
|
||
token=a_tok,
|
||
target=b_tok,
|
||
evidence_kind="link_reciprocity",
|
||
derived_at=derived_at,
|
||
derived_from=derived_from,
|
||
)
|
||
if inserted:
|
||
syn_ins += 1
|
||
else:
|
||
syn_skip += 1
|
||
|
||
conn.commit()
|
||
return {
|
||
"reciprocal_pairs": pair_count,
|
||
"synonyms_inserted": syn_ins,
|
||
"synonyms_skipped": syn_skip,
|
||
}
|
||
|
||
|
||
def backfill_token_idf(
|
||
conn: sqlite3.Connection,
|
||
*,
|
||
derived_from: str | None = None,
|
||
) -> dict[str, int]:
|
||
"""Populate ``concept_token_idf`` with chunk-frequency for every
|
||
token that appears in ``concept_relations`` (as token OR target).
|
||
|
||
Uses FTS5's ``fts5vocab`` virtual table to read per-term doc
|
||
frequencies directly from the chunks_fts index — no full table
|
||
scan. Bounded by the size of the concept_relations token set
|
||
(~10-50k unique tokens per shard, much smaller than the 6M-term
|
||
full vocab).
|
||
|
||
The output drives ``synonym_expand``'s cap-time ranking: when the
|
||
expanded set exceeds ``MAX_TOTAL_TOKENS``, neighbors with lower
|
||
doc_freq (rarer in corpus = more topical) win the truncation.
|
||
Replaces the prior alphabetical-truncation heuristic.
|
||
|
||
Idempotent: ``INSERT OR REPLACE`` overwrites any existing row, so
|
||
re-running on a re-ingested shard refreshes the counts.
|
||
|
||
Returns ``{"tokens_indexed": N, "total_docs": M, "elapsed_ms": K}``.
|
||
"""
|
||
derived_at = int(time.time())
|
||
derived_from = derived_from or "extract.backfill_token_idf"
|
||
t0 = time.time()
|
||
|
||
# Total docs for IDF normalization.
|
||
total_docs = conn.execute("SELECT COUNT(*) FROM documents").fetchone()[0]
|
||
if total_docs == 0:
|
||
return {"tokens_indexed": 0, "total_docs": 0, "elapsed_ms": 0}
|
||
|
||
# Materialize the union of tokens & targets from concept_relations.
|
||
# Lowercase already at concept_relations write time so direct match.
|
||
unique_tokens = set(
|
||
r[0]
|
||
for r in conn.execute(
|
||
"SELECT DISTINCT token FROM concept_relations "
|
||
"UNION SELECT DISTINCT target FROM concept_relations"
|
||
).fetchall()
|
||
)
|
||
if not unique_tokens:
|
||
return {"tokens_indexed": 0, "total_docs": total_docs, "elapsed_ms": 0}
|
||
|
||
# FTS5 vocab table — virtual; cheap to create on the fly.
|
||
conn.execute(
|
||
"CREATE VIRTUAL TABLE IF NOT EXISTS _tmp_chunks_vocab "
|
||
"USING fts5vocab(chunks_fts, 'col')"
|
||
)
|
||
try:
|
||
# Bulk-fetch doc-freq for every token in concept_relations.
|
||
# The IN clause with a temp set is the cleanest path; vocab
|
||
# row count is bounded by |unique_tokens| not the full 6M.
|
||
placeholders = ",".join("?" for _ in unique_tokens)
|
||
rows = conn.execute(
|
||
f"SELECT term, doc FROM _tmp_chunks_vocab WHERE term IN ({placeholders})",
|
||
tuple(unique_tokens),
|
||
).fetchall()
|
||
finally:
|
||
conn.execute("DROP TABLE _tmp_chunks_vocab")
|
||
|
||
# Insert/upsert. Tokens NOT found in vocab (concept-relations row
|
||
# added but no chunk contains the term) get doc_freq=0 so the
|
||
# ranking still has a row to look up; default-rank-as-rare-but-
|
||
# untrusted falls naturally out of the math (we treat doc_freq=0
|
||
# as "highest rarity" — same as a hapax).
|
||
conn.executemany(
|
||
"INSERT OR REPLACE INTO concept_token_idf "
|
||
"(token, doc_freq, total_docs, derived_at) VALUES (?, ?, ?, ?)",
|
||
[(term, freq, total_docs, derived_at) for term, freq in rows],
|
||
)
|
||
indexed_terms = {r[0] for r in rows}
|
||
missing_terms = unique_tokens - indexed_terms
|
||
if missing_terms:
|
||
conn.executemany(
|
||
"INSERT OR REPLACE INTO concept_token_idf "
|
||
"(token, doc_freq, total_docs, derived_at) VALUES (?, 0, ?, ?)",
|
||
[(t, total_docs, derived_at) for t in missing_terms],
|
||
)
|
||
conn.commit()
|
||
|
||
elapsed_ms = int((time.time() - t0) * 1000)
|
||
return {
|
||
"tokens_indexed": len(rows) + len(missing_terms),
|
||
"tokens_with_chunk_hits": len(rows),
|
||
"total_docs": total_docs,
|
||
"elapsed_ms": elapsed_ms,
|
||
}
|
||
|
||
|
||
# Registry: evidence_kind → extractor callable.
|
||
# Adding a new extractor: pick a stable evidence_kind string, implement
|
||
# the (conn, *, derived_from) -> dict signature, register it here.
|
||
# CLI command `aborist concepts derive --extractor X` reads this map.
|
||
def backfill_documents_fts(
|
||
conn: sqlite3.Connection,
|
||
*,
|
||
derived_from: str | None = None,
|
||
) -> dict[str, int]:
|
||
"""Populate ``documents_fts`` with every documents.title row.
|
||
|
||
Replaces the un-indexable LOWER(title) LIKE '%tok%' title-LIKE
|
||
search. After this runs, _search_titles can use FTS5 MATCH for
|
||
O(K) hash lookups instead of O(corpus × |tokens|) full scan —
|
||
which means synonym-expanded title search becomes affordable
|
||
on long queries without blowing the per-shard budget.
|
||
|
||
Idempotent: DELETE FROM documents_fts; INSERT ... — we reset &
|
||
rebuild rather than incrementally upsert because FTS5 contentless
|
||
tables don't support partial-key dedupe well. Cost is bounded by
|
||
document count (~870k per shard).
|
||
|
||
Returns ``{"rows_indexed": N, "elapsed_ms": M}``.
|
||
"""
|
||
derived_at = int(time.time())
|
||
derived_from = derived_from or "extract.backfill_documents_fts"
|
||
t0 = time.time()
|
||
conn.execute("DELETE FROM documents_fts")
|
||
cursor = conn.execute(
|
||
"INSERT INTO documents_fts (rowid, title) "
|
||
"SELECT rowid, title FROM documents WHERE title IS NOT NULL"
|
||
)
|
||
rows_indexed = cursor.rowcount
|
||
conn.commit()
|
||
return {
|
||
"rows_indexed": rows_indexed,
|
||
"elapsed_ms": int((time.time() - t0) * 1000),
|
||
}
|
||
|
||
|
||
EXTRACTORS: dict[str, Callable[..., dict[str, int]]] = {
|
||
"link_reciprocity": link_reciprocity_synonym,
|
||
# Not a relation extractor — populates concept_token_idf for IDF
|
||
# ranking at synonym_expand cap-time. Run AFTER any synonym
|
||
# extractor since it indexes the union of token + target columns.
|
||
"token_idf": backfill_token_idf,
|
||
# Not a relation extractor — populates the documents_fts virtual
|
||
# table that replaces the un-indexable title-LIKE backup search
|
||
# in qa.query._search_corpus. Run once per shard at ingest or
|
||
# after a bulk title backfill.
|
||
"documents_fts": backfill_documents_fts,
|
||
}
|