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>
146 lines
5.2 KiB
Python
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())
|