arborist/aborist/concepts/extract.py
russell@unturf.com 7887a588f9
qa(retrieval): documents_fts FTS5 index — title search 1250× faster
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 (commit 1d70c4f).

(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 commit 1d70c4f added 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).
2026-05-02 06:57:10 -04:00

288 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
}