Spaces:
Running on Zero
fix: Three silent retrieval bugs, and bridge in 88% of the corpus
Browse filesEach 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 +17 -2
- controlai_rag/document_loader.py +6 -1
- controlai_rag/embeddings.py +145 -0
- controlai_rag/retriever.py +288 -0
- controlai_rag/textfix.py +77 -0
- scripts/calibrate_retrieval.py +138 -0
- scripts/ingest_processed_corpus.py +154 -0
- scripts/repair_chunk_ids.py +89 -0
- scripts/repair_corpus_text.py +62 -0
|
@@ -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 =
|
| 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 =
|
| 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,
|
|
@@ -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 |
-
|
|
|
|
|
|
|
| 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(),
|
|
@@ -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
|
|
@@ -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}")
|
|
@@ -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()
|
|
@@ -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())
|
|
@@ -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())
|
|
@@ -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())
|
|
@@ -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())
|