Spaces:
Running
Running
eliezer avihail
ci: Backfill content workflow (skip crawl) + empty-snapshot purge guard (#98)
bfd0450 unverified | """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" | |
| 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} | |
| 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 <dl> 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} | |