Spaces:
Running
Running
File size: 6,737 Bytes
efd296e a36cd22 efd296e a36cd22 efd296e a36cd22 efd296e a36cd22 efd296e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | """Generate hypothetical questions for API reference pages (QuOTE-style).
Why: the reranker measurement (2026-07-09) left a residue of genuinely-buried
pages — Linear, Conv2d, random_split, WeightedRandomSampler, einsum — where a
descriptive question ("what's the standard fully-connected layer?") matches
NOTHING the page carries, not even its one-sentence gloss (Linear still sat at
true dense rank ~3,400 post-gloss). The literature's index-side answer
(QuOTE, arXiv:2502.10976; HyPE) is to index the QUESTIONS a page answers,
turning question→document matching into question→question matching — paid
once at index time, not per query like HyDE.
What: for every api-kind page in the corpus snapshot, ask an LLM for a few
short questions a user would ask that this page answers — phrased in everyday
task vocabulary, mostly WITHOUT naming the symbol (the vocabulary bridge is
the whole point; the symbol token is already in the index). The questions are
folded into indexed_text() by index/embed.py — feeding both the page's vector
and its tsvector, the exact channel pair that flipped CrossEntropyLoss.
Output: index/questions.jsonl — {"url", "questions": [...]} per line,
committed. Same shape as the gloss pipeline: batched, flushed per batch,
resumable (already-covered URLs are skipped) — rate-limit deaths just mean
"run it again".
Usage: python scripts/generate_questions.py [--limit N] [--batch N]
(needs an LLM key; corpus snapshot must exist — run the crawl first)
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from dotenv import load_dotenv
from scripts.generate_glosses import api_pages, existing_urls_of, git_checkpoint
QUESTIONS_PATH = Path(__file__).parent.parent / "index" / "questions.jsonl"
QUESTIONS_PER_PAGE = 5
QUESTION_MAX_CHARS = 160
SYSTEM = (
"You write search-bridging questions for PyTorch documentation reference "
f"pages. For each numbered page (symbol/title + excerpt) write "
f"{QUESTIONS_PER_PAGE} distinct short questions (8-20 words) a PyTorch "
"user would ask that THIS page answers. Phrase them the way users talk "
"about the TASK, in everyday ML vocabulary; at most one question may name "
"the symbol itself — the rest must describe what it does or when you need "
"it (e.g. for Linear: 'What's the standard fully-connected layer that "
"applies a weight matrix and bias?'). Reply with a JSON array only, one "
'item per page, no other text: [{"i": 0, "questions": ["...", ...]}, ...]'
)
def batch_prompt(batch: list[dict]) -> str:
from index.embed import symbol_from_url
blocks = []
for i, page in enumerate(batch):
symbol = symbol_from_url(page["url"]) or page["title"]
blocks.append(f"### {i}\nsymbol: {symbol}\nexcerpt: {page['excerpt']}")
return "\n\n".join(blocks) + f"\n\nJSON array with {len(batch)} question sets:"
def parse_questions(raw: str, n: int) -> dict[int, list[str]]:
"""{index: [questions]} from the model's reply; malformed items are dropped."""
start, end = raw.find("["), raw.rfind("]")
if start == -1 or end == -1:
return {}
try:
items = json.loads(raw[start : end + 1])
except json.JSONDecodeError:
return {}
out: dict[int, list[str]] = {}
for item in items if isinstance(items, list) else []:
if not isinstance(item, dict):
continue
i, qs = item.get("i"), item.get("questions")
if not (isinstance(i, int) and 0 <= i < n and isinstance(qs, list)):
continue
clean = [q.strip()[:QUESTION_MAX_CHARS] for q in qs if isinstance(q, str) and q.strip()]
if clean:
out[i] = clean[:QUESTIONS_PER_PAGE]
return out
def main() -> int:
load_dotenv()
parser = argparse.ArgumentParser()
parser.add_argument("--limit", type=int, default=0, help="cover at most N pages (0 = all)")
parser.add_argument("--batch", type=int, default=50, help="pages per LLM call")
parser.add_argument("--sleep", type=float, default=0.0, help="pause between calls (s)")
parser.add_argument(
"--push",
action="store_true",
help="commit+push the jsonl after every batch (CI runs; keeps progress "
"if the job is cancelled/timed out). Off by default so local runs don't commit.",
)
args = parser.parse_args()
from agent.llm import GenerationError, _raw_completion
from ingest.crawl import CORPUS_DIR
if not CORPUS_DIR.exists() or not any(CORPUS_DIR.rglob("*.md")):
print("corpus snapshot is empty — run the crawl (Build Index) first", flush=True)
return 1
done = existing_urls_of(QUESTIONS_PATH)
todo = [p for p in api_pages(CORPUS_DIR) if p["url"] not in done]
if args.limit:
todo = todo[: args.limit]
print(f"[questions] {len(done)} pages already covered, {len(todo)} to go", flush=True)
if not todo:
return 0
written = failed_batches = 0
with QUESTIONS_PATH.open("a") as out:
for at in range(0, len(todo), args.batch):
batch = todo[at : at + args.batch]
try:
raw = _raw_completion(batch_prompt(batch), system=SYSTEM, timeout=180.0)
except GenerationError as exc:
print(f"[questions] batch at {at} failed: {exc}", flush=True)
failed_batches += 1
if failed_batches >= 5:
print("[questions] 5 failed batches — provider looks down, stopping",
flush=True)
break
continue
sets = parse_questions(raw, len(batch))
if not sets:
print(f"[questions] batch at {at}: unparseable reply, skipped", flush=True)
failed_batches += 1
continue
for i, qs in sorted(sets.items()):
out.write(json.dumps({"url": batch[i]["url"], "questions": qs},
ensure_ascii=False) + "\n")
out.flush() # checkpoint: kill/rate-limit here loses nothing
written += len(sets)
print(f"[questions] {at + len(batch)}/{len(todo)} pages seen, "
f"{written} question sets written", flush=True)
if args.push:
git_checkpoint(QUESTIONS_PATH, "questions")
time.sleep(args.sleep)
print(f"[questions] done: {written} new question sets → {QUESTIONS_PATH}", flush=True)
# partial success is success (resumable); total failure is loud
return 0 if written else 1
if __name__ == "__main__":
sys.exit(main())
|