wiki-kb/wikikb/indexer.py
kuhyx 71e4a6115e Consolidate CK3/KCD wiki knowledge bases into one generic wiki-kb
Merges ~/ck3-knowledge and ~/kcd-knowledge, which were 90% duplicate code and
neither of which had ever been committed anywhere.

Works on any wiki, not just MediaWiki:
- sources.py splits the engine-specific parts (enumerate, fetch) behind a Source
  protocol. MediaWikiSource is preferred whenever an api.php exists;
  SitemapHtmlSource handles custom engines with no API (verified on Fextralife).
  Everything downstream was already source-agnostic.
- scripts/detect_wiki.py probes /api.php, /w/api.php then /sitemap.xml and prints
  the config to use. The API path and article URL vary independently — Fandom
  serves /api.php but /wiki/{Title}.
- Profiles are plain env files, since config.py was already fully env-driven.

Per-wiki behaviour that must be measured rather than assumed:
- Subpage policy. On the CK3 wiki "A/B" is a mod; on the KCD wiki it is the
  sequel, and rejecting subpages deletes 441 pages of wanted content.
- Noise sections. "Other languages" tables were 7% of KCD chunks and matched
  English queries by repeating every title in eight languages.
- Variant tagging for wikis covering two editions, with scopes that exclude the
  other edition but keep both/unknown pages.

Build performance: length bucketing in embed_efficiently is 2.7x (fastembed pads
each batch to its longest member; 184-char median vs 2000-char max). With noise
sections that is 61 min -> 20 min on a 6,140-page wiki at half the workers.

Fandom portable-infoboxes are transformed to tables before markdownify —
otherwise stat values survive but detach from their labels.

100% branch coverage, ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 18:12:56 +02:00

146 lines
5.2 KiB
Python

"""Build the search index: SQLite (chunks + FTS5) and a row-aligned embeddings file."""
from __future__ import annotations
import sqlite3
import sys
from collections.abc import Callable
from pathlib import Path
import numpy as np
from . import config
from .chunker import Chunk, chunk_document
from .embed import embed_passages
_SCHEMA = """
CREATE TABLE chunks (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
url TEXT NOT NULL,
source TEXT NOT NULL,
variant TEXT NOT NULL,
section TEXT NOT NULL,
text TEXT NOT NULL
);
CREATE INDEX chunks_source ON chunks(source);
CREATE INDEX chunks_variant ON chunks(variant);
CREATE VIRTUAL TABLE chunks_fts USING fts5(
text, title, section,
content='chunks', content_rowid='id'
);
"""
# Passages are stored at rowid = position+1; embeddings row `i` aligns with rowid i+1.
EmbedFn = Callable[[list[str]], np.ndarray]
def _embed_text(chunk: Chunk) -> str:
"""Context-augmented text for embedding: page title + section + body.
The stored/displayed chunk text stays clean, but the embedding carries which
page and section a chunk is from, so trait/topic queries rank the right page up
instead of any page that merely mentions the query's generic words.
"""
return f"{chunk.title}. {chunk.section}. {chunk.text}"
def embed_efficiently(texts: list[str], embed_fn: EmbedFn) -> np.ndarray:
"""Embed passages, avoiding two large sources of wasted compute.
1. **Length bucketing.** fastembed pads every batch to its longest member, so one
2000-char passage makes the other 31 in its batch pay that cost. Wiki chunks
are wildly uneven (on the KCD wiki: 184-char median, 2000-char max, 53% under
200 chars), so most of the compute was padding. Sorting by length before
batching measured **2.7x faster** end to end.
2. **Deduplication.** Identical passages are embedded once and the vector reused.
Note this is usually a small win: ``_embed_text`` prefixes each passage with
its page title, which makes per-page boilerplate distinct again (16% of raw
chunk texts were duplicates on the KCD wiki, but only 0.1% after prefixing).
Vectors are returned in the caller's ORIGINAL order. That is load-bearing: the
index's one alignment invariant is that embedding row ``i`` is chunk rowid
``i+1``, so the sort must be undone here rather than leaking to the caller.
"""
if not texts:
return embed_fn([])
first_index: dict[str, int] = {}
for text in texts:
first_index.setdefault(text, len(first_index))
unique = list(first_index)
by_length = sorted(range(len(unique)), key=lambda i: len(unique[i]))
sorted_vectors = embed_fn([unique[i] for i in by_length])
unique_vectors = np.empty_like(sorted_vectors)
unique_vectors[by_length] = sorted_vectors # undo the length sort
return unique_vectors[[first_index[t] for t in texts]] # scatter duplicates back
def collect_chunks(corpus_dir: Path) -> list[Chunk]:
"""Chunk every markdown document in the corpus, in stable filename order."""
chunks: list[Chunk] = []
for doc in sorted(corpus_dir.glob("*.md")):
chunks.extend(chunk_document(doc.read_text(encoding="utf-8")))
return chunks
def build_index(
*,
corpus_dir: Path | None = None,
db_path: Path | None = None,
embeddings_path: Path | None = None,
embed_fn: EmbedFn = embed_passages,
log: Callable[[str], None] = print,
) -> int:
"""(Re)build the index from the corpus. Returns the chunk count.
Paths default to the live ``config`` values, resolved at call time so tests
can redirect them via monkeypatch.
"""
corpus_dir = corpus_dir or config.CORPUS_DIR
db_path = db_path or config.DB_PATH
embeddings_path = embeddings_path or config.EMBEDDINGS_PATH
chunks = collect_chunks(corpus_dir)
log(f"Chunked {len(chunks)} passages from {corpus_dir}")
vectors = embed_efficiently([_embed_text(c) for c in chunks], embed_fn)
log(f"Embedded {vectors.shape[0]} passages (dim={vectors.shape[1] if vectors.size else 0})")
db_path.parent.mkdir(parents=True, exist_ok=True)
db_path.unlink(missing_ok=True)
con = sqlite3.connect(db_path)
try:
con.executescript(_SCHEMA)
con.executemany(
"INSERT INTO chunks (id, title, url, source, variant, section, text) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
[
(i + 1, c.title, c.url, c.source, c.variant, c.section, c.text)
for i, c in enumerate(chunks)
],
)
con.execute(
"INSERT INTO chunks_fts (rowid, text, title, section) "
"SELECT id, text, title, section FROM chunks"
)
con.commit()
finally:
con.close()
np.save(embeddings_path, vectors)
log(f"Wrote {db_path} and {embeddings_path}")
return len(chunks)
def main(argv: list[str] | None = None) -> int:
count = build_index()
print(f"\nIndex built: {count} chunks.")
return 0
if __name__ == "__main__": # pragma: no cover
from . import cpu
pinned = cpu.isolate()
print(f"CPU-isolated to {len(pinned)} cores (reserved {sorted(config.RESERVED_CORES)}).")
sys.exit(main())