Spaces:
Sleeping
Sleeping
Initial deploy: full app with 4-tier keyword research, per-task models; RAG index served from a separate private dataset repo
f23046e verified | """ | |
| SUPERSEDED by scripts/build_index_from_crawler.py — kept for reference only. | |
| See README.md for the current build path. | |
| Build the RAG vector index from local books (Appendix A) + crawled articles | |
| (scripts/crawl_sources.py output). Runs entirely on the user's machine. | |
| The output index (rag_index/) intentionally stores ONLY opaque chunk IDs and | |
| raw text — no title, author, URL, or domain. The Space can therefore only | |
| ever cite retrieved context as "Source 1", "Source 2", etc. | |
| Usage: | |
| python build_index.py --books-dir ../books --crawled-dir ../crawled --out ../rag_index | |
| python build_index.py --push-dataset --dataset-repo-id yourname/dm-rag-index --hf-token hf_... | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import hashlib | |
| import json | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| import tiktoken | |
| from bs4 import BeautifulSoup | |
| from pypdf import PdfReader | |
| from tqdm import tqdm | |
| ENCODING = tiktoken.get_encoding("cl100k_base") | |
| CHUNK_TOKENS = 800 | |
| OVERLAP_TOKENS = 100 | |
| EMBED_MODEL = "BAAI/bge-small-en-v1.5" | |
| EMBED_BATCH = 64 | |
| def read_txt(path: Path) -> str: | |
| return path.read_text(encoding="utf-8", errors="ignore") | |
| def read_pdf(path: Path) -> str: | |
| reader = PdfReader(str(path)) | |
| return "\n".join(page.extract_text() or "" for page in reader.pages) | |
| def read_epub(path: Path) -> str: | |
| import ebooklib | |
| from ebooklib import epub | |
| book = epub.read_epub(str(path)) | |
| parts = [] | |
| for item in book.get_items(): | |
| if item.get_type() == ebooklib.ITEM_DOCUMENT: | |
| soup = BeautifulSoup(item.get_content(), "html.parser") | |
| parts.append(soup.get_text(separator="\n")) | |
| return "\n".join(parts) | |
| READERS = {".txt": read_txt, ".pdf": read_pdf, ".epub": read_epub} | |
| def chunk_text(text: str) -> list[str]: | |
| tokens = ENCODING.encode(text) | |
| if not tokens: | |
| return [] | |
| chunks = [] | |
| step = CHUNK_TOKENS - OVERLAP_TOKENS | |
| for start in range(0, len(tokens), step): | |
| window = tokens[start : start + CHUNK_TOKENS] | |
| if len(window) < 50: # drop tiny tail fragments | |
| break | |
| chunks.append(ENCODING.decode(window)) | |
| if start + CHUNK_TOKENS >= len(tokens): | |
| break | |
| return chunks | |
| def opaque_id(text: str) -> str: | |
| return hashlib.sha1(text.encode("utf-8")).hexdigest()[:16] | |
| def iter_book_documents(books_dir: Path): | |
| if not books_dir.exists(): | |
| return | |
| for path in sorted(books_dir.iterdir()): | |
| reader = READERS.get(path.suffix.lower()) | |
| if reader is None: | |
| continue | |
| try: | |
| text = reader(path) | |
| except Exception as exc: | |
| print(f"[book] failed to read {path.name}: {exc}") | |
| continue | |
| if text.strip(): | |
| yield text | |
| def iter_crawled_documents(crawled_dir: Path): | |
| if not crawled_dir.exists(): | |
| return | |
| for txt_path in sorted(crawled_dir.glob("*/pages/*.txt")): | |
| text = read_txt(txt_path) | |
| if text.strip(): | |
| yield text | |
| def build_chunks(books_dir: Path, crawled_dir: Path) -> list[str]: | |
| all_chunks: list[str] = [] | |
| seen_ids: set[str] = set() | |
| sources = [ | |
| ("book", iter_book_documents(books_dir)), | |
| ("article", iter_crawled_documents(crawled_dir)), | |
| ] | |
| for kind, docs in sources: | |
| for doc_text in tqdm(list(docs), desc=f"chunking {kind}s"): | |
| for chunk in chunk_text(doc_text): | |
| cid = opaque_id(chunk) | |
| if cid in seen_ids: | |
| continue # de-dup identical chunks (e.g. re-run) | |
| seen_ids.add(cid) | |
| all_chunks.append(chunk) | |
| return all_chunks | |
| def embed_chunks(chunks: list[str]): | |
| from sentence_transformers import SentenceTransformer | |
| model = SentenceTransformer(EMBED_MODEL) | |
| embeddings = model.encode( | |
| chunks, batch_size=EMBED_BATCH, show_progress_bar=True, normalize_embeddings=True | |
| ) | |
| return embeddings | |
| def write_index(chunks: list[str], embeddings, out_dir: Path): | |
| import chromadb | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| client = chromadb.PersistentClient(path=str(out_dir)) | |
| collection = client.get_or_create_collection( | |
| name="dm_rag", metadata={"hnsw:space": "cosine"} | |
| ) | |
| ids = [opaque_id(c) for c in chunks] | |
| for i in tqdm(range(0, len(chunks), EMBED_BATCH), desc="writing index"): | |
| batch_ids = ids[i : i + EMBED_BATCH] | |
| batch_docs = chunks[i : i + EMBED_BATCH] | |
| batch_emb = embeddings[i : i + EMBED_BATCH].tolist() | |
| # No metadatas passed: the index must never carry title/author/URL/domain. | |
| collection.upsert(ids=batch_ids, documents=batch_docs, embeddings=batch_emb) | |
| meta = { | |
| "model": EMBED_MODEL, | |
| "chunk_tokens": CHUNK_TOKENS, | |
| "overlap_tokens": OVERLAP_TOKENS, | |
| "count": len(chunks), | |
| "built_at": datetime.now(timezone.utc).isoformat(), | |
| } | |
| (out_dir / "index_meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8") | |
| print(f"Wrote {len(chunks)} chunks to {out_dir}") | |
| def push_to_dataset(out_dir: Path, repo_id: str, hf_token: str): | |
| from huggingface_hub import HfApi | |
| api = HfApi(token=hf_token) | |
| api.create_repo(repo_id=repo_id, repo_type="dataset", private=True, exist_ok=True) | |
| api.upload_folder(folder_path=str(out_dir), repo_id=repo_id, repo_type="dataset") | |
| print(f"Pushed index to private dataset: {repo_id}") | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Build the local RAG index") | |
| parser.add_argument("--books-dir", default="../books") | |
| parser.add_argument("--crawled-dir", default="../crawled") | |
| parser.add_argument("--out", default="../rag_index") | |
| parser.add_argument("--push-dataset", action="store_true") | |
| parser.add_argument("--dataset-repo-id", default=None) | |
| parser.add_argument("--hf-token", default=None) | |
| args = parser.parse_args() | |
| books_dir = Path(args.books_dir) | |
| crawled_dir = Path(args.crawled_dir) | |
| out_dir = Path(args.out) | |
| chunks = build_chunks(books_dir, crawled_dir) | |
| if not chunks: | |
| print("No chunks found — check --books-dir and --crawled-dir paths.") | |
| return | |
| embeddings = embed_chunks(chunks) | |
| write_index(chunks, embeddings, out_dir) | |
| if args.push_dataset: | |
| if not args.dataset_repo_id or not args.hf_token: | |
| raise SystemExit("--push-dataset requires --dataset-repo-id and --hf-token") | |
| push_to_dataset(out_dir, args.dataset_repo_id, args.hf_token) | |
| if __name__ == "__main__": | |
| main() | |