🔍 RAG

RAG — 3 Documents

RAG Architectures, Advanced RAG, Vector Databases

3
Documents
8,182
Total Lines

📄 Retrieval-Augmented Generation (RAG) — 830 lines

A Comprehensive Guide to Grounding LLMs with External Knowledge

ACPAgentBERTBenchmarkDeploymentEnsembleEvaluationFine-tuningGPTGPU

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

  1. Build a diverse evaluation dataset: 100–500+ query-answer pairs covering typical, edge-case, and adversarial queries.
  2. Test retrieval in isolation: Measure recall@k before evaluating the full pipeline.
  3. Track cost and latency alongside quality metrics.
  4. Segment metrics by query type: Factual lookup vs. synthesis vs. multi-hop.
  5. 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.

AgentAlignmentBERTBenchmarkEnsembleEvaluationGPTInferenceLLMMultimodal

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-1

Multi-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=self

Fusion 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.

BERTBenchmarkCLIPDPODeploymentGPUInferenceLoRAPPOQuantization

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