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 | """ | |
| Builds rag_index/ from the crawled corpus in the sibling v:\\crawler project | |
| (79 digital-marketing books + the web crawl across SEO / Social Media | |
| Marketing / Online Advertising / Digital Marketing General), instead of this | |
| project's own crawled_*/books-2 folders. | |
| Adds one metadata field beyond the original build_index.py: "category" (one | |
| of "seo", "social_media", "online_ads", "general") — a coarse topical bucket, | |
| not identifying information — so seo.py/social.py/ads.py can each retrieve | |
| from their own domain instead of the whole mixed corpus. Title/author/URL/ | |
| domain are still never stored, preserving the "Source N" anonymization | |
| guarantee. | |
| Usage: | |
| python build_index_from_crawler.py --limit 50 # quick correctness check | |
| python build_index_from_crawler.py --reset # full rebuild | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import hashlib | |
| import json | |
| import os | |
| import re | |
| import sqlite3 | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| import tiktoken | |
| from tqdm import tqdm | |
| CRAWLER_DB = Path(r"V:\crawler\data\default\meta.db") | |
| ENCODING = tiktoken.get_encoding("cl100k_base") | |
| CHUNK_TOKENS = 800 | |
| OVERLAP_TOKENS = 100 | |
| EMBED_MODEL = "BAAI/bge-small-en-v1.5" | |
| EMBED_BATCH = 64 | |
| CATEGORY_MAP = { | |
| "SEO": "seo", | |
| "SOCIAL MEDIA MARKETING": "social_media", | |
| "ONLINE ADVERTISING": "online_ads", | |
| "DIGITAL MARKETING (General)": "general", | |
| } | |
| BOOK_PREFIX_MAP = [ | |
| (re.compile(r"^digital marketing seo", re.I), "seo"), | |
| (re.compile(r"^digital marketing social", re.I), "social_media"), | |
| (re.compile(r"^digital marketing ads", re.I), "online_ads"), | |
| ] | |
| BOOK_KEYWORD_MAP = [ | |
| (re.compile(r"\bseo\b|search engine optimi[sz]ation|link[- ]building", re.I), "seo"), | |
| (re.compile(r"social media|facebook|instagram|tiktok|youtube|influencer", re.I), "social_media"), | |
| (re.compile(r"\bads?\b|advertising|adwords|google ads|\bppc\b", re.I), "online_ads"), | |
| ] | |
| def classify_book(filename: str) -> str: | |
| for pattern, category in BOOK_PREFIX_MAP: | |
| if pattern.search(filename): | |
| return category | |
| for pattern, category in BOOK_KEYWORD_MAP: | |
| if pattern.search(filename): | |
| return category | |
| return "general" | |
| def opaque_id(text: str) -> str: | |
| return hashlib.sha1(text.encode("utf-8")).hexdigest()[:16] | |
| 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: | |
| break | |
| chunks.append(ENCODING.decode(window)) | |
| if start + CHUNK_TOKENS >= len(tokens): | |
| break | |
| return chunks | |
| def _document_text(conn: sqlite3.Connection, file_id: int) -> str: | |
| pages = conn.execute( | |
| "SELECT text FROM pages WHERE file_id=? ORDER BY page_no", (file_id,) | |
| ).fetchall() | |
| return "\n\n".join(p["text"] for p in pages if p["text"]) | |
| def iter_documents(crawler_db: Path, limit: int | None = None): | |
| """Yields (text, category) for every crawled file (books + web articles).""" | |
| conn = sqlite3.connect(str(crawler_db)) | |
| conn.row_factory = sqlite3.Row | |
| web_rows = conn.execute( | |
| """ | |
| SELECT f.id as file_id, s.category as category | |
| FROM candidates c | |
| JOIN sources s ON s.id = c.source_id | |
| JOIN files f ON f.path = c.url | |
| WHERE c.selected = 1 | |
| """ | |
| ).fetchall() | |
| book_rows = conn.execute( | |
| "SELECT id as file_id, path FROM files WHERE path NOT LIKE 'http%'" | |
| ).fetchall() | |
| count = 0 | |
| for row in web_rows: | |
| if limit and count >= limit: | |
| break | |
| text = _document_text(conn, row["file_id"]) | |
| if text.strip(): | |
| yield text, CATEGORY_MAP.get(row["category"], "general") | |
| count += 1 | |
| for row in book_rows: | |
| if limit and count >= limit: | |
| break | |
| text = _document_text(conn, row["file_id"]) | |
| if text.strip(): | |
| yield text, classify_book(Path(row["path"]).name) | |
| count += 1 | |
| conn.close() | |
| def build_chunks(crawler_db: Path, limit: int | None = None): | |
| all_chunks: list[tuple[str, str]] = [] | |
| seen_ids: set[str] = set() | |
| for doc_text, category in tqdm(list(iter_documents(crawler_db, limit=limit)), desc="chunking"): | |
| for chunk in chunk_text(doc_text): | |
| cid = opaque_id(chunk) | |
| if cid in seen_ids: | |
| continue | |
| seen_ids.add(cid) | |
| all_chunks.append((chunk, category)) | |
| return all_chunks | |
| def embed_and_write(chunks_with_category: list[tuple[str, str]], out_dir: Path, reset: bool = False): | |
| import chromadb | |
| import torch | |
| from sentence_transformers import SentenceTransformer | |
| if torch.cuda.is_available(): | |
| device = "cuda" | |
| batch_size = 128 | |
| else: | |
| device = "cpu" | |
| batch_size = EMBED_BATCH | |
| torch.set_num_threads(os.cpu_count() or 4) # CPU default under-uses available cores | |
| print(f"[embed] using device={device}, batch_size={batch_size}") | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| client = chromadb.PersistentClient(path=str(out_dir)) | |
| if reset: | |
| try: | |
| client.delete_collection("dm_rag") | |
| except Exception: | |
| pass | |
| collection = client.get_or_create_collection(name="dm_rag", metadata={"hnsw:space": "cosine"}) | |
| model = SentenceTransformer(EMBED_MODEL, device=device) | |
| texts = [c for c, _ in chunks_with_category] | |
| categories = [cat for _, cat in chunks_with_category] | |
| ids = [opaque_id(t) for t in texts] | |
| for i in tqdm(range(0, len(texts), batch_size), desc="embedding+writing"): | |
| batch_texts = texts[i : i + batch_size] | |
| batch_ids = ids[i : i + batch_size] | |
| batch_cats = categories[i : i + batch_size] | |
| batch_emb = model.encode( | |
| batch_texts, batch_size=batch_size, normalize_embeddings=True, show_progress_bar=False | |
| ).tolist() | |
| collection.upsert( | |
| ids=batch_ids, | |
| documents=batch_texts, | |
| embeddings=batch_emb, | |
| metadatas=[{"category": c} for c in batch_cats], | |
| ) | |
| counts_by_cat: dict[str, int] = {} | |
| for c in categories: | |
| counts_by_cat[c] = counts_by_cat.get(c, 0) + 1 | |
| meta = { | |
| "model": EMBED_MODEL, | |
| "chunk_tokens": CHUNK_TOKENS, | |
| "overlap_tokens": OVERLAP_TOKENS, | |
| "count": len(texts), | |
| "counts_by_category": counts_by_cat, | |
| "built_at": datetime.now(timezone.utc).isoformat(), | |
| "source": "v:/crawler (79 books + web crawl across 4 categories)", | |
| } | |
| (out_dir / "index_meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8") | |
| print(f"Wrote {len(texts)} chunks to {out_dir}") | |
| print(f"By category: {counts_by_cat}") | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Build rag_index/ from v:/crawler's crawled corpus") | |
| parser.add_argument("--crawler-db", default=str(CRAWLER_DB)) | |
| parser.add_argument("--out", default=str(Path(__file__).resolve().parent.parent / "rag_index")) | |
| parser.add_argument("--limit", type=int, default=None, help="Limit documents processed (for testing)") | |
| parser.add_argument("--reset", action="store_true", help="Wipe the existing collection first") | |
| args = parser.parse_args() | |
| chunks = build_chunks(Path(args.crawler_db), limit=args.limit) | |
| if not chunks: | |
| print("No chunks produced — check --crawler-db path.") | |
| return | |
| embed_and_write(chunks, Path(args.out), reset=args.reset) | |
| if __name__ == "__main__": | |
| main() | |