"""Embed corpus chunks locally (bge-small on CPU) and upsert into Neon.
Local by decision: Gemini's free embedding quota (~100 items/day) would take
weeks for a 7K-chunk corpus. A small open model has no quota, no key, and no
cost — the whole corpus embeds in minutes on a CI runner's CPU, and the same
model embeds queries at answer time.
Overnight/CI-safe by construction:
- resumable: a chunk whose (chunk_key, content_hash) is already in the DB is
skipped, and every batch commits — kill it anytime, re-run continues;
- content is embedded and indexed (tsvector) but never stored.
"""
from __future__ import annotations
import hashlib
import json
import os
import threading
from collections.abc import Iterator
from functools import cache
from pathlib import Path
from ingest.chunk_docs import chunk_page
from ingest.crawl import CORPUS_DIR, load_page
EMBED_MODEL = os.environ.get("TORCHDOCS_EMBED_MODEL", "BAAI/bge-small-en-v1.5")
# bge convention: queries get an instruction prefix, documents do not
QUERY_PREFIX = "Represent this sentence for searching relevant passages: "
BATCH_SIZE = 128
MAX_EMBED_CHARS = 2000 # bge context is 512 tokens; beyond it is truncated anyway
# Contextual-Retrieval glosses: {url: one-sentence plain-language description},
# generated by scripts/generate_glosses.py and committed. indexed_text()
# prepends the page's gloss so terse reference pages embed (and tsvector-match)
# near the descriptive questions users actually ask.
GLOSSES_PATH = Path(__file__).parent / "glosses.jsonl"
# QuOTE-style hypothetical questions: {url: [task-vocabulary questions the page
# answers]}, generated by scripts/generate_questions.py and committed.
# indexed_text() folds them in — question→question matching for pages whose
# one-sentence gloss couldn't bridge the vocabulary gap (measured 2026-07-09:
# Linear still at true dense rank ~3,400 post-gloss).
QUESTIONS_PATH = Path(__file__).parent / "questions.jsonl"
@cache
def load_glosses() -> dict[str, str]:
if not GLOSSES_PATH.exists():
return {}
rows = (json.loads(line) for line in GLOSSES_PATH.open() if line.strip())
return {row["url"]: row["gloss"] for row in rows}
@cache
def load_questions() -> dict[str, list[str]]:
if not QUESTIONS_PATH.exists():
return {}
rows = (json.loads(line) for line in QUESTIONS_PATH.open() if line.strip())
return {row["url"]: row["questions"] for row in rows}
def _enrichment_stamp(path: Path) -> str:
"""Content hash of a committed enrichment file (glosses / questions) —
folded into the recipe below, so any content change (or first arrival)
forces the one-time full re-embed itself; no manual version bump to
forget."""
if not path.exists():
return "none"
return hashlib.sha256(path.read_bytes()).hexdigest()[:8]
def _gloss_stamp() -> str:
return _enrichment_stamp(GLOSSES_PATH)
def _questions_stamp() -> str:
return _enrichment_stamp(QUESTIONS_PATH)
# bump the version prefix when indexed_text()'s SHAPE changes → forces a
# one-time full re-embed (dims same, so the row-skip check would otherwise keep
# stale vectors). v7: QuOTE-style hypothetical questions join the indexed text
# (v6 fixed synopsis extraction to read the Sphinx
description line). The
# model tag is folded in so swapping models is itself a recipe change: a
# same-dims swap (e.g. two 768d models) still forces a re-embed, and index_meta
# stays honest about which model's vectors are live (a dims change also
# rebuilds the table outright — see index/db.ensure_schema). The gloss and
# question stamps do the same for enrichment-content changes.
EMBED_RECIPE = f"v7-{EMBED_MODEL.split('/')[-1]}-g{_gloss_stamp()}-q{_questions_stamp()}"
def chunk_key(unit: dict) -> str:
raw = f"{unit['url']}#{unit['anchor']}#{' > '.join(unit['heading_path'])}"
# part 0 keeps the legacy key format on purpose: existing rows stay valid
# and the (chunk_key, content_hash) skip still holds, so introducing
# size-capped parts embeds ONLY the new part-rows — no full re-embed.
if unit.get("part", 0):
raw += f"#part{unit['part']}"
return hashlib.sha256(raw.encode()).hexdigest()
def symbol_from_url(url: str) -> str:
"""The qualified symbol an API page documents, from its filename.
generated/torch.nn.functional.scaled_dot_product_attention.html
→ torch.nn.functional.scaled_dot_product_attention
"""
stem = url.rsplit("/", 1)[-1].removesuffix(".html")
root = stem.split(".")[0]
return stem if ("." in stem and root in {"torch", "torchvision", "torchaudio"}) else ""
def indexed_text(unit: dict) -> str:
"""What we embed AND tsvector: symbol + synopsis + gloss + questions +
heading + body.
The symbol gives the page strong lexical weight for symbol-typed queries.
The synopsis (the page's own first prose sentence, extracted at chunk time)
and the gloss (LLM-written, Contextual Retrieval) are the semantic bridge
to descriptive questions — reference pages' own text is signature-shaped
and embeds far from "which loss takes raw logits?", so the plain-language
sentences are prepended to every chunk of the page, feeding both the
vector and the tsvector. The hypothetical questions (QuOTE-style) go one
step further for pages the gloss couldn't move: they put the QUESTION
phrasing itself into both channels, so a user's descriptive query matches
question→question instead of question→signature.
"""
parts = []
symbol = symbol_from_url(unit["url"])
if symbol:
parts.append(symbol)
if unit.get("synopsis"):
parts.append(unit["synopsis"])
gloss = load_glosses().get(unit["url"], "")
if gloss:
parts.append(gloss)
questions = load_questions().get(unit["url"], [])
if questions:
parts.append(" ".join(questions))
heading = " > ".join(unit.get("heading_path", []))
if heading:
parts.append(heading)
parts.append(unit["content"])
return "\n".join(parts)
def iter_corpus_units(corpus_dir: Path = CORPUS_DIR) -> Iterator[dict]:
"""Walk the snapshot and yield every chunk unit of every page."""
for path in sorted(corpus_dir.rglob("*.md")):
meta, body = load_page(path)
yield from chunk_page(meta, body)
def batches(items: list, size: int = BATCH_SIZE) -> Iterator[list]:
for i in range(0, len(items), size):
yield items[i : i + size]
_MODEL_LOCK = threading.Lock()
_MODEL = None
def _model():
# Double-checked load: functools.cache would let two concurrent first
# queries (before _warm_up finishes, or when it is skipped) both enter the
# body and load the 130MB model twice. Guard the build with a lock and a
# re-check so it happens exactly once; encode() itself is safe to call
# concurrently on the shared instance.
global _MODEL
if _MODEL is None:
with _MODEL_LOCK:
if _MODEL is None:
from sentence_transformers import SentenceTransformer
print(f"[embed] loading {EMBED_MODEL} (first run downloads ~130MB)")
_MODEL = SentenceTransformer(EMBED_MODEL, device="cpu")
return _MODEL
def embed_texts(texts: list[str]) -> list[list[float]]:
"""Embed document chunks locally; unit-normalized vectors."""
vectors = _model().encode(
[t[:MAX_EMBED_CHARS] for t in texts],
normalize_embeddings=True,
show_progress_bar=False,
)
return [v.tolist() for v in vectors]
def embed_query(text: str) -> list[float]:
"""Embed a search query (bge instruction prefix) for retrieve()."""
return embed_texts([QUERY_PREFIX + text])[0]
def existing_hashes(conn) -> dict[str, str]:
rows = conn.execute("select chunk_key, content_hash from chunks").fetchall()
return dict(rows)
UPSERT = """
insert into chunks (chunk_key, url, anchor, page_title, heading_path, library,
kind, source_link, content_hash, index_version, part,
embedding, tsv, content)
values (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, to_tsvector('english', %s), %s)
on conflict (chunk_key) do update set
page_title = excluded.page_title, heading_path = excluded.heading_path,
library = excluded.library, kind = excluded.kind,
source_link = excluded.source_link, content_hash = excluded.content_hash,
index_version = excluded.index_version, part = excluded.part,
embedding = excluded.embedding, tsv = excluded.tsv, content = excluded.content
"""
def _backfill_content(conn, units: list[dict]) -> None:
"""Populate `content` for rows that predate the column (all of them on the
first run after it shipped). The embed loop only writes new/changed chunks,
so the unchanged majority need a one-time fill — a plain metadata UPDATE, no
re-embedding. Cheap on later runs: once content is set it is never '' again.
"""
need = {
row[0]
for row in conn.execute("select chunk_key from chunks where content = ''").fetchall()
}
if not need:
return
by_key = {chunk_key(u): u["content"] for u in units}
params = [(by_key[k], k) for k in need if by_key.get(k)]
filled = 0
for batch in batches(params, 500):
with conn.cursor() as cur:
cur.executemany("update chunks set content = %s where chunk_key = %s", batch)
conn.commit() # checkpoint: safe to kill and re-run
filled += len(batch)
print(f"[embed] backfilled content for {filled} chunks")
def build_index(index_version: str, corpus_dir: Path = CORPUS_DIR, embed_fn=None) -> dict:
"""Embed every new/changed chunk in the snapshot into Neon."""
from index.db import connect, ensure_schema
embed_fn = embed_fn or embed_texts
units = list(iter_corpus_units(corpus_dir))
if not units:
# an empty snapshot would make EVERY db row "stale" and the purge below
# would wipe the whole live index. Refuse — a crawl must run first. This
# guards --skip-crawl runs (e.g. the Backfill workflow) against a cache miss.
raise SystemExit(
"refusing to embed: corpus snapshot is empty (no pages found under the "
"snapshot dir) — run a crawl first; aborting so the live index is not purged"
)
with connect() as conn:
ensure_schema(conn)
from index.db import get_meta, set_meta
known = existing_hashes(conn)
db_keys = set(known) # real rows in the DB, used for the stale purge below
if get_meta(conn, "embed_recipe") != EMBED_RECIPE:
print(f"[embed] embed recipe changed → full re-embed ({EMBED_RECIPE})")
known = {} # ignore existing hashes so every chunk is re-embedded once
todo = [u for u in units if known.get(chunk_key(u)) != u["content_hash"]]
print(f"[embed] {len(units)} chunks in snapshot, {len(todo)} new/changed to embed")
done = 0
for batch in batches(todo):
vectors = embed_fn([indexed_text(u) for u in batch])
with conn.cursor() as cur:
for unit, vector in zip(batch, vectors, strict=True):
cur.execute(
UPSERT,
(
chunk_key(unit),
unit["url"],
unit["anchor"],
unit["page_title"],
" > ".join(unit["heading_path"]),
unit["library"],
unit["kind"],
unit["source_link"],
unit["content_hash"],
index_version,
unit.get("part", 0),
str(vector),
indexed_text(unit),
unit["content"], # raw section text, served at answer time
),
)
conn.commit() # checkpoint: safe to kill and re-run from here
done += len(batch)
print(f"[embed] {done}/{len(todo)} embedded")
# one-time fill of `content` for chunks the embed loop didn't touch
# (unchanged rows from before the column shipped)
_backfill_content(conn, units)
# purge rows whose chunk no longer exists in the snapshot (renamed
# headings, deleted pages) — dead pointers must not win retrieval.
# Purge off db_keys, not `known`: a recipe bump zeroes `known`, and
# purging off it would silently never delete anything.
live_keys = {chunk_key(u) for u in units}
stale = [k for k in db_keys if k not in live_keys]
for batch in batches(stale, 500):
conn.execute("delete from chunks where chunk_key = any(%s)", (batch,))
conn.commit()
if stale:
print(f"[embed] purged {len(stale)} stale chunks")
# stamp the recipe only after a full pass, so a mid-run death re-forces
# the full re-embed next time instead of leaving mixed vector recipes
set_meta(conn, "embed_recipe", EMBED_RECIPE)
conn.commit()
total = conn.execute("select count(*) from chunks").fetchone()[0]
return {"snapshot_chunks": len(units), "embedded": len(todo), "db_total": total}