eliezer avihail Claude commited on
Commit
efd296e
·
unverified ·
1 Parent(s): 8b4423f

QuOTE questions in the enrichment pipeline + one-click eval default (#88)

Browse files

* QuOTE-style hypothetical questions join the content-enrichment pipeline

The reranker measurement left five genuinely-buried pages — Linear, Conv2d,
random_split, WeightedRandomSampler, einsum — where a descriptive question
matches nothing the page carries, not even its gloss (Linear still 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 rather than per query like HyDE.

scripts/generate_questions.py mirrors the gloss pipeline exactly: batched,
flushed per batch, resumable via the shared existing_urls_of() check, core
pages first. 5 short task-vocabulary questions per api page, at most one
naming the symbol — the vocabulary bridge is the point. Output is committed
to index/questions.jsonl.

index/embed.py folds the questions into indexed_text() (after the gloss,
before the heading), feeding BOTH the vector and the tsvector — the exact
channel pair the reranker measurement proved decisive (CrossEntropyLoss
flipped via tsvector+rerank). The questions file gets its own recipe stamp
(shared _enrichment_stamp), and the shape change bumps the recipe to v7, so
the next Build Index re-embeds once automatically.

Both workflows run the new pass: Generate glosses gains a questions step
(continue-on-error so a failure never discards the glosses written above),
and Build Index's crawl → enrich → embed sequence now glosses AND questions
new pages before embedding. Provider defaults flipped to openai-compat (hy3
with credit carries the corpus; gemini stays the fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LX1gm8fuzK2ZeEWi3Zar1b

* Eval default suite = retrieval+judge: the app-dispatch button must measure everything

The GitHub mobile app cannot set workflow_dispatch inputs — it fires with the
defaults. So the default must BE the measurement one click should give: a new
retrieval+judge suite option (recall/MRR + the answer-quality anchor) becomes
the default, and both job conditions accept it. agentic stays opt-in — it is
the slow, LLM-heavy benchmark that should not run on every click.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LX1gm8fuzK2ZeEWi3Zar1b

---------

Co-authored-by: Claude <noreply@anthropic.com>

.github/workflows/build-index.yml CHANGED
@@ -82,16 +82,26 @@ jobs:
82
  OPENAI_COMPAT_API_KEY: ${{ secrets.OPENROUTER }}
83
  TORCHDOCS_OPENAI_COMPAT_MODEL: ${{ inputs.llm || 'tencent/hy3:free,meta-llama/llama-3.3-70b-instruct:free' }}
84
  run: python scripts/generate_glosses.py --limit 0 --batch 30 --sleep 5
85
- - name: Commit any new glosses (also on gloss failure — partial progress is saved)
 
 
 
 
 
 
 
 
 
 
86
  if: always()
87
  run: |
88
  git config user.name "github-actions[bot]"
89
  git config user.email "github-actions[bot]@users.noreply.github.com"
90
- git add -f index/glosses.jsonl
91
- git diff --cached --quiet || git commit -m "index: glosses from Actions run [skip ci]"
92
  git pull --rebase origin main # main may have moved during the run
93
  git push
94
- - name: Embed into Neon (now with the glosses in indexed_text)
95
  env:
96
  PYTHONUNBUFFERED: "1"
97
  NEON_URL: ${{ secrets.NEON }}
 
82
  OPENAI_COMPAT_API_KEY: ${{ secrets.OPENROUTER }}
83
  TORCHDOCS_OPENAI_COMPAT_MODEL: ${{ inputs.llm || 'tencent/hy3:free,meta-llama/llama-3.3-70b-instruct:free' }}
84
  run: python scripts/generate_glosses.py --limit 0 --batch 30 --sleep 5
85
+ - name: Hypothetical questions for new pages (QuOTE-style, incremental)
86
+ continue-on-error: true # same contract as the gloss step
87
+ env:
88
+ PYTHONUNBUFFERED: "1"
89
+ TORCHDOCS_PROVIDER: ${{ inputs.provider || 'openai-compat' }}
90
+ GEMINI_API_KEY: ${{ secrets.GOOGLE_API }}
91
+ OPENAI_COMPAT_BASE_URL: https://openrouter.ai/api/v1
92
+ OPENAI_COMPAT_API_KEY: ${{ secrets.OPENROUTER }}
93
+ TORCHDOCS_OPENAI_COMPAT_MODEL: ${{ inputs.llm || 'tencent/hy3:free,meta-llama/llama-3.3-70b-instruct:free' }}
94
+ run: python scripts/generate_questions.py --limit 0 --batch 10 --sleep 5
95
+ - name: Commit any new enrichment (also on failure — partial progress is saved)
96
  if: always()
97
  run: |
98
  git config user.name "github-actions[bot]"
99
  git config user.email "github-actions[bot]@users.noreply.github.com"
100
+ git add -f index/glosses.jsonl index/questions.jsonl
101
+ git diff --cached --quiet || git commit -m "index: content enrichment from Actions run [skip ci]"
102
  git pull --rebase origin main # main may have moved during the run
103
  git push
104
+ - name: Embed into Neon (now with glosses + questions in indexed_text)
105
  env:
106
  PYTHONUNBUFFERED: "1"
107
  NEON_URL: ${{ secrets.NEON }}
.github/workflows/eval.yml CHANGED
@@ -1,23 +1,28 @@
1
  name: Eval
2
 
3
  # The measurement loop, one button:
4
- # suite=retrieval (default) — recall@k + MRR over the 100-question set
5
- # against the LIVE index, plus the rank diagnostic (where do the known-miss
6
- # pages actually sit in their kind-pool). No LLM needed.
 
 
7
  # suite=agentic — the agent-loop benchmark (loop vs single-shot coverage
8
  # delta). Needs LLM keys; slow on free tiers.
9
  # suite=judge — LLM-as-judge answer quality (faithfulness / relevance /
10
  # citation-correctness) over the grounded path. Needs LLM keys.
11
- # suite=all — all three.
12
  # Results are committed back to main for before/after diffing.
13
  on:
14
  workflow_dispatch:
15
  inputs:
16
  suite:
17
- description: "what to measure"
 
 
 
18
  type: choice
19
- options: [retrieval, agentic, judge, all]
20
- default: retrieval
21
  set:
22
  description: "retrieval eval set (v1 = 100 questions, v0 = original 13)"
23
  type: string
@@ -63,7 +68,7 @@ permissions:
63
 
64
  jobs:
65
  retrieval:
66
- if: ${{ inputs.suite == 'retrieval' || inputs.suite == 'all' }}
67
  runs-on: ubuntu-latest
68
  timeout-minutes: 15
69
  steps:
@@ -140,7 +145,7 @@ jobs:
140
  git push
141
 
142
  judge:
143
- if: ${{ inputs.suite == 'judge' || inputs.suite == 'all' }}
144
  runs-on: ubuntu-latest
145
  timeout-minutes: 120
146
  steps:
 
1
  name: Eval
2
 
3
  # The measurement loop, one button:
4
+ # suite=retrieval+judge (default) — recall@k + MRR over the 100-question set
5
+ # against the LIVE index (plus the rank diagnostic), AND the LLM-as-judge
6
+ # answer-quality anchor. The default is the full one-click measurement
7
+ # because the GitHub mobile app cannot set workflow inputs.
8
+ # suite=retrieval — recall/MRR only. No LLM needed.
9
  # suite=agentic — the agent-loop benchmark (loop vs single-shot coverage
10
  # delta). Needs LLM keys; slow on free tiers.
11
  # suite=judge — LLM-as-judge answer quality (faithfulness / relevance /
12
  # citation-correctness) over the grounded path. Needs LLM keys.
13
+ # suite=all — everything.
14
  # Results are committed back to main for before/after diffing.
15
  on:
16
  workflow_dispatch:
17
  inputs:
18
  suite:
19
+ description: "what to measure. The default runs retrieval AND judge —
20
+ deliberately, because the GitHub mobile app can't set inputs, so the
21
+ default must be the measurement we actually want one click to give:
22
+ recall/MRR plus the answer-quality anchor. agentic stays opt-in."
23
  type: choice
24
+ options: [retrieval+judge, retrieval, agentic, judge, all]
25
+ default: retrieval+judge
26
  set:
27
  description: "retrieval eval set (v1 = 100 questions, v0 = original 13)"
28
  type: string
 
68
 
69
  jobs:
70
  retrieval:
71
+ if: ${{ inputs.suite == 'retrieval' || inputs.suite == 'retrieval+judge' || inputs.suite == 'all' }}
72
  runs-on: ubuntu-latest
73
  timeout-minutes: 15
74
  steps:
 
145
  git push
146
 
147
  judge:
148
+ if: ${{ inputs.suite == 'judge' || inputs.suite == 'retrieval+judge' || inputs.suite == 'all' }}
149
  runs-on: ubuntu-latest
150
  timeout-minutes: 120
151
  steps:
.github/workflows/generate-glosses.yml CHANGED
@@ -1,21 +1,26 @@
1
  name: Generate glosses
2
 
3
- # Contextual Retrieval, index side: LLM-write a one-sentence plain-language
4
- # gloss for every api reference page in the corpus snapshot (batched, ~300
5
- # calls for 3.6K pages). Resumable: already-glossed URLs are skipped and
6
- # partial progress is committed even on failure — re-run to fill gaps.
7
- # After glosses land, run Build Index (the changed recipe stamp forces the
8
- # one-time full re-embed), then Eval retrieval to measure the effect.
 
 
 
 
 
9
  on:
10
  workflow_dispatch:
11
  inputs:
12
  provider:
13
- description: "LLM provider. Free quotas are tight (measured 2026-07-08):
14
- gemini = 20 req/day PER MODEL, OpenRouter = 50 req/day total without
15
- credits. A failed provider falls through to the other (both keys set)."
16
  type: choice
17
- options: [gemini, openai-compat]
18
- default: gemini
19
  gemini_model:
20
  description: "gemini model — the free 20/day quota is per model, so
21
  re-running with a different one (gemini-2.5-flash-lite,
@@ -28,19 +33,27 @@ on:
28
  type: string
29
  default: "tencent/hy3:free,meta-llama/llama-3.3-70b-instruct:free"
30
  limit:
31
- description: "gloss at most N pages this run (0 = all remaining)"
32
  type: string
33
  default: "0"
34
 
35
  permissions:
36
  contents: write
37
 
38
- concurrency: generate-glosses # two writers would duplicate gloss lines
39
 
40
  jobs:
41
  glosses:
42
  runs-on: ubuntu-latest
43
- timeout-minutes: 180
 
 
 
 
 
 
 
 
44
  steps:
45
  - uses: actions/checkout@v4
46
  - uses: actions/setup-python@v5
@@ -55,23 +68,21 @@ jobs:
55
  restore-keys: corpus-
56
  - run: pip install -e .
57
  - name: Write glosses
58
- env:
59
- PYTHONUNBUFFERED: "1"
60
- TORCHDOCS_PROVIDER: ${{ inputs.provider }}
61
- GEMINI_API_KEY: ${{ secrets.GOOGLE_API }}
62
- TORCHDOCS_GEMINI_MODEL: ${{ inputs.gemini_model }}
63
- OPENAI_COMPAT_BASE_URL: https://openrouter.ai/api/v1
64
- OPENAI_COMPAT_API_KEY: ${{ secrets.OPENROUTER }}
65
- TORCHDOCS_OPENAI_COMPAT_MODEL: ${{ inputs.model }}
66
  # batch 30 → ~121 calls for the whole corpus; the run is resumable, so
67
  # each dispatch chips away at whatever today's quotas allow
68
  run: python scripts/generate_glosses.py --limit "${{ inputs.limit }}" --batch 30 --sleep 5
 
 
 
 
 
 
69
  - name: Commit whatever was written (also on failure — the run is resumable)
70
  if: always()
71
  run: |
72
  git config user.name "github-actions[bot]"
73
  git config user.email "github-actions[bot]@users.noreply.github.com"
74
- git add index/glosses.jsonl
75
- git diff --cached --quiet || git commit -m "index: glosses from Actions run [skip ci]"
76
  git pull --rebase origin main # main may have moved during the run
77
  git push
 
1
  name: Generate glosses
2
 
3
+ # Index-side content enrichment, two passes over the corpus snapshot:
4
+ # 1. glosses a one-sentence plain-language description per api page
5
+ # (Contextual Retrieval)
6
+ # 2. hypothetical questions a few task-vocabulary questions each api page
7
+ # answers (QuOTE-style), for pages whose gloss alone couldn't bridge the
8
+ # vocabulary gap (measured 2026-07-09: Linear at dense rank ~3,400
9
+ # post-gloss)
10
+ # Both are batched and resumable: already-covered URLs are skipped and partial
11
+ # progress is committed even on failure — re-run to fill gaps. After they
12
+ # land, run Build Index (the changed recipe stamp forces the one-time full
13
+ # re-embed), then Eval retrieval to measure the effect.
14
  on:
15
  workflow_dispatch:
16
  inputs:
17
  provider:
18
+ description: "LLM provider. openai-compat = OpenRouter (hy3 with credit
19
+ carries the full corpus); gemini free = 20 req/day per model. A failed
20
+ provider falls through to the other (both keys set)."
21
  type: choice
22
+ options: [openai-compat, gemini]
23
+ default: openai-compat
24
  gemini_model:
25
  description: "gemini model — the free 20/day quota is per model, so
26
  re-running with a different one (gemini-2.5-flash-lite,
 
33
  type: string
34
  default: "tencent/hy3:free,meta-llama/llama-3.3-70b-instruct:free"
35
  limit:
36
+ description: "cover at most N pages per pass this run (0 = all remaining)"
37
  type: string
38
  default: "0"
39
 
40
  permissions:
41
  contents: write
42
 
43
+ concurrency: generate-glosses # two writers would duplicate enrichment lines
44
 
45
  jobs:
46
  glosses:
47
  runs-on: ubuntu-latest
48
+ timeout-minutes: 300
49
+ env:
50
+ PYTHONUNBUFFERED: "1"
51
+ TORCHDOCS_PROVIDER: ${{ inputs.provider }}
52
+ GEMINI_API_KEY: ${{ secrets.GOOGLE_API }}
53
+ TORCHDOCS_GEMINI_MODEL: ${{ inputs.gemini_model }}
54
+ OPENAI_COMPAT_BASE_URL: https://openrouter.ai/api/v1
55
+ OPENAI_COMPAT_API_KEY: ${{ secrets.OPENROUTER }}
56
+ TORCHDOCS_OPENAI_COMPAT_MODEL: ${{ inputs.model }}
57
  steps:
58
  - uses: actions/checkout@v4
59
  - uses: actions/setup-python@v5
 
68
  restore-keys: corpus-
69
  - run: pip install -e .
70
  - name: Write glosses
 
 
 
 
 
 
 
 
71
  # batch 30 → ~121 calls for the whole corpus; the run is resumable, so
72
  # each dispatch chips away at whatever today's quotas allow
73
  run: python scripts/generate_glosses.py --limit "${{ inputs.limit }}" --batch 30 --sleep 5
74
+ - name: Write hypothetical questions (QuOTE-style)
75
+ # a question failure must not throw away the glosses written above —
76
+ # the commit step below runs regardless and both passes are resumable
77
+ continue-on-error: true
78
+ # 5 questions/page is a longer reply than a gloss → smaller batches
79
+ run: python scripts/generate_questions.py --limit "${{ inputs.limit }}" --batch 10 --sleep 5
80
  - name: Commit whatever was written (also on failure — the run is resumable)
81
  if: always()
82
  run: |
83
  git config user.name "github-actions[bot]"
84
  git config user.email "github-actions[bot]@users.noreply.github.com"
85
+ git add index/glosses.jsonl index/questions.jsonl
86
+ git diff --cached --quiet || git commit -m "index: content enrichment from Actions run [skip ci]"
87
  git pull --rebase origin main # main may have moved during the run
88
  git push
index/embed.py CHANGED
@@ -36,6 +36,13 @@ MAX_EMBED_CHARS = 2000 # bge context is 512 tokens; beyond it is truncated anyw
36
  # near the descriptive questions users actually ask.
37
  GLOSSES_PATH = Path(__file__).parent / "glosses.jsonl"
38
 
 
 
 
 
 
 
 
39
 
40
  @cache
41
  def load_glosses() -> dict[str, str]:
@@ -45,26 +52,42 @@ def load_glosses() -> dict[str, str]:
45
  return {row["url"]: row["gloss"] for row in rows}
46
 
47
 
48
- def _gloss_stamp() -> str:
49
- """Content hash of the gloss file — folded into the recipe below, so any
50
- gloss change (or first arrival) forces the one-time full re-embed itself;
51
- no manual version bump to forget."""
52
- if not GLOSSES_PATH.exists():
 
 
 
 
 
 
 
 
 
53
  return "none"
54
- return hashlib.sha256(GLOSSES_PATH.read_bytes()).hexdigest()[:8]
 
 
 
 
 
 
 
 
55
 
56
 
57
  # bump the version prefix when indexed_text()'s SHAPE changes → forces a
58
  # one-time full re-embed (dims same, so the row-skip check would otherwise keep
59
- # stale vectors). v6: synopsis extraction fixed to read the Sphinx <dl>
60
- # description line v5 accidentally extracted the signature+github-url head,
61
- # duplicating the chunk's own opening (a semantic no-op, measured: distances
62
- # unchanged to 3 decimals). The model tag is folded in so swapping models is
63
- # itself a recipe change: a same-dims swap (e.g. two 768d models) still forces
64
- # a re-embed, and index_meta stays honest about which model's vectors are live
65
- # (a dims change also rebuilds the table outright see index/db.ensure_schema).
66
- # The gloss stamp does the same for gloss-content changes.
67
- EMBED_RECIPE = f"v6-{EMBED_MODEL.split('/')[-1]}-g{_gloss_stamp()}"
68
 
69
 
70
  def chunk_key(unit: dict) -> str:
@@ -89,7 +112,8 @@ def symbol_from_url(url: str) -> str:
89
 
90
 
91
  def indexed_text(unit: dict) -> str:
92
- """What we embed AND tsvector: symbol + synopsis + gloss + heading + body.
 
93
 
94
  The symbol gives the page strong lexical weight for symbol-typed queries.
95
  The synopsis (the page's own first prose sentence, extracted at chunk time)
@@ -97,7 +121,10 @@ def indexed_text(unit: dict) -> str:
97
  to descriptive questions — reference pages' own text is signature-shaped
98
  and embeds far from "which loss takes raw logits?", so the plain-language
99
  sentences are prepended to every chunk of the page, feeding both the
100
- vector and the tsvector.
 
 
 
101
  """
102
  parts = []
103
  symbol = symbol_from_url(unit["url"])
@@ -108,6 +135,9 @@ def indexed_text(unit: dict) -> str:
108
  gloss = load_glosses().get(unit["url"], "")
109
  if gloss:
110
  parts.append(gloss)
 
 
 
111
  heading = " > ".join(unit.get("heading_path", []))
112
  if heading:
113
  parts.append(heading)
 
36
  # near the descriptive questions users actually ask.
37
  GLOSSES_PATH = Path(__file__).parent / "glosses.jsonl"
38
 
39
+ # QuOTE-style hypothetical questions: {url: [task-vocabulary questions the page
40
+ # answers]}, generated by scripts/generate_questions.py and committed.
41
+ # indexed_text() folds them in — question→question matching for pages whose
42
+ # one-sentence gloss couldn't bridge the vocabulary gap (measured 2026-07-09:
43
+ # Linear still at true dense rank ~3,400 post-gloss).
44
+ QUESTIONS_PATH = Path(__file__).parent / "questions.jsonl"
45
+
46
 
47
  @cache
48
  def load_glosses() -> dict[str, str]:
 
52
  return {row["url"]: row["gloss"] for row in rows}
53
 
54
 
55
+ @cache
56
+ def load_questions() -> dict[str, list[str]]:
57
+ if not QUESTIONS_PATH.exists():
58
+ return {}
59
+ rows = (json.loads(line) for line in QUESTIONS_PATH.open() if line.strip())
60
+ return {row["url"]: row["questions"] for row in rows}
61
+
62
+
63
+ def _enrichment_stamp(path: Path) -> str:
64
+ """Content hash of a committed enrichment file (glosses / questions) —
65
+ folded into the recipe below, so any content change (or first arrival)
66
+ forces the one-time full re-embed itself; no manual version bump to
67
+ forget."""
68
+ if not path.exists():
69
  return "none"
70
+ return hashlib.sha256(path.read_bytes()).hexdigest()[:8]
71
+
72
+
73
+ def _gloss_stamp() -> str:
74
+ return _enrichment_stamp(GLOSSES_PATH)
75
+
76
+
77
+ def _questions_stamp() -> str:
78
+ return _enrichment_stamp(QUESTIONS_PATH)
79
 
80
 
81
  # bump the version prefix when indexed_text()'s SHAPE changes → forces a
82
  # one-time full re-embed (dims same, so the row-skip check would otherwise keep
83
+ # stale vectors). v7: QuOTE-style hypothetical questions join the indexed text
84
+ # (v6 fixed synopsis extraction to read the Sphinx <dl> description line). The
85
+ # model tag is folded in so swapping models is itself a recipe change: a
86
+ # same-dims swap (e.g. two 768d models) still forces a re-embed, and index_meta
87
+ # stays honest about which model's vectors are live (a dims change also
88
+ # rebuilds the table outright see index/db.ensure_schema). The gloss and
89
+ # question stamps do the same for enrichment-content changes.
90
+ EMBED_RECIPE = f"v7-{EMBED_MODEL.split('/')[-1]}-g{_gloss_stamp()}-q{_questions_stamp()}"
 
91
 
92
 
93
  def chunk_key(unit: dict) -> str:
 
112
 
113
 
114
  def indexed_text(unit: dict) -> str:
115
+ """What we embed AND tsvector: symbol + synopsis + gloss + questions +
116
+ heading + body.
117
 
118
  The symbol gives the page strong lexical weight for symbol-typed queries.
119
  The synopsis (the page's own first prose sentence, extracted at chunk time)
 
121
  to descriptive questions — reference pages' own text is signature-shaped
122
  and embeds far from "which loss takes raw logits?", so the plain-language
123
  sentences are prepended to every chunk of the page, feeding both the
124
+ vector and the tsvector. The hypothetical questions (QuOTE-style) go one
125
+ step further for pages the gloss couldn't move: they put the QUESTION
126
+ phrasing itself into both channels, so a user's descriptive query matches
127
+ question→question instead of question→signature.
128
  """
129
  parts = []
130
  symbol = symbol_from_url(unit["url"])
 
135
  gloss = load_glosses().get(unit["url"], "")
136
  if gloss:
137
  parts.append(gloss)
138
+ questions = load_questions().get(unit["url"], [])
139
+ if questions:
140
+ parts.append(" ".join(questions))
141
  heading = " > ".join(unit.get("heading_path", []))
142
  if heading:
143
  parts.append(heading)
scripts/generate_glosses.py CHANGED
@@ -106,10 +106,16 @@ def parse_glosses(raw: str, n: int) -> dict[int, str]:
106
  return out
107
 
108
 
109
- def existing_urls() -> set[str]:
110
- if not GLOSSES_PATH.exists():
 
 
111
  return set()
112
- return {json.loads(line)["url"] for line in GLOSSES_PATH.open() if line.strip()}
 
 
 
 
113
 
114
 
115
  def main() -> int:
 
106
  return out
107
 
108
 
109
+ def existing_urls_of(path: Path) -> set[str]:
110
+ """URLs already covered in a jsonl enrichment file — the resume check,
111
+ shared with generate_questions.py (same append-and-skip pipeline shape)."""
112
+ if not path.exists():
113
  return set()
114
+ return {json.loads(line)["url"] for line in path.open() if line.strip()}
115
+
116
+
117
+ def existing_urls() -> set[str]:
118
+ return existing_urls_of(GLOSSES_PATH)
119
 
120
 
121
  def main() -> int:
scripts/generate_questions.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate hypothetical questions for API reference pages (QuOTE-style).
2
+
3
+ Why: the reranker measurement (2026-07-09) left a residue of genuinely-buried
4
+ pages — Linear, Conv2d, random_split, WeightedRandomSampler, einsum — where a
5
+ descriptive question ("what's the standard fully-connected layer?") matches
6
+ NOTHING the page carries, not even its one-sentence gloss (Linear still sat at
7
+ true dense rank ~3,400 post-gloss). The literature's index-side answer
8
+ (QuOTE, arXiv:2502.10976; HyPE) is to index the QUESTIONS a page answers,
9
+ turning question→document matching into question→question matching — paid
10
+ once at index time, not per query like HyDE.
11
+
12
+ What: for every api-kind page in the corpus snapshot, ask an LLM for a few
13
+ short questions a user would ask that this page answers — phrased in everyday
14
+ task vocabulary, mostly WITHOUT naming the symbol (the vocabulary bridge is
15
+ the whole point; the symbol token is already in the index). The questions are
16
+ folded into indexed_text() by index/embed.py — feeding both the page's vector
17
+ and its tsvector, the exact channel pair that flipped CrossEntropyLoss.
18
+
19
+ Output: index/questions.jsonl — {"url", "questions": [...]} per line,
20
+ committed. Same shape as the gloss pipeline: batched, flushed per batch,
21
+ resumable (already-covered URLs are skipped) — rate-limit deaths just mean
22
+ "run it again".
23
+
24
+ Usage: python scripts/generate_questions.py [--limit N] [--batch N]
25
+ (needs an LLM key; corpus snapshot must exist — run the crawl first)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import json
32
+ import sys
33
+ import time
34
+ from pathlib import Path
35
+
36
+ sys.path.insert(0, str(Path(__file__).parent.parent))
37
+
38
+ from dotenv import load_dotenv
39
+
40
+ from scripts.generate_glosses import api_pages, existing_urls_of
41
+
42
+ QUESTIONS_PATH = Path(__file__).parent.parent / "index" / "questions.jsonl"
43
+
44
+ QUESTIONS_PER_PAGE = 5
45
+ QUESTION_MAX_CHARS = 160
46
+
47
+ SYSTEM = (
48
+ "You write search-bridging questions for PyTorch documentation reference "
49
+ f"pages. For each numbered page (symbol/title + excerpt) write "
50
+ f"{QUESTIONS_PER_PAGE} distinct short questions (8-20 words) a PyTorch "
51
+ "user would ask that THIS page answers. Phrase them the way users talk "
52
+ "about the TASK, in everyday ML vocabulary; at most one question may name "
53
+ "the symbol itself — the rest must describe what it does or when you need "
54
+ "it (e.g. for Linear: 'What's the standard fully-connected layer that "
55
+ "applies a weight matrix and bias?'). Reply with a JSON array only, one "
56
+ 'item per page, no other text: [{"i": 0, "questions": ["...", ...]}, ...]'
57
+ )
58
+
59
+
60
+ def batch_prompt(batch: list[dict]) -> str:
61
+ from index.embed import symbol_from_url
62
+
63
+ blocks = []
64
+ for i, page in enumerate(batch):
65
+ symbol = symbol_from_url(page["url"]) or page["title"]
66
+ blocks.append(f"### {i}\nsymbol: {symbol}\nexcerpt: {page['excerpt']}")
67
+ return "\n\n".join(blocks) + f"\n\nJSON array with {len(batch)} question sets:"
68
+
69
+
70
+ def parse_questions(raw: str, n: int) -> dict[int, list[str]]:
71
+ """{index: [questions]} from the model's reply; malformed items are dropped."""
72
+ start, end = raw.find("["), raw.rfind("]")
73
+ if start == -1 or end == -1:
74
+ return {}
75
+ try:
76
+ items = json.loads(raw[start : end + 1])
77
+ except json.JSONDecodeError:
78
+ return {}
79
+ out: dict[int, list[str]] = {}
80
+ for item in items if isinstance(items, list) else []:
81
+ if not isinstance(item, dict):
82
+ continue
83
+ i, qs = item.get("i"), item.get("questions")
84
+ if not (isinstance(i, int) and 0 <= i < n and isinstance(qs, list)):
85
+ continue
86
+ clean = [q.strip()[:QUESTION_MAX_CHARS] for q in qs if isinstance(q, str) and q.strip()]
87
+ if clean:
88
+ out[i] = clean[:QUESTIONS_PER_PAGE]
89
+ return out
90
+
91
+
92
+ def main() -> int:
93
+ load_dotenv()
94
+ parser = argparse.ArgumentParser()
95
+ parser.add_argument("--limit", type=int, default=0, help="cover at most N pages (0 = all)")
96
+ parser.add_argument("--batch", type=int, default=10, help="pages per LLM call")
97
+ parser.add_argument("--sleep", type=float, default=2.0, help="pause between calls (s)")
98
+ args = parser.parse_args()
99
+
100
+ from agent.llm import GenerationError, _raw_completion
101
+ from ingest.crawl import CORPUS_DIR
102
+
103
+ if not CORPUS_DIR.exists() or not any(CORPUS_DIR.rglob("*.md")):
104
+ print("corpus snapshot is empty — run the crawl (Build Index) first", flush=True)
105
+ return 1
106
+
107
+ done = existing_urls_of(QUESTIONS_PATH)
108
+ todo = [p for p in api_pages(CORPUS_DIR) if p["url"] not in done]
109
+ if args.limit:
110
+ todo = todo[: args.limit]
111
+ print(f"[questions] {len(done)} pages already covered, {len(todo)} to go", flush=True)
112
+ if not todo:
113
+ return 0
114
+
115
+ written = failed_batches = 0
116
+ with QUESTIONS_PATH.open("a") as out:
117
+ for at in range(0, len(todo), args.batch):
118
+ batch = todo[at : at + args.batch]
119
+ try:
120
+ raw = _raw_completion(batch_prompt(batch), system=SYSTEM, timeout=120.0)
121
+ except GenerationError as exc:
122
+ print(f"[questions] batch at {at} failed: {exc}", flush=True)
123
+ failed_batches += 1
124
+ if failed_batches >= 5:
125
+ print("[questions] 5 failed batches — provider looks down, stopping",
126
+ flush=True)
127
+ break
128
+ continue
129
+ sets = parse_questions(raw, len(batch))
130
+ if not sets:
131
+ print(f"[questions] batch at {at}: unparseable reply, skipped", flush=True)
132
+ failed_batches += 1
133
+ continue
134
+ for i, qs in sorted(sets.items()):
135
+ out.write(json.dumps({"url": batch[i]["url"], "questions": qs},
136
+ ensure_ascii=False) + "\n")
137
+ out.flush() # checkpoint: kill/rate-limit here loses nothing
138
+ written += len(sets)
139
+ print(f"[questions] {at + len(batch)}/{len(todo)} pages seen, "
140
+ f"{written} question sets written", flush=True)
141
+ time.sleep(args.sleep)
142
+
143
+ print(f"[questions] done: {written} new question sets → {QUESTIONS_PATH}", flush=True)
144
+ # partial success is success (resumable); total failure is loud
145
+ return 0 if written else 1
146
+
147
+
148
+ if __name__ == "__main__":
149
+ sys.exit(main())
tests/index/test_embed.py CHANGED
@@ -99,6 +99,40 @@ def test_gloss_stamp_changes_the_recipe_with_gloss_content(monkeypatch, tmp_path
99
  assert first not in ("none", embed._gloss_stamp())
100
 
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  def test_iter_corpus_units_walks_snapshot(tmp_path):
103
  save_page("https://docs.pytorch.org/docs/stable/optim.html", "core", HTML, tmp_path)
104
  units = list(iter_corpus_units(tmp_path))
 
99
  assert first not in ("none", embed._gloss_stamp())
100
 
101
 
102
+ def test_indexed_text_folds_in_the_pages_hypothetical_questions(monkeypatch):
103
+ # QuOTE-style: the question phrasing itself joins both channels, so a
104
+ # descriptive query matches question→question instead of question→signature
105
+ from index.embed import indexed_text
106
+
107
+ url = "https://docs.pytorch.org/docs/stable/generated/torch.nn.Linear.html"
108
+ questions = [
109
+ "What's the standard fully-connected layer that applies a weight matrix and bias?",
110
+ "How do I add a dense layer to my network?",
111
+ ]
112
+ monkeypatch.setattr("index.embed.load_glosses", lambda: {})
113
+ monkeypatch.setattr("index.embed.load_questions", lambda: {url: questions})
114
+ unit = {"url": url, "heading_path": ["Linear"], "content": "in_features (int)"}
115
+ text = indexed_text(unit)
116
+ for q in questions:
117
+ assert q in text
118
+ # questions sit between the symbol block and the body, like the gloss
119
+ assert text.index("torch.nn.Linear") < text.index(questions[0]) < text.index("in_features")
120
+ # a page with no questions is unchanged
121
+ assert questions[0] not in indexed_text({**unit, "url": url.replace("Linear", "GELU")})
122
+
123
+
124
+ def test_questions_stamp_changes_the_recipe_with_question_content(monkeypatch, tmp_path):
125
+ # same contract as the gloss stamp: new/changed questions force the re-embed
126
+ from index import embed
127
+
128
+ monkeypatch.setattr(embed, "QUESTIONS_PATH", tmp_path / "questions.jsonl")
129
+ assert embed._questions_stamp() == "none"
130
+ embed.QUESTIONS_PATH.write_text('{"url": "u", "questions": ["q"]}\n')
131
+ first = embed._questions_stamp()
132
+ embed.QUESTIONS_PATH.write_text('{"url": "u", "questions": ["other"]}\n')
133
+ assert first not in ("none", embed._questions_stamp())
134
+
135
+
136
  def test_iter_corpus_units_walks_snapshot(tmp_path):
137
  save_page("https://docs.pytorch.org/docs/stable/optim.html", "core", HTML, tmp_path)
138
  units = list(iter_corpus_units(tmp_path))
tests/scripts/test_generate_questions.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Question generation: the pure parts — prompt shape, reply parsing, resume."""
2
+
3
+ import json
4
+
5
+ from scripts.generate_glosses import existing_urls_of
6
+ from scripts.generate_questions import (
7
+ QUESTION_MAX_CHARS,
8
+ QUESTIONS_PER_PAGE,
9
+ batch_prompt,
10
+ parse_questions,
11
+ )
12
+
13
+
14
+ def _page(url, title="T", excerpt="Applies a linear transformation."):
15
+ return {"url": url, "title": title, "excerpt": excerpt}
16
+
17
+
18
+ def test_batch_prompt_numbers_pages_and_uses_symbol():
19
+ prompt = batch_prompt(
20
+ [
21
+ _page("https://docs.pytorch.org/docs/stable/generated/torch.nn.Linear.html"),
22
+ _page("https://docs.pytorch.org/docs/stable/amp.html", title="AMP page"),
23
+ ]
24
+ )
25
+ assert "### 0" in prompt and "### 1" in prompt
26
+ assert "torch.nn.Linear" in prompt # symbol derived from the url
27
+ assert "AMP page" in prompt # no symbol in the url → the title stands in
28
+
29
+
30
+ def test_parse_questions_plain_json():
31
+ raw = json.dumps(
32
+ [
33
+ {"i": 0, "questions": ["How do I add a fully-connected layer?", " spaced "]},
34
+ {"i": 1, "questions": ["What splits a dataset randomly?"]},
35
+ ]
36
+ )
37
+ out = parse_questions(raw, 2)
38
+ assert out[0][0] == "How do I add a fully-connected layer?"
39
+ assert out[0][1] == "spaced"
40
+ assert out[1] == ["What splits a dataset randomly?"]
41
+
42
+
43
+ def test_parse_questions_survives_fences_and_prose():
44
+ raw = 'Sure!\n```json\n[{"i": 0, "questions": ["q1"]}]\n```\nDone.'
45
+ assert parse_questions(raw, 1) == {0: ["q1"]}
46
+
47
+
48
+ def test_parse_questions_drops_malformed_items():
49
+ raw = json.dumps(
50
+ [
51
+ {"i": 5, "questions": ["out of range"]}, # index beyond the batch
52
+ {"i": 0, "questions": []}, # empty list → dropped
53
+ {"i": 1, "questions": ["ok", 7, ""]}, # non-strings/blanks filtered
54
+ "not a dict",
55
+ ]
56
+ )
57
+ assert parse_questions(raw, 2) == {1: ["ok"]}
58
+
59
+
60
+ def test_parse_questions_caps_count_and_length():
61
+ many = [f"question {i}?" for i in range(QUESTIONS_PER_PAGE + 4)]
62
+ long = "x" * (QUESTION_MAX_CHARS + 50)
63
+ out = parse_questions(json.dumps([{"i": 0, "questions": many + [long]}]), 1)
64
+ assert len(out[0]) == QUESTIONS_PER_PAGE
65
+ assert all(len(q) <= QUESTION_MAX_CHARS for q in out[0])
66
+
67
+
68
+ def test_parse_questions_non_json_is_empty():
69
+ assert parse_questions("the pages look great", 1) == {}
70
+
71
+
72
+ def test_existing_urls_of_resumes_from_the_output_file(tmp_path):
73
+ path = tmp_path / "questions.jsonl"
74
+ assert existing_urls_of(path) == set() # no file yet → nothing covered
75
+ path.write_text(
76
+ '{"url": "https://a", "questions": ["q"]}\n\n{"url": "https://b", "questions": ["q"]}\n'
77
+ )
78
+ assert existing_urls_of(path) == {"https://a", "https://b"}