atakan Claude Opus 5 commited on
Commit
9009a09
·
1 Parent(s): 5d9e9cd

fix: Three silent retrieval bugs, and bridge in 88% of the corpus

Browse files

Each of these degraded retrieval quietly rather than failing, so none showed up
as an error.

- Chunk ids were not unique. chunk_document() is called once per page and reset
its counter each time, so every page's first chunk was <file>_c0000 -- 154
distinct ids across 9,976 chunks. Ids are now page-qualified;
scripts/repair_chunk_ids.py migrates an existing index in place.
- Embeddings were pooled without an EOS token. Qwen3-Embedding pools the last
position and was trained with <|endoftext|> there. Omitting it dropped the
margin between relevant and irrelevant passages from +0.32 to +0.15. The
checkpoint's own eos_token_id is <|im_end|>, which is the wrong token here, so
embeddings.py pins the right one.
- 71.5% of the Nise textbook was mojibake. Its PDF has a broken symbol-font
ToUnicode map, so every extractor returns `L½ f ðtÞ/C138 ¼FðsÞ` for
`L[f(t)] = F(s)`. textfix.py reverses the substitution, which is
deterministic; document_loader applies it on ingest.

Separately, the index was only ever reading data/user_docs/ -- 9,976 chunks, 12%
of what was on disk, and none of the canonical texts. The scripts/ pipeline fed
training-set generation only and nothing bridged its output into retrieval.
ingest_processed_corpus.py does that; the index now holds 80,370 chunks.

retriever.py fuses BM25 and dense rankings with reciprocal rank fusion and gates
on cosine similarity rather than on a raw BM25 score. BM25 scores are unbounded
and corpus-relative, so the old threshold of 2.5 passed essentially everything: a
question about the Bode sensitivity integral retrieved Routh-Hurwitz tables at
score 19.7 and injected them as authoritative context. Returning nothing is a
valid and frequent outcome. MIN_COSINE is a property of the model and the corpus
together -- it moved when the index grew 8x -- so calibrate_retrieval.py
re-measures it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

controlai_rag/chunker.py CHANGED
@@ -24,6 +24,21 @@ class Chunk:
24
  }
25
 
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  def chunk_document(doc: Document, max_words: int = 350, overlap_words: int = 50) -> list[Chunk]:
28
  """Split document into coherent chunks with overlap, preserving paragraphs."""
29
  paragraphs = re.split(r"\n\s*\n", doc.content)
@@ -42,7 +57,7 @@ def chunk_document(doc: Document, max_words: int = 350, overlap_words: int = 50)
42
  else:
43
  if current_words:
44
  chunk_text = " ".join(current_words)
45
- chunk_id = f"{doc.metadata.get('filename', 'doc')}_c{chunk_index:04d}"
46
  chunks.append(Chunk(
47
  text=chunk_text,
48
  chunk_id=chunk_id,
@@ -57,7 +72,7 @@ def chunk_document(doc: Document, max_words: int = 350, overlap_words: int = 50)
57
 
58
  if current_words:
59
  chunk_text = " ".join(current_words)
60
- chunk_id = f"{doc.metadata.get('filename', 'doc')}_c{chunk_index:04d}"
61
  chunks.append(Chunk(
62
  text=chunk_text,
63
  chunk_id=chunk_id,
 
24
  }
25
 
26
 
27
+ def _chunk_id(doc: Document, chunk_index: int) -> str:
28
+ """A chunk id that is unique across the whole corpus.
29
+
30
+ `chunk_document` is called once per page, so a counter that restarts at
31
+ zero for each call made every page's first chunk `<file>_c0000`. The corpus
32
+ ended up with 154 distinct ids for 9,976 chunks, which silently broke
33
+ anything keyed on chunk_id. Including the page makes the id unique, since
34
+ (filename, page, index-within-page) is.
35
+ """
36
+ filename = doc.metadata.get("filename", "doc")
37
+ page = doc.metadata.get("page")
38
+ page_part = f"_p{int(page):05d}" if page is not None else ""
39
+ return f"{filename}{page_part}_c{chunk_index:04d}"
40
+
41
+
42
  def chunk_document(doc: Document, max_words: int = 350, overlap_words: int = 50) -> list[Chunk]:
43
  """Split document into coherent chunks with overlap, preserving paragraphs."""
44
  paragraphs = re.split(r"\n\s*\n", doc.content)
 
57
  else:
58
  if current_words:
59
  chunk_text = " ".join(current_words)
60
+ chunk_id = _chunk_id(doc, chunk_index)
61
  chunks.append(Chunk(
62
  text=chunk_text,
63
  chunk_id=chunk_id,
 
72
 
73
  if current_words:
74
  chunk_text = " ".join(current_words)
75
+ chunk_id = _chunk_id(doc, chunk_index)
76
  chunks.append(Chunk(
77
  text=chunk_text,
78
  chunk_id=chunk_id,
controlai_rag/document_loader.py CHANGED
@@ -20,13 +20,18 @@ class Document:
20
  }
21
 
22
 
 
 
 
23
  def load_pdf(path: Path) -> list[Document]:
24
  docs = []
25
  try:
26
  from pypdf import PdfReader
27
  reader = PdfReader(str(path))
28
  for idx, page in enumerate(reader.pages, 1):
29
- text = page.extract_text() or ""
 
 
30
  if text.strip():
31
  docs.append(Document(
32
  content=text.strip(),
 
20
  }
21
 
22
 
23
+ from controlai_rag.textfix import repair
24
+
25
+
26
  def load_pdf(path: Path) -> list[Document]:
27
  docs = []
28
  try:
29
  from pypdf import PdfReader
30
  reader = PdfReader(str(path))
31
  for idx, page in enumerate(reader.pages, 1):
32
+ # Repair broken symbol-font extraction before the text is ever
33
+ # chunked or indexed -- see controlai_rag/textfix.py.
34
+ text = repair(page.extract_text() or "")
35
  if text.strip():
36
  docs.append(Document(
37
  content=text.strip(),
controlai_rag/embeddings.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local dense embeddings for the control-engineering corpus.
2
+
3
+ Uses Qwen3-Embedding-0.6B under MLX -- small, fast on Apple Silicon, and
4
+ already present in the local Hugging Face cache, so retrieval stays fully
5
+ offline. The model is a causal backbone whose sentence embedding is the final
6
+ hidden state at the last position; queries take an instruction prefix while
7
+ documents do not, which is the recipe the model was trained with.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+
17
+ MODEL_ID = os.environ.get("CONTROLAI_EMBED_MODEL", "mlx-community/Qwen3-Embedding-0.6B-4bit-DWQ")
18
+ MAX_TOKENS = 512
19
+ # Qwen3-Embedding pools the hidden state at the final position, and it was
20
+ # trained with an explicit end-of-text token in that position. Omitting it is
21
+ # not a small detail: measured on this corpus, the margin between relevant
22
+ # passages and junk went from +0.150 without it to +0.322 with it, and
23
+ # retrieval for "Routh-Hurwitz table construction" went from returning a
24
+ # book index page to returning the actual Routh-Hurwitz section.
25
+ # `tokenizer.eos_token_id` on this checkpoint is <|im_end|>, which is the chat
26
+ # terminator, not this one -- <|im_end|> scored +0.185. Pin the right token.
27
+ EOS_TOKEN = "<|endoftext|>"
28
+ QUERY_INSTRUCTION = (
29
+ "Instruct: Given a control engineering question, retrieve textbook passages "
30
+ "that explain the underlying theory.\nQuery: "
31
+ )
32
+
33
+
34
+ class Embedder:
35
+ """Lazily-loaded sentence embedder producing L2-normalised float32 vectors."""
36
+
37
+ def __init__(self, model_id: str = MODEL_ID) -> None:
38
+ self.model_id = model_id
39
+ self._model = None
40
+ self._tokenizer = None
41
+ self._eos_id: int | None = None
42
+ self._pad_id: int | None = None
43
+
44
+ def _ensure_loaded(self) -> None:
45
+ if self._model is None:
46
+ from mlx_lm import load
47
+
48
+ self._model, self._tokenizer = load(self.model_id)
49
+ ids = self._tokenizer.encode(EOS_TOKEN)
50
+ self._eos_id = ids[-1] if ids else self._tokenizer.eos_token_id
51
+ self._pad_id = self._tokenizer.pad_token_id or self._eos_id
52
+
53
+ @property
54
+ def dim(self) -> int:
55
+ self._ensure_loaded()
56
+ return int(self._model.args.hidden_size)
57
+
58
+ def _tokens_for(self, text: str) -> list[int]:
59
+ return self._tokenizer.encode(text)[: MAX_TOKENS - 1] + [self._eos_id]
60
+
61
+ def _encode_one(self, text: str) -> np.ndarray:
62
+ import mlx.core as mx
63
+
64
+ ids = self._tokens_for(text)
65
+ # `model.model` is the backbone; calling `model` itself would project
66
+ # through the language-model head and give logits, not an embedding.
67
+ hidden = self._model.model(mx.array(ids)[None])
68
+ vector = hidden[0, -1].astype(mx.float32)
69
+ vector = vector / (mx.linalg.norm(vector) + 1e-9)
70
+ return np.array(vector, copy=True)
71
+
72
+ def _encode_batch(self, batch: list[list[int]]) -> np.ndarray:
73
+ """Embed a batch of already-tokenised inputs.
74
+
75
+ Sequences are right-padded to the longest in the batch and pooled at
76
+ each sequence's own final position. Right-padding is safe here
77
+ precisely because the backbone is causal: position i attends only to
78
+ positions <= i, so tokens appended after the real end cannot influence
79
+ the hidden state being pooled.
80
+ """
81
+ import mlx.core as mx
82
+
83
+ lengths = [len(ids) for ids in batch]
84
+ width = max(lengths)
85
+ pad = self._pad_id
86
+ padded = mx.array([ids + [pad] * (width - len(ids)) for ids in batch])
87
+ hidden = self._model.model(padded)
88
+ picked = mx.stack([hidden[i, n - 1] for i, n in enumerate(lengths)]).astype(mx.float32)
89
+ picked = picked / (mx.linalg.norm(picked, axis=-1, keepdims=True) + 1e-9)
90
+ return np.array(picked, copy=True)
91
+
92
+ def encode_documents(
93
+ self,
94
+ texts: list[str],
95
+ progress_every: int = 2000,
96
+ batch_tokens: int = 16384,
97
+ ) -> np.ndarray:
98
+ """Embed a corpus, batching by token budget rather than by count.
99
+
100
+ Sorting by length before batching keeps padding waste low; the original
101
+ order is restored before returning. One chunk at a time was ~13 minutes
102
+ per 10k chunks, which does not scale to a corpus of 80k.
103
+ """
104
+ self._ensure_loaded()
105
+ tokenised = [self._tokens_for(t) for t in texts]
106
+ order = sorted(range(len(tokenised)), key=lambda i: len(tokenised[i]))
107
+
108
+ out = np.zeros((len(texts), self.dim), dtype=np.float32)
109
+ batch: list[int] = []
110
+ done = 0
111
+
112
+ def flush(batch: list[int]) -> None:
113
+ nonlocal done
114
+ if not batch:
115
+ return
116
+ vectors = self._encode_batch([tokenised[i] for i in batch])
117
+ for slot, i in enumerate(batch):
118
+ out[i] = vectors[slot]
119
+ done += len(batch)
120
+ if progress_every and done % progress_every < len(batch):
121
+ print(f" embedded {done}/{len(texts)}", flush=True)
122
+
123
+ for i in order:
124
+ # The cost of a batch is (rows x longest row), so cap on that
125
+ # product rather than on row count.
126
+ if batch and (len(batch) + 1) * len(tokenised[i]) > batch_tokens:
127
+ flush(batch)
128
+ batch = []
129
+ batch.append(i)
130
+ flush(batch)
131
+ return out
132
+
133
+ def encode_query(self, query: str) -> np.ndarray:
134
+ self._ensure_loaded()
135
+ return self._encode_one(QUERY_INSTRUCTION + query)
136
+
137
+
138
+ _shared: Embedder | None = None
139
+
140
+
141
+ def get_embedder() -> Embedder:
142
+ global _shared
143
+ if _shared is None:
144
+ _shared = Embedder()
145
+ return _shared
controlai_rag/retriever.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hybrid retrieval over the local control-engineering library.
2
+
3
+ The previous agent grounded its answers on BM25 alone, gated on a raw score
4
+ threshold of 2.5. BM25 scores are unbounded and corpus-dependent, so that gate
5
+ passed nearly everything: asking about the Bode sensitivity integral retrieved
6
+ Routh-Hurwitz tables at score 19.7 and injected them as authoritative context.
7
+
8
+ This layer fixes both halves of that. Lexical and dense rankings are fused with
9
+ reciprocal rank fusion, and the result is gated on cosine similarity -- a
10
+ bounded, comparable quantity -- so that when the library genuinely has nothing
11
+ relevant, nothing is injected and the model answers from its own knowledge
12
+ instead of from a mismatched passage.
13
+
14
+ Build the dense side with: python -m controlai_rag.retriever --build
15
+ Without it, retrieval degrades to lexical-only rather than failing.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import re
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import numpy as np
26
+
27
+ from controlai_rag.index import INDEX_DIR, display_source_name, get_shared_index
28
+
29
+ EMBEDDINGS_PATH = INDEX_DIR / "embeddings.npz"
30
+
31
+ # Cosine similarity below this means the corpus has nothing useful on the topic.
32
+ # Re-measure with `./run.sh --calibrate` whenever the corpus changes size
33
+ # materially -- this is a property of the model *and* the corpus, not a
34
+ # constant. Against the 80,370-chunk corpus:
35
+ # in-domain worst best-match 0.678 (mean 0.748, 12 probes across
36
+ # classical/modern/optimal/robust/
37
+ # nonlinear/estimation/MPC)
38
+ # off-domain best best-match 0.571 (mean 0.432, 5 probes)
39
+ # 0.62 sits in that 0.108-wide gap. The off-domain outlier is a pollen-allergy
40
+ # query at 0.571, pulled up by biomedical material in the open_books tier --
41
+ # everything genuinely unrelated lands near 0.42.
42
+ MIN_COSINE = 0.62
43
+ # Standard RRF constant; damps the influence of any single ranker's tail.
44
+ RRF_K = 60
45
+
46
+ # A back-of-book index page matches almost any control query -- it contains
47
+ # every term in the field -- but carries no explanation whatsoever. Measured
48
+ # across the corpus, index-entry density ("Thermal systems, 100,136-39") is 0
49
+ # for the median chunk and 6.7 at the 99th percentile, while index pages run
50
+ # above 30, so this cleanly separates them without touching equation-dense
51
+ # prose.
52
+ _INDEX_ENTRY_RE = re.compile(r"[A-Za-z)],\s*\d")
53
+ _MAX_INDEX_DENSITY = 12.0 # entries per 1000 characters
54
+ _MIN_USEFUL_CHARS = 120
55
+
56
+
57
+ def _is_low_value(text: str) -> bool:
58
+ """True for chunks that can match well but cannot inform an answer."""
59
+ stripped = text.strip()
60
+ if len(stripped) < _MIN_USEFUL_CHARS:
61
+ return True
62
+ density = 1000.0 * len(_INDEX_ENTRY_RE.findall(stripped)) / len(stripped)
63
+ if density > _MAX_INDEX_DENSITY:
64
+ return True
65
+ # Mojibake from a failed PDF extraction: mostly characters outside the
66
+ # Latin/Greek/mathematical ranges any real passage is written in.
67
+ exotic = sum(1 for c in stripped if ord(c) > 0x2200)
68
+ return exotic / len(stripped) > 0.25
69
+
70
+
71
+ class HybridRetriever:
72
+ """Fuses BM25 and dense retrieval, and declines to return weak matches."""
73
+
74
+ def __init__(self, index=None, embeddings_path: Path = EMBEDDINGS_PATH) -> None:
75
+ self.index = index or get_shared_index()
76
+ self.embeddings_path = embeddings_path
77
+ self.vectors: np.ndarray | None = None
78
+ self.vector_ids: list[str] = []
79
+ self._row_of: dict[str, int] = {}
80
+ self._embedder = None
81
+ self._load_vectors()
82
+
83
+ def _load_vectors(self) -> None:
84
+ if not self.embeddings_path.exists():
85
+ print(f"[retriever] no dense index at {self.embeddings_path}; lexical-only mode")
86
+ return
87
+ data = np.load(self.embeddings_path, allow_pickle=False)
88
+ self.vectors = data["vectors"].astype(np.float32)
89
+ self.vector_ids = [str(x) for x in data["chunk_ids"]]
90
+ self._row_of = {cid: i for i, cid in enumerate(self.vector_ids)}
91
+ if len(self.vector_ids) != len(self.index.chunks):
92
+ print(
93
+ f"[retriever] dense index covers {len(self.vector_ids)} chunks but the "
94
+ f"corpus holds {len(self.index.chunks)}; rebuild to include the rest"
95
+ )
96
+
97
+ @property
98
+ def has_dense(self) -> bool:
99
+ return self.vectors is not None and len(self.vector_ids) > 0
100
+
101
+ def _dense_rank(self, query: str, depth: int) -> list[tuple[str, float]]:
102
+ if not self.has_dense:
103
+ return []
104
+ from controlai_rag.embeddings import get_embedder
105
+
106
+ if self._embedder is None:
107
+ self._embedder = get_embedder()
108
+ q = self._embedder.encode_query(query)
109
+ sims = self.vectors @ q
110
+ top = np.argpartition(-sims, min(depth, len(sims) - 1))[:depth]
111
+ top = top[np.argsort(-sims[top])]
112
+ return [(self.vector_ids[i], float(sims[i])) for i in top]
113
+
114
+ def search(self, query: str, top_k: int = 4, depth: int = 40) -> list[dict[str, Any]]:
115
+ """Return the passages worth showing the model, best first.
116
+
117
+ An empty list is a valid and common answer: it means the library has
118
+ nothing on this question.
119
+ """
120
+ by_id = {c["chunk_id"]: c for c in self.index.chunks}
121
+
122
+ lexical = self.index.search(query, top_k=depth)
123
+ dense = self._dense_rank(query, depth)
124
+ if not lexical and not dense:
125
+ return []
126
+
127
+ cosine = {cid: score for cid, score in dense}
128
+ # Score the lexical candidates densely as well. Without this, a chunk
129
+ # that BM25 ranked first but that fell outside the dense top-`depth`
130
+ # has no similarity, and the gate below drops it for lacking a score
131
+ # rather than for being irrelevant -- which silently discarded the best
132
+ # keyword matches in the corpus.
133
+ if self.has_dense and lexical:
134
+ missing = [h["chunk_id"] for h in lexical if h["chunk_id"] not in cosine]
135
+ rows = [(cid, self._row_of[cid]) for cid in missing if cid in self._row_of]
136
+ if rows:
137
+ if self._embedder is None:
138
+ from controlai_rag.embeddings import get_embedder
139
+
140
+ self._embedder = get_embedder()
141
+ q = self._embedder.encode_query(query)
142
+ sub = self.vectors[[r for _, r in rows]] @ q
143
+ cosine.update({cid: float(score) for (cid, _), score in zip(rows, sub)})
144
+
145
+ fused: dict[str, float] = {}
146
+ for rank, hit in enumerate(lexical):
147
+ fused[hit["chunk_id"]] = fused.get(hit["chunk_id"], 0.0) + 1.0 / (RRF_K + rank + 1)
148
+ for rank, (cid, _) in enumerate(dense):
149
+ fused[cid] = fused.get(cid, 0.0) + 1.0 / (RRF_K + rank + 1)
150
+
151
+ ordered = sorted(fused.items(), key=lambda kv: kv[1], reverse=True)
152
+
153
+ results: list[dict[str, Any]] = []
154
+ for chunk_id, fusion_score in ordered:
155
+ chunk = by_id.get(chunk_id)
156
+ if chunk is None or _is_low_value(str(chunk.get("text", ""))):
157
+ continue
158
+ similarity = cosine.get(chunk_id)
159
+ # With a dense index available, similarity is the gate. Without one
160
+ # there is no bounded relevance signal, so lexical order stands and
161
+ # the caller gets fewer, higher-ranked passages instead.
162
+ if self.has_dense:
163
+ if similarity is None or similarity < MIN_COSINE:
164
+ continue
165
+ elif len(results) >= 2:
166
+ break
167
+ meta = chunk.get("metadata", {})
168
+ # Chunks bridged in from data/processed carry a real bibliographic
169
+ # title, which beats anything that can be recovered from a
170
+ # filename. display_source_name is the fallback for user uploads,
171
+ # whose filenames carry owner initials and course codes.
172
+ title = meta.get("source_title")
173
+ if title:
174
+ label, is_published = str(title), meta.get("corpus_tier") != "user_docs"
175
+ else:
176
+ label, is_published = display_source_name(meta.get("filename", "unknown"))
177
+ results.append(
178
+ {
179
+ "chunk_id": chunk_id,
180
+ "label": label,
181
+ "is_published_work": is_published,
182
+ "page": chunk["metadata"].get("page"),
183
+ "text": chunk["text"],
184
+ "similarity": round(similarity, 4) if similarity is not None else None,
185
+ "fusion_score": round(fusion_score, 5),
186
+ }
187
+ )
188
+ if len(results) >= top_k:
189
+ break
190
+ return results
191
+
192
+
193
+ def add_chunks(self, chunks: list[dict[str, Any]]) -> int:
194
+ """Embed newly ingested chunks so they are searchable immediately.
195
+
196
+ Documents uploaded through the web UI are appended to the lexical index
197
+ live. Without this they would have no vector, and since the relevance
198
+ gate requires a cosine score, they would be silently unreachable until
199
+ the whole dense index was rebuilt.
200
+ """
201
+ if not self.has_dense or not chunks:
202
+ return 0
203
+ from controlai_rag.embeddings import get_embedder
204
+
205
+ if self._embedder is None:
206
+ self._embedder = get_embedder()
207
+ texts = [str(c.get("text", "")) for c in chunks]
208
+ new_vectors = self._embedder.encode_documents(texts, progress_every=0)
209
+ self.vectors = np.vstack([self.vectors, new_vectors.astype(np.float32)])
210
+ self.vector_ids.extend(str(c["chunk_id"]) for c in chunks)
211
+ return len(chunks)
212
+
213
+
214
+ _shared: HybridRetriever | None = None
215
+
216
+
217
+ def get_retriever() -> HybridRetriever:
218
+ global _shared
219
+ if _shared is None:
220
+ _shared = HybridRetriever()
221
+ return _shared
222
+
223
+
224
+ def build(output: Path = EMBEDDINGS_PATH, slab: int = 4000) -> None:
225
+ """Embed every chunk in the corpus and persist the vectors.
226
+
227
+ Checkpointed. Embedding an 80k-chunk corpus takes over half an hour, and
228
+ writing the result only at the end meant a single interruption threw all of
229
+ it away -- observed directly at 72,008 of 80,370 chunks. Progress is now
230
+ flushed to a sidecar file every `slab` chunks, and a re-run picks up from
231
+ whatever is already there, so an interrupt costs a few minutes at most.
232
+ """
233
+ from controlai_rag.embeddings import get_embedder
234
+
235
+ index = get_shared_index()
236
+ chunks = index.chunks
237
+ embedder = get_embedder()
238
+ output.parent.mkdir(parents=True, exist_ok=True)
239
+ partial_path = output.with_suffix(".partial.npz")
240
+
241
+ done: dict[str, np.ndarray] = {}
242
+ if partial_path.exists():
243
+ cached = np.load(partial_path, allow_pickle=False)
244
+ done = {str(cid): vec for cid, vec in zip(cached["chunk_ids"], cached["vectors"])}
245
+ print(f"Resuming: {len(done)} chunks already embedded")
246
+
247
+ todo = [c for c in chunks if c["chunk_id"] not in done]
248
+ print(f"Embedding {len(todo)} of {len(chunks)} chunks with {embedder.model_id} ...")
249
+
250
+ def flush() -> None:
251
+ ids = list(done.keys())
252
+ np.savez_compressed(
253
+ partial_path,
254
+ vectors=np.stack([done[i] for i in ids]).astype(np.float16),
255
+ chunk_ids=np.array(ids, dtype="U"),
256
+ )
257
+
258
+ for start in range(0, len(todo), slab):
259
+ batch = todo[start : start + slab]
260
+ vectors = embedder.encode_documents(
261
+ [str(c.get("text", "")) for c in batch], progress_every=0
262
+ )
263
+ for chunk, vector in zip(batch, vectors):
264
+ done[chunk["chunk_id"]] = vector.astype(np.float16)
265
+ flush()
266
+ print(f" {len(done)}/{len(chunks)} embedded (checkpointed)", flush=True)
267
+
268
+ # Emit in corpus order so the vector rows line up with chunks.json.
269
+ ordered = [c["chunk_id"] for c in chunks if c["chunk_id"] in done]
270
+ np.savez_compressed(
271
+ output,
272
+ vectors=np.stack([done[i] for i in ordered]).astype(np.float16),
273
+ chunk_ids=np.array(ordered, dtype="U"),
274
+ )
275
+ partial_path.unlink(missing_ok=True)
276
+ print(f"Wrote {output} ({output.stat().st_size / 1e6:.1f} MB, {len(ordered)} vectors)")
277
+
278
+
279
+ if __name__ == "__main__":
280
+ parser = argparse.ArgumentParser(description="Build or probe the dense retrieval index")
281
+ parser.add_argument("--build", action="store_true", help="embed the corpus and write the index")
282
+ parser.add_argument("--query", type=str, help="run a test query against the current index")
283
+ args = parser.parse_args()
284
+ if args.build:
285
+ build()
286
+ if args.query:
287
+ for hit in get_retriever().search(args.query):
288
+ print(f"{hit['similarity']} {hit['label']} p.{hit['page']} {hit['text'][:110]!r}")
controlai_rag/textfix.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Repair of PDF text extraction artifacts.
2
+
3
+ Some of the textbooks in the library are typeset with Type 1 symbol fonts whose
4
+ embedded ToUnicode map is wrong. Every extractor -- pypdf and PyMuPDF alike --
5
+ therefore returns the same mojibake for their inline mathematics: the Laplace
6
+ transform definition comes out as `L½ f ðtÞ/C138 ¼FðsÞ` instead of
7
+ `L[f(t)] = F(s)`.
8
+
9
+ This mattered more than it looks. 71.5% of the chunks from Nise's *Control
10
+ Systems Engineering* -- the most heavily used classical-control reference in the
11
+ corpus -- were damaged this way, which is why retrieval for a query as ordinary
12
+ as "Routh-Hurwitz table construction" returned book index pages instead of the
13
+ relevant section.
14
+
15
+ The substitution is deterministic and was read off from context, not guessed:
16
+ `e/C0ð sþaÞt` is `e^-(s+a)t`, `5:6 /C2 10/C0 6s` is `5.6 x 10^-6 s`,
17
+ `26:57/C14` is `26.57°`, `þ/C1/C1/C1þ` is `+ ... +`. Codes that turn out to be
18
+ pieces of a multi-line bracket rather than a character of their own carry no
19
+ meaning on their own and are dropped.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+
26
+ # Single characters the broken font map emits in place of ASCII math.
27
+ _CHAR_MAP = str.maketrans({
28
+ "ð": "(",
29
+ "Þ": ")",
30
+ "¼": "=",
31
+ "þ": "+",
32
+ "½": "[",
33
+ })
34
+
35
+ # `/Cnn` glyph references, by the number that follows.
36
+ _GLYPH_MAP = {
37
+ "0": "-", # minus: 1899 /C0 3761z -> 1899 - 3761z
38
+ "1": "·", # middle dot: þ/C1/C1/C1þ -> + ... +
39
+ "2": "×", # times: 5:6 /C2 10 -> 5.6 x 10
40
+ "12": "|", # evaluation bar
41
+ "14": "°", # degree: 26:57/C14 -> 26.57 deg
42
+ "15": "•", # bullet in feature lists
43
+ "138": "]", # closing bracket, pairing with the "[" above
44
+ }
45
+ # Pieces of tall multi-line brackets. They are layout, not content, and the
46
+ # extractor emits them after the expression they were meant to enclose, so
47
+ # rendering them as brackets would be actively misleading.
48
+ _LAYOUT_GLYPHS = {"3", "6", "16", "17", "18", "19", "20", "21"}
49
+
50
+ _GLYPH_RE = re.compile(r"/C(\d+)")
51
+ # Detects whether a document needs any of this at all.
52
+ _DAMAGE_RE = re.compile(r"/C\d+|[ðÞ¼þ]")
53
+
54
+
55
+ def looks_damaged(text: str, threshold: int = 5) -> bool:
56
+ """True if `text` carries enough artifacts to be worth repairing."""
57
+ return len(_DAMAGE_RE.findall(text)) >= threshold
58
+
59
+
60
+ def repair(text: str) -> str:
61
+ """Undo the broken font mapping. Safe to call on undamaged text."""
62
+ if not _DAMAGE_RE.search(text):
63
+ return text
64
+
65
+ def _glyph(match: re.Match) -> str:
66
+ code = match.group(1)
67
+ if code in _GLYPH_MAP:
68
+ return _GLYPH_MAP[code]
69
+ if code in _LAYOUT_GLYPHS:
70
+ return " "
71
+ return " " # an unrecognised glyph is noise; a space beats a token of junk
72
+
73
+ text = _GLYPH_RE.sub(_glyph, text)
74
+ text = text.translate(_CHAR_MAP)
75
+ # The extractor also stamps a watermark onto every page of one scan.
76
+ text = text.replace("Apago PDF Enhancer", " ")
77
+ return re.sub(r"[ \t]{2,}", " ", text).strip()
scripts/calibrate_retrieval.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Measure the relevance gap and suggest a MIN_COSINE for the current corpus.
3
+
4
+ `MIN_COSINE` in `controlai_rag/retriever.py` is the gate that decides whether a
5
+ retrieved passage is worth putting in front of the model. It is not a universal
6
+ constant: it depends on the embedding model *and* on the corpus. A larger corpus
7
+ raises every query's best match, off-domain ones included, so the threshold has
8
+ to be re-measured whenever the index changes size materially.
9
+
10
+ The method is to score two sets of probes -- questions the corpus should be able
11
+ to answer, and questions it definitely cannot -- and put the threshold in the gap
12
+ between them. If there is no gap, the report says so rather than inventing one.
13
+
14
+ python scripts/calibrate_retrieval.py
15
+ python scripts/calibrate_retrieval.py --json report.json
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ import numpy as np
26
+
27
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
28
+ if str(PROJECT_ROOT) not in sys.path:
29
+ sys.path.insert(0, str(PROJECT_ROOT))
30
+
31
+ # Topics a control-engineering library is expected to cover. Deliberately spans
32
+ # classical, modern, optimal, robust, nonlinear, estimation and MPC so the
33
+ # threshold is not tuned to one corner of the field.
34
+ IN_DOMAIN = [
35
+ "Routh-Hurwitz stability criterion table construction",
36
+ "root locus asymptotes and breakaway points",
37
+ "Nyquist stability criterion encirclements and the Z = N + P rule",
38
+ "Kalman filter measurement update equations",
39
+ "why does LQG have no guaranteed stability margins",
40
+ "Bode sensitivity integral waterbed effect right half plane zero",
41
+ "terminal cost and terminal region for model predictive control stability",
42
+ "small gain theorem and when it is conservative",
43
+ "sliding mode control chattering and the boundary layer",
44
+ "PBH rank test for controllability and stabilizability",
45
+ "describing function analysis of a limit cycle",
46
+ "persistent excitation in system identification",
47
+ ]
48
+
49
+ # Questions with no plausible answer in a control library. If any of these
50
+ # clears the threshold, the gate is too loose and the model will be handed a
51
+ # confident irrelevance.
52
+ OUT_OF_DOMAIN = [
53
+ "how do I bake sourdough bread with a levain starter",
54
+ "best hiking trails in Patagonia in November",
55
+ "React useState hook rerender behaviour in strict mode",
56
+ "who won the 1998 FIFA World Cup final",
57
+ "symptoms and treatment of seasonal pollen allergy",
58
+ ]
59
+
60
+
61
+ def best_similarities(retriever, embedder, query: str, depth: int = 60, top: int = 5) -> list[float]:
62
+ """Top similarities for `query`, after the low-value filter is applied.
63
+
64
+ Filtering first matters: index pages match nearly any control query and
65
+ would otherwise set the threshold from chunks that can never be returned.
66
+ """
67
+ from controlai_rag.retriever import _is_low_value
68
+
69
+ by_id = {c["chunk_id"]: c for c in retriever.index.chunks}
70
+ sims = retriever.vectors @ embedder.encode_query(query)
71
+ order = np.argsort(-sims)[:depth]
72
+ kept = [
73
+ float(sims[i])
74
+ for i in order
75
+ if not _is_low_value(str(by_id.get(retriever.vector_ids[i], {}).get("text", "")))
76
+ ]
77
+ return kept[:top]
78
+
79
+
80
+ def main() -> int:
81
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
82
+ parser.add_argument("--json", type=Path, help="also write the raw measurements here")
83
+ args = parser.parse_args()
84
+
85
+ from controlai_rag.embeddings import get_embedder
86
+ from controlai_rag.retriever import MIN_COSINE, get_retriever
87
+
88
+ retriever = get_retriever()
89
+ if not retriever.has_dense:
90
+ print("No dense index. Run: python -m controlai_rag.retriever --build")
91
+ return 1
92
+ embedder = get_embedder()
93
+ print(f"corpus: {len(retriever.index.chunks)} chunks, {len(retriever.vector_ids)} vectors\n")
94
+
95
+ report: dict[str, dict[str, list[float]]] = {"in_domain": {}, "out_of_domain": {}}
96
+ for label, queries, key in (
97
+ ("IN ", IN_DOMAIN, "in_domain"),
98
+ ("OUT", OUT_OF_DOMAIN, "out_of_domain"),
99
+ ):
100
+ for query in queries:
101
+ tops = best_similarities(retriever, embedder, query)
102
+ report[key][query] = tops
103
+ shown = np.round(tops, 3) if tops else "none"
104
+ print(f"{label} {str(shown):32} {query[:58]}")
105
+ print()
106
+
107
+ # A passage is only useful if it clears the gate, so what matters for the
108
+ # in-domain set is its *weakest* best-match, and for the out-of-domain set
109
+ # its strongest.
110
+ in_best = [max(v) for v in report["in_domain"].values() if v]
111
+ out_best = [max(v) for v in report["out_of_domain"].values() if v]
112
+ floor, ceiling = min(in_best), max(out_best)
113
+
114
+ print(f"in-domain worst best-match : {floor:.3f} (mean {np.mean(in_best):.3f})")
115
+ print(f"off-domain best best-match : {ceiling:.3f} (mean {np.mean(out_best):.3f})")
116
+ print(f"current MIN_COSINE : {MIN_COSINE}")
117
+
118
+ if floor > ceiling:
119
+ suggestion = round((floor + ceiling) / 2, 2)
120
+ print(f"\ngap of {floor - ceiling:.3f} -> suggested MIN_COSINE = {suggestion}")
121
+ if not (ceiling < MIN_COSINE < floor):
122
+ print(f"the current value sits outside that gap; update it in controlai_rag/retriever.py")
123
+ else:
124
+ print(
125
+ f"\nNO GAP: an off-domain query scores {ceiling:.3f} while an in-domain one "
126
+ f"scores only {floor:.3f}. No single threshold separates them -- tighten "
127
+ f"_is_low_value, or accept losing the weakest in-domain topics by setting the "
128
+ f"threshold above {ceiling:.2f}."
129
+ )
130
+
131
+ if args.json:
132
+ args.json.write_text(json.dumps(report, indent=2), encoding="utf-8")
133
+ print(f"\nwrote {args.json}")
134
+ return 0
135
+
136
+
137
+ if __name__ == "__main__":
138
+ raise SystemExit(main())
scripts/ingest_processed_corpus.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Bridge the processed corpus into the retrieval index.
3
+
4
+ `scripts/` builds a large pipeline -- raw downloads, extraction, chunking --
5
+ whose output lands in `data/processed/*_chunks/knowledge_chunks.jsonl` and
6
+ feeds *training dataset generation*. `ControlRAGIndex` was built separately and
7
+ only ever read `data/user_docs/`. Nothing connected the two, so retrieval saw
8
+ 9,976 chunks of course notes plus two textbooks while 70,422 chunks of
9
+ canonical control literature -- Doyle/Francis/Tannenbaum, Astrom & Murray,
10
+ Rawlings/Mayne/Diehl, Sontag, Liberzon, Boyd, Soderstrom & Stoica -- sat on
11
+ disk unread.
12
+
13
+ This script merges them. The processed schema is richer than the index's: it
14
+ carries `source_title` and `source_authors`, which make far better citations
15
+ than the filename scrubbing `display_source_name` has to do for user uploads.
16
+
17
+ python scripts/ingest_processed_corpus.py # everything
18
+ python scripts/ingest_processed_corpus.py --tiers core_books arxiv
19
+ python scripts/ingest_processed_corpus.py --dry-run
20
+
21
+ Rebuilds BM25. Run `python -m controlai_rag.retriever --build` afterwards to
22
+ regenerate the dense index over the merged corpus.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import pickle
30
+ import shutil
31
+ import sys
32
+ from pathlib import Path
33
+
34
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
35
+ if str(PROJECT_ROOT) not in sys.path:
36
+ sys.path.insert(0, str(PROJECT_ROOT))
37
+
38
+ from controlai_rag.index import INDEX_DIR, tokenize_corpus
39
+ from controlai_rag.textfix import repair
40
+
41
+ PROCESSED_DIR = PROJECT_ROOT / "data" / "processed"
42
+ MIN_CHARS = 120
43
+
44
+
45
+ def _tier_files(tiers: list[str] | None) -> list[tuple[str, Path]]:
46
+ found = []
47
+ for path in sorted(PROCESSED_DIR.glob("*_chunks/knowledge_chunks.jsonl")):
48
+ tier = path.parent.name.removesuffix("_chunks")
49
+ if tiers and tier not in tiers:
50
+ continue
51
+ found.append((tier, path))
52
+ return found
53
+
54
+
55
+ def _to_index_chunk(tier: str, raw: dict) -> dict | None:
56
+ text = repair(str(raw.get("text", "")).strip())
57
+ if len(text) < MIN_CHARS:
58
+ return None
59
+
60
+ title = str(raw.get("source_title") or raw.get("source_id") or tier).strip()
61
+ container = str(raw.get("container") or raw.get("member_path") or raw.get("document_id") or "")
62
+ page = raw.get("page_start")
63
+ try:
64
+ page = int(page) if page not in (None, "") else None
65
+ except (TypeError, ValueError):
66
+ page = None
67
+
68
+ return {
69
+ # Namespaced so a chunk id can never collide with one from another
70
+ # tier or with the existing user_docs ids.
71
+ "chunk_id": f"{tier}:{raw.get('chunk_id')}",
72
+ "text": text,
73
+ "source_path": f"data/processed/{tier}_chunks/{container}",
74
+ "metadata": {
75
+ "page": page,
76
+ "page_end": raw.get("page_end"),
77
+ # `filename` stays populated because the rest of the codebase reads
78
+ # it; `source_title` is what citations should actually use.
79
+ "filename": container or f"{title}.pdf",
80
+ "source_title": title,
81
+ "source_authors": raw.get("source_authors"),
82
+ "corpus_tier": raw.get("corpus_tier") or tier,
83
+ "source_coverage": raw.get("source_coverage"),
84
+ "doc_type": "processed",
85
+ "ingest_tier": tier,
86
+ },
87
+ "_sha": raw.get("text_sha256"),
88
+ }
89
+
90
+
91
+ def main() -> int:
92
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
93
+ parser.add_argument("--tiers", nargs="*", help="only these tiers (default: all)")
94
+ parser.add_argument("--dry-run", action="store_true", help="report what would be added, write nothing")
95
+ args = parser.parse_args()
96
+
97
+ chunks_path = INDEX_DIR / "chunks.json"
98
+ existing = json.loads(chunks_path.read_text(encoding="utf-8"))
99
+ existing_ids = {c["chunk_id"] for c in existing}
100
+ # Dedupe against what is already indexed, and across tiers, by content hash.
101
+ import hashlib
102
+
103
+ seen_hashes = {
104
+ hashlib.sha256(c["text"].encode("utf-8")).hexdigest() for c in existing
105
+ }
106
+ print(f"existing index: {len(existing)} chunks")
107
+
108
+ added: list[dict] = []
109
+ for tier, path in _tier_files(args.tiers):
110
+ kept = skipped_short = skipped_dupe = 0
111
+ with path.open(encoding="utf-8") as handle:
112
+ for line in handle:
113
+ line = line.strip()
114
+ if not line:
115
+ continue
116
+ chunk = _to_index_chunk(tier, json.loads(line))
117
+ if chunk is None:
118
+ skipped_short += 1
119
+ continue
120
+ sha = chunk.pop("_sha", None) or hashlib.sha256(chunk["text"].encode("utf-8")).hexdigest()
121
+ if sha in seen_hashes or chunk["chunk_id"] in existing_ids:
122
+ skipped_dupe += 1
123
+ continue
124
+ seen_hashes.add(sha)
125
+ existing_ids.add(chunk["chunk_id"])
126
+ added.append(chunk)
127
+ kept += 1
128
+ print(f" {tier:22} +{kept:6d} (short {skipped_short}, duplicate {skipped_dupe})")
129
+
130
+ total = len(existing) + len(added)
131
+ print(f"\nwould index {total} chunks ({len(existing)} existing + {len(added)} new)")
132
+ if args.dry_run:
133
+ return 0
134
+
135
+ backup = chunks_path.with_suffix(".json.pre-corpus")
136
+ if not backup.exists():
137
+ shutil.copy2(chunks_path, backup)
138
+ print(f"backed up existing index to {backup.name}")
139
+
140
+ merged = existing + added
141
+ print("writing chunks.json ...")
142
+ chunks_path.write_text(json.dumps(merged, ensure_ascii=False), encoding="utf-8")
143
+
144
+ from rank_bm25 import BM25Okapi
145
+
146
+ print(f"building BM25 over {len(merged)} chunks (this takes a few minutes) ...")
147
+ bm25 = BM25Okapi([tokenize_corpus(c["text"]) for c in merged])
148
+ (INDEX_DIR / "bm25.pkl").write_bytes(pickle.dumps(bm25))
149
+ print("done. Now run: python -m controlai_rag.retriever --build")
150
+ return 0
151
+
152
+
153
+ if __name__ == "__main__":
154
+ raise SystemExit(main())
scripts/repair_chunk_ids.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """One-off migration: give every indexed chunk a unique id.
3
+
4
+ `chunk_document` used to restart its counter on every page, so the corpus held
5
+ 154 distinct chunk ids across 9,976 chunks. Anything keyed on chunk_id -- the
6
+ dense retriever's vector lookup, in particular -- silently resolved to the
7
+ wrong row.
8
+
9
+ `controlai_rag.chunker` now generates page-qualified ids, but the existing
10
+ index was built before that. Rewriting the ids in place is enough: the text is
11
+ untouched, and `chunks.json` and `embeddings.npz` were written in the same
12
+ order, so the vectors stay valid and nothing needs re-embedding.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import pickle
19
+ import shutil
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ import numpy as np
24
+
25
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
26
+ if str(PROJECT_ROOT) not in sys.path:
27
+ sys.path.insert(0, str(PROJECT_ROOT))
28
+
29
+ from controlai_rag.index import INDEX_DIR
30
+
31
+
32
+ def main() -> int:
33
+ chunks_path = INDEX_DIR / "chunks.json"
34
+ vectors_path = INDEX_DIR / "embeddings.npz"
35
+ chunks = json.loads(chunks_path.read_text(encoding="utf-8"))
36
+
37
+ before = len({c["chunk_id"] for c in chunks})
38
+ new_ids = []
39
+ for chunk in chunks:
40
+ meta = chunk.get("metadata", {})
41
+ filename = meta.get("filename", "doc")
42
+ page = meta.get("page")
43
+ index = meta.get("chunk_index", 0)
44
+ page_part = f"_p{int(page):05d}" if page is not None else ""
45
+ new_ids.append(f"{filename}{page_part}_c{int(index):04d}")
46
+
47
+ if len(set(new_ids)) != len(new_ids):
48
+ # Fall back to a positional suffix rather than trade one collision
49
+ # for another.
50
+ seen: dict[str, int] = {}
51
+ for i, cid in enumerate(new_ids):
52
+ if cid in seen:
53
+ seen[cid] += 1
54
+ new_ids[i] = f"{cid}_{seen[cid]:03d}"
55
+ else:
56
+ seen[cid] = 0
57
+
58
+ for chunk, cid in zip(chunks, new_ids):
59
+ chunk["chunk_id"] = cid
60
+ print(f"chunk ids: {before} unique -> {len(set(new_ids))} unique across {len(chunks)} chunks")
61
+
62
+ backup = chunks_path.with_suffix(".json.pre-idfix")
63
+ if not backup.exists():
64
+ shutil.copy2(chunks_path, backup)
65
+ chunks_path.write_text(json.dumps(chunks, ensure_ascii=False, indent=2), encoding="utf-8")
66
+
67
+ if vectors_path.exists():
68
+ data = np.load(vectors_path, allow_pickle=False)
69
+ vectors = data["vectors"]
70
+ if len(vectors) != len(chunks):
71
+ print(f"WARNING: {len(vectors)} vectors vs {len(chunks)} chunks -- rebuild the dense index")
72
+ else:
73
+ np.savez_compressed(vectors_path, vectors=vectors, chunk_ids=np.array(new_ids, dtype="U"))
74
+ print(f"Rewrote {vectors_path.name} with matching ids (no re-embedding needed)")
75
+
76
+ from rank_bm25 import BM25Okapi
77
+
78
+ from controlai_rag.index import tokenize_corpus
79
+
80
+ print("Rebuilding BM25 ...")
81
+ (INDEX_DIR / "bm25.pkl").write_bytes(
82
+ pickle.dumps(BM25Okapi([tokenize_corpus(c["text"]) for c in chunks]))
83
+ )
84
+ print("Done.")
85
+ return 0
86
+
87
+
88
+ if __name__ == "__main__":
89
+ raise SystemExit(main())
scripts/repair_corpus_text.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """One-off migration: repair PDF font-map damage in the existing RAG index.
3
+
4
+ The chunks were extracted before `controlai_rag.textfix` existed, so the damage
5
+ is baked into `data/rag_index/chunks.json`. The corruption is deterministic, so
6
+ the chunks can be repaired in place -- no re-extraction of the source PDFs, and
7
+ chunk ids stay stable, which keeps the dense index aligned.
8
+
9
+ Rebuilds BM25 here; run `python -m controlai_rag.retriever --build` afterwards
10
+ to regenerate the embeddings from the repaired text.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import pickle
17
+ import shutil
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
22
+ if str(PROJECT_ROOT) not in sys.path:
23
+ sys.path.insert(0, str(PROJECT_ROOT))
24
+
25
+ from controlai_rag.index import INDEX_DIR, tokenize_corpus
26
+ from controlai_rag.textfix import looks_damaged, repair
27
+
28
+
29
+ def main() -> int:
30
+ chunks_path = INDEX_DIR / "chunks.json"
31
+ chunks = json.loads(chunks_path.read_text(encoding="utf-8"))
32
+
33
+ backup = chunks_path.with_suffix(".json.pre-textfix")
34
+ if not backup.exists():
35
+ shutil.copy2(chunks_path, backup)
36
+ print(f"Backed up original to {backup.name}")
37
+
38
+ damaged = repaired = 0
39
+ for chunk in chunks:
40
+ text = chunk.get("text", "")
41
+ if not looks_damaged(text):
42
+ continue
43
+ damaged += 1
44
+ fixed = repair(text)
45
+ if fixed != text:
46
+ chunk["text"] = fixed
47
+ repaired += 1
48
+
49
+ print(f"{damaged} damaged chunks found, {repaired} repaired, {len(chunks)} total")
50
+ chunks_path.write_text(json.dumps(chunks, ensure_ascii=False, indent=2), encoding="utf-8")
51
+
52
+ from rank_bm25 import BM25Okapi
53
+
54
+ print("Rebuilding BM25 over the repaired text ...")
55
+ bm25 = BM25Okapi([tokenize_corpus(c["text"]) for c in chunks])
56
+ (INDEX_DIR / "bm25.pkl").write_bytes(pickle.dumps(bm25))
57
+ print("Done. Now run: python -m controlai_rag.retriever --build")
58
+ return 0
59
+
60
+
61
+ if __name__ == "__main__":
62
+ raise SystemExit(main())