Spaces:
Running on Zero
Running on Zero
File size: 11,342 Bytes
9936912 1f68b63 9936912 48ee375 9936912 1f68b63 48ee375 9936912 48ee375 9936912 1f68b63 9936912 48ee375 9936912 48ee375 9936912 48ee375 9936912 48ee375 9936912 48ee375 9936912 48ee375 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 | """Local Offline Hybrid BM25 Index with metadata preservation and citation support."""
from __future__ import annotations
import json
import pickle
import re
from pathlib import Path
from typing import Any
try:
from rank_bm25 import BM25Okapi
except ImportError:
BM25Okapi = None
from controlai_rag.chunker import Chunk
INDEX_DIR = Path("data/rag_index")
def tokenize_corpus(text: str) -> list[str]:
# Lowercase and extract alphanumeric tokens + mathematical symbols
return re.findall(r"\b\w+\b|[+\-*/^_]", text.lower())
# Only chunk body text is tokenized into BM25, so a query naming a source
# ("what does Nise say about stability") cannot match the book it refers to.
# These constants drive a post-retrieval boost for chunks whose *filename*
# matches a distinctive query term.
SOURCE_MATCH_BOOST = 1.6
# A filename token is only distinctive enough to boost on if it appears in at
# most this fraction of the indexed files (floored at 2 files, so the rule
# still works on a small corpus). An author surname like "nise" or "ogata"
# appears in exactly one file; generic domain words appear in many.
SOURCE_TOKEN_MAX_FILE_FRACTION = 0.02
# Words that identify the subject rather than a specific source. Even if the
# corpus grows large enough for these to slip under the fraction cutoff, they
# must never trigger a source boost.
GENERIC_SOURCE_TOKENS = {
"control", "controls", "systems", "system", "engineering", "theory",
"lecture", "lectures", "notes", "chapter", "solutions", "solution",
"exercise", "exercises", "book", "textbook", "txtbk", "edition", "vol",
"part", "final", "exam", "slides", "course", "intro", "introduction",
}
class ControlRAGIndex:
"""Fast, local, offline search index over control engineering documents."""
def __init__(self, index_dir: Path = INDEX_DIR) -> None:
self.index_dir = index_dir
self.chunks: list[dict[str, Any]] = []
self.bm25: Any | None = None
self._distinctive_source_tokens: set[str] = set()
self._load_if_exists()
self._build_source_token_map()
def _build_source_token_map(self) -> None:
"""Index which filename tokens are distinctive enough to boost on.
Author surnames and title words unique to a few files ("nise", "ogata",
"kharitonov") identify a source; words common across the corpus do not.
"""
if not self.chunks:
return
files_per_token: dict[str, set[str]] = {}
all_files: set[str] = set()
for chunk in self.chunks:
fname = str(chunk.get("metadata", {}).get("filename", ""))
if not fname:
continue
all_files.add(fname)
for token in set(tokenize_corpus(fname)):
files_per_token.setdefault(token, set()).add(fname)
if not all_files:
return
cutoff = max(2, int(len(all_files) * SOURCE_TOKEN_MAX_FILE_FRACTION))
self._distinctive_source_tokens = {
token
for token, files in files_per_token.items()
if len(files) <= cutoff
and len(token) > 2
and not token.isdigit()
and token not in GENERIC_SOURCE_TOKENS
}
def build_from_chunks(self, chunks: list[Chunk]) -> None:
if BM25Okapi is None:
return
self.chunks = [c.to_dict() for c in chunks]
corpus = [tokenize_corpus(c.text) for c in chunks]
self.bm25 = BM25Okapi(corpus)
self.save()
def add_chunks(self, new_chunks: list[Chunk]) -> None:
"""Add new chunks to the existing index and rebuild BM25."""
new_dict_chunks = [c.to_dict() for c in new_chunks]
self.chunks.extend(new_dict_chunks)
corpus = [tokenize_corpus(c["text"]) for c in self.chunks]
self.bm25 = BM25Okapi(corpus)
# A newly uploaded document may introduce a new author/title, so the
# distinctive-source vocabulary has to be recomputed alongside BM25.
self._build_source_token_map()
self.save()
def save(self) -> None:
self.index_dir.mkdir(parents=True, exist_ok=True)
with (self.index_dir / "chunks.json").open("w", encoding="utf-8") as f:
json.dump(self.chunks, f, ensure_ascii=False, indent=2)
with (self.index_dir / "bm25.pkl").open("wb") as f:
pickle.dump(self.bm25, f)
def _load_if_exists(self) -> bool:
chunks_file = self.index_dir / "chunks.json"
bm25_file = self.index_dir / "bm25.pkl"
if chunks_file.exists() and bm25_file.exists():
try:
with chunks_file.open("r", encoding="utf-8") as f:
self.chunks = json.load(f)
with bm25_file.open("rb") as f:
self.bm25 = pickle.load(f)
return True
except Exception as exc:
print(f"Warning: Failed to load existing index: {exc}")
return False
def search(self, query: str, top_k: int = 5, source_filter: str | None = None) -> list[dict[str, Any]]:
if not self.bm25 or not self.chunks:
return []
tokens = tokenize_corpus(query)
if not tokens:
return []
scores = self.bm25.get_scores(tokens)
ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
# Re-rank a widened candidate pool so that a chunk from an explicitly
# named source can be promoted above a body-text-only BM25 match.
query_source_tokens = set(tokens) & self._distinctive_source_tokens
pool = ranked[: max(top_k * 10, 60)]
rescored: list[tuple[float, int]] = []
for idx in pool:
base = float(scores[idx])
if base <= 0.0:
continue
score = base
if query_source_tokens:
fname_tokens = set(tokenize_corpus(str(self.chunks[idx].get("metadata", {}).get("filename", ""))))
if query_source_tokens & fname_tokens:
score *= SOURCE_MATCH_BOOST
rescored.append((score, idx))
rescored.sort(key=lambda pair: pair[0], reverse=True)
results = []
for score, idx in rescored:
chunk = self.chunks[idx]
if source_filter and source_filter.lower() not in chunk["source_path"].lower():
continue
fname = chunk["metadata"].get("filename", "unknown")
source_name, is_published = display_source_name(fname)
results.append({
"chunk_id": chunk["chunk_id"],
"score": round(score, 3),
"source": chunk["source_path"],
"filename": fname,
# User-facing label -- never show the raw filename in an answer.
"source_name": source_name,
"is_published_work": is_published,
"page": chunk["metadata"].get("page"),
"text": chunk["text"],
})
if len(results) >= top_k:
break
return results
# A single process-wide index instance. The agent, the retrieval tool, and the
# document-upload endpoint must all read and mutate the SAME in-memory object:
# with separate instances, a document uploaded through the web UI is written to
# disk but stays invisible to the running agent until the server restarts.
_shared_index: ControlRAGIndex | None = None
def get_shared_index(index_dir: Path = INDEX_DIR) -> ControlRAGIndex:
"""Return the process-wide shared RAG index, loading it on first use."""
global _shared_index
if _shared_index is None:
_shared_index = ControlRAGIndex(index_dir)
return _shared_index
# --- Human-readable source names -------------------------------------------
# Indexed filenames carry private organisational cruft -- owner initials
# ("JD_", "B&B_"), course codes ("AMC", "PLMMR"), and scan artefacts
# ("txtbk", "DEFINITIVO", a doubled ".pdf.pdf"). Those must never reach a user
# as a citation, so every hit also carries a cleaned display name plus whether
# it is a published work (citable by author/title) or personal course notes
# (referred to generically).
_PUBLISHED_SOURCES: dict[str, str] = {
"norman s. nise - control systems engineering": "Nise, *Control Systems Engineering*",
"ogata modern control engineering 5th txtbk": "Ogata, *Modern Control Engineering* (5th ed.)",
}
# Course code -> the subject it stands for, so a generic remainder such as
# "CAM Course Notes Part 2" still resolves to something meaningful.
_COURSE_CODES: dict[str, str] = {
"SAS": "Safety in Automation Systems",
"AMC": "Advanced and Multivariable Control",
"CIR": "Control of Industrial Robots",
"PLMMR": "Perception, Localization and Mapping for Mobile Robots",
"NC": "Networked Control",
"CAM": "Computer-Aided Manufacturing",
"PSC": "Production Systems Control",
"MIDA": "Model Identification and Data Analysis",
"MIDA1": "Model Identification and Data Analysis",
"ACEHV": "Autonomous and Connected Electric/Hybrid Vehicles",
"ACHEV": "Autonomous and Connected Electric/Hybrid Vehicles",
"ACAV": "Autonomous and Connected Vehicles",
"DDCSD": "Data-Driven Control System Design",
"SACI": "Industrial Automation and Communication Systems",
"ICT": "Information and Communication Technology",
}
_OWNER_PREFIX_RE = re.compile(r"^(?:B&B|BBB|JD|LP|EC|AC|FG|RB|XX)[_\-\s]+", re.IGNORECASE)
_NOISE_RE = re.compile(
r"\b(?:txtbk|definitivo|margini\s+larghi|theory\s+notes|practice\s+notes|final)\b|\(.*?\)",
re.IGNORECASE,
)
def display_source_name(filename: str) -> tuple[str, bool]:
"""Map an indexed filename to (display name, is_published_work)."""
if not filename:
return ("local reference", False)
stem = str(filename)
while True:
lowered = stem.lower()
for ext in (".pdf", ".md", ".txt", ".json", ".jsonl"):
if lowered.endswith(ext):
stem = stem[: -len(ext)]
break
else:
break
key = " ".join(stem.split()).lower()
if key in _PUBLISHED_SOURCES:
return (_PUBLISHED_SOURCES[key], True)
name = _OWNER_PREFIX_RE.sub("", stem)
# A leading all-caps token is a course code; swap it for its subject.
subject = ""
parts = name.replace("_", " ").split()
if parts:
head = parts[0].strip(":-").upper()
if head in _COURSE_CODES:
subject = _COURSE_CODES[head]
parts = parts[1:]
elif len(parts) > 1 and 2 <= len(head) <= 6 and head.isalpha() and parts[0].isupper():
parts = parts[1:]
remainder = _NOISE_RE.sub("", " ".join(parts))
remainder = " ".join(remainder.replace("_", " ").split()).strip(" -–—")
generic = remainder.lower() in {
"", "course notes", "lecture notes", "notes", "exercise sessions",
"summary", "course notes part 1", "course notes part 2", "lectures",
}
if subject and generic:
label = subject
elif subject and remainder:
label = subject if remainder.lower() in subject.lower() else f"{subject} - {remainder}"
else:
label = remainder or subject or "local reference"
return (f"{label} (course notes)", False)
|