Placement-RAG / data_indexer.py
IamSamk
Placement Policy RAG chatbot
00d3560
Raw
History Blame Contribute Delete
6.88 kB
"""Build the Qdrant vector index from the curated Markdown policy docs.
Reads every ``Contextmd/*.md`` file (the hand-verified policy Markdown) and
applies a two-stage chunking strategy tuned to keep *named rules* intact:
Stage 1 — MarkdownHeaderTextSplitter splits on Markdown headers (#, ##, ###).
Because the policy gives each rule its own header (e.g.
"### 4.1 Basic Offer Rule (1× Offer)"), this alone keeps most named
rules together as a single section, and preserves the header text so
the rule/section name travels with the chunk for citation.
Stage 2 — RecursiveCharacterTextSplitter only sub-splits sections that exceed
CHUNK_SIZE (with CHUNK_OVERLAP). Sections shorter than CHUNK_SIZE
pass through untouched, so short named rules remain single chunks.
Chunks are embedded with FastEmbed (ONNX, local CPU) and upserted into a Qdrant
local collection using cosine distance.
"""
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-7s | data_indexer | %(message)s",
)
log = logging.getLogger("data_indexer")
# --- Config (non-secret operational values; env with build-safe defaults) -----
EMBEDDING_MODEL_ID = os.environ.get("EMBEDDING_MODEL_ID", "BAAI/bge-small-en-v1.5")
QDRANT_STORAGE_PATH = os.environ.get("QDRANT_STORAGE_PATH", "./qdrant_storage")
QDRANT_COLLECTION_NAME = os.environ.get("QDRANT_COLLECTION_NAME", "placement_policy")
CHUNK_SIZE = int(os.environ.get("CHUNK_SIZE", "1000"))
CHUNK_OVERLAP = int(os.environ.get("CHUNK_OVERLAP", "150"))
FASTEMBED_CACHE_DIR = os.environ.get("FASTEMBED_CACHE_DIR", ".fastembed_cache")
# Curated, pre-verified Markdown is the indexed source of truth. (The main
# policy PDF is scanned/image-only, so this hand-checked Markdown is more
# accurate than any automated PDF extraction.) convert_docs.py validates these
# files against the source PDFs before indexing.
SOURCE_DIR = Path("Contextmd")
# Header levels to split on. Metadata keys are ordered from broadest to
# narrowest so we can reconstruct a readable "section path" for citation.
HEADERS_TO_SPLIT_ON = [
("#", "doc_title"),
("##", "section"),
("###", "subsection"),
]
def load_chunks():
"""Run the two-stage split over every Contextmd/*.md file. Returns a list of
LangChain Documents (page_content + metadata)."""
from langchain_text_splitters import (
MarkdownHeaderTextSplitter,
RecursiveCharacterTextSplitter,
)
md_files = sorted(SOURCE_DIR.glob("*.md"))
if not md_files:
raise FileNotFoundError(
f"No Markdown files in {SOURCE_DIR.resolve()}. "
f"Expected the curated policy Markdown there."
)
# strip_headers=False keeps the header line inside the chunk so the rule /
# section name is embedded and retrievable.
header_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=HEADERS_TO_SPLIT_ON,
strip_headers=False,
)
char_splitter = RecursiveCharacterTextSplitter(
chunk_size=CHUNK_SIZE,
chunk_overlap=CHUNK_OVERLAP,
separators=["\n\n", "\n", ". ", " ", ""],
)
header_docs = []
for md_file in md_files:
text = md_file.read_text(encoding="utf-8")
sections = header_splitter.split_text(text)
for doc in sections:
doc.metadata["source"] = md_file.name
doc.metadata["section_path"] = _section_path(doc.metadata)
header_docs.extend(sections)
log.info("%s -> %d header section(s)", md_file.name, len(sections))
# Stage 2: only oversized sections get sub-split; short named rules stay whole.
chunks = char_splitter.split_documents(header_docs)
log.info(
"Chunking complete: %d header section(s) -> %d final chunk(s) "
"(chunk_size=%d, overlap=%d)",
len(header_docs),
len(chunks),
CHUNK_SIZE,
CHUNK_OVERLAP,
)
return chunks
def _section_path(metadata: dict) -> str:
"""Human-readable breadcrumb from header metadata, e.g.
'RV UNIVERSITY – PLACEMENT POLICY > SECTION 4 — OFFER RULES > 4.1 Basic Offer Rule'."""
parts = [
metadata.get("doc_title"),
metadata.get("section"),
metadata.get("subsection"),
]
return " > ".join(p for p in parts if p)
def embed_texts(texts):
from fastembed import TextEmbedding
log.info("Loading FastEmbed model '%s' ...", EMBEDDING_MODEL_ID)
model = TextEmbedding(model_name=EMBEDDING_MODEL_ID, cache_dir=FASTEMBED_CACHE_DIR)
log.info("Embedding %d chunk(s) ...", len(texts))
vectors = [v.tolist() for v in model.embed(texts)]
dim = len(vectors[0])
log.info("Embedded %d chunk(s); vector dim = %d", len(vectors), dim)
return vectors, dim
def build_index(chunks) -> None:
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, PointStruct, VectorParams
texts = [c.page_content for c in chunks]
vectors, dim = embed_texts(texts)
client = QdrantClient(path=QDRANT_STORAGE_PATH)
try:
if client.collection_exists(QDRANT_COLLECTION_NAME):
log.info("Dropping existing collection '%s'", QDRANT_COLLECTION_NAME)
client.delete_collection(QDRANT_COLLECTION_NAME)
log.info(
"Creating collection '%s' (size=%d, distance=COSINE)",
QDRANT_COLLECTION_NAME,
dim,
)
client.create_collection(
collection_name=QDRANT_COLLECTION_NAME,
vectors_config=VectorParams(size=dim, distance=Distance.COSINE),
)
points = [
PointStruct(
id=i,
vector=vectors[i],
payload={
"text": chunks[i].page_content,
"source": chunks[i].metadata.get("source", ""),
"section_path": chunks[i].metadata.get("section_path", ""),
},
)
for i in range(len(chunks))
]
client.upsert(collection_name=QDRANT_COLLECTION_NAME, points=points)
count = client.count(QDRANT_COLLECTION_NAME).count
log.info("Upserted %d point(s). Collection now holds %d point(s).", len(points), count)
finally:
client.close()
def main() -> int:
try:
chunks = load_chunks()
if not chunks:
log.error("No chunks produced; nothing to index.")
return 1
build_index(chunks)
except Exception: # noqa: BLE001 — fail the build loudly on any indexing error
log.exception("Indexing FAILED")
return 1
log.info("Indexing complete.")
return 0
if __name__ == "__main__":
sys.exit(main())