RAG — 3 Documents
RAG Architectures, Advanced RAG, Vector Databases
📄 Retrieval-Augmented Generation (RAG) — 830 lines
A Comprehensive Guide to Grounding LLMs with External Knowledge
1. What is RAG?
Retrieval-Augmented Generation (RAG) is an AI paradigm that combines a retrieval system with a generative language model. Instead of relying solely on the parametric knowledge baked into an LLM during training, RAG first retrieves relevant document chunks from an external knowledge base (corpus, database, or search index) and then feeds them as context to the LLM to produce a grounded, factually grounded response.
The term was formally introduced by Lewis et al. in the 2020 paper "Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks", where they proposed a hybrid architecture that marries a dense retriever (DPR — Dense Passage Retriever) with a seq2seq generator (BART).
Core Concept
User Query
|
v
[Retriever] ---> External Knowledge Base (Vector DB / Index)
|
v
Retrieved Context (top-k chunks)
|
v
[Generator / LLM] ---> Grounded Response
RAG is not a single technique but a family of approaches that differ in when retrieval happens, what is retrieved, and how it is fused with generation.
2. Why is RAG Needed?
Large Language Models (LLMs) are powerful but suffer from fundamental limitations that RAG directly addresses:
2.1 Stale Knowledge
LLMs have a knowledge cutoff — they know nothing about events, documents, or data created after their training date. RAG enables access to real-time or recent information without retraining.
2.2 Hallucination
LLMs are prone to generating plausible-sounding but factually incorrect content ("hallucination"). RAG grounds generation in retrieved evidence, dramatically reducing fabricated claims.
2.3 Lack of Attributability
A pure LLM cannot cite sources for its claims. RAG naturally supports attribution — the response can reference specific chunks from the knowledge base, enabling verifiability.
2.4 Domain Specificity
General-purpose LLMs lack deep knowledge of proprietary or niche domains (legal, medical, enterprise internal docs). RAG allows injecting domain-specific knowledge without fine-tuning.
2.5 Data Freshness and Updates
Updating a vector database is orders of magnitude cheaper and faster than retraining a model. RAG systems can be updated by simply re-indexing new documents.
2.6 Cost Efficiency
Fine-tuning an LLM for every new knowledge domain is expensive and risks catastrophic forgetting. RAG keeps the LLM frozen and updates only the retrieval index.
2.7 Regulatory Compliance
Industries like healthcare (HIPAA), finance (SOX), and law (GDPR) often require that AI outputs be traceable to source documents. RAG provides this traceability natively.
3. RAG Architecture
A canonical RAG system comprises three main stages:
3.1 Indexing Pipeline
The indexing pipeline prepares raw documents for efficient retrieval.
Raw Documents
|
[Document Loader] ---> Parsed Text / Markdown / PDF
|
[Text Splitter / Chunker] ---> Overlapping Chunks (tokens / sentences / paragraphs)
|
[Embedding Model] ---> Dense Vector Representations
|
[Vector Store] ---> Indexed Embeddings + Metadata
Key components:
- Document Loader: Ingests files from various formats (PDF, HTML, DOCX, Markdown, plain text, databases).
- Text Splitter / Chunker: Divides documents into manageable segments. Strategy choices (fixed-size, semantic, recursive, agentic) have a direct impact on retrieval quality.
- Embedding Model: Converts text chunks into fixed-dimensional dense vectors that capture semantic meaning.
- Vector Store: Persists embeddings and supports efficient similarity search (ANN — Approximate Nearest Neighbor).
3.2 Retrieval Stage
At query time, the user's question is embedded with the same embedding model, and the vector store returns the top-k most semantically similar chunks.
User Query
|
[Embedding Model] ---> Query Vector
|
[Vector Store (ANN Search)] ---> Top-k Chunks
|
[Optional: Re-ranker] ---> Re-scored / Filtered Context
Common retrieval strategies:
- Dense Retrieval: Query and documents embedded into the same dense vector space; cosine / dot-product / L2 similarity used to rank results.
- Sparse Retrieval (BM25): Keyword-based lexical matching; complementary to dense retrieval.
- Hybrid Retrieval: Combination of dense + sparse, often with reciprocal rank fusion (RRF) or learned weighting.
- Re-ranking: A cross-encoder model (e.g., Cohere Rerank, BGE-Reranker) re-scores the top-k retrieved chunks for improved precision.
3.3 Generation Stage
The retrieved chunks are injected into the LLM's prompt as context, and the LLM generates a response conditioned on both the query and the evidence.
System Prompt + Retrieved Chunks + User Query
|
[LLM] ---> Grounded Response
Prompt template (simplified):
You are a helpful assistant. Use the following context to answer the question.
If the context does not contain enough information, say so.
Context:
{retrieved_chunks}
Question: {user_query}
Answer:
Variations:
- Concatenation: All top-k chunks are concatenated into a single context window.
- Sliding Window: Chunks are fed iteratively for long-document scenarios.
- Conditional Generation: Only retrieve when the LLM's internal uncertainty is high (adaptive RAG).
4. Detailed Workflow
Below is a step-by-step walkthrough of a RAG query lifecycle.
Step 1: Query Input
The user submits a natural language query (e.g., "What were the Q4 2024 revenue numbers for Acme Corp?").
Step 2: Query Pre-processing
- Query Rewriting: LLM reframes the query for better retrieval (e.g., "Acme Corp Q4 2024 revenue").
- Query Decomposition: Complex queries are split into sub-queries for multi-hop retrieval.
- HyDE (Hypothetical Document Embedding): An LLM generates a hypothetical answer document, then that document's embedding is used for retrieval instead of the raw query.
Step 3: Query Embedding
The (possibly rewritten) query is passed through the embedding model to produce a query vector.
Step 4: Vector Search
The query vector searches the vector store using an ANN algorithm (HNSW, IVF, PQ, etc.) to retrieve the top-k nearest neighbors.
Parameters:
- k — number of chunks to retrieve (typically 3–10).
- score_threshold — minimum similarity score for inclusion.
- filter — metadata filters (date range, document source, author, etc.).
Step 5: Post-retrieval Processing
- Re-ranking: Apply a cross-encoder to reorder results by query-chunk relevance.
- Deduplication: Remove near-identical chunks.
- Compression: Summarize or extract the most relevant sentences from long chunks.
- Context Window Management: Truncate or prioritize chunks to fit within the LLM's context window (e.g., 4K, 8K, 128K tokens).
Step 6: Prompt Construction
Assemble the prompt with system instructions, the processed context, and the original user query.
Step 7: Generation
The LLM generates the final response, often with instructions to cite chunk IDs or source documents.
Step 8: Post-generation Processing
- Citation Formatting: Convert internal chunk references to human-readable citations.
- Factuality Check: Optional second LLM call to verify claims against retrieved context.
- Audit Logging: Store query, retrieved chunks, generated response, and metrics for monitoring.
5. Implementation Patterns
RAG systems can be classified along a maturity spectrum:
5.1 Naive RAG (or Simple RAG)
The original formulation: index → retrieve → generate, in a single pass.
Characteristics:
- Single retrieval step before generation.
- Fixed chunk size (typically 256–1024 tokens).
- No query rewriting or post-processing.
- No re-ranking.
Pros: Simple to implement, low latency.
Cons: Poor handling of complex / multi-hop queries, no mechanism for retrieval failure, context window limitations.
When to use: Prototyping, simple Q&A over small corpora, low-latency requirements.
5.2 Advanced RAG
Introduces pre-retrieval and post-retrieval optimizations.
Pre-retrieval improvements:
- Query expansion: Generate multiple related queries to improve recall.
- Query transformation: LLM-based rewriting, Step-back prompting (ask a broader question first).
- HyDE: Use LLM-generated hypothetical document for retrieval.
Post-retrieval improvements:
- Re-ranking: Cross-encoder or Cohere Rerank on top-k results.
- Context compression: Filter irrelevant sentences within retrieved chunks.
- Sliding window / iterative retrieval: For multi-hop questions.
Indexing improvements:
- Hierarchical indices: Summary-level index → chunk-level index.
- Metadata filtering: Pre-filter on date, author, source type.
- Multi-representation indexing: Store both a summary and full text for each chunk.
Pros: Significantly higher accuracy than Naive RAG.
Cons: Increased latency and complexity, more moving parts to tune.
5.3 Modular RAG
A flexible, composable architecture where retrieval and generation components can be rearranged, replaced, or augmented with additional modules.
Core modules:
- Search Module: Dense, sparse, hybrid, or external API (Google Search, Bing, etc.).
- Routing Module: Direct query to the appropriate retriever or data source based on query type.
- Memory Module: Maintain conversation history for multi-turn retrieval.
- Fusion Module: Combine results from multiple retrievers.
- Adaptive Module: Decide whether to retrieve based on query difficulty.
- Verification Module: Confirm generated claims against retrieved evidence.
Common Modular RAG patterns:
| Pattern | Description |
|---|---|
| Routing RAG | Classify query → route to domain-specific index or retriever |
| Self-RAG | LLM decides to retrieve, and reflects on retrieved passages |
| Corrective RAG (CRAG) | Check retrieval quality; if poor, rewrite query or fall back to web search |
| Adaptive RAG | Dynamic retrieval decisions based on LLM uncertainty |
| Graph RAG | Use knowledge graph structure alongside dense retrieval for multi-hop reasoning |
| Agentic RAG | LLM agent with tool-use — can call retriever multiple times, use other tools, and plan retrieval strategies |
| Multi-modal RAG | Retrieve text, images, tables, and audio simultaneously |
Pros: Maximum flexibility and accuracy; state-of-the-art results.
Cons: Highest complexity; engineering overhead; latency management is non-trivial.
6. Chunking Strategies
Chunking is arguably the single most impactful design decision in a RAG system. The goal is to produce segments that are semantically self-contained, contextually coherent, and optimally sized for both retrieval and generation.
6.1 Fixed-Size Chunking
The simplest approach: split text every N characters / tokens, often with overlap.
def fixed_size_chunk(text, chunk_size=512, overlap=64):
chunks = []
start = 0
while start < len(text):
end = min(start + chunk_size, len(text))
# optionally break at nearest sentence boundary
chunks.append(text[start:end])
start += chunk_size - overlap
return chunks
Pros: Trivial to implement, deterministic, fast.
Cons: No semantic boundaries — chunks may split sentences or paragraphs in the middle, losing context.
6.2 Semantic Chunking
Split text at natural semantic boundaries — sentences, paragraphs, or topic shifts.
Approaches:
- Paragraph-based: Split on double newlines.
- Document structure: Use Markdown / HTML headings as boundaries.
- Embedding-based: Chunk boundaries are determined by abrupt changes in embedding similarity (smaller inter-sentence similarity → boundary).
- Model-based: Use a sentence-boundary detection model (e.g., spaCy sentence tokenizer, nltk.sent_tokenize).
Pros: Chunks are contextually coherent; higher retrieval quality.
Cons: Chunks can be highly variable in length; risk of very long or very short chunks.
6.3 Recursive Chunking
A strategy popularized by LangChain: apply multiple splitters in sequence, falling back from most aggressive to least aggressive.
LangChain RecursiveCharacterTextSplitter behavior:
1. Attempt to split on paragraph boundaries (\n\n).
2. If any chunk exceeds the max size, fall back to sentence boundaries (.).
3. If still too large, split on word boundaries.
4. Final fallback: character-by-character split.
recursive_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
separators=["\n\n", "\n", ".", "!", "?", ",", " ", ""]
)
Pros: Graceful handling of long documents; produces chunks of approximately uniform size while respecting structure.
Cons: Still operates at the lexical level, not fully semantic.
6.4 Agentic / LLM-based Chunking
Use an LLM to determine chunk boundaries by understanding the document's narrative structure and topic flow.
Example approach:
- Feed a sliding window of N sentences to an LLM.
- Ask the LLM: "Does this sentence start a new topic? If yes, propose a chunk boundary here."
- Merge decisions across the document.
LLM-assisted summarization chunking:
- Generate a short summary for each natural paragraph.
- Store both summary (for retrieval) and full paragraph (for generation).
- Retrieve by summary embedding; use full content for generation.
Pros: Highest quality chunking; respects document-level semantics.
Cons: Expensive (LLM calls), slow, non-deterministic, difficult to scale to large corpora.
6.5 Comparison Table
| Strategy | Semantic Coherence | Implementation Effort | Cost | Chunk Size Uniformity | Best For |
|---|---|---|---|---|---|
| Fixed-size | Low | Very Low | None | High | Quick prototypes |
| Semantic | High | Medium | Low | Low | Well-structured docs |
| Recursive | Medium | Low | None | Medium-High | General purpose |
| Agentic / LLM-based | Very High | High | High | Low | High-value, complex docs |
7. Embedding Models
Embedding models map text to dense vector representations. The quality of embeddings directly determines retrieval accuracy.
7.1 text-embedding-ada-002 (OpenAI)
- Dimensions: 1536
- Context window: 8192 tokens
- Pricing: Pay-per-token (approximately $0.0001 / 1K tokens)
- Strength: Excellent general-purpose embeddings; strong zero-shot performance across domains.
- Limitations: Closed-source; vendor lock-in; data sent to OpenAI; no fine-tuning on custom data.
7.2 Sentence-Transformers (SBERT)
An open-source library providing hundreds of pre-trained models.
| Model | Dimensions | Strengths |
|---|---|---|
all-MiniLM-L6-v2 |
384 | Fast, lightweight, good for CPU |
all-mpnet-base-v2 |
768 | Higher accuracy, larger |
multi-qa-mpnet-base-dot-v1 |
768 | Optimized for QA retrieval |
intfloat/e5-large-v2 |
1024 | State-of-the-art open-source |
Strengths: Open-source; can be fine-tuned on domain data; runs locally; huge model zoo.
Limitations: Requires local GPU for high throughput; performance varies by model size.
7.3 BGE (BAAI General Embedding)
Developed by the Beijing Academy of Artificial Intelligence (BAAI). Current state-of-the-art among open-source embedding models.
| Model | Dimensions | Notes |
|---|---|---|
BAAI/bge-small-en-v1.5 |
384 | Lightweight, competitive |
BAAI/bge-base-en-v1.5 |
768 | Good balance |
BAAI/bge-large-en-v1.5 |
1024 | Best accuracy, 1.34B params |
BAAI/bge-m3 |
1024 | Multi-lingual, dense + sparse + multi-vector |
Key feature: Trained with a dedicated retrieval fine-tuning stage; supports instruction= parameter for query-side embedding.
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-base-en-v1.5")
query_emb = model.encode("Query: What is RAG?")
doc_emb = model.encode("RAG is a retrieval-augmented generation paradigm...")
7.4 E5 / E5-Mistral (Microsoft)
E5 (EmbEddings from bidirEctional Encoder rEpresentations) and its successor E5-Mistral achieve top-tier results on MTEB (Massive Text Embedding Benchmark).
| Model | Dimensions | Notes |
|---|---|---|
intfloat/e5-base-v2 |
768 | Strong general-purpose |
intfloat/e5-large-v2 |
1024 | Top MTEB scores on release |
intfloat/e5-mistral-7b-instruct |
4096 | LLM-based, 7B params, instruction-tuned |
Key feature: Uses LLM (Mistral-7B) as backbone for E5-Mistral, enabling instruction-aware embeddings.
7.5 Embedding Model Selection Criteria
| Criterion | Considerations |
|---|---|
| Accuracy | Check MTEB leaderboard for your task (retrieval, clustering, classification) |
| Dimensions | Higher dimensions = more storage + slower search; diminishing returns after 768 |
| Latency | Smaller models (384d) are 5-10x faster than large (1024d) |
| Domain fit | Fine-tune on domain corpus if possible; BGE and E5 support fine-tuning |
| Context length | Need longer context (8192+ tokens)? ada-002 or BGE-M3 support this |
| Cost | Managed (OpenAI, Cohere) = pay-per-token; open-source = self-hosted GPU cost |
| Privacy | On-premise embeddings required for sensitive data → sentence-transformers or BGE |
7.6 MTEB Leaderboard (Top Models as of 2025)
| Rank | Model | Avg Score | Size |
|---|---|---|---|
| 1 | intfloat/e5-mistral-7b-instruct |
66.6 | 7B |
| 2 | BAAI/bge-large-en-v1.5 |
64.2 | 1.3B |
| 3 | Cohere/embed-english-v3.0 |
64.0 | API |
| 4 | text-embedding-3-large (OpenAI) |
64.0 | API |
| 5 | intfloat/e5-large-v2 |
62.2 | ~300M |
Note: Scores change frequently; always check the latest MTEB results.
8. Vector Databases
Vector databases are purpose-built for storing embeddings and performing fast Approximate Nearest Neighbor (ANN) search.
8.1 Pinecone
Type: Fully managed cloud service (serverless / pod-based).
Key Features:
- Auto-scaling; no infrastructure management.
- Built-in hybrid search (dense + sparse via pinecone-text).
- Single-stage metadata filtering.
- Namespace isolation for multi-tenancy.
- Supports up to 200 dimensions free tier (pod-based).
Algorithms: HNSW, with optimized SIMD instructions.
Pros: No ops overhead; excellent documentation; fast onboarding.
Cons: Vendor lock-in; only cloud-hosted (no self-hosted option); expensive at scale.
8.2 Weaviate
Type: Open-source / cloud (Weaviate Cloud Services).
Key Features:
- Native vector + scalar storage.
- Built-in modules for OpenAI, Cohere, HuggingFace, and custom models.
- GraphQL and REST APIs.
- Hybrid search (BM25 + vector) with weighted fusion.
- Multi-tenancy, sharding, replication.
Algorithms: HNSW by default; also supports PQ compression.
Pros: Open-source; self-hostable; rich module ecosystem; hybrid search.
Cons: Steeper learning curve; self-hosting requires operational expertise.
8.3 Qdrant
Type: Open-source / cloud (Qdrant Cloud).
Key Features:
- Written in Rust — very fast.
- Rich filtering with payload indexing.
- Scalar and Product Quantization for memory reduction.
- Binary quantization (1-bit) for extreme compression.
- Multi-vector support.
Algorithms: HNSW only; highly optimized Rust implementation.
Pros: Fastest ANN search speeds; memory-efficient; Docker one-liner to self-host.
Cons: Fewer built-in integrations than Weaviate; smaller ecosystem.
8.4 Milvus
Type: Open-source / cloud (Zilliz Cloud).
Key Features:
- Cloud-native design (separate storage, compute, and indexing layers).
- Multiple index types: IVF, HNSW, DiskANN, and GPU-accelerated indices.
- Hybrid search (dense + sparse) with ensemble reranking.
- Streaming and batch ingestion.
Algorithms: IVF_FLAT, IVF_SQ8, HNSW, GPU_IVF, DiskANN.
Pros: Production-proven at billion-scale; GPU indexing for massive throughput; Apache 2.0 license.
Cons: Heavy deployment (~6+ microservices for full cluster); steep learning curve; more complex than alternatives.
8.5 Chroma
Type: Open-source, lightweight embedded database.
Key Features:
- Python-native; pip-installable.
- In-memory and persistent (SQLite-backed) modes.
- Simple API: collection.add, collection.query.
- Metadata storage and filtering.
- Built-in embedding function wrappers (OpenAI, SentenceTransformers).
Pros: Zero-config; ideal for prototyping and small projects; tight Python integration.
Cons: No distributed / production-scale capabilities; limited index types; not suitable for >1M vectors.
8.6 Comparison Table
| Feature | Pinecone | Weaviate | Qdrant | Milvus | Chroma |
|---|---|---|---|---|---|
| License | Proprietary | BSD-3 | Apache 2.0 | Apache 2.0 | Apache 2.0 |
| Self-hosted | No | Yes | Yes | Yes | Yes (embedded) |
| Cloud option | Yes (serverless) | Yes (WCS) | Yes (Cloud) | Yes (Zilliz) | No |
| Hybrid search | Yes | Yes | Yes (1.11+) | Yes | No |
| ANN algorithm | HNSW | HNSW | HNSW | IVF, HNSW, DiskANN, GPU | HNSW |
| Max scale | Billions | Billions | Billions | 10B+ | <1M |
| Ease of use | Very easy | Medium | Easy | Hard | Very easy |
| Rust-based | No | No | Yes | No | No |
| Metadata filtering | Yes | Yes | Yes (indexed) | Yes | Basic |
| Multi-tenancy | Namespaces | Tenant objects | Collections | Collections | Collections |
8.7 Choosing a Vector Database
| Use Case | Recommendation |
|---|---|
| Prototyping / small projects | Chroma |
| Production < 10M vectors | Qdrant (self-hosted) or Pinecone (cloud) |
| Production > 10M vectors, cloud | Pinecone or Qdrant Cloud |
| Production > 100M vectors, self-hosted | Milvus or Qdrant |
| Enterprise, hybrid search | Weaviate or Qdrant |
| GPU-accelerated indexing | Milvus |
| Full-text + vector hybrid, rich filtering | Qdrant or Weaviate |
9. RAG Evaluation
Evaluating RAG systems is more nuanced than evaluating either retrieval or generation in isolation. Both components must be assessed together.
9.1 Evaluation Dimensions
| Dimension | What It Measures | Key Metrics |
|---|---|---|
| Retrieval Quality | Can the system find relevant documents? | Recall@k, Precision@k, MRR, NDCG, MAP |
| Generation Quality | Is the generated text good? | BLEU, ROUGE, METEOR, Perplexity |
| Groundedness | Does generation stay faithful to retrieved context? | Faithfulness, Answer Relevance, Context Precision |
| End-to-End Utility | Does the whole system help the user? | User satisfaction, task completion rate |
| Efficiency | How fast / cheap is the system? | Latency p50/p95, tokens processed, cost per query |
9.2 RAGAS Framework
RAGAS (Retrieval Augmented Generation Assessment) is the most widely adopted evaluation framework for RAG systems, introduced by Shahul et al. in 2024. It defines several reference-free metrics:
Faithfulness
Measures whether the generated answer is factually consistent with the retrieved context.
How it works:
1. Decompose the generated answer into individual claims (atomic statements).
2. For each claim, check if it can be inferred from the retrieved context.
3. Faithfulness = (Number of supported claims) / (Total claims).
Range: 0 to 1. Higher is better.
Answer Relevance
Measures how well the generated answer addresses the user's question.
How it works:
1. Given the question and answer, ask an LLM to generate n reverse questions based on the answer.
2. Compute the cosine similarity between the original question and the generated questions (embedded).
3. Answer Relevance = average cosine similarity.
Range: 0 to 1. Higher is better.
Context Precision
Measures the signal-to-noise ratio in the retrieved context — are the relevant chunks ranked higher?
How it works:
1. For each ranked chunk, check relevance to the question.
2. Weight chunks by rank (higher rank = more weight if relevant).
3. Context Precision = sum over relevant chunks of (precision at that rank) / total relevant chunks.
Range: 0 to 1. Higher is better.
Context Recall
Measures whether all the information needed to answer the question is present in the retrieved context.
How it works:
1. Decompose the ground-truth answer (or the question itself) into claims.
2. For each claim, check if it can be inferred from the retrieved context.
3. Context Recall = (Number of supported claims) / (Total claims).
Range: 0 to 1. Higher is better.
Aspect Critique
Evaluates generation on specific qualitative aspects:
| Aspect | Question to Judge LLM |
|---|---|
| Harmlessness | Is the answer safe? |
| Correctness | Is the answer factually correct? |
| Coherence | Is the answer logically structured? |
| Conciseness | Is the answer free of unnecessary detail? |
9.3 Other Evaluation Approaches
LLM-as-a-Judge
Use a strong LLM (GPT-4, Claude, Gemini) to score RAG outputs on custom rubrics.
Score 1-5 for each criterion:
- Groundedness: Does the answer reference retrieved context?
- Completeness: Does the answer fully address the question?
- Helpfulness: How useful is the answer to the user?
Human Evaluation
Gold standard but expensive. Useful for:
- Building and validating automated metrics.
- End-user satisfaction studies.
- A/B testing in production.
Embedding-based Metrics
- Answer Semantic Similarity: Cosine similarity between embedding of generated answer and ground-truth answer.
- BERTScore: Token-level precision and recall using BERT embeddings.
9.4 Evaluation Pipeline Best Practices
- Build a diverse evaluation dataset: 100–500+ query-answer pairs covering typical, edge-case, and adversarial queries.
- Test retrieval in isolation: Measure recall@k before evaluating the full pipeline.
- Track cost and latency alongside quality metrics.
- Segment metrics by query type: Factual lookup vs. synthesis vs. multi-hop.
- Monitor drift over time: Re-run the same eval set after every index update.
10. Best Practices
10.1 Indexing
- Chunk with paragraphs, not characters. Use recursive or semantic chunking; avoid fixed-size splitting across paragraphs.
- Add chunk overlap. 10–20% overlap between consecutive chunks prevents information loss at boundaries.
- Store rich metadata. Include document title, date, source URL, section heading, and chunk index for filtering and citation.
- Create parent-child relationships. Retrieve summaries (child) but generate from full documents (parent) — improves retrieval quality while preserving context.
- Multi-representation indexing. Store a concise summary and the full content; retrieve by summary for better semantic matching.
10.2 Embeddings
- Normalize embeddings (L2 normalize) to enable cosine similarity via dot product.
- Use separate instructions for query vs. document embedding (BGE, E5-instruct).
- Batch encode during indexing for GPU efficiency.
- Fine-tune on domain data if your corpus is specialized (legal, medical, code).
- Monitor embedding drift when updating models.
10.3 Retrieval
- Always use hybrid search (dense + sparse) for best recall.
- Retrieve more than you need and re-rank. Fetch
k=20-50, then re-rank to top 5-10. - Apply metadata filters aggressively. Date range, source type, author filters dramatically improve precision.
- Implement query rewriting. An LLM can transform vague queries into specific, search-optimized queries.
- Use multi-stage retrieval for complex queries: broad recall → narrow precision.
10.4 Generation
- Instruct the LLM to cite sources. "For each claim, cite the source chunk ID."
- Handle "no relevant context" gracefully. If retrieval returns low-confidence chunks, have the LLM say "I could not find sufficient information."
- Enable streaming for better user experience (LLM generates while context is visible).
- Limit context window. Don't fill the entire LLM context window — leave room for reasoning.
- Use system prompts to reduce hallucination. "If the context does not contain the answer, say 'I don't know'. Do not make up information."
10.5 Production Considerations
- Cache frequent queries at the retrieval layer to reduce latency and cost.
- Measure and log retrieval latency, generation latency, token counts, and per-query cost.
- Set up evaluation as CI/CD pipeline. Run your eval suite on every index update and model change.
- Use A/B testing. Compare retrieval strategies, chunk sizes, and embedding models on live traffic.
- Implement guardrails. Input filtering (prompt injection, PII detection) and output filtering (toxicity, hallucination detection).
11. Challenges and Limitations
11.1 Retrieval Failure Modes
- Irrelevant context retrieved: The retriever returns chunks that are semantically similar but factually irrelevant to the user's question.
- Missing context: The required information exists in the corpus but was not retrieved (low recall).
- Out-of-date context: Retrieved chunks contain stale information that contradicts the current state.
11.2 Context Window Constraints
Even with long-context LLMs (128K–200K tokens), feeding many chunks can:
- Introduce noise and distract the LLM.
- Cause the LLM to "lost in the middle" — performance degrades on information in the middle of long prompts.
- Increase cost (tokens are expensive).
11.3 Chunking Trade-offs
- Small chunks → higher retrieval precision, but may lack full context for answering.
- Large chunks → more context per retrieval, but lower embedding specificity and noisier retrieval.
11.4 Embedding Quality
- Domain shift: Off-the-shelf embedding models perform poorly on specialized domains (legal, medical, scientific).
- Fine-tuning cost: Domain-adapted embeddings require labeled data and compute.
- Multi-lingual challenges: Many embedding models perform poorly on non-English text.
11.5 Latency and Cost
| Component | Typical latency | Cost driver |
|---|---|---|
| Embedding query | 10–50ms (GPU), 100–300ms (CPU) | Compute / token |
| ANN search | 5–50ms (HNSW) | Compute / index size |
| Re-ranking | 100–500ms per batch | Compute / model size |
| LLM generation | 500ms–15s per response | Tokens generated |
End-to-end latency for a production RAG system is typically 1–5 seconds, depending on context size and model.
11.6 Evaluation Challenges
- No single ground truth — many queries have multiple valid answers.
- Reference-free metrics are noisy — LLM-as-a-judge has its own biases and inconsistencies.
- Evaluation is expensive — running full RAGAS or GPT-4 evaluation on every change is cost-prohibitive.
11.7 Security and Privacy
- Prompt injection: Malicious queries can extract or manipulate retrieved content.
- Data leakage: Retrieved sensitive documents may appear in generated responses.
- Indirect prompt injection: Adversarial text in retrieved chunks can hijack the LLM.
11.8 Maintenance Burden
- Index updates: Re-chunking and re-embedding the entire corpus when strategies change.
- Model versioning: Coordinating embedding model versions with retrieval index compatibility.
- Monitoring drift: Embedding distributions drift over time as new documents are added.
📄 Advanced RAG (Retrieval-Augmented Generation) — 3627 lines
Comprehensive reference on advanced RAG techniques: chunking strategies, multi-representation indexing, HyDE, re-rankers, hybrid search, fusion methods, query transformation, GraphRAG, Agentic RAG, evaluation, caching, multi-modal RAG, and latency optimization.
Chunking Strategies
Chunking is the process of dividing documents into smaller, retrievable pieces. The quality of chunking directly impacts retrieval accuracy, context relevance, and overall RAG system performance.
Fixed-Size Chunking
The simplest approach: documents are split into chunks of a predetermined size (character count, token count, or word count).
Implementation:
def fixed_size_chunk(text, chunk_size=512, overlap=0):
"""Fixed-size chunking by character count."""
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunk = text[start:end]
chunks.append(chunk)
start += chunk_size - overlap
return chunks
def fixed_token_chunk(text, chunk_tokens=256, overlap_tokens=25, tokenizer=None):
"""Fixed-size chunking by token count."""
tokens = tokenizer.encode(text)
chunks = []
start = 0
while start < len(tokens):
end = start + chunk_tokens
chunk_tokens_list = tokens[start:end]
chunk_text = tokenizer.decode(chunk_tokens_list)
chunks.append(chunk_text)
start += chunk_tokens - overlap_tokens
return chunks
Parameters:
- chunk_size: Number of characters/tokens per chunk (typical: 256-1024 tokens).
- overlap: Number of tokens shared between consecutive chunks (typical: 10-25%).
Advantages:
- Simple and fast
- Deterministic and reproducible
- Easy to benchmark and optimize
Disadvantages:
- No semantic boundaries
- May split sentences or paragraphs
- Context can be lost at chunk boundaries
- Not content-aware
When to Use:
- Initial prototyping and baselines
- Homogeneous content (e.g., logs, structured text)
- When speed is primary concern
Recursive Splitter
Recursively splits text using a hierarchy of separators (paragraphs, sentences, words) to maintain semantic coherence.
Algorithm:
1. Try splitting by the first separator (e.g., "\n\n" for paragraphs).
2. If any chunk exceeds max size, recursively split it using the next separator.
3. Continue until all chunks are under the size limit.
Implementation (LangChain-style):
from typing import List
class RecursiveCharacterSplitter:
def __init__(self, chunk_size=1000, chunk_overlap=200, separators=None):
self.chunk_size = chunk_size
self.chunk_overlap = chunk_overlap
self.separators = separators or ["\n\n", "\n", ".", " ", ""]
def split_text(self, text: str) -> List[str]:
return self._recursive_split(text, self.separators)
def _recursive_split(self, text: str, separators: List[str]) -> List[str]:
if len(text) <= self.chunk_size:
return [text]
if not separators:
# No more separators, split by characters
return self._split_by_chars(text)
separator = separators[0]
if separator:
splits = text.split(separator)
else:
splits = list(text)
chunks = []
current_chunk = []
current_size = 0
for split in splits:
split_size = len(split)
if current_size + split_size > self.chunk_size:
if current_chunk:
chunks.append(separator.join(current_chunk))
current_chunk = current_chunk[-self._overlap_count:]
current_size = sum(len(s) for s in current_chunk) + len(current_chunk) - 1
current_chunk.append(split)
current_size = len(split)
else:
current_chunk.append(split)
current_size += split_size + (1 if current_chunk else 0) # +1 for separator
if current_chunk:
chunks.append(separator.join(current_chunk))
return chunks
Common Separator Hierarchy:
["\n\n", "\n", ".", "!", "?", ",", " ", ""]
Advantages:
- Respects natural text boundaries
- Produces more coherent chunks
- Better than fixed-size for most documents
- Configurable separator hierarchy
Disadvantages:
- More complex than fixed-size
- May still produce imperfect chunks
- Separator choice impacts quality
Semantic Chunking
Chunks are created based on semantic similarity between adjacent sentences or paragraphs, ensuring each chunk covers a coherent topic.
Embedding Similarity Approach
Uses embedding similarity to detect topic boundaries:
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
class SemanticChunker:
def __init__(self, embedding_model, threshold=0.5, min_chunk_tokens=50, max_chunk_tokens=500):
self.embedding_model = embedding_model
self.threshold = threshold
self.min_chunk_tokens = min_chunk_tokens
self.max_chunk_tokens = max_chunk_tokens
def chunk(self, sentences):
"""Group sentences into semantic chunks based on embedding similarity."""
if len(sentences) <= 1:
return sentences
# Get embeddings for all sentences
embeddings = self.embedding_model.embed_documents(sentences)
chunks = []
current_chunk = [sentences[0]]
for i in range(1, len(sentences)):
# Measure similarity between consecutive sentences
similarity = cosine_similarity(
[embeddings[i-1]], [embeddings[i]]
)[0][0]
current_tokens = len(' '.join(current_chunk).split())
if similarity < self.threshold and current_tokens >= self.min_chunk_tokens:
# Topic boundary detected
chunks.append(' '.join(current_chunk))
current_chunk = [sentences[i]]
el...Chunk Optimization
Optimizing chunk parameters is critical for RAG performance.
Retrieval Evaluation
Metrics for evaluating chunk quality:
class ChunkEvaluator:
def __init__(self, retriever, qa_pairs):
self.retriever = retriever
self.qa_pairs = qa_pairs # List of (question, relevant_chunk_id, answer)
def evaluate(self):
"""Evaluate chunking quality."""
results = {
"hit_rate": 0,
"mrr": 0, # Mean Reciprocal Rank
"precision": 0,
"recall": 0
}
for question, relevant_id, _ in self.qa_pairs:
retrieved_chunks = self.retriever.retrieve(question, k=10)
retrieved_ids = [c.id for c in retrieved_chunks]
# Hit rate: was relevant chunk in top-k?
if relevant_id in retrieved_ids:
results["hit_rate"] += 1
# MRR: rank position of first relevant
rank = retrieved_ids.index(relevant_id) if relevant_id in retrieved_ids else -1
if rank >= 0:
results["mrr"] += 1.0 / (rank + 1)
# Precision at k
results["precision"] += self._precision_at_k(retrieved_ids, relevant_id)
n = len(self.qa_pairs)
return {k: v/n for k, v in results.items()}
Chunk Size Experiments
Systematic approach to finding optimal chunk size:
import matplotlib.pyplot as plt
class ChunkSizeOptimizer:
def __init__(self, documents, retriever_factory, evaluator):
self.documents = documents
self.retriever_factory = retriever_factory
self.evaluator = evaluator
def optimize(self, chunk_sizes=[256, 512, 768, 1024, 1536, 2048], overlaps=[0, 50, 100, 200]):
"""Test different chunk sizes and overlaps."""
results = []
for size in chunk_sizes:
for overlap in overlaps:
if overlap >= size:
continue
# Create chunks with these parameters
chunks = self._create_chunks(size, overlap)
# Build retriever
retriever = self.retriever_factory(chunks)
# Evaluate
metrics = self.evaluator(retriever).evaluate()
metrics["chunk_size"] = size
metrics["overlap"] = overlap
results.append(metrics)
return results
def visualize(self, results):
"""Plot chunk size vs performance."""
sizes = sorted(set(r["chunk_size"] for r in results))
for metric in ["hit_rate", "mrr"]:
scores = []
for size in sizes:
size_results = [r for r in results if r["chunk_size"] == size]
avg_score = sum(r[metric] for r in size_results) / len(size_results)
scores.append(avg_score)
plt.plot(sizes, scores, label=metric)
plt.xlabel("Chunk Size (tokens)")
plt.ylabel("Score")
plt.legend()
plt.show()
Typical Findings:
- Small chunks (128-256): Better precision, worse recall, more chunks to search
- Medium chunks (512-1024): Good balance for most applications
- Large chunks (1536-2048): Better recall for multi-sentence queries, worse for precise retrieval
- Very large (>2048): Often detrimental as context contains noise
Overlap Strategies
Fixed Overlap:
def chunk_with_overlap(text, chunk_size, overlap_tokens):
"""Fixed overlap between consecutive chunks."""
chunks = []
start = 0
while start < len(text):
end = min(start + chunk_size, len(text))
chunk = text[start:end]
chunks.append(chunk)
start += chunk_size - overlap_tokens
return chunks
Dynamic Overlap:
def dynamic_overlap(text, chunk_size, min_overlap=0.1, max_overlap=0.3):
"""Adjust overlap based on content similarity."""
sentences = split_sentences(text)
chunks = []
i = 0
while i < len(sentences):
current = []
token_count = 0
while i < len(sentences) and token_count < chunk_size:
current.append(sentences[i])
token_count += len(sentences[i].split())
i += 1
# Check semantic similarity at boundary
if i < len(sentences) and token_count >= chunk_size:
similarity = sentence_similarity(sentences[i-1], sentences[i])
overlap_ratio = min_overlap + (max_overlap - min_overlap) * (1 - similarity)
overlap_tokens = int(chunk_size * overlap_ratio)
# Roll back to include overlap
while overlap_tokens > 0 and i > 0:
prev_tokens = len(sentences[i-1Multi-Representation Indexing
Instead of indexing only raw chunks, multiple representations are indexed to improve retrieval.
HyDE (Hypothetical Document Embeddings)
Generate hypothetical documents that answer the query, then embed these for retrieval.
Detailed coverage in HyDE section.
Dense X
Dense X indexing creates multiple dense vectors per document chunk, each capturing different aspects.
class DenseXIndexer:
def __init__(self, embedding_model, num_representations=3):
self.embedding_model = embedding_model
self.num_representations = num_representations
def index_chunk(self, chunk_text):
"""Create multiple representations for a single chunk."""
representations = []
# 1. Direct embedding
direct_emb = self.embedding_model.embed_query(chunk_text)
representations.append(direct_emb)
# 2. Summary embedding
summary = self._summarize(chunk_text)
summary_emb = self.embedding_model.embed_query(summary)
representations.append(summary_emb)
# 3. Keyword-focused embedding
keywords = self._extract_keywords(chunk_text)
keyword_text = " ".join(keywords)
keyword_emb = self.embedding_model.embed_query(keyword_text)
representations.append(keyword_emb)
# 4. Question-focused embedding (what questions does this chunk answer?)
questions = self._generate_questions(chunk_text)
for q in questions[:self.num_representations - 3]:
q_emb = self.embedding_model.embed_query(q)
representations.append(q_emb)
return representations
Summary Retrieval
Index document summaries alongside chunks for efficient high-level retrieval.
class SummaryRetrievalIndex:
def __init__(self, vector_store, summary_model):
self.vector_store = vector_store
self.summary_model = summary_model
def index_document(self, doc_id, chunks, metadata=None):
"""Index both summaries and chunks."""
# Create document summary
full_text = "\n".join(chunks)
summary = self.summary_model.summarize(full_text)
# Store summary with document-level metadata
self.vector_store.add_documents(
[summary],
metadatas=[{
"type": "summary",
"doc_id": doc_id,
"chunk_ids": list(range(len(chunks))),
**(metadata or {})
}],
ids=[f"summary_{doc_id}"]
)
# Store individual chunks
for i, chunk in enumerate(chunks):
self.vector_store.add_documents(
[chunk],
metadatas=[{
"type": "chunk",
"doc_id": doc_id,
"chunk_index": i,
**(metadata or {})
}],
ids=[f"chunk_{doc_id}_{i}"]
)
def retrieve(self, query, k=5):
"""Two-stage retrieval: summaries first, then chunks."""
# Stage 1: Retrieve summaries
summary_results = self.vector_store.similarity_search(
query, k=k, filter={"type": "summary"}
)
# Stage 2: Retrieve chunks from top documents
doc_ids = [r.metadata["doc_id"] for r in summary_results]
chunk_results = self.vector_store.similarity_search(
query, k=k, filter={"type": "chunk", "doc_id": {"$in": doc_ids}}
)
return chunk_results
Contextual Retrieval
Anthropic's contextual retrieval approach enriches each chunk with surrounding context.
class ContextualRetrieval:
"""
Enriches each chunk with context from the document before indexing.
Based on Anthropic's contextual retrieval approach.
"""
def __init__(self, llm, embedding_model):
self.llm = llm
self.embedding_model = embedding_model
def prepare_chunk(self, chunk, document_context):
"""Add explanatory context to each chunk."""
context_prompt = f"""
Here is a chunk from a larger document:
<chunk>{chunk}</chunk>
Here is the document context surrounding this chunk:
<context>{document_context}</context>
Provide a concise context for this chunk that would help a reader
understand its place in the document. Include:
- What is this chunk about
- How it relates to surrounding content
- Key entities or topics referenced
Context:
"""
context = self.llm.invoke(context_prompt)
# Store the enriched chunk
enriched_chunk = f"[Context: {context}]\n\n{chunk}"
return enriched_chunk
def index_document(self, chunks):
"""Index chunks with contextual enrichment."""
enriched_chunks = []
for i, chunk in enumerate(chunks):
# Get surrounding context
surrounding = self._get_surrounding_context(chunks, i)
enriched = self.prepare_chunk(chunk, surrounding)
enriched_chunks.append(enriched)
# Index enriched chunks
embeddings = self.embedding_model.embed_documents(enriched_chunks)
return enriched_chunks, embeddings
def _get_surrounding_context(self, chunks, idx, window=2):
"""Get text surrounding a chunk."""
start = max(0, idx - window)
end = min(len(chunks), idx + window + 1)
return "\n...Hierarchical Indices
Hierarchical indexing maintains multiple levels of granularity for efficient retrieval.
Parent-Child Chunking
Large "parent" chunks are split into smaller "child" chunks. Retrieval happens at the child level, but context comes from the parent.
class ParentChildRetriever:
"""
Parent-child chunking: retrieve child chunks but return parent context.
"""
def __init__(self, vector_store, parent_size=1000, child_size=200):
self.vector_store = vector_store
self.parent_size = parent_size
self.child_size = child_size
def index_document(self, doc_id, text):
"""Create parent and child chunks."""
# Create parent chunks
parent_chunks = self._split(text, self.parent_size, overlap=100)
for parent_idx, parent in enumerate(parent_chunks):
parent_id = f"parent_{doc_id}_{parent_idx}"
# Store parent
self.vector_store.add_documents(
[parent],
metadatas=[{
"type": "parent",
"doc_id": doc_id,
"parent_id": parent_id
}],
ids=[parent_id]
)
# Create and store child chunks
child_chunks = self._split(parent, self.child_size, overlap=20)
for child_idx, child in enumerate(child_chunks):
child_id = f"child_{doc_id}_{parent_idx}_{child_idx}"
self.vector_store.add_documents(
[child],
metadatas=[{
"type": "child",
"doc_id": doc_id,
"parent_id": parent_id,
"child_id": child_id
}],
ids=[child_id]
)
def retrieve(self, query, k=5):
"""Retrieve child chunks and return parent context."""
# Retrieve child chunks
child_results = self.vector_store.similarity_search(
query, k=k, filter={"type": "child"}
)
# Get unique parent IDs
parent_ids = list(set(r.metadata["parent_id"] for r in child_results))
# Retrieve parent chunks
parent_results = self.vector_store.similarity_search(
query, k=len(parent_ids), filter={"type": "parent", "parent_id": {"$in": parent_ids}}
)
return parent_results, child_results
Benefits:
- More precise retrieval (child level)
- More complete context (parent level)
- Balances precision and recall
Section-Level + Chunk-Level Indices
Documents are indexed at both section (high-level) and chunk (detailed) levels.
class MultiLevelIndex:
def __init__(self, vector_store):
self.vector_store = vector_store
def index_structured_document(self, doc_id, sections):
"""Index document with section and chunk levels.
sections: list of {heading, content, sub_sections}
"""
for i, section in enumerate(sections):
section_id = f"sec_{doc_id}_{i}"
# Index section summary
section_summary = f"{section['heading']}: {self._summarize(section['content'])}"
self.vector_store.add_documents(
[section_summary],
metadatas=[{"level": "section", "doc_id": doc_id, "section_id": section_id}],
ids=[section_id]
)
# Index chunks within section
chunks = self._chunk_content(section['content'])
for j, chunk in enumerate(chunks):
chunk_id = f"ch_{doc_id}_{i}_{j}"
self.vector_store.add_documents(
[chunk],
metadatas=[{
"level": "chunk",
"doc_id": doc_id,
"section_id": section_id,
"heading": section['heading']
}],
ids=[chunk_id]
)
def iterative_retrieve(self, query, k_final=5):
"""Iterative retrieval: sections first, then chunks."""
# Level 1: Retrieve relevant sections
section_results = self.vector_store.similarity_search(
query, k=3, filter={"level": "section"}
)
section_ids = [r.metadata["section_id"] for r in section_results]
# Level 2: Retrieve chunks from relevant sections
chunk_results = self.vector_store.similarity_search(
query, k=k_final,
filter={"level": "chunk", "section_id": {"$in": section_ids}}
)
return chunk_results
Iterative Retrieval
Progressive refinement of search results through multiple rounds.
class IterativeRetriever:
def __init__(self, vector_store, llm, max_iterations=3):
self.vector_store = vector_store
self.llm = llm
self.max_iterations = max_iterations
def retrieve(self, query, k=5):
"""Iteratively refine retrieval based on intermediate results."""
all_results = []
current_query = query
for iteration in r...HyDE (Hypothetical Document Embeddings)
HyDE is a technique where a generated hypothetical document replaces the query for embedding-based retrieval.
Generate Then Retrieve (Standard HyDE)
class HyDE:
"""
Hypothetical Document Embeddings:
1. Generate a hypothetical document that would answer the query
2. Embed the hypothetical document
3. Retrieve chunks similar to the hypothetical document
"""
def __init__(self, llm, embedding_model, vector_store):
self.llm = llm
self.embedding_model = embedding_model
self.vector_store = vector_store
def generate_hypothetical_document(self, query):
"""Generate a hypothetical document that answers the query."""
prompt = f"""
Given the following question, write a paragraph that would serve
as a perfect answer. Include specific details and factual information.
Question: {query}
Hypothetical Answer:
"""
response = self.llm.invoke(prompt)
return response.content
def retrieve(self, query, k=5):
"""Retrieve using HyDE."""
# Step 1: Generate hypothetical document
hypothetical_doc = self.generate_hypothetical_document(query)
# Step 2: Embed the hypothetical document
hyde_embedding = self.embedding_model.embed_query(hypothetical_doc)
# Step 3: Retrieve using hypothetical document embedding
results = self.vector_store.similarity_search_by_vector(
hyde_embedding, k=k
)
return results, hypothetical_doc
Why HyDE Works:
- Query embeddings may not overlap well with document embeddings
- Hypothetical documents are in the same domain as stored documents
- Bridging the "embedding gap" between queries and documents
- Particularly effective for abstract or multi-faceted queries
Retrieve Then Generate (Inverse HyDE)
class InverseHyDE:
"""
Alternative approach: retrieve first, then generate hypothetical
document for re-ranking.
"""
def __init__(self, llm, embedding_model, vector_store):
self.llm = llm
self.embedding_model = embedding_model
self.vector_store = vector_store
def retrieve(self, query, k_initial=20, k_final=5):
"""Retrieve, generate HyDE, re-retrieve."""
# Step 1: Initial retrieval with query
initial_results = self.vector_store.similarity_search(query, k=k_initial)
# Step 2: Generate hypothetical document from initial results
context = "\n\n".join([r.page_content[:500] for r in initial_results[:3]])
hypo_doc = self.llm.invoke(f"""
Question: {query}
Based on these sources, generate a comprehensive answer:
{context}
Answer:
""")
# Step 3: Re-retrieve using hypothetical document
hyde_emb = self.embedding_model.embed_query(hypo_doc.content)
final_results = self.vector_store.similarity_search_by_vector(
hyde_emb, k=k_final
)
return final_results
HyDE Variations
Multi-HyDE: Generate multiple hypothetical documents for diverse perspectives.
class MultiHyDE:
def generate_diverse_hypotheticals(self, query, n=3):
"""Generate multiple hypothetical documents from different angles."""
prompts = [
f"Write a detailed technical answer to: {query}",
f"Write a simple, accessible explanation for: {query}",
f"Write a critical analysis addressing: {query}"
]
hypotheticals = []
for prompt in prompts:
response = self.llm.invoke(prompt)
hypotheticals.append(response.content)
return hypotheticals
def retrieve(self, query, k=5):
"""Retrieve using multiple hypothetical documents."""
hypotheticals = self.generate_diverse_hypotheticals(query)
all_results = []
for hypo in hypotheticals:
embedding = self.embedding_model.embed_query(hypo)
results = self.vector_store.similarity_search_by_vector(embedding, k=k)
all_results.extend(results)
# Deduplicate and rank
return self.deduplicate_and_rank(all_results, k)
HyDE with Self-Critique:
class SelfCritiqueHyDE:
def retrieve(self, query, k=5):
"""HyDE with self-critique of hypothetical document quality."""
# Generate initial hypothetical document
hypo = self.generate_hypothetical_document(query)
# Critique and refine
critique = self.llm.invoke(f"""
Evaluate this hypothetical answer for:
1. Relevance to the question
2. Factual accuracy
3. Completeness
Question: {query}
Answer: {hypo}
Suggested improvements:
""")
# Generate improved hypothetical document
improved_hypo = self.llm.invoke(f"""
Original question: {query}
Previous attempt: {hypo}
Critique: {critique.content}
Write an improved answer:
""")
# Retrieve with improved hypothetical document
embedding = self.embedding_model.embed_query(improved_hypo.content)
return self.vector_store.similarity_search_by_vector(embedding, k=k)
Re-Rankers
Re-rankers improve retrieval quality by scoring initial results with more sophisticated models.
Cross-Encoders
Cross-encoders jointly encode query and document for relevance scoring.
from sentence_transformers import CrossEncoder
class CrossEncoderReranker:
def __init__(self, model_name="cross-encoder/ms-marco-MiniLM-L-6-v2"):
self.model = CrossEncoder(model_name)
def rerank(self, query, documents, k=10):
"""Score and re-rank documents."""
pairs = [[query, doc] for doc in documents]
scores = self.model.predict(pairs)
# Sort by score descending
scored_docs = list(zip(documents, scores))
scored_docs.sort(key=lambda x: x[1], reverse=True)
return [doc for doc, score in scored_docs[:k]], [score for doc, score in scored_docs[:k]]
Popular Cross-Encoder Models:
| Model | Description | Speed |
|-------|-------------|-------|
| cross-encoder/ms-marco-MiniLM-L-2-v2 | Fast, good accuracy | Fastest |
| cross-encoder/ms-marco-MiniLM-L-4-v2 | Balanced | Fast |
| cross-encoder/ms-marco-MiniLM-L-6-v2 | Good accuracy | Moderate |
| cross-encoder/ms-marco-MiniLM-L-12-v2 | Highest accuracy | Slow |
Cohere Rerank
import cohere
class CohereReranker:
def __init__(self, api_key, model="rerank-english-v3.0"):
self.client = cohere.Client(api_key)
self.model = model
def rerank(self, query, documents, k=10):
"""Use Cohere's rerank API."""
response = self.client.rerank(
model=self.model,
query=query,
documents=documents,
top_n=k
)
reranked = []
for result in response.results:
reranked.append({
"document": documents[result.index],
"relevance_score": result.relevance_score
})
return reranked
BGE Reranker
from FlagEmbedding import FlagReranker
class BGEReranker:
def __init__(self, model_name="BAAI/bge-reranker-v2-m3"):
self.model = FlagReranker(model_name, use_fp16=True)
def rerank(self, query, documents, k=10):
"""BGE re-ranker from BAAI."""
pairs = [[query, doc] for doc in documents]
scores = self.model.compute_score(pairs)
scored = list(zip(documents, scores))
scored.sort(key=lambda x: x[1], reverse=True)
return scored[:k]
BAAI bge-reranker-v2 Series:
| Model | Languages | Description |
|-------|-----------|-------------|
| bge-reranker-v2-m3 | Multilingual | Best overall, 100+ languages |
| bge-reranker-v2-gemma | English | Based on Gemma, high accuracy |
| bge-reranker-v2-minicpm | English | Efficient, good performance |
monoBERT
monoBERT is a cross-encoder that processes query-document pairs through BERT:
class MonoBERTReranker:
def __init__(self, model_name="monobert-base-msmarco"):
self.model = AutoModelForSequenceClassification.from_pretrained(model_name)
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
def rerank(self, query, documents, k=10):
"""monoBERT re-ranker."""
features = self.tokenizer(
[query] * len(documents),
documents,
padding=True,
truncation=True,
return_tensors="pt",
max_length=512
)
with torch.no_grad():
scores = self.model(**features).logits.squeeze(-1).numpy()
scored = list(zip(documents, scores))
scored.sort(key=lambda x: x[1], reverse=True)
return scored[:k]
ColBERTv2 Late Interaction
ColBERTv2 uses a late interaction architecture that balances efficiency and effectiveness.
class ColBERTv2Reranker:
"""
ColBERTv2 late interaction: token-level matching instead of single score.
"""
def __init__(self, checkpoint="colbert-ir/colbertv2.0"):
from colbert import Searcher
self.searcher = Searcher(index="index_path", checkpoint=checkpoint)
def search(self, query, k=10):
"""ColBERTv2 search with late interaction scoring."""
results = self.searcher.search(query, k=k)
# results: (rank, passage_id, score) tuples
return results
def rerank(self, query, documents, k=10):
"""Late interaction re-ranking."""
# Encode query and documents
query_emb = self.encode_query(query)
doc_embs = [self.encode_doc(doc) for doc in documents]
# Late interaction: max similarity over all token pairs
scores = []
for doc_emb in doc_embs:
# (num_query_tokens, num_doc_tokens)
sim_matrix = torch.matmul(query_emb, doc_emb.T)
max_sim = sim_matrix.max(dim=-1).values.sum()
scores.append(max_sim.item())
scored = l...Hybrid Search
Hybrid search combines multiple search methods (dense, sparse, keyword) for robust retrieval.
Dense Search
Uses embedding models for semantic similarity search.
class DenseRetriever:
def __init__(self, embedding_model, vector_store):
self.embedding_model = embedding_model
self.vector_store = vector_store
def search(self, query, k=10):
query_emb = self.embedding_model.embed_query(query)
results = self.vector_store.similarity_search_by_vector(query_emb, k=k)
return results
Embedding Models:
- OpenAI: text-embedding-3-small, text-embedding-3-large
- Cohere: embed-english-v3.0, embed-multilingual-v3.0
- BAAI: bge-large-en-v1.5, bge-m3
- intfloat: e5-mistral-7b-instruct
- Sentence Transformers: all-MiniLM-L6-v2
Sparse Search (SPLADE / BM25 / uniCOIL)
Sparse retrieval using learned sparse representations or term-based matching.
SPLADE
from transformers import AutoModelForMaskedLM, AutoTokenizer
class SPLADERetriever:
"""
SPLADE: Sparse Lexical and Expansion Model.
Learns to expand queries with relevant terms and produce sparse vectors.
"""
def __init__(self, model_name="naver/splade-cocondenser-selfdistil"):
self.model = AutoModelForMaskedLM.from_pretrained(model_name)
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
def encode(self, text):
"""Generate sparse vector representation."""
tokens = self.tokenizer(text, return_tensors="pt")
with torch.no_grad():
output = self.model(**tokens)
# SPLADE uses ReLU + log1p activation
sparse_emb = torch.log1p(torch.relu(output.logits))
sparse_emb = sparse_emb.max(dim=1).values
return sparse_emb.squeeze()
def search(self, query, index, k=10):
"""SPLADE search over pre-computed sparse index."""
query_vec = self.encode(query)
scores = {}
for doc_id, doc_vec in index.items():
score = (query_vec * doc_vec).sum().item()
scores[doc_id] = score
sorted_docs = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return sorted_docs[:k]
BM25 (Okapi BM25)
from rank_bm25 import BM25Okapi
class BM25Retriever:
"""
BM25: Probabilistic retrieval model based on term frequency and document length.
"""
def __init__(self):
self.bm25 = None
self.documents = []
def index(self, documents):
"""Index documents with BM25."""
tokenized_corpus = [self._tokenize(doc) for doc in documents]
self.bm25 = BM25Okapi(tokenized_corpus)
self.documents = documents
def search(self, query, k=10):
"""Search using BM25."""
tokenized_query = self._tokenize(query)
scores = self.bm25.get_scores(tokenized_query)
# Get top-k indices
top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:k]
return [(self.documents[i], scores[i]) for i in top_indices]
def _tokenize(self, text):
"""Simple tokenization."""
return text.lower().split()
BM25 Parameters:
- k1: Term saturation parameter (default: 1.2-1.6)
- b: Length normalization (default: 0.75)
- epsilon: Smoothing for small scores
uniCOIL
class UniCOILRetriever:
"""
uniCOIL: Expanded term weighting with COIL architecture.
"""
def __init__(self, model_name="castorini/unicoil-d2v-msmarco-passage"):
self.model = T5EncoderModel.from_pretrained(model_name)
self.tokenizer = T5Tokenizer.from_pretrained(model_name)
def encode(self, text):
"""Generate term-weighted sparse representation."""
inputs = self.tokenizer(text, return_tensors="pt", truncation=True, max_length=512)
with torch.no_grad():
outputs = self.model(**inputs)
# uniCOIL generates term importance weights
weights = outputs.last_hidden_state.mean(dim=1).squeeze()
return weights
Keyword Search (BM25 / Elasticsearch)
from elasticsearch import Elasticsearch
class ElasticsearchKeywordRetriever:
def __init__(self, host="localhost", port=9200, index="documents"):
self.es = Elasticsearch([f"http://{host}:{port}"])
self.index = index
def create_index(self):
"""Create index with keyword search configuration."""
mapping = {
"mappings": {
"properties": {
"text": {
"type": "text",
"analyzer": "standard",
"similarity": "BM25"
}
}
}
}
self.es.indices.create(index=selfFusion Methods
Fusion methods combine results from multiple retrieval strategies.
RRF (Reciprocal Rank Fusion)
class RRF:
"""
Reciprocal Rank Fusion: Combines multiple ranked lists.
RRF score = sum(1 / (k + rank(doc, list)))
"""
def __init__(self, k=60):
self.k = k
def fuse(self, ranked_lists):
"""
ranked_lists: list of lists, each is [(doc_id, score), ...]
Returns: fused ranking [(doc_id, rrf_score), ...]
"""
rrf_scores = {}
for ranked_list in ranked_lists:
for rank, (doc_id, _) in enumerate(ranked_list):
if doc_id not in rrf_scores:
rrf_scores[doc_id] = 0
rrf_scores[doc_id] += 1.0 / (self.k + rank + 1)
# Sort by RRF score descending
fused = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
return fused
Weighted Fusion
class WeightedFusion:
"""
Weighted combination of scores from different retrieval methods.
"""
def __init__(self, weights=None):
self.weights = weights or {"dense": 0.5, "sparse": 0.3, "keyword": 0.2}
def fuse(self, results_dict):
"""
results_dict: {"method_name": {doc_id: score, ...}, ...}
"""
fused_scores = {}
for method, docs in results_dict.items():
weight = self.weights.get(method, 1.0)
# Normalize scores within each method
max_score = max(docs.values()) if docs else 1.0
for doc_id, score in docs.items():
normalized = score / max_score if max_score > 0 else 0
if doc_id not in fused_scores:
fused_scores[doc_id] = 0
fused_scores[doc_id] += weight * normalized
return sorted(fused_scores.items(), key=lambda x: x[1], reverse=True)
Linear Combination
class LinearCombinationFusion:
"""
Simple linear combination of normalized scores.
"""
def combine(self, scores_list, weights=None):
"""
scores_list: list of dicts {doc_id: normalized_score}
weights: list of weights for each score dict
"""
if weights is None:
weights = [1.0/len(scores_list)] * len(scores_list)
combined = {}
for scores, weight in zip(scores_list, weights):
if scores:
max_score = max(scores.values())
for doc_id, score in scores.items():
normalized = score / max_score
combined[doc_id] = combined.get(doc_id, 0) + weight * normalized
return sorted(combined.items(), key=lambda x: x[1], reverse=True)
Learning to Rank (LTR)
from sklearn.ensemble import GradientBoostingRegressor
class LearningToRank:
"""
Learned fusion using gradient boosted trees.
"""
def __init__(self):
self.model = GradientBoostingRegressor(
n_estimators=100,
max_depth=3,
learning_rate=0.1
)
def extract_features(self, doc, query):
"""Extract ranking features."""
features = {
"bm25_score": compute_bm25(doc, query),
"dense_similarity": cosine_similarity(doc_emb, query_emb),
"cross_encoder_score": cross_encoder_score(query, doc),
"doc_length": len(doc),
"query_term_overlap": term_overlap(query, doc),
"title_match": title_match(query, doc),
}
return list(features.values())
def train(self, training_pairs):
"""
training_pairs: [(query, doc, relevance_label), ...]
"""
X, y = [], []
for query, doc, label in training_pairs:
features = self.extract_features(doc, query)
X.append(features)
y.append(label)
self.model.fit(X, y)
def predict(self, query, documents):
"""Score documents using learned model."""
scores = []
for doc in documents:
features = self.extract_features(doc, query)
score = self.model.predict([features])[0]
scores.append(score)
return scores
Query Transformation
Query transformation adapts or rewrites queries to improve retrieval effectiveness.
Query Rewriting
Rewrites the query based on conversation history or query analysis.
class QueryRewriter:
def __init__(self, llm):
self.llm = llm
def rewrite_with_history(self, current_query, history):
"""Rewrite query incorporating conversation history."""
prompt = f"""
Conversation history:
{history}
Current query: {current_query}
Rewrite the current query to include necessary context from the
conversation history. Output only the rewritten query.
"""
response = self.llm.invoke(prompt)
return response.content.strip()
def rewrite_for_retrieval(self, query):
"""Rewrite query to be more effective for retrieval."""
prompt = f"""
Original query: {query}
Rewrite this query to improve search retrieval effectiveness:
- Use keywords that would appear in relevant documents
- Remove conversational filler
- Make it specific and searchable
Rewritten query:
"""
response = self.llm.invoke(prompt)
return response.content.strip()
Query Expansion
Expands the query with related terms or generated relevant documents.
class QueryExpander:
def __init__(self, llm):
self.llm = llm
def expand_with_terms(self, query, num_expansions=5):
"""Expand query with related terms."""
prompt = f"""
Query: {query}
Generate {num_expansions} alternative phrasings or related terms
for this query that would help find relevant documents.
Output one per line.
"""
response = self.llm.invoke(prompt)
expansions = [q.strip() for q in response.content.strip().split('\n') if q.strip()]
return [query] + expansions[:num_expansions]
def expand_with_generated_docs(self, query, num_docs=3):
"""Generate hypothetical relevant documents and extract terms."""
prompt = f"""
For the query: "{query}"
Generate {num_docs} short relevant document snippets that would
ideally answer this query.
"""
response = self.llm.invoke(prompt)
generated_docs = response.content.strip()
# Extract key terms from generated docs
extraction_prompt = f"""
From these documents relevant to "{query}":
{generated_docs}
Extract 5 key search terms or phrases that would be most useful for retrieval.
"""
terms_response = self.llm.invoke(extraction_prompt)
terms = [t.strip() for t in terms_response.content.strip().split('\n') if t.strip()]
return [query] + terms
Step-Back Prompting
Generates a more abstract or general query to retrieve background knowledge.
class StepBackPrompting:
"""
Step-back prompting: generate a more abstract query to retrieve
high-level context, then answer the specific query.
"""
def __init__(self, llm, retriever):
self.llm = llm
self.retriever = retriever
def step_back(self, query):
"""Generate a step-back (more abstract) question."""
prompt = f"""
Original question: {query}
What broader topic or concept does this question relate to?
Generate a more general question that would help answer the original.
Step-back question:
"""
response = self.llm.invoke(prompt)
return response.content.strip()
def retrieve_with_step_back(self, query, k=5):
"""Retrieve using both specific and step-back queries."""
# Get step-back query
stepback_query = self.step_back(query)
# Retrieve with both queries
specific_results = self.retriever.retrieve(query, k=k)
general_results = self.retriever.retrieve(stepback_query, k=k)
# Combine results
combined = self._combine_results(specific_results, general_results)
return combined
Decomposed Queries
Breaks complex queries into simpler sub-queries for multi-hop retrieval.
class QueryDecomposer:
def __init__(self, llm):
self.llm = llm
def decompose(self, complex_query):
"""Decompose multi-hop query into sub-queries."""
prompt = f"""
Complex query: {complex_query}
Break this down into simpler sub-queries that can be answered
independently and then combined.
Output one sub-query per line.
"""
response = self.llm.invoke(prompt)
sub_queries = [q.strip() for q in response.content.strip().split('\n') if q.strip()]
return sub_queries
def parallel_retrieve(self, complex_query, retriever):
"""Retrieve for all sub-queries in parallel."""
sub_queries = self.decompose(complex_query)
all_results = []
for sub_query in sub_queries:
results = retriever.retrieve(sub_query)
all_results.extend(results)
return all_results
Query Routing
Query routing directs queries to the most appropriate retrieval strategy or knowledge source.
Fixed Routing
class FixedRouter:
"""Route based on fixed rules."""
def route(self, query):
"""Simple rule-based routing."""
if "news" in query.lower() or "latest" in query.lower():
return "web_search"
elif "code" in query.lower() or "function" in query.lower():
return "code_index"
elif "who" in query.lower() or "what" in query.lower():
return "knowledge_base"
else:
return "general_retriever"
Content-Based Routing
class ContentBasedRouter:
"""Route based on query content analysis."""
def __init__(self):
self.classifier = load_classifier() # e.g., zero-shot classifier
def route(self, query):
"""Classify query domain and route accordingly."""
domains = ["technical", "medical", "legal", "general", "creative"]
classification = self.classifier(query, domains)
domain = classification[0]["label"]
return self._domain_to_index(domain)
def _domain_to_index(self, domain):
router = {
"technical": "tech_docs_index",
"medical": "medical_knowledge_base",
"legal": "legal_corpus",
"creative": "general_index",
"general": "general_index"
}
return router.get(domain, "general_index")
LLM-Based Routing
class LLMBasedRouter:
"""Use LLM to determine best routing strategy."""
def __init__(self, llm):
self.llm = llm
def route(self, query, available_strategies):
"""LLM decides which retrieval strategy to use."""
prompt = f"""
Query: {query}
Available strategies:
{available_strategies}
Which strategy is best suited for this query?
Consider: query complexity, required knowledge domain, specificity.
Strategy:
Reason:
"""
response = self.llm.invoke(prompt)
# Parse strategy from response
return self._parse_strategy(response.content)
def route_with_parameters(self, query):
"""Get routing decision with retrieval parameters."""
prompt = f"""
Query: {query}
Decide:
1. Retrieval method (dense, sparse, hybrid)
2. Number of documents to retrieve
3. Re-ranker needed (yes/no)
4. Query expansion needed (yes/no)
Output JSON:
{{"method": "...", "k": 5, "rerank": true, "expand": false}}
"""
response = self.llm.invoke(prompt)
return json.loads(response.content)
Metadata-Based Routing
class MetadataRouter:
"""Route based on query metadata or context."""
def route(self, query, user_context=None):
"""Route based on user and query metadata."""
route_decision = {
"index": "general",
"filters": {}
}
if user_context:
# User-specific routing
if user_context.get("department") == "engineering":
route_decision["index"] = "technical_docs"
elif user_context.get("department") == "legal":
route_decision["index"] = "legal_docs"
# Date filter
if user_context.get("timeframe"):
route_decision["filters"]["date"] = {
"$gte": user_context["timeframe"]["start"],
"$lte": user_context["timeframe"]["end"]
}
# Content-based metadata routing
if "product" in query.lower():
route_decision["filters"]["category"] = "product"
return route_decision
Multi-Hop RAG
Multi-hop RAG performs iterative retrieval where each step informs the next.
Iterative Retrieval
class IterativeMultiHopRAG:
"""
Iterative retrieval: retrieve → generate → extract queries → retrieve again
"""
def __init__(self, llm, retriever, max_hops=3):
self.llm = llm
self.retriever = retriever
self.max_hops = max_hops
def answer(self, question):
"""Multi-hop RAG with iterative retrieval."""
context = []
for hop in range(self.max_hops):
# Retrieve
results = self.retriever.retrieve(question, k=5)
new_context = [r.page_content for r in results]
context.extend(new_context)
if hop < self.max_hops - 1:
# Determine if more information is needed
analysis = self.llm.invoke(f"""
Question: {question}
Known information:
{chr(10).join(context)}
Can this question be answered with current information?
If no, what additional information is needed?
Generate a follow-up search query.
Answerable (yes/no):
Follow-up query (if not answerable):
""")
if "yes" in analysis.content.lower().split('\n')[0]:
break
# Extract follow-up query
lines = analysis.content.strip().split('\n')
for line in lines:
if "follow-up" in line.lower() or "query" in line.lower():
question = line.split(":")[-1].strip()
break
# Generate final answer
final_prompt = f"""
Question: {question}
Retrieved information:
{chr(10).join(context)}
Provide a comprehensive answer based on the retrieved information:
"""
return self.llm.invoke(final_prompt).content
Multi-Hop Query Decomposition
class DecompositionMultiHop:
"""
Decompose multi-hop question into sub-questions and answer sequentially.
"""
def __init__(self, llm, retriever):
self.llm = llm
self.retriever = retriever
def decompose_and_answer(self, question):
"""Decompose into sub-questions and answer sequentially."""
# Step 1: Decompose
sub_questions = self._decompose(question)
# Step 2: Answer sub-questions sequentially
intermediate_answers = {}
for i, sub_q in enumerate(sub_questions):
# Retrieve with context from previous answers
context = list(intermediate_answers.values())
results = self.retriever.retrieve(
sub_q + " " + " ".join(context), k=5
)
# Answer sub-question
answer = self.llm.invoke(f"""
Sub-question: {sub_q}
Retrieved:
{chr(10).join([r.page_content for r in results])}
Previous context: {chr(10).join(context)}
Answer:
""")
intermediate_answers[f"step_{i}"] = answer.content
# Step 3: Synthesize final answer
final = self.llm.invoke(f"""
Original question: {question}
Intermediate answers:
{chr(10).join([f"{k}: {v}" for k, v in intermediate_answers.items()])}
Final answer:
""")
return final.content
def _decompose(self, question):
"""Use LLM to break down multi-hop question."""
prompt = f"""
Break this multi-hop question into sequential sub-questions.
Each sub-question should be answerable from retrieved documents.
Question: {question}
Sub-questions (one per line):
"""
response = self.llm.invoke(prompt)
return [q.strip() for q in response.content.strip().split('\n') if q.strip()]
GraphRAG
GraphRAG combines knowledge graphs with RAG for structured knowledge retrieval.
Microsoft GraphRAG
Microsoft's GraphRAG builds a knowledge graph from documents, enabling global and local search.
Architecture:
Documents
↓
Entity Extraction (LLM-based)
↓
Entity Resolution & Deduplication
↓
Community Detection (Leiden algorithm)
↓
Community Summarization
↓
┌──────────────────────┐
│ Graph Index │
│ ├── Entities │
│ ├── Relationships │
│ ├── Communities │
│ └── Summaries │
└──────────────────────┘
↕ ↕
Local Search Global Search
Implementation:
class MicrosoftGraphRAG:
"""
GraphRAG: Global and Local Search over knowledge graphs.
"""
def __init__(self, llm):
self.llm = llm
self.graph = nx.Graph()
self.communities = []
self.summaries = {}
def build_from_documents(self, documents):
"""Build knowledge graph from documents."""
for doc in documents:
# Extract entities and relationships
entities, relationships = self._extract_graph(doc)
# Add to graph
for entity in entities:
self.graph.add_node(entity["name"], **entity)
for rel in relationships:
self.graph.add_edge(rel["source"], rel["target"], **rel)
# Detect communities
self._detect_communities()
# Generate summaries
self._summarize_communities()
def _extract_graph(self, document):
"""Extract entities and relationships using LLM."""
prompt = f"""
Extract entities and relationships from this text.
Text: {document}
Output JSON format:
{{
"entities": [{{"name": "...", "type": "...", "description": "..."}}],
"relationships": [{{"source": "...", "target": "...", "relationship": "..."}}]
}}
"""
response = self.llm.invoke(prompt)
return self._parse_extraction(response.content)
def _detect_communities(self):
"""Use Leiden algorithm for community detection."""
import leidenalg as la
partition = la.find_partition(
self.graph,
la.ModularityVertexPartition
)
self.communities = partition
def _summarize_communities(self):
"""Generate summaries for each community."""
for i, community in enumerate(self.communities):
members = [self.graph.nodes[n] for n in community]
prompt = f"""
Summarize this group of related entities:
{members}
What is this community about? What are the key themes?
"""
response = self.llm.invoke(prompt)
self.summaries[i] = response.content
def local_search(self, query, k=10):
"""Search within relevant communities."""
# Find relevant entities via embedding similarity
entity_texts = [f"{n['name']}: {n.get('description', '')}"
for n in self.graph.nodes.values()]
relevant_entities = self._semantic_search(query, entity_texts, k=k)
# Expand to connected entities
expanded = set(relevant_entities)
for entity in relevant_entities:
neighbors = list(self.graph.neighbors(entity))
expanded.update(neighbors[:5])
# Generate response from subgraph
subgraph_text = self._subgraph_to_text(expanded)
return self.llm.invoke(f"""
Query: {query}
Relevant information from knowledge graph:
{subgraph_text}
Answer:
""")
def global_search(self, query):
"""Search across all community summaries."""
summaries_text = "\n\n".join([
f"Community {i}: {s}"
for i, s in self.summaries.items()
])
return self.llm.invoke(f"""
Query: {query}
All community summaries:
{summaries_text}
Synthesize information across communities to answer:
""")
LightRAG
LightRAG combines graph structure with edge weights for efficient retrieval.
class LightRAG:
"""
LightRAG: Lightweight Graph RAG with edge weights.
"""
def __init__(self, embedding_model):
self.embedding_model = embedding_model
self.entities = {} # name -> {embedding, description}
self.relations = [] # (source, target, weight, description)
def add_text(self, text):
"""Extract and add entities/relations from text."""
# LLM-based extraction
entities, relations = self._extract(text)
for entity in entities:
if entity["name"] not in self.entities:
emb = self.embedding_model.embed_query(entity["description"])
self.entities[entity["name"]] = {
"embedding": emb,
"description": entity["description"],
"type": entity["type"]
}
for rel in relations:
weight = self._compute_relevance(rel)
self.relations.append({
"source": rel["source"],
"target": 📄 Vector Databases — 3725 lines
A comprehensive, deeply technical guide to vector databases for production AI systems. Covers search algorithms, indexing strategies, major database platforms, and deployment patterns.
1. Vector Search Fundamentals
1.1 ANN vs kNN
k-Nearest Neighbors (kNN) performs an exact search: given a query vector, it computes the distance to every vector in the database and returns the k closest. This guarantees perfect recall but is O(N × D) per query — linear in the number of vectors and their dimensionality. At 1M vectors of 768 dimensions, a single exact search takes hundreds of milliseconds on CPU and tens of milliseconds on GPU — unacceptable for latency-sensitive applications.
Approximate Nearest Neighbor (ANN) sacrifices a small amount of recall for dramatic speed improvements. ANN algorithms preprocess the vector space into an index structure that allows sub-linear search time, typically O(log N) or O(√N). The trade-off is configurable: at 99% recall, ANN can be 10–100× faster than exact search.
When to Use Each:
| Criteria | kNN (Exact) | ANN (Approximate) |
|---|---|---|
| Dataset size | <10K vectors | >10K vectors |
| Latency requirement | Not critical | <100ms p99 |
| Recall requirement | 100% required | 95–99% acceptable |
| Query frequency | Low | High |
| Use case | Debugging, validation, small datasets | Production RAG, semantic search, recommendations |
1.2 Indexing Types
IVF (Inverted File Index)
IVF partitions the vector space into Voronoi cells via k-means clustering. At search time, only vectors in the nprobe closest cells to the query are examined.
Structure:
Cluster centroids (nlist centroids learned via k-means)
→ Each vector assigned to nearest centroid
→ Inverted lists: for each centroid, list of vectors + their IDs
Parameters:
| Parameter | Description | Typical Range | Effect |
|---|---|---|---|
nlist |
Number of Voronoi cells (clusters) | 100–32768 | Higher = finer granularity, more accurate, slower indexing |
nprobe |
Number of cells probed during search | 1–256 | Higher = more accurate, slower search |
Performance Characteristics:
- Search complexity: O(nlist × D + (N/nlist) × nprobe × D)
- Memory: O(N × D) for vectors + O(nlist × D) for centroids
- Build time: O(N × nlist × D × niter) for k-means
Choosing nlist and nprobe:
Rule of thumb: nlist = 4 × sqrt(N) for balanced performance
Example: N = 1M → nlist = 4 × 1000 = 4000
nprobe tuning: start at nprobe = sqrt(nlist) and double until recall saturates
Example: nlist = 4096 → start nprobe = 64
HNSW (Hierarchical Navigable Small World)
HNSW builds a multi-layer graph where the top layer has few nodes (long-range connections) and lower layers have increasingly many nodes (short-range connections). Search traverses from top to bottom, narrowing the search radius at each layer.
Structure:
Layer 0: All N nodes (bottom layer - base)
Layer 1: ~N / M nodes (sparser)
Layer 2: ~N / M² nodes
...
Layer L: ~1 node (top layer - entry point)
Parameters:
| Parameter | Description | Typical Range | Effect |
|---|---|---|---|
M |
Maximum number of connections per node per layer | 8–64 | Higher = more accurate, more memory, slower build |
Mmax |
Maximum connections for Layer 0 (often 2× M) | 16–128 | Higher = better recall for dense areas |
efConstruction |
Dynamic candidate list size during build | 100–500 | Higher = better index quality, slower build |
efSearch |
Dynamic candidate list size during search | 50–500 | Higher = better recall, slower search |
Performance Characteristics:
- Search complexity: O(log N × M × efSearch)
- Memory: ~(M × 2 + Mmax) × N × (12–16 bytes per link) + vector storage
- Build time: O(N × (log N + efConstruction × M))
Memory Calculation (HNSW):
For N=1M, M=32, Mmax=64:
Links: 1M × (32 × 2 + 64) × 12 bytes ≈ 1.5 GB (link metadata)
Vectors: 1M × 768 × 4 bytes (float32) = 3.1 GB
Total: ~4.6 GB
Choosing HNSW Parameters:
M = 16: Good balance for most use cases (16 connections/node)
M = 32: Higher recall, ~2× memory
M = 64: Maximum recall, ~4× memory
efConstruction = 200: Good default
efConstruction = 400: Better for high-recall requirements
efSearch = search_radius * M (approximately)
efSearch = 100 → 99% recall (typical)
efSearch = 200 → 99.5% recall
DiskANN (SSD-Optimized Vamana Graph)
DiskANN is designed for billion-scale datasets that don't fit in RAM. It uses a Vamana graph (a variant of HNSW with optimized degree constraints) stored primarily on SSD with only the graph structure cached in memory.
Vamana Graph Properties:
- Directed graph with out-degree bound R and in-degree bound R
- Greedy search with a priority queue
- Robustness parameter α controls exploration vs exploitation during build
Parameters:
| Parameter | Description | Typical Range |
|---|---|---|
R |
Degree bound for each node | 32–128 |
L |
Build complexity (candidate list size) | 100–500 |
α |
Robustness parameter (≥1) | 1.0–2.0 |
Architecture:
SSD:
├── Compressed Vectors (PQ-encoded, 8 bytes per dimension group)
└── Full Precision Vectors (for re-ranking)
RAM:
├── Graph Structure (R × N × 4 bytes for node IDs + metadata)
└── PQ Codebooks (negligible)
Search Flow:
1. Navigate Vamana graph (graph in RAM, PQ vectors in SSD cache)
2. Retrieve top-k candidates
3. Re-rank with full-precision vectors (optional, SSD fetch)
Performance:
- 1B vectors: ~8 TB SSD, ~16 GB RAM (for graph), <50ms search
- R=64, α=1.2 typically gives 95%+ recall at 1M QPS on SSD
PQ (Product Quantization)
Product Quantization compresses vectors by splitting them into sub-vectors and quantizing each sub-vector separately using a learned codebook.
Process:
Original vector: [v₁, v₂, ..., v_D] (D dimensions)
Split into M sub-vectors:
[v₁...v_{D/M}], [v_{D/M+1}...v_{2D/M}], ..., [v_{D-D/M}...v_D]
For each sub-space, learn k centroids (codebook) via k-means:
Sub-space 1: codebook {c₁¹, c₁², ..., c₁ᵏ}
Sub-space 2: codebook {c₂¹, c₂², ..., c₂ᵏ}
...
Sub-space M: codebook {c_M¹, c_M², ..., c_Mᵏ}
Encode each sub-vector as index of nearest centroid:
Original vector → [id₁, id₂, ..., id_M] where id_j ∈ [0, k-1]
Compression ratio:
Original: D × 4 bytes (float32)
Encoded: M × log₂(k) bits
Parameters:
| Parameter | Description | Typical Range | Effect |
|---|---|---|---|
M |
Number of sub-vectors / codebooks | 4–128 | Higher = more accurate, less compression |
k |
Codebook size per sub-space | 256 (8 bit) | Higher = more accurate, more memory |
codebook_size |
Number of centroids | 256–65536 | 256 is standard (8-bit index) |
Compression Examples:
D=768, M=96, k=256 (8-bit):
Original: 768 × 4 = 3072 bytes
PQ: 96 × 1 = 96 bytes
Compression ratio: 32×
Quality: ~90% recall at 32× compression (SDC)
D=768, M=32, k=256:
Compression: 768 → 32 bytes (24×)
Quality: ~95% recall
D=768, M=8, k=256:
Compression: 768 → 8 bytes (96×)
Quality: ~70% recall
Search with PQ (ADC — Asymmetric Distance Computation):
For query q, compute distance to each PQ-encoded vector x:
d(q, x)² ≈ Σ d(q_j - c_j[id_j])² for j = 1...M
Where:
- q_j is the j-th sub-vector of query
- c_j[id_j] is the centroid for the j-th subspace for x
- Pre-compute distance from
- Anomaly detection (outliers have large L2 distance from centroids)
Comparison Table
Metric
Range
Sensitive to Magnitude
Proper Metric
Normalized Form
Cosine
[0, 1]
No
No (pseudo-metric)
L2-normalize → dot product
Dot Product
(-∞, ∞)
Yes
No
N/A
Euclidean (L2)
[0, ∞)
Yes
Yes
L2-normalize → √(2 - 2×cosine)
L2 Squared
[0, ∞)
Yes
No
Same ordering as L2
Hamming
[0, D]
N/A
Yes
N/A
Jaccard
[0, 1]
N/A
Yes
N/A
2.2 Normalization Strategies in Production
Pre-normalization (Recommended):
def normalize_embeddings(embeddings):
"""L2 normalize a batch of embeddings."""
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
return embeddings / np.clip(norms, 1e-10, None)
# During ingestion
vectors = normalize_embeddings(vectors)
# Query also normalized
query = normalize_embeddings(query[np.newaxis, :])[0]
Storage Considerations:
- Normalized float32 vectors: same disk space, but dot product can replace cosine
- Normalized + SQ8: 4× compression with minimal recall loss
- Normalized + Binary: 32× compression, ~90% recall
2.3 Metric Selection by Database
Database
Supported Metrics
Faiss
METRIC_INNER_PRODUCT, METRIC_L2, METRIC_L1, METRIC_Linf, etc.
Chroma
cosine (default), l2, ip
Pinecone
cosine (default), dotproduct, euclidean
Qdrant
Cosine, Dot, L2, Manhattan
Weaviate
cosine (default), dot, l2-squared, hamming, manhattan
Milvus
L2, IP, COSINE, HAMMING, JACCARD
LanceDB
L2, Cosine, Dot
Elasticsearch
cosine, dot_product, l1, l2, hamming
3. Faiss
Faiss (Facebook AI Similarity Search) is the most widely used vector search library — the de facto benchmark and foundation for many production systems. It is a library, not a database, but its index types form the backbone of many vector database internals.
3.1 IndexFactory String
Faiss uses a compact string format to compose indices:
IndexFactory: "IVF4096,PQ32"
↓ ↓
Inverted File Product Quantization
with 4096 with 32 sub-vectors
centroids
Component Prefixes:
| Prefix | Meaning | Example |
|---|---|---|
Flat |
No compression, exact storage | IndexFlatIP |
IVF |
Inverted File index | IVF4096 |
HNSW |
Hierarchical Navigable Small World | HNSW32 |
PQ |
Product Quantization | PQ16 |
SQ |
Scalar Quantization | SQ8 |
PCA |
PCA dimension reduction | PCA64 |
OPQ |
Optimized Product Quantization | OPQ16 |
LSH |
Locality Sensitive Hashing | LSH |
IDMap |
Add IDs to index (for removal) | IDMap,IVF4096,PQ32 |
Refine |
Re-rank with full precision | IVF4096,PQ32,Refine(Flat) |
RFlat |
Re-rank with exact distances | Same as Refine |
Common Factory Strings:
# Exact search
"Flat" # Brute force, L2
"FlatIP" # Brute force, inner product
"FlatIP,IDMap" # With ID support
# IVF variants
"IVF100,Flat" # IVF, no compression
"IVF4096,Flat" # IVF, 4096 centroids
"IVF4096,Flat,IDMap" # IVF with IDs
# IVF + PQ (memory efficient)
"IVF4096,PQ16" # IVF + 16-byte PQ codes
"IVF4096,PQ32" # IVF + 32-byte PQ codes
"IVF16384,PQ64" # More centroids, larger codes
# IVF + SQ (balanced)
"IVF4096,SQ8" # IVF + scalar quantization
"IVF16384,SQ8" # More centroids
# HNSW
"HNSW32,Flat" # HNSW with exact vectors
"HNSW32,PQ16" # HNSW with PQ compression
# Preprocessing + index
"PCA64,IVF4096,PQ16" # PCA to 64D, then IVF+P
"OPQ16_256,IVF4096,PQ16" # OPQ rotation, then IVF+PQ
3.2 Index Types in Detail
IndexFlat (Brute Force, Exact)
import faiss
d = 768 # dimension
index = faiss.IndexFlatL2(d)
# or
index = faiss.IndexFlatIP(d)
# Add vectors
index.add(vectors) # vectors: (N, d) float32 array
# Search
D, I = index.search(query, k=10) # D: distances, I: indices
Characteristics:
- O(N × D) per query
- No indexing structure — just raw storage
- Memory: N × D × 4 bytes
- Best for: Small datasets (<100K), validation, exact search
IndexIVFFlat (Inverted File, No Compression)
nlist = 100
quantizer = faiss.IndexFlatL2(d)
index = faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_L2)
# Train (required for IVF)
index.train(vectors)
index.add(vectors)
# Set search parameter
index.nprobe = 10 # Number of probe cells
# Search
D, I = index.search(query, k=10)
Characteristics:
- Faster than Flat at 100K+ vectors
- Exact vectors stored in each inverted list
- Memory: N × D × 4 bytes + nlist × D × 4 (centroids)
IndexIVFPQ (Inverted File + Product Quantization)
nlist = 4096
m = 32 # number of PQ sub-vectors
nbits = 8 # bits per sub-vector
quantizer = faiss.IndexFlatL2(d)
index = faiss.IndexIVFPQ(quantizer, d, nlist, m, nbits)
# Train
index.train(vectors)
index.add(vectors)
# Search
index.nprobe = 10
D, I = index.search(query, k=10)
Characteristics:
- Most popular index for large-scale search
- Memory: N × m × nbits/8 bytes + centroids + PQ codebooks
- 1M vectors × 768 dims: ~100 MB for PQ32 vs ~3 GB for Flat
IndexIVFScalarQuantizer
index = faiss.IndexIVFScalarQuantizer(
quantizer, d, nlist, faiss.ScalarQuantizer.QT_8bit
)
index.train(vectors)
index.add(vectors)
index.nprobe = 10
Characteristics:
- 4× memory reduction vs Flat
- Better quality than PQ at same bit rate for some distributions
- Supports QT_4bit, QT_6bit, QT_8bit
IndexHNSWFlat
M = 32
index = faiss.IndexHNSWFlat(d, M)
index.hnsw.efConstruction = 200
index.add(vectors)
# Search
index.hnsw.efSearch = 64
D, I = index.search(query, k=10)
Characteristics:
- Very fast search at high recall
- Memory: N × D × 4 + link metadata
- Better than IVF for small-to-medium datasets (<10M)
IndexHNSWPQ
M = 32
m = 32
index = faiss.IndexHNSWPQ(d, m, M)
index.hnsw.efConstruction = 200
index.train(vectors)
index.add(vectors)
index.hnsw.efSearch = 64
Characteristics:
- HNSW graph with PQ-compressed vectors
- Balanced speed, memory, and accuracy
IndexBinaryIVF
index = faiss.IndexBinaryIVF(quantizer, d // 8, nlist)
index.train(binary_vectors)
index.add(binary_vectors)
index.nprobe = 10
# Search (returns Hamming distances)
D, I = index.search(query_binary, k=10)
Characteristics:
- For binary vectors only
- Uses Hamming distance
- Extremely fast (XOR + popcount)
3.3 GPU Support
Faiss GPU accelerates search via:
import faiss
# CPU index
cpu_index = faiss.IndexFlatL2(d)
# Move to single GPU
gpu_index = faiss.index_cpu_to_gpu(gpu_resources, 0, cpu_index)
# Multi-GPU
gpu_indices = faiss.index_cpu_to_all_gpus(cpu_index)
# Direct GPU index construction
config = faiss.GpuIndexFlatConfig()
config.useFloat16 = True # Use half-precision storage
config.usePrecomputed = False # Precompute distances (faster but more memory)
config.device = 0
gpu_index_flat = faiss.GpuIndexFlatL2(d, config)
GpuIndexFlat:
config = faiss.GpuIndexFlatConfig()
config.useFloat16 = True # Store vectors in FP16 (half memory, minimal quality loss)
config.storeTransposed = False
config.device = 0
index = faiss.GpuIndexFlatL2(d, config)
GpuIndexIVF:
config = faiss.GpuIndexIVFConfig()
config.device = 0
config.useFloat16CoarseQuantizer = False # Coarse quantizer in FP32
# Enable precomputed tables (faster but more memory)
config.usePr...4. Chroma
Chroma is an open-source, developer-friendly vector database with a Rust core and Python/JS client libraries. It is designed for simplicity and rapid prototyping, but also supports production deployment.
4.1 Architecture
Python/JS Client
↕ HTTP or gRPC
Rust Core (server or embedded)
↕
Storage Backend (RocksDB/SQLite by default)
↕
Embedding Function (local or API-based)
Core Design:
- Full-stack vector database (embeddings + storage + search)
- Embedding function integrated into client (no separate embedding step)
- Written in Rust for performance, with first-class Python bindings
4.2 Python Client
import chromadb
from chromadb.config import Settings
# In-memory (default)
client = chromadb.Client()
# Persistent
client = chromadb.Client(Settings(
chroma_db_impl="duckdb+parquet",
persist_directory="./chroma_data"
))
# Distributed (HTTP client)
client = chromadb.HttpClient(
host="chroma-server.example.com",
port=8000
)
4.3 Collection Operations
# Create collection
collection = client.create_collection(
name="my_collection",
metadata={"description": "Enterprise document embeddings"},
embedding_function=None, # Provide custom embedding function
)
# Or get existing
collection = client.get_or_create_collection("my_collection")
# Add documents
collection.add(
embeddings=[[1.2, 2.3, ...], ...], # Or use embedding_function
documents=["doc1", "doc2"],
metadatas=[{"source": "pdf"}, {"source": "web"}],
ids=["id1", "id2"],
)
# Add with automatic embedding
collection.add(
documents=["Text to embed automatically"],
ids=["auto_id1"],
)
# Get by ID
result = collection.get(
ids=["id1"],
include=["documents", "metadatas", "embeddings"]
)
# Update
collection.update(
ids=["id1"],
documents=["Updated document"],
metadatas=[{"source": "updated"}],
)
# Delete
collection.delete(ids=["id1"])
# Or delete with filter
collection.delete(where={"source": "obsolete_source"})
# Count
count = collection.count()
# List collections
collections = client.list_collections()
# Delete collection
client.delete_collection("my_collection")
4.4 Metadata Filtering
Chroma's where filter syntax supports rich metadata queries:
Comparison Operators:
# Exact match
collection.get(where={"source": "pdf"})
# Numeric comparison
collection.get(where={"year": {"$gt": 2020}})
collection.get(where={"year": {"$gte": 2020}})
collection.get(where={"year": {"$lt": 2023}})
collection.get(where={"year": {"$lte": 2023}})
collection.get(where={"year": {"$ne": 2022}})
# String operations
collection.get(where={"source": {"$contains": "news"}})
Compound Operators:
# AND
collection.get(where={
"$and": [
{"source": "pdf"},
{"year": {"$gte": 2020}},
]
})
# OR
collection.get(where={
"$or": [
{"source": "pdf"},
{"source": "web"},
]
})
# IN
collection.get(where={"category": {"$in": ["legal", "finance"]}})
# NOT IN
collection.get(where={"category": {"$nin": ["spam", "temporary"]}})
Filter + Search:
results = collection.query(
query_embeddings=[[0.1, 0.2, ...]],
n_results=10,
where={"year": {"$gte": 2023}},
where_document={"$contains": "machine learning"}, # Filter by doc content
)
4.5 Embedding Functions
from chromadb import EmbeddingFunction
from sentence_transformers import SentenceTransformer
class CustomEmbeddingFunction(EmbeddingFunction):
def __init__(self):
self.model = SentenceTransformer('all-MiniLM-L6-v2')
def __call__(self, texts):
return self.model.encode(texts).tolist()
# Custom embedding function
collection = client.create_collection(
name="custom_emb",
embedding_function=CustomEmbeddingFunction(),
)
# Or use Chroma's built-in default (all-MiniLM-L6-v2)
# collection = client.create_collection(name="default_emb")
4.6 HNSW Configuration
Chroma uses HNSW internally and exposes some parameters:
collection = client.create_collection(
name="hnsw_collection",
metadata={
"hnsw:space": "cosine", # "cosine" | "l2" | "ip"
"hnsw:construction_ef": 200, # efConstruction parameter
"hnsw:M": 32, # Maximum connections per node
"hnsw:search_ef": 100, # efSearch parameter
"hnsw:num_threads": 4, # Threads for indexing
},
)
HNSW Parameters:
| Parameter | Key | Default | Effect |
|---|---|---|---|
| Space | hnsw:space | "l2" | Distance function |
| M | hnsw:M | 16 | Connections per node |
| efConstruction | hnsw:construction_ef | 200 | Build quality vs time |
| efSearch | hnsw:search_ef | 100 | Search quality vs speed |
| num_threads | hnsw:num_threads | 4 | Index build parallelism |
4.7 Persistence and Distributed Mode
Persistence:
# Persistent client saves to disk automatically
client = chromadb.PersistentClient(path="./chroma_db")
# Data is stored in:
# ./chroma_db/
# ├── chroma.sqlite3
5. Pinecone
Pinecone is a fully managed, cloud-native vector database designed for production AI workloads. It offers both serverless and pod-based architectures.
5.1 Architecture
Serverless (Podless)
Pinecone's latest architecture eliminates manual pod management:
Client → Pinecone API → Serverless Infrastructure
├── Storage-Optimized Tier
├── Compute Tier (auto-scaling)
└── Metadata Index
Key Features:
- Zero infrastructure management
- Auto-scaling (pods added/removed automatically)
- Pay-per-usage (no idle costs)
- Index creation without specifying pod size
- Storage-optimized architecture (vectors stored efficiently)
Creating a Serverless Index:
import pinecone
pc = pinecone.Pinecone(api_key="your-api-key")
pc.create_index(
name="serverless-index",
dimension=768,
metric="cosine",
spec=ServerlessSpec(
cloud="aws",
region="us-east-1",
),
)
Pod-Based (Legacy/Classic)
Traditional Pinecone with dedicated compute pods:
Client → Pinecone API → Pod Cluster
├── Pod 1 (shard)
├── Pod 2 (replica)
├── Pod 3 (shard + replica)
└── ...
Pod Types:
| Pod Type | Purpose | vCPU | RAM | Storage |
|---|---|---|---|---|
| p1 | Performance (search speed) | 2 vCPU | 8 GB | 400 GB |
| s1 | Standard (balanced) | 2 vCPU | 16 GB | 400 GB |
| e1 | Economy (cost-effective) | 2 vCPU | 8 GB | 400 GB |
Capacity per Pod (estimated, depends on dimension and metadata):
| Pod Type | Vectors (1536D) | Queries/sec (single pod) |
|---|---|---|
| p1 | ~1M | ~500 |
| s1 | ~2M | ~200 |
| e1 | ~5M | ~50 |
Creating a Pod-based Index:
pc.create_index(
name="pod-index",
dimension=768,
metric="cosine",
pod_type="p1",
pods=1,
replicas=1,
shards=1,
)
5.2 Pod Sizing
Replicas vs Shards:
- Replicas: Copies of the data for high availability and read throughput. More replicas = higher QPS.
- Shards: Data partitioned across pods. More shards = larger capacity.
Sizing Formula:
Total capacity = Vectors per shard × Shards
Read throughput = Pod throughput × Replicas
Write throughput = Pod throughput (limited by shards)
Sizing Examples:
# 10M vectors, 768D, cosine metric, high throughput
pc.create_index(
name="production-index",
dimension=768,
metric="cosine",
pod_type="s1",
pods=4, # 4 pods total
shards=2, # 2 shards (5M vectors each)
replicas=2, # 2 replicas of each shard
)
# 100M vectors, low throughput, low cost
pc.create_index(
name="large-archive",
dimension=768,
metric="cosine",
pod_type="e1",
pods=20, # 20 pods
shards=20, # 20 shards (5M each)
replicas=1, # No replication
)
5.3 Index Configuration
pc.create_index(
name="my-index",
dimension=768,
metric="cosine", # "cosine" | "dotproduct" | "euclidean"
spec=ServerlessSpec(
cloud="aws",
region="us-west-2",
),
# Pod-based only:
# metadata_config={"indexed": ["category", "year"]}, # Pre-define metadata fields
)
5.4 Vector Operations
index = pc.Index("my-index")
# Upsert vectors
index.upsert(
vectors=[
{
"id": "vec1",
"values": [0.1, 0.2, ...], # 768-dim float vector
"metadata": {"category": "science", "year": 2023},
},
{
"id": "vec2",
"values": [0.3, 0.4, ...],
"metadata": {"category": "history", "year": 2022},
},
],
namespace="ns1", # Optional namespace
)
# Upsert in batches (recommended for large datasets)
def batch_upsert(index, vectors, batch_size=100):
for i in range(0, len(vectors), batch_size):
batch = vectors[i:i+batch_size]
index.upsert(vectors=batch)
# Query
results = index.query(
vector=[0.1, 0.2, ...],
top_k=10,
namespace="ns1",
include_metadata=True,
include_values=False,
filter={"category": {"$eq": "science"}},
)
# Query with sparse vectors (hybrid search)
results = index.query(
vector=dense_vector,
sparse_vector={
"indices": [1, 5, 100],
"values": [0.5, 0.3, 0.2],
},
top_k=10,
)
# Fetch by ID
result = index.fetch(ids=["vec1", "vec2"], namespace="ns1")
# Update
index.update(
id="vec1",
values=[0.5, 0.6, ...],
set_metadata={"category": "updated"},
namespace="ns1",
)
# Delete
index.delete(ids=["vec1"], namespace="ns1")
index.delete(filter={"category": {"$eq": "obsolete"}}, namespace="ns1")
index.delete(delete_all=True, namespace="ns1") # Clear namespace
# Describe index stats
stats = index.describe_index_stats()
5.5 Namespaces
Namespaces partition vectors within a single index:
# Different namespaces for different use cases
index.upsert(vectors=vecs_legal, namespace="legal")
index.upsert(vectors=vecs_medical, namespace="medical")
# Query specific namespace
results = index.query(
vector=query,
top_k=10,
namespace="legal", # Only searches this namespace
)
Characteristics:
- Unlimited namespaces per index
- Each namespace can have different metadata schemas
- No cross-namespace querying (single namespace per query)
- Namespace isolation for multi-tenant setups
5.6 Metadata Filtering
Pinecone supports rich metadata filtering:
# Equality
filter = {"category": {"$eq": "science"}}
# N...6. Qdrant
Qdrant is a high-performance vector database written in Rust, designed for production deployments with rich filtering, quantization, and advanced storage options.
6.1 Architecture
Client (REST/gRPC)
↕
Qdrant Service
├── Collections
│ ├── Collection (logical unit)
│ │ ├── Segments (immutable + mutable)
│ │ │ ├── Vector Storage
│ │ │ ├── Payload (metadata) Index
│ │ │ └── Quantization Data
│ │ └── WAL (Write-Ahead Log)
│ └── Shards (distributed)
└── Snapshots
6.2 Collections
from qdrant_client import QdrantClient
from qdrant_client.http import models
client = QdrantClient(host="localhost", port=6333)
# Create collection
client.create_collection(
collection_name="my_collection",
vectors_config=models.VectorParams(
size=768,
distance=models.Distance.COSINE,
on_disk=True, # Store vectors on disk (not RAM)
),
optimizers_config=models.OptimizersConfigDiff(
default_segment_number=2,
memmap_threshold=20000,
),
hnsw_config=models.HnswConfigDiff(
m=16,
ef_construct=200,
full_scan_threshold=10000,
),
quantization_config=models.ScalarQuantization(
scalar=models.ScalarQuantizationConfig(
type=models.ScalarType.INT8,
quantile=0.99,
always_ram=True,
),
),
)
# Get collection info
info = client.get_collection("my_collection")
Collection Configuration Parameters:
| Parameter | Description | Default |
|---|---|---|
vectors_config |
Vector size, distance metric, storage | Required |
optimizers_config |
Optimization behavior | Default |
hnsw_config |
HNSW graph parameters | Default |
quantization_config |
Quantization for memory reduction | None |
shard_number |
Number of shards (distributed) | 1 |
replication_factor |
Data replication | 1 |
write_consistency_factor |
Consistency for writes | 1 |
on_disk_payload |
Payload on disk (not in RAM) | False |
6.3 Vector Operations
# Upsert points
client.upsert(
collection_name="my_collection",
points=[
models.PointStruct(
id=1,
vector=[0.1, 0.2, ...], # 768-dim
payload={
"title": "Document 1",
"category": "science",
"year": 2023,
"tags": ["AI", "ML"],
"price": 29.99,
},
),
],
)
# Query
results = client.search(
collection_name="my_collection",
query_vector=[0.1, 0.2, ...],
limit=10,
with_payload=True,
with_vectors=False,
score_threshold=0.5, # Minimum similarity score
)
# Batch query
results = client.search_batch(
collection_name="my_collection",
requests=[
models.SearchRequest(vector=query1, limit=10),
models.SearchRequest(vector=query2, limit=10),
],
)
# Query by ID (fetching results similar to given ID)
results = client.recommend(
collection_name="my_collection",
positive=[1, 2, 3], # Similar to these IDs
negative=[4, 5], # Dissimilar to these IDs
limit=10,
)
# Scroll (sequential scan)
scroll_result = client.scroll(
collection_name="my_collection",
limit=100,
with_payload=True,
offset=previous_offset, # Pagination
)
# Count
count = client.count(
collection_name="my_collection",
count_filter=... # Optional filter
)
# Update payload (metadata)
client.set_payload(
collection_name="my_collection",
payload={"new_field": "value"},
points=[1, 2, 3],
)
# Delete by filter
client.delete(
collection_name="my_collection",
points_filter=models.Filter(
must=[
models.FieldCondition(
key="year",
range=models.Range(gte=2020),
),
],
),
)
6.4 Payload Indexing
Payload (metadata) fields can be indexed for efficient filtering:
# Create keyword index
client.create_payload_index(
collection_name="my_collection",
field_name="category",
field_type=models.PayloadSchemaType.KEYWORD,
)
# Create integer index
client.create_payload_index(
collection_name="my_collection",
field_name="year",
field_type=models.PayloadSchemaType.INTEGER,
)
# Create float index
client.create_payload_index(
collection_name="my_collection",
field_name="price",
field_type=models.PayloadSchemaType.FLOAT,
)
# Create geo index
client.create_payload_index(
collection_name="my_collection",
field_name="location",
field_type=models.PayloadSchemaType.GEO,
)
# Create text index (for full-text search)
client.create_payload_index(
Client (GraphQL / REST)
↕
Weaviate Server
├── Modules
│ ├── text2vec-* (embedding generation)
│ ├── generative-* (generation)
│ ├── qna-openai (question answering)
│ ├── ner-transformers (named entity recognition)
│ └── custom modules
├── Vector Index (HNSW or Flat)
├── Object Storage
└── Inverted Index (for filtering)
7.2 GraphQL API
Weaviate's primary API is GraphQL, enabling complex queries in a single request:
import weaviate
import weaviate.classes as wvc
client = weaviate.connect_to_local()
# Get class (equivalent to collection)
class_obj = client.collections.get("Document")
# GraphQL query
response = class_obj.query.near_text(
query="machine learning concepts",
limit=10,
return_metadata=wvc.query.MetadataQuery(distance=True, score=True),
)
# Raw GraphQL (for complex queries)
query = """
{
Get {
Document(
nearText: {
concepts: ["machine learning"]
}
limit: 10
where: {
operator: GreaterThan
path: ["year"]
valueInt: 2020
}
) {
title
content
_additional {
distance
}
}
}
}
"""
response = client.query.raw(query)
7.3 Classical API (REST)
# Create schema / class
class_obj = client.collections.create(
name="Document",
vectorizer_config=wvc.config.Configure.Vectorizer.text2vec_openai(),
properties=[
wvc.config.Property(name="title", data_type=wvc.config.DataType.TEXT),
wvc.config.Property(name="content", data_type=wvc.config.DataType.TEXT),
wvc.config.Property(name="year", data_type=wvc.config.DataType.INT),
],
)
# Insert data
doc = class_obj.data.insert(
properties={
"title": "Introduction to AI",
"content": "Artificial intelligence is...",
"year": 2023,
},
)
# Batch import
with class_obj.batch.dynamic() as batch:
for doc in documents:
batch.add_object(
properties={
"title": doc["title"],
"content": doc["content"],
"year": doc["year"],
},
vector=doc.get("vector"), # Optional: provide pre-computed vector
)
7.4 Hybrid Search
Weaviate combines dense (vector) and sparse (BM25) search:
response = class_obj.query.hybrid(
query="quantum computing",
alpha=0.75, # Weight: 0 = pure BM25, 1 = pure vector
limit=10,
return_metadata=wvc.query.MetadataQuery(score=True, explain_score=True),
)
# For each result:
for obj in response.objects:
print(f"Score: {obj.metadata.score}")
print(f"Explanation: {obj.metadata.explain_score}")
Alpha Parameter:
- alpha = 0.0: Pure keyword search (BM25)
- alpha = 0.5: Equal weight
- alpha = 1.0: Pure vector search
- alpha = 0.75: Default
7.5 Generative Search
Weaviate can generate responses based on retrieved objects:
Single (per-object generation):
response = class_obj.generate.near_text(
query="quantum computing",
limit=5,
single_prompt="Summarize {title}: {content}",
)
# Generates a separate summary for each retrieved object
Group (aggregated generation):
response = class_obj.generate.near_text(
query="quantum computing",
limit=5,
grouped_task="Write a comprehensive overview based on the following information:",
)
# Generates a single response using all retrieved objects
7.6 Modules
Weaviate's modular architecture allows pluggable AI capabilities:
Vectorizer Modules (automatic embedding):
# OpenAI
client = weaviate.connect_to_wcs(
cluster_url="https://xxx.weaviate.network",
auth_credentials=wvc.init.Auth.api_key("api-key"),
headers={"X-OpenAI-Api-Key": "sk-..."},
)
# Cohere
client = weaviate.connect_to_local(
headers={"X-Cohere-Api-Key": "cohere-key"},
)
# Hugging Face
client = weaviate.connect_to_local(
headers={"X-HuggingFace-Api-Key": "hf-key"},
)
# Or use local inference (text2vec-transformers)
# docker run -p 8080:8080 semitechnologies/transformers-inference:sentence-transformers-multi-qa-MiniLM-L6-cos-v1
Generative Modules:
# Generate responses using OpenAI
class_obj = client.collections.create(
name="FAQ",
generative_config=wvc.config.Configure.Generative.openai(),
)
# Generate using Cohere
class_obj = client.collections.create(
name="FAQ",
generative_config=wvc.config.Configure.Generative.cohere(),
)
Other Modules:
# QnA Module (extractive QA)
response = class_obj.query.near_text(
query="What is the capital of France?",
limit=1,
qna_prompt="Answer from the provided context:"
)
# Returns answer spans with start/end positions
# NER Module
# Extracts named entities automatically during import
7.7 Multi-Tenancy
Weaviate supports data isolation for multi-tenant applications:
# Enable multi-tenancy on a class
class_obj = client.collections.create(
name="TenantData",
multi_tenant_config=wvc.config.Configure.multi_tenancy(enabled=True),
properties=[
wvc.config.Property(name="content", data_type=wvc.config.DataType.TEXT),
],
)
# Create tenants
8. Milvus
Milvus is a cloud-native vector database designed for billion-scale similarity search with distributed architecture and GPU acceleration.
8.1 Distributed Architecture
Load Balancer
↓
Proxy (stateless, handles client requests)
↓
┌──────────────────────────────────────┐
│ Message Broker │ (Kafka / Pulsar)
└──────┬──────────────┬───────────────┘
↓ ↓ ↓
Root Coord Data Coord Query Coord Index Coord
↓ ↓ ↓ ↓
Root Service Data Nodes Query Nodes Index Nodes
(storage) (search) (index build)
Component Roles:
| Component | Responsibility |
|---|---|
| Root Coord | Cluster management, schema management, TSO (timestamp oracle) |
| Data Coord | Manages data nodes, segment lifecycle, garbage collection |
| Query Coord | Manages query nodes, load balancing, read requests |
| Index Coord | Manages index building, index versioning |
| Proxy | Client-facing gateway, authentication, request routing |
| Data Nodes | Store and manage data segments |
| Query Nodes | Execute vector and scalar queries |
| Index Nodes | Build vector indexes |
8.2 Collections and Schema
from pymilvus import MilvusClient, Collection, CollectionSchema, FieldSchema, DataType
# Milvus Lite (embedded, for development)
client = MilvusClient("milvus.db")
# Milvus Server
client = MilvusClient(uri="http://localhost:19530")
# Create schema
schema = MilvusClient.create_schema(
auto_id=False,
enable_dynamic_field=True, # Allow arbitrary metadata fields
)
schema.add_field(field_name="id", datatype=DataType.INT64, is_primary=True)
schema.add_field(field_name="vector", datatype=DataType.FLOAT_VECTOR, dim=768)
schema.add_field(field_name="category", datatype=DataType.VARCHAR, max_length=100)
schema.add_field(field_name="year", datatype=DataType.INT64)
# Create collection
client.create_collection(
collection_name="my_collection",
schema=schema,
index_params={
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {"M": 16, "efConstruction": 200},
},
consistency_level="BoundedStaleness", # Consistency level
)
8.3 Index Types
Milvus supports a wide range of index types through its Knowhere library:
# FLAT (brute force, exact search)
index_params = {
"index_type": "FLAT",
"metric_type": "L2",
}
# IVF_FLAT (inverted file, no compression)
index_params = {
"index_type": "IVF_FLAT",
"metric_type": "L2",
"params": {"nlist": 1024},
}
# IVF_SQ8 (inverted file + scalar quantization)
index_params = {
"index_type": "IVF_SQ8",
"metric_type": "L2",
"params": {"nlist": 1024},
}
# IVF_PQ (inverted file + product quantization)
index_params = {
"index_type": "IVF_PQ",
"metric_type": "L2",
"params": {"nlist": 1024, "m": 16, "nbits": 8},
}
# HNSW (hierarchical navigable small world)
index_params = {
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {"M": 16, "efConstruction": 200},
}
# DISKANN (SSD-optimized, billion-scale)
index_params = {
"index_type": "DISKANN",
"metric_type": "L2",
"params": {},
}
# Binary indexes
index_params = {
"index_type": "BIN_FLAT", # For binary vectors
"metric_type": "HAMMING",
}
index_params = {
"index_type": "BIN_IVF_FLAT",
"metric_type": "HAMMING",
"params": {"nlist": 1024},
}
Index Type Decision Guide:
| Index Type | Dataset Size | Recall | Build Speed | Memory |
|---|---|---|---|---|
| FLAT | <100K | 100% | N/A | Highest |
| IVF_FLAT | 100K-10M | 99% | Fast | High |
| IVF_SQ8 | 1M-50M | 98% | Fast | Medium |
| IVF_PQ | 10M-100M | 95% | Medium | Low |
| HNSW | 100K-10M | 99.5% | Slow | High |
| DISKANN | 100M-1B+ | 95% | Slow | Very Low (SSD) |
8.4 GPU Acceleration
Milvus supports GPU-accelerated index building and search via Knowhere:
# GPU index types
index_params = {
"index_type": "GPU_IVF_FLAT", # GPU version of IVF_FLAT
"metric_type": "L2",
"params": {"nlist": 1024},
}
# GPU HNSW
index_params = {
"index_type": "GPU_HNSW",
"metric_type": "L2",
"params": {"M": 16, "efConstruction": 200},
}
# GPU configuration
import pymilvus
pymilvus.config.GPU_ENABLE = True
# Specify GPU devices
# Set env: MILVUS_GPU_DEVICE_COUNT=1
# Or in milvus.yaml:
# gpu:
# enable: true
# cache_capacity_gb: 4
# search_devices: [0, 1]
# build_index_devices: [0]
8.5 Consistency Levels
Milvus offers four consistency levels:
client.insert(
collection_name="my_collection",
data=[...],
consistency_level="Strong", # or "BoundedStaleness", "Session", "Eventually"
)
| Level | Description | Use Case |
|---|---|---|
| Strong | Read sees all committed writes immediately | Financial, critical data |
| BoundedStaleness | Read allows small latency (default) | Most production workloads |
| Session | Read-your-writes consistency | User session data |
| Eventually | Fastest, may return stale data | High-throughput logging |
8.6 Sharding and Partitions
# Channel-based sharding (automatic)
# Milvus automatically shards based on the message broker partitions
# Partitioning (manual data organization)
client.create_partition(
collection_name="my_collection",
partition_name="year_2023",
)
client.create_partition(
collection_name="my_collection",
partition_name="year_2024",
)
# Insert into specific partition
client.insert(
collection_name="my_collection",
data=[...],
partition_name="year_2023",
)
# Search in specific partition
results = client.search(
collection_name="my_collection",
data=[query_vector],
limit=10,
partition_names=["year_2023"],
)
8.7 Replication
# Milvus uses a distributed architecture with built-in replication
# Configure via milvus.yaml:
# dataCoord:
# segment:
# replicationFactor: 3
# channel:
# replicationFactor: 3
# Query load balancing (read replicas)
# Configure query nodes:
# queryNode:
# replication:
# enabled: true
# replicaNumber: 3
8.8 Kafka Integration
Milvus uses Kafka (or Pulsar) as its log broker:
# milvus.yaml configuration
kafka:
brokerLis...9. LanceDB
LanceDB is an embedded vector database (like SQLite for vectors) built in Rust with a columnar storage format optimized for ML workflows.
9.1 Architecture
Client (Python / Rust / JS / Swift)
↕
LanceDB Embedded
├── Lance Columnar Format
│ ├── Vector Columns
│ ├── Scalar Columns
│ └── Metadata
├── ANN Index (IVF + PQ)
└── GPU-Accelerated Search (optional)
9.2 Embedded Database
import lancedb
# Open local database (creates if not exists)
db = lancedb.connect("./lancedb_data")
# Create table from data
db.create_table(
"my_table",
data=[
{"vector": [0.1, 0.2, ...], "text": "doc1", "id": 1},
{"vector": [0.3, 0.4, ...], "text": "doc2", "id": 2},
],
)
# Or create table with schema
import pyarrow as pa
schema = pa.schema([
pa.field("vector", pa.list_(pa.float32(), list_size=768)),
pa.field("text", pa.string()),
pa.field("year", pa.int32()),
])
db.create_table("structured_table", schema=schema)
# Open existing table
table = db.open_table("my_table")
9.3 Columnar Storage
Lance format is columnar (like Parquet) with optimizations for ML:
# Add data efficiently (column-oriented insert)
table.add([
{"vector": [0.5, 0.6, ...], "text": "doc3", "year": 2023},
{"vector": [0.7, 0.8, ...], "text": "doc4", "year": 2024},
])
# Query with column selection
results = table.search(query_vector).select(["text", "year"]).to_pandas()
# Streaming reads (for large datasets)
for batch in table.to_batches():
print(batch.num_rows)
Advantages of Columnar Storage:
- Only reads required columns (reduces I/O)
- High compression ratios (LZ4, ZSTD)
- Predicate pushdown (filter before loading)
- Zero-copy reads (memory-mapped)
- Efficient partial updates
9.4 GPU-Accelerated ANN
# Create IVF_PQ index with GPU support
table.create_index(
metric="L2",
num_partitions=256, # IVF centroids
num_sub_vectors=96, # PQ sub-vectors
index_cache_size=1000, # Cache size in MB
accelerator="cuda", # Use GPU for index building
)
# GPU-accelerated search
results = table.search(query_vector, accelerator="cuda").limit(10).to_pandas()
9.5 Multi-Modal Support
LanceDB stores any data type supported by Arrow:
# Images (as bytes)
table.add([
{
"vector": [0.1, 0.2, ...],
"image_bytes": open("photo.jpg", "rb").read(),
"caption": "A photo",
}
])
# Audio features
table.add([
{
"vector": [0.3, 0.4, ...],
"audio_embedding": audio_emb,
"transcript": "Hello world",
}
])
9.6 Zero-Copy
LanceDB uses memory-mapped files for zero-copy reads:
# Open table with zero-copy (no data loading until accessed)
table = db.open_table("large_table")
# First access triggers page-fault-based loading
results = table.search(query_vector).limit(10).to_pandas()
# Subsequent accesses are cached in OS page cache
9.7 Disk-Based Index
LanceDB's index is designed for disk-based operation:
# Index stored on disk, loaded lazily
table.create_index(
metric="L2",
num_partitions=4096,
num_sub_vectors=32,
index_cache_size=2048, # MB of index to cache in RAM
)
# Memory-efficient for billion-scale datasets
# Typical: 1B vectors × 768D → ~3 TB raw data, ~50 GB index
When to Use LanceDB:
- Single-process applications (Python scripts, serverless functions)
- Multi-modal data pipelines
- GPU-accelerated workloads on single machine
- Applications needing embedded database (no server dependency)
- Research and experimentation
10. Elasticsearch
Elasticsearch, traditionally a full-text search engine, now supports dense and sparse vector search. It's often used for hybrid search combining keyword and semantic matching.
10.1 Dense Vector Field
PUT /my-index
{
"mappings": {
"properties": {
"title-vector": {
"type": "dense_vector",
"dims": 768,
"index": true,
"similarity": "cosine", // "cosine" | "dot_product" | "l1" | "l2" | "hamming"
"index_options": {
"type": "hnsw",
"m": 16,
"ef_construction": 200
}
},
"content": { "type": "text" },
"category": { "type": "keyword" },
"year": { "type": "integer" }
}
}
}
Similarity Metrics:
| Metric | Description | When to Use |
|---|---|---|
| cosine | Cosine similarity (1 − cos(θ)) | Text embeddings (default) |
| dot_product | 1 − dot product | Normalized vectors |
| l1 | L1 distance | Sparse or robust embeddings |
| l2 | Euclidean squared | Standard vector search |
| hamming | Hamming distance | Binary vectors |
10.2 Vector Search Queries
# kNN search
response = client.search(
index="my-index",
knn={
"field": "title-vector",
"query_vector": [0.1, 0.2, ...],
"k": 10,
"num_candidates": 100, # How many candidates to consider
},
)
# Cosine similarity in script_score
response = client.search(
index="my-index",
query={
"script_score": {
"query": {"match_all": {}},
"script": {
"source": "cosineSimilarity(params.query_vector, 'title-vector') + 1.0",
"params": {"query_vector": [0.1, 0.2, ...]},
},
}
},
)
10.3 Sparse Vector Field
Elasticsearch supports sparse vectors for models like SPLADE:
PUT /my-index
{
"mappings": {
"properties": {
"sparse-vector": {
"type": "sparse_vector"
},
"content": { "type": "text" }
}
}
}
# Index sparse vector
client.index(
index="my-index",
document={
"sparse-vector": {"1": 0.5, "100": 0.3, "500": 0.2}, # term_id: weight
"content": "Document text",
},
)
# Sparse vector query
response = client.search(
index="my-index",
query={
"sparse_vector": {
"field": "sparse-vector",
"query_vector": {"1": 0.3, "200": 0.7},
},
},
)
10.4 ELSER (Elastic Learned Sparse EncodeR)
ELSER is Elasticsearch's built-in sparse retrieval model:
PUT /my-index
{
"mappings": {
"properties": {
"content-elser": {
"type": "sparse_vector"
},
"content": { "type": "text" }
}
}
}
# Deploy ELSER model
client.ml.put_trained_model(
model_id="elser_model_2",
input={"field_names": ["text_field"]},
)
# Inference pipeline
client.ingest.put_pipeline(
id="elser-pipeline",
processors=[
{
"inference": {
"model_id": ".elser_model_2",
"target_field": "content-elser",
"field_map": {"content": "text_field"},
}
}
],
)
# Reindex with ELSER embeddings
client.reindex(
source={"index": "my-index-raw"},
dest={"index": "my-index-elser", "pipeline": "elser-pipeline"},
)
10.5 Hybrid Query
Elasticsearch's strength is combining vector search with full-text search:
# Hybrid: kNN + BM25
response = client.search(
index="my-index",
query={
"bool": {
"must": [
{"match": {"content": "quantum computing"}}, # BM25
],
"filter": [
{"term": {"category": "science"}},
{"range": {"year": {"gte": 2020}}},
],
}
},
knn={
"field": "title-vector",
"query_vector": [0.1, 0.2, ...],
"k": 10,
"num_candidates": 100,
"filter": {
"term": {"category": "science"},
},
},
# Combine scores: linear combination
rank={"rrf": {"window_size": 100, "rank_constant": 60}},
)
Reciprocal Rank Fusion (RRF):
# Combine multiple result sets using RRF
response = client.search(
index="my-index",
query={
"bool": {
"must": {"match": {"content": "search terms"}},
},
},
knn={
"field": "title-vector",
"query_vector"<...11. Vector Database Comparison
11.1 Feature Comparison Matrix (20+ Dimensions)
| Feature | Faiss | Chroma | Pinecone | Qdrant | Weaviate | Milvus | LanceDB | Elasticsearch |
|---|---|---|---|---|---|---|---|---|
| Type | Library | Embedded/Server | Serverless/Pods | Server | Server | Distributed | Embedded | Server |
| Language | C++/Python | Rust | Go | Rust | Go | Go/Java | Rust | Java |
| License | MIT | Apache 2.0 | Proprietary | Apache 2.0 | BSD-3 | Apache 2.0 | Apache 2.0 | Elastic |
| Cloud managed | No | Chroma Cloud | ✓ | Qdrant Cloud | Weaviate Cloud | Zilliz Cloud | No | Elastic Cloud |
| Self-hosted | N/A | ✓ | Pod-based | ✓ | ✓ | ✓ | N/A | ✓ |
| Performance (1M, 768D) |
|---|---|---|---|---|---|---|---|
| Index build | Fastest | Fast | Managed | Fast | Medium | Medium | Fast | Slow |
| Query latency (p99) | <5ms | <10ms | <10ms | <5ms | <10ms | <5ms | <5ms | <20ms |
| Recall @10 (HNSW) | 99% | 99% | 99% | 99% | 99% | 99% | 99% | 98% |
| QPS (1M vectors, single) | 1000+ | 500+ | 500+ | 1000+ | 500+ | 2000+ | 500+ | 200+ |
| Scalability |
|---|---|---|---|---|---|---|---|
| Max vectors (single node) | 10M | 10M | 5M per pod | 10M+ | 10M+ | 100M+ | 1B+ | 10M |
| Distributed | No | No | ✓ | ✓ | ✓ | ✓ | No | ✓ |
| Sharding | No | No | ✓ | ✓ | Multi-tenant | ✓ | No | ✓ |
| Replication | No | No | ✓ | ✓ | ✓ | ✓ | No | ✓ |
| Filtering |
|---|---|---|---|---|---|---|---|
| Metadata filtering | No | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Geo search | No | No | Limited | ✓ | ✓ | ✓ | No | ✓ |
| Hybrid search | No | No | ✓ | ✓ | ✓ | Limited | No | ✓ |
| Pre-filter optimization | N/A | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Vector Features |
|---|---|---|---|---|---|---|---|
| Multi-vector | No | No | No | ✓ | No | No | No | No |
| Sparse vectors | No | No | ✓ | ✓ | ✓ | No | No | ✓ |
| Binary vectors | ✓ | No | No | ✓ | ✓ | ✓ | No | ✓ |
| Quantization | ✓ | No | No | ✓ | ✓ | ✓ | ✓ | No |
| GPU acceleration | ✓ | No | No | No | No | ✓ | ✓ | No |
| Operations |
|---|---|---|---|---|---|---|---|
| Backup/Restore | Manual | No | ✓ | ✓ | ✓ | ✓ | Manual | ✓ |
| Monitoring | No | No | ✓ | ✓ | ✓ | ✓ | No | ✓ |
| RBAC | No | No | ✓ | ✓ | ✓ | ✓ | No | ✓ |
| Audit logging | No | No | ✓ | Limited | ✓ | ✓ | No | ✓ |
| Ecosystem |
|---|---|---|---|---|---|---|---|
| Python client | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| JavaScript client | No | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Java client | No | No | ✓ | ✓ | ✓ | ✓ | No | ✓ |
| Go client | No | No | ✓ | ✓ | ✓ | ✓ | No | ✓ |
| REST API | No | ✓ | ✓ | ✓ | ✓ | ✓ | No | ✓ |
| gRPC API | No | No | ✓ | ✓ | No | ✓ | No | No |
11.2 Performance Benchmarks
1M vectors, 768D, Cosine, HNSW (M=16, efConstruction=200):
| Database | Index Time | Index Size (RAM) | QPS (1 thread) | QPS (8 threads) | p95 Latency | Recall@10 |
|---|---|---|---|---|---|---|
| Faiss (CPU) | 45s | 3.1 GB | 1200 | 8500 | 4ms | 99.2% |
| Chroma | 60s | 3.5 GB | 600 | 4000 | 8ms | 98.5% |
| Qdrant | 50s | 3.2 GB | 1100 | 7800 | 5ms | 99.0% |
| Weaviate | 70s | 3.8 GB | 700 | 5000 | 7ms | 98.8% |
| Milvus | 55s | 3.3 GB | 1500 | 10000 | 3ms | 99.3% |
| LanceDB | 40s | 3.0 GB | 800 | 5500 | 6ms | 99.0% |
| Elasticsearch | 120s | 4.0 GB | 300 | 2000 | 15ms | 98.0% |
10M vectors, 768D, Cosine, IVF_PQ (nlist=4096, m=32):
| Database | Index Time | Index Size | QPS | Recall@10 |
|---|---|---|---|---|
| Faiss (CPU) | 8 min | 450 MB | 800 | 95% |
| Qdrant (with SQ) | 10 min | 500 MB | 600 | 94% |
| Milvus | 9 min | 480 MB | 1000 | 95% |
| LanceDB | 7 min | 420 MB | 500 | 94% |
100M vectors, 768D, Cosine, DISKANN/DiskANN-like:
| Database | Index Time | Storage Size | QPS | Recall@10 |
|---|---|---|---|---|
| Milvus (DISKANN) | 2 hr | 60 GB (SSD) + 4 GB (RAM) | 300 | 96% |
| Qdrant (on_disk) | 2.5 hr | 65 GB (SSD) + 2 GB (RAM) | 200 | 95% |
11.3 Cost Comparison
Estimated Monthly Costs (100M vectors, 768D, 1000 QPS):
| Database | Hosting | Monthly Cost | Notes |
|---|---|---|---|
| Pinecone | Serverless | $5,000-15,000 | Pay per vector + queries |
| Pinecone | Pod-based (s1.x32) | $8,000-12,000 | 32 pods at ~$0.50/hr |
| Qdrant | Cloud (dedicated) | $2,000-5,000 | 4 nodes at ~$2/hr |
| Milvus | Zilliz Cloud | $3,000-8,000 | 3 query nodes, managed |
| Weaviate | Weaviate Cloud | $3,000-6,000 | 2-3 nodes |
| Elasticsearch | Elastic Cloud | $4,000-10,000 | Hot + warm tier |
| Self-hosted (Qdrant) | 4× bare metal | $2,000-4,000 | Hardware + ops |
| Self-hosted (Milvus) | 8-node K8s cluster | $3,000-6,000 | K8s + storage + ops |
| Faiss + custom API | 1× large VM | $1,000-2,000 | Manual scaling |
11.4 Decision Flowchart
Q1: Do you need an embedded database (no server)?
Yes → LanceDB (GPU) or Chroma (simplicity)
No → Q2
Q2: Is this a fully managed service or self-hosted?
Managed → Q3
Self-hosted → Q4
Q3: What's your scale?
<10M vectors, hybrid search → Elasticsearch (if already using ES)
<10M vectors, pure vector → Pinecone or Qdrant Cloud
>10M vectors → Pinecone serverless or Milvus (Zilliz)
Q4: What's your team's technical expertise?
High → Milvus (complex but powerful) or Faiss + custom infra
Medium → Qdrant (easy to operate) or Weaviate (GraphQL-native)
Low → Chroma (simplicity)
Q5: Specific requirements?
GPU acceleration → Faiss (GPU) or LanceDB (GPU)
Billion-scale → Milvus (DISKANN) or Qdrant (on_disk)
Geo-spatial → Qdrant or Elasticsearch
Sparse + dense hybrid → Elasticsearch or Weaviate
Multi-modal / multi-vector → Qdrant
Multi-tenancy at scale → Qdrant or Weaviate
11.5 Migration Considerations
When to Switch Databases:
| Signal | Current DB | Recommended |
|---|---|---|
| Exceeding 10M vectors | Chroma/Weaviate/Small Milvus | Milvus/Qdrant |
| Need GPU acceleration | CPU-only Faiss/Milvus | Faiss GPU/LanceDB/Milvus GPU |
| Latency too high | Elasticsearch/Pinecone | Qdrant/Milvus |
| Cost too high | Managed cloud | Self-hosted Qdrant/Milvus |
| Need hybrid search | FAISS/Chroma | Elasticsearch/Weaviate/Qdrant |
| Need geo-spatial | Pinecone/Chroma | Qdrant/Weaviate |
Migration Pattern:
1. Export vectors from old system (with IDs + metadata)
2. Validate exported data (count, checksum)
3. Create collection in new system with same schema
4. Bulk import (batch insert with parallelism)
5. Run comparison queries (verify top-K matches)
6. Gradual traffic migration (10% → 50% → 100%)
7. Monitor latency, recall, error rates
8. Decommission old system after validation period
Appendices
A. Glossary
| Term | Definition |
|---|---|
| ANN | Approximate Nearest Neighbor — search that trades accuracy for speed |
| kNN | k-Nearest Neighbors — exact search |
| IVF | Inverted File Index — partitions space into Voronoi cells |
| HNSW | Hierarchical Navigable Small World — multi-layer graph index |
| PQ | Product Quantization — compresses vectors via sub-vector quantization |
| SQ | Scalar Quantization — reduces precision to 8-bit integers |
| ADC | Asymmetric Distance Computation — query full-precision, vectors compressed |
| Voronoi Cell | Region of space closest to a centroid |
| nprobe | Number of IVF cells to search (higher = more accurate) |
| efSearch | HNSW search width (higher = more accurate) |
| efConstruction | HNSW build width (higher = better index quality) |
| M | HNSW connections per node (higher = more accurate) |
B. Index Selection Quick Reference
Dataset Size | Recall Needed | Memory Budget | Recommended Index
<100K | Any | Any | FLAT (exact)
100K-1M | 99%+ | High | HNSW
100K-1M | 95-99% | Medium | IVF_FLAT
1M-10M | 95-98% | Low | IVF_PQ
1M-10M | 99%+ | High | HNSW
10M-100M | 95-98% | Low-Medium | IVF_PQ (with SQ)
10M-100M | 99%+ | High | HNSW (if RAM available)
100M-1B | 90-95% | Very Low | DISKANN / ON_DISK
100M-1B | 95-98% | Low | IVF_PQ (SSD-cached)
C. Embedding Dimension Impact
| Dimension | Storage (1M vectors, FP32) | HNSW Memory | IVF_Memory (PQ16) |
|---|---|---|---|
| 384 | 1.5 GB | 3.0 GB | 80 MB |
| 768 | 3.1 GB | 4.6 GB | 160 MB |
| 1024 | 4.0 GB | 5.5 GB | 210 MB |
| 1536 | 6.0 GB | 7.5 GB | 310 MB |
| 2048 | 8.0 GB | 9.5 GB | 410 MB |
| 4096 | 16 GB | 17.5 GB | 820 MB |
D. Known Limitations
| Database | Limitation | Impact |
|---|---|---|
| Faiss | No built-in metadata filtering | Must impl. or use pre-filtering |
| Faiss | No persistence (library, not DB) | Must build storage layer |
| Chroma | No distributed mode in OSS | Single-node only |
| Chroma | Limited metadata filter operators | Simple queries only |
| Pinecone | Proprietary (vendor lock-in) | Hard to migrate |
| Pinecone | Cost at scale | ~10× self-hosted costs |
| Qdrant | Smaller community | Fewer integrations |
| Weaviate | Resource-heavy (Go runtime) | Higher baseline memory |
| Milvus | Complex deployment | High ops overhead |
| Milvus | Kafka dependency | Operational complexity |
| LanceDB | Single-process only | Not for distributed apps |
| Elasticsearch | Vector search not primary use | Higher latency, lower QPS |
Last Updated: May 2026
Author: AI Enterprise Knowledge Base
License: Internal use — Enterprise Knowledge Management
AI Library of Alexandria — RAG · 3 Docs · 8,182 Lines
Home · June 2026