Chunking is where most pipelines silently fail
- Semantic chunking over fixed-size chunking. I use a two-pass approach. First pass: split by natural document boundaries (headings, sections, paragraph breaks). Second pass: merge small chunks that share semantic context and split oversized ones at sentence boundaries.Preserve document structure. Every chunk carries metadata: the document title, section heading hierarchy, page number, and the chunk's position in the document. When the LLM gets a chunk from page 47 of a technical manual, it needs to know it's reading "Section 4.2: Error Handling" under "Chapter 4: API Reference."Overlapping context windows. I don't just overlap tokens. I prepend a one-sentence summary of the previous chunk. This costs a few extra tokens but dramatically reduces the "torn context" problem where a chunk starts mid-thought.For one client's legal document system, switching from fixed 512-token chunks to semantic chunking with structural metadata improved answer accuracy from 71% to 89% overnight.
Retrieval is not just cosine similarity
- Stage 1: Hybrid search. I combine dense vector search (embeddings) with sparse keyword search (BM25). Some queries are best served by semantic similarity. Others need exact keyword matching. A query like "what's our policy on HIPAA compliance" needs the keyword "HIPAA" to anchor the search, not just semantic similarity to "health regulations."I weight them dynamically: if the query contains domain-specific terms or acronyms, I boost the BM25 score. For conversational queries, I lean on the vector search.Stage 2: Re-ranking. The initial retrieval pulls back 20-30 candidates. A cross-encoder re-ranker (I use a fine-tuned model based on `ms-marco-MiniLM`) scores each candidate against the original query with full attention. This is computationally heavier than cosine similarity, but it catches the nuance that embedding distance misses.Stage 3: Contextual filtering. Not every high-scoring chunk belongs in the final context. I filter based on document recency (for time-sensitive domains), user permissions (some documents are restricted), and redundancy (if three chunks say the same thing, I keep the most comprehensive one).This three-stage approach added about 200ms of latency. It cut hallucination rates by over 60%.
Hallucination detection is not optional
The embedding model matters more than the LLM
Evaluation is a system, not a one-time test
The architecture that actually works
- - Document ingestion: Custom pipeline with Apache Tika for parsing, plus format-specific extractors for PDFs with tables.
- - Chunking: Semantic chunker I built that respects document structure. Open-sourcing this soon.
- - Embeddings: Self-hosted e5-large-v2 on a GPU instance. Batched processing with a queue.
- - Vector store: Qdrant. I evaluated Pinecone, Weaviate, and Milvus. Qdrant won on filtering performance and self-hosting simplicity.
- - BM25 index: Elasticsearch, running alongside the vector store.
- - Re-ranker: Cross-encoder model served via a lightweight FastAPI service.
- - Orchestration: LangChain for the initial prototype, then replaced with custom Python code. LangChain's abstractions help you start fast but fight you when you need fine-grained control.
- - Generation: Claude API with structured outputs for citation enforcement.
- - Monitoring: Custom dashboard tracking retrieval relevance, hallucination rates, and user satisfaction.
