vantage / scripts /tag_topics.py
sourabh gupta
feat(pipeline): RAG quality gate, live coverage, streaming API, recursion limit
c24f0b6
Raw
History Blame Contribute Delete
10.5 kB
# File Objective : Tag every indexed chunk with the topics whose string appears in its article.
# Scope : Dev script β€” payload-only update to an already-populated Qdrant collection.
# What it does : 1. Loads the topic vocabulary (Gartner + GDELT) from topics.csv.
# 2. Loads all HuffPost articles into a DataFrame (doc_id, text).
# 3. Runs ONE word-boundary regex per topic across the whole column,
# collecting which doc_ids mention each topic.
# 4. Inverts that into doc_id β†’ [topics] and writes it to each chunk's
# `topics` payload field in Qdrant β€” vectors are never touched.
# What it does not: 1. Re-embed, re-chunk, or re-ingest (the slow 2-hour path).
# 2. Do semantic / fuzzy matching β€” a topic is "present" only if its
# exact string occurs in the article (case-insensitive, word-bounded).
# 3. Create payload indexes or compute gap scores.
#
# Live logging : prints a banner, then per-phase progress, then PER TOPIC the number of
# docs matched, and for every 50th topic dumps the raw matched documents.
#
# Usage : PYTHONPATH=packages python scripts/tag_topics.py # full run
# LIMIT=10 PYTHONPATH=packages python scripts/tag_topics.py # 10-doc dry test
from __future__ import annotations
import csv
import json
import os
import re
import time
import uuid
from pathlib import Path
import pandas as pd
from qdrant_client import models as qm
from vantage_core.adapters.vector_qdrant import QdrantVectorRepo
from vantage_core.constants import QDRANT_COLLECTION_NAME
# ── Configuration ────────────────────────────────────────────────────────────
TOPICS_CSV = Path("packages/vantage_core/seeds/topics.csv")
DATA_PATH = Path("data/news_category.json")
SCROLL_BATCH = 512 # points fetched per Qdrant scroll page
WRITE_BATCH = 256 # set-payload ops per batch_update_points request
DUMP_EVERY = 50 # print raw matched docs on every Nth topic
DUMP_SAMPLE = 5 # how many raw docs to show in that dump
LIMIT = int(os.environ["LIMIT"]) if os.environ.get("LIMIT") else None
# ── Logging helper ───────────────────────────────────────────────────────────
def log(msg: str = "") -> None:
"""Print immediately (unbuffered) so progress is visible live."""
print(msg, flush=True)
def banner(title: str) -> None:
log(f"\n── {title} " + "─" * max(0, 56 - len(title)))
# ── Phase 1: load inputs ─────────────────────────────────────────────────────
def load_topics() -> list[str]:
"""Return the topic vocabulary from topics.csv (the `keyword` column)."""
with TOPICS_CSV.open() as f:
return [row["keyword"].strip() for row in csv.DictReader(f) if row["keyword"].strip()]
def load_articles(limit: int | None) -> pd.DataFrame:
"""Stream HuffPost JSONL into a DataFrame of (doc_id, text).
`text` mirrors exactly what was ingested as chunk_text: headline + short_description.
`doc_id` is the row's yield-index as a string β€” identical to the loader/ingest scheme.
"""
rows: list[dict[str, str]] = []
doc_id = 0
with DATA_PATH.open(encoding="utf-8") as fh:
for line in fh:
row = json.loads(line)
headline = row.get("headline", "").strip()
desc = row.get("short_description", "").strip()
text = f"{headline}. {desc}" if desc else headline
if len(text) < 20: # skip noise rows (matches loader.py)
continue
rows.append({"doc_id": str(doc_id), "text": text})
doc_id += 1
if limit is not None and doc_id >= limit:
break
return pd.DataFrame(rows)
# ── Phase 2: match topics β†’ documents ────────────────────────────────────────
def match_topics_to_docs(df: pd.DataFrame, topics: list[str]) -> dict[str, list[str]]:
"""For each topic, find the docs whose text contains it; invert to doc_id β†’ [topics].
One vectorized regex scan per topic across the whole column (pandas str.contains in C).
Prints the match count for every topic, and the raw matched docs every DUMP_EVERY topics.
"""
doc_topics: dict[str, list[str]] = {doc_id: [] for doc_id in df["doc_id"]}
t0 = time.time()
for i, topic in enumerate(topics, start=1):
pattern = rf"\b{re.escape(topic)}\b" # word-bounded so 'ai' β‰  'maintain'
hit_mask = df["text"].str.contains(pattern, case=False, regex=True, na=False)
matched = df.loc[hit_mask]
for doc_id in matched["doc_id"]:
doc_topics[doc_id].append(topic)
log(f" [{i:>3}/{len(topics)}] {topic[:45]:<45} β†’ {len(matched):>6} docs")
if i % DUMP_EVERY == 0 and not matched.empty:
log(f" ── raw matches for '{topic}' (showing {DUMP_SAMPLE}) ──")
for _, r in matched.head(DUMP_SAMPLE).iterrows():
log(f" doc {r['doc_id']:>6} | {r['text'][:90]}")
log(f"\n matched {len(topics)} topics over {len(df)} docs in {time.time()-t0:.1f}s")
return doc_topics
# ── Phase 3a: write topicβ†’doc mapping to CSV ─────────────────────────────────
COVERAGE_CSV = Path("data/topic_coverage.csv")
def write_coverage_csv(doc_topics: dict[str, list[str]]) -> None:
"""Write (topic, doc_id) pairs to CSV β€” one row per match."""
rows = [
{"topic": topic, "doc_id": doc_id}
for doc_id, topics in doc_topics.items()
for topic in topics
]
COVERAGE_CSV.parent.mkdir(parents=True, exist_ok=True)
with COVERAGE_CSV.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["topic", "doc_id"])
writer.writeheader()
writer.writerows(rows)
log(f" [csv] wrote {len(rows)} rows β†’ {COVERAGE_CSV}")
# ── Phase 3b: write topics into Qdrant payloads (commented out) ───────────────
def point_id(chunk_id: str) -> str:
"""Stable Qdrant point UUID from chunk_id β€” mirrors Chunk.qdrant_uuid()."""
return str(uuid.uuid5(uuid.NAMESPACE_DNS, chunk_id))
def write_matched_only(doc_topics: dict[str, list[str]]) -> None:
"""Full run: only write to docs that actually matched β‰₯1 topic.
Computes point IDs directly from doc_id (no collection scroll needed).
Docs with zero topic matches are left untouched β€” no empty writes.
Note: uses {doc_id}-0 (covers 99.87% of docs; multi-chunk docs get chunk-0 updated).
"""
client = QdrantVectorRepo()._client
matched = {doc_id: topics for doc_id, topics in doc_topics.items() if topics}
ops: list[qm.SetPayloadOperation] = []
written = 0
t0 = time.time()
def flush() -> None:
nonlocal written
if ops:
client.batch_update_points(collection_name=QDRANT_COLLECTION_NAME, update_operations=ops)
written += len(ops)
ops.clear()
for doc_id, topics in matched.items():
ops.append(qm.SetPayloadOperation(set_payload=qm.SetPayload(
payload={"topics": topics}, points=[point_id(f"{doc_id}-0")],
)))
if len(ops) >= WRITE_BATCH:
flush()
log(f" [qdrant] written={written:>6}/{len(matched)} elapsed={time.time()-t0:.0f}s")
flush()
log(f" [qdrant] done β€” wrote to {written} matched docs (skipped {len(doc_topics)-written} with no topics) elapsed={time.time()-t0:.1f}s")
def write_targeted(doc_topics: dict[str, list[str]]) -> list[str]:
"""LIMIT/dry-run mode: write directly to computed point IDs ({doc_id}-0).
No filter, no index, no full scroll β€” touches only the docs under test.
"""
client = QdrantVectorRepo()._client
ops = [
qm.SetPayloadOperation(set_payload=qm.SetPayload(
payload={"topics": topics}, points=[point_id(f"{doc_id}-0")],
))
for doc_id, topics in doc_topics.items()
]
client.batch_update_points(collection_name=QDRANT_COLLECTION_NAME, update_operations=ops)
log(f" [qdrant] wrote topics to {len(ops)} points (doc_id-0)")
return [point_id(f"{doc_id}-0") for doc_id in doc_topics]
def read_back(ids: list[str]) -> None:
"""Re-fetch the written points from Qdrant to confirm the payload persisted."""
client = QdrantVectorRepo()._client
points = client.retrieve(
collection_name=QDRANT_COLLECTION_NAME, ids=ids,
with_payload=["doc_id", "title", "topics"], with_vectors=False,
)
banner("Read-back from Qdrant")
for p in sorted(points, key=lambda x: int(x.payload["doc_id"])):
pl = p.payload
log(f" doc {pl['doc_id']:>3} | topics={pl.get('topics')}")
log(f" {pl['title'][:80]}")
# ── Orchestration ────────────────────────────────────────────────────────────
def main() -> None:
topics = load_topics()
dry_run = LIMIT is not None
banner(f"Tag Topics{' [LIMIT=' + str(LIMIT) + ']' if dry_run else ''}")
log(f" topics={len(topics)} collection={QDRANT_COLLECTION_NAME} source={DATA_PATH}")
banner("Phase 1 β€” load articles")
df = load_articles(LIMIT)
log(f" loaded {len(df)} articles")
banner("Phase 2 β€” match topics β†’ documents")
doc_topics = match_topics_to_docs(df, topics)
matched_docs = sum(1 for tps in doc_topics.values() if tps)
log(f" docs with β‰₯1 topic: {matched_docs}/{len(doc_topics)}")
banner("Phase 3a β€” write coverage CSV")
write_coverage_csv(doc_topics)
# banner("Phase 3b β€” write topics to Qdrant")
# if dry_run:
# ids = write_targeted(doc_topics)
# read_back(ids)
# else:
# write_matched_only(doc_topics)
banner("Done")
log("")
if __name__ == "__main__":
main()