Indexing for RAG — Everything You Need to Know (OSS Only)
Takes your embedded chunks and organises them into a structure that makes approximate nearest-neighbour (ANN) search fast at query time. Without an index, every query would do a full linear scan across all vectors — fine at 10k chunks, unusable at 1M+.
Two layers of indexing
- Vector index — the ANN structure for similarity search
- Metadata index — standard DB indexes (B-tree, GIN) for filtering before/after vector search
Both matter. Missing metadata indexes kills performance on filtered queries just as much as missing vector indexes.
ANN algorithms
IVF (Inverted File Index)
Clusters vectors into buckets, searches only nearby buckets. Lower memory, faster build, slightly lower recall.
Query time: Fast (searches subset of clusters)
Build time: Fast (needs training on sample data)
Memory: Lower than HNSW
Recall: Good (~90-95% with enough nprobe)
Flat (brute force)
Exact search, no approximation. Only viable under ~100k vectors.
Query time: O(n) — linear scan
Recall: 100% exact
Memory: Low (just the vectors)
OSS vector stores compared
| types | pgvector | Qdrant | Weaviate | Chroma | Milvus |
|---|---|---|---|---|---|
| Best for | Existing Postgres stack | Standalone prod | Built-in hybrid | Dev/prototyping | Massive scale |
| HNSW | ✓ | ✓ | ✓ | ✓ | ✓ |
| IVF | ✓ | ✗ | ✗ | ✗ | ✓ |
| Hybrid search | Manual | ✓ | ✓ | ✗ | ✓ |
| Metadata filtering | SQL | ✓ | ✓ | Basic | ✓ |
| Multi-tenancy | SQL | Collections/payload | Multi-tenancy API | ✗ | Partitions |
| Ops overhead | None (already Postgres) | Low | Medium | None | High |
Production recommendation
pgvector if you’re already on Postgres. Zero extra infra, SQL filtering, HNSW support since 0.5.0. Use Qdrant if you need a dedicated vector store with built-in hybrid search or more tuning control.
pgvector — full production setup
Install
pip install pgvector django psycopg2-binaryCREATE EXTENSION IF NOT EXISTS vector;Django model
# models.py
from django.db import models
from pgvector.django import VectorField, HnswIndex, IvfflatIndex
class DocumentChunk(models.Model):
document_id = models.UUIDField(db_index=True)
tenant_id = models.UUIDField(db_index=True)
content = models.TextField()
embedding = VectorField(dimensions=768)
embedding_model = models.CharField(max_length=100)
chunk_strategy = models.CharField(max_length=100)
index_status = models.CharField(
max_length=20,
default="pending",
choices=[
("pending", "Pending"),
("indexed", "Indexed"),
("stale", "Stale"),
("failed", "Failed"),
],
db_index=True,
)
token_count = models.IntegerField()
source_path = models.TextField()
page_number = models.IntegerField(null=True)
content_hash = models.CharField(max_length=64, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
indexes = [
# Vector index — HNSW for prod
HnswIndex(
name="chunk_embedding_hnsw",
fields=["embedding"],
m=16, # neighbours per layer — 16 is default, increase for higher recall
ef_construction=64, # build-time search width — higher = better index, slower build
opclasses=["vector_cosine_ops"],
),
# Metadata indexes for filtered queries
models.Index(fields=["tenant_id", "index_status"]),
models.Index(fields=["document_id"]),
]Migration
# migrations/0001_initial.py
from django.db import migrations
from pgvector.django import VectorExtension
class Migration(migrations.Migration):
operations = [
VectorExtension(),
# ... rest of migration
]HNSW tuning params
HnswIndex(
m=16, # edges per node per layer
# 8–16 for most workloads
# 32–64 for very high recall requirements
# higher = more memory, slower build, better recall
ef_construction=64, # beam width during build
# 64–128 for prod
# higher = slower build, better index quality
# no effect on query speed
)At query time, control recall vs speed tradeoff:
from django.db import connection
# Set per-session before querying
with connection.cursor() as cursor:
cursor.execute("SET hnsw.ef_search = 100")
# ef_search: how many candidates to explore at query time
# default 40 — increase for higher recall at cost of latency
# 100–200 for prod qualityBulk indexing
from pgvector.django import CosineDistance
from .models import DocumentChunk
from .embeddings import embed
def bulk_index(chunks: list[dict]):
objs = []
texts = [c["content"] for c in chunks]
embeddings = embed(texts) # your embed() singleton from previous section
for chunk, vec in zip(chunks, embeddings):
objs.append(DocumentChunk(
document_id = chunk["document_id"],
tenant_id = chunk["tenant_id"],
content = chunk["content"],
embedding = vec,
embedding_model = "bge-base-en-v1.5",
chunk_strategy = "recursive_512_50",
token_count = chunk["token_count"],
source_path = chunk["source_path"],
content_hash = chunk["content_hash"],
index_status = "indexed",
))
DocumentChunk.objects.bulk_create(
objs,
batch_size=200,
update_conflicts=True, # upsert
update_fields=["embedding", "content", "content_hash", "index_status", "updated_at"],
unique_fields=["document_id", "chunk_index"], # requires unique_together on model
)Querying with metadata pre-filter
def retrieve(
query: str,
tenant_id: str,
top_k: int = 5,
source_path: str | None = None,
) -> list[DocumentChunk]:
q_vec = embed([f"Represent this sentence for searching relevant passages: {query}"])[0]
qs = (
DocumentChunk.objects
.filter(tenant_id=tenant_id, index_status="indexed") # metadata pre-filter
.annotate(distance=CosineDistance("embedding", q_vec))
.order_by("distance")
[:top_k]
)
if source_path:
qs = qs.filter(source_path=source_path)
return list(qs)Qdrant — production setup
Install + run
pip install qdrant-client
docker run -p 6333:6333 -v $(pwd)/qdrant_data:/qdrant/storage qdrant/qdrantCreate collection with HNSW
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance, VectorParams, HnswConfigDiff,
OptimizersConfigDiff, PayloadSchemaType,
)
client = QdrantClient(host="localhost", port=6333)
client.create_collection(
collection_name="document_chunks",
vectors_config=VectorParams(
size=768,
distance=Distance.COSINE,
),
hnsw_config=HnswConfigDiff(
m=16,
ef_construct=64,
full_scan_threshold=10_000, # fall back to flat below this count
),
optimizers_config=OptimizersConfigDiff(
indexing_threshold=20_000, # build HNSW index after N vectors
),
)
# Create payload indexes for fast filtering
client.create_payload_index(
collection_name="document_chunks",
field_name="tenant_id",
field_schema=PayloadSchemaType.KEYWORD,
)
client.create_payload_index(
collection_name="document_chunks",
field_name="index_status",
field_schema=PayloadSchemaType.KEYWORD,
)Bulk upsert
from qdrant_client.models import PointStruct
import uuid
def bulk_index_qdrant(chunks: list[dict]):
texts = [c["content"] for c in chunks]
embeddings = embed(texts)
points = [
PointStruct(
id=str(uuid.uuid4()),
vector=vec,
payload={
"document_id": str(chunk["document_id"]),
"tenant_id": str(chunk["tenant_id"]),
"content": chunk["content"],
"source_path": chunk["source_path"],
"chunk_strategy": "recursive_512_50",
"embedding_model": "bge-base-en-v1.5",
"index_status": "indexed",
}
)
for chunk, vec in zip(chunks, embeddings)
]
client.upsert(
collection_name="document_chunks",
points=points,
wait=True, # wait for indexing to complete — set False for async
)Querying with filter
from qdrant_client.models import Filter, FieldCondition, MatchValue
def retrieve_qdrant(query: str, tenant_id: str, top_k: int = 5):
q_vec = embed([f"Represent this sentence for searching relevant passages: {query}"])[0]
results = client.search(
collection_name="document_chunks",
query_vector=q_vec,
query_filter=Filter(
must=[
FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id)),
FieldCondition(key="index_status", match=MatchValue(value="indexed")),
]
),
limit=top_k,
with_payload=True,
)
return [r.payload for r in results]Index lifecycle management
Track every chunk’s index state and handle re-indexing on document change:
import hashlib
def compute_hash(content: str) -> str:
return hashlib.sha256(content.encode()).hexdigest()
def mark_stale_on_update(document_id: str, new_chunks: list[dict]):
"""Mark existing chunks stale if content has changed."""
existing = DocumentChunk.objects.filter(document_id=document_id)
new_hashes = {c["chunk_index"]: compute_hash(c["content"]) for c in new_chunks}
to_stale = [
c.id for c in existing
if new_hashes.get(c.chunk_index) != c.content_hash
]
DocumentChunk.objects.filter(id__in=to_stale).update(index_status="stale")
def reindex_stale(batch_size: int = 100):
"""Celery task — pick up stale chunks and re-embed."""
stale = DocumentChunk.objects.filter(index_status="stale")[:batch_size]
if not stale:
return
texts = [c.content for c in stale]
embeddings = embed(texts)
for chunk, vec in zip(stale, embeddings):
chunk.embedding = vec
chunk.embedding_model = "bge-base-en-v1.5"
chunk.index_status = "indexed"
chunk.content_hash = compute_hash(chunk.content)
DocumentChunk.objects.bulk_update(
stale,
["embedding", "embedding_model", "index_status", "content_hash", "updated_at"],
)Common failure modes
| Problem | Fix |
|---|---|
| Full scan instead of HNSW | Check EXPLAIN — index only used above min_rows threshold in pgvector |
| Low recall on filtered queries | Pre-filter reduces candidate pool — increase ef_search or top_k |
| Index not built yet | pgvector defers HNSW build until row count threshold — insert dummy rows in dev or set threshold lower |
| Stale vectors serving wrong results | Always filter index_status="indexed" in queries |
| Dimension mismatch on model upgrade | New model → new VectorField(dimensions=N) column, dual-write during migration |
| Slow bulk insert | Use bulk_create with batch_size=200, disable autocommit, insert embeddings as lists not numpy arrays |