digital-marketer / scripts /build_index_from_crawler.py
vivekchakraverty's picture
Initial deploy: full app with 4-tier keyword research, per-task models; RAG index served from a separate private dataset repo
f23046e verified
Raw
History Blame Contribute Delete
7.67 kB
"""
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()