"""Repair a work in the Kantor RAG index using clean OCR text. Usage: replace_work(title, pages) where pages = {page_no: clean_text}. - Removes ALL existing chunks of that work whose page range is inside the OCR'd page set; re-chunks the clean text page-aware (~800 chars); embeds with nomic (NO prefix — matches how the index was built); rebuilds index.faiss + index_data.pkl + index_summary.json. """ import pickle, json, re, faiss import numpy as np from sentence_transformers import SentenceTransformer DB = "database" CHUNK_SIZE = 800 _model = None def model(): global _model if _model is None: _model = SentenceTransformer("nomic-ai/nomic-embed-text-v1.5", trust_remote_code=True) return _model def clean_ocr(text): """Cleanup of Unlimited-OCR output: layout markers, html tables, images.""" # blank-page hallucination if "contains no text" in text or "Ground Truth" in text: return "" text = re.sub(r"<\|det\|>\w+\s*\[[\d,\s]*\]?<\|/det\|>", " ", text) # det markers text = re.sub(r"<\|[^>]{0,40}?\|>", " ", text) # any other special tokens text = re.sub(r"!\[.*?\]\(.*?\)", "", text) # images text = re.sub(r"\s*<(td|th)>", " | ", text) # table cells -> pipes text = re.sub(r"\s*", "\n", text) text = re.sub(r"<[^>]{1,30}>", " ", text) # remaining html tags text = re.sub(r"[ \t]{2,}", " ", text) text = re.sub(r"\n{3,}", "\n\n", text) return text.strip() def chunk_pages(pages, chunk_size=CHUNK_SIZE): """pages: dict {page_no: text}. Returns list of (text, start_page, end_page). Concatenates pages in order and cuts ~chunk_size chars on word boundaries, tracking which pages each chunk covers (mimics original page-aware chunking).""" # build char -> page map parts, page_of_char = [], [] for p in sorted(pages): t = clean_ocr(pages[p]) if not t: continue parts.append(t) page_of_char.extend([p] * (len(t) + 1)) # +1 for joining newline full = "\n".join(parts) page_of_char = page_of_char[:len(full)] chunks, pos = [], 0 while pos < len(full): end = min(pos + chunk_size, len(full)) if end < len(full): sp = full.rfind(" ", pos + int(chunk_size * 0.6), end) nl = full.rfind("\n", pos + int(chunk_size * 0.6), end) cut = max(sp, nl) if cut > pos: end = cut text = full[pos:end].strip() if len(text) >= 40: chunks.append((text, page_of_char[pos], page_of_char[min(end, len(full)-1)])) pos = end + 1 return chunks def replace_work(title, pages, db=DB): with open(f"{db}/index_data.pkl", "rb") as f: data = pickle.load(f) texts, metas = data["texts"], data["metadatas"] index = faiss.read_index(f"{db}/index.faiss") assert index.ntotal == len(texts) page_set = set(pages) keep, remove_meta = [], None for i, m in enumerate(metas): if m.get("title") == title: s, e = m.get("start_page") or 0, m.get("end_page") or m.get("start_page") or 0 if set(range(s, e + 1)) & page_set: remove_meta = dict(m) continue keep.append(i) removed = len(texts) - len(keep) if removed == 0 or remove_meta is None: raise SystemExit(f"No chunks matched title={title!r} pages={sorted(page_set)}") new_chunks = chunk_pages(pages) new_texts = [c[0] for c in new_chunks] new_metas = [] for text, sp, ep in new_chunks: m = {k: v for k, v in remove_meta.items() if k not in ("start_page", "end_page")} m["start_page"], m["end_page"] = sp, ep new_metas.append(m) # embeddings: keep old vectors, embed new texts (no prefix, matches index) old_vecs = index.reconstruct_n(0, index.ntotal) kept_vecs = old_vecs[keep] new_vecs = model().encode(new_texts, normalize_embeddings=True, batch_size=16, show_progress_bar=False).astype("float32") all_vecs = np.vstack([kept_vecs, new_vecs]) out_texts = [texts[i] for i in keep] + new_texts out_metas = [metas[i] for i in keep] + new_metas new_index = faiss.IndexFlatIP(all_vecs.shape[1]) new_index.add(all_vecs) faiss.write_index(new_index, f"{db}/index.faiss") with open(f"{db}/index_data.pkl", "wb") as f: pickle.dump({"texts": out_texts, "metadatas": out_metas}, f) try: summ = json.load(open(f"{db}/index_summary.json")) summ["total_chunks"] = len(out_texts) json.dump(summ, open(f"{db}/index_summary.json", "w"), indent=2) except Exception as e: print("summary update skipped:", e) print(f"OK: removed {removed} old chunks, added {len(new_texts)} clean chunks " f"(index: {index.ntotal} -> {new_index.ntotal})") if __name__ == "__main__": import sys, pathlib title = "Interbehavioral psychology and scientific analysis of data and operations" pages = {} for f in pathlib.Path("ocr_out").glob("ibp_1956_p*.md"): n = int(re.search(r"p(\d+)\.md", f.name).group(1)) pages[n] = f.read_text() replace_work(title, pages)