Rag pipeline
Профессиональные Data Engineering Agent Skills для разработки AI Agentic Data Platform
npx -y skills add ivanshamaev/de-agent-skills --skill rag_pipelineAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 13 stars13 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Use when designing, building, or debugging a RAG (Retrieval-Augmented Generation) data pipeline — document ingestion, chunking strategies (fixed/recursive/semantic/structure-aware), embedding models (OpenAI/Cohere/sentence-transformers), vector stores (pgvector/Chroma/Qdrant/Weaviate), incremental refresh, hybrid retrieval (dense + BM25 + RRF), re-ranking with cross-encoders, metadata filtering, and production monitoring.
SKILL.md
32.9 KB, as published. Nobody here has run it
RAG Data Pipeline
When to Use
Load this skill when the user needs to:
- Design or implement end-to-end RAG ingestion and retrieval pipelines
- Choose or implement a chunking strategy (fixed-size, recursive, semantic, document-structure-aware)
- Embed documents with OpenAI, Cohere, or local sentence-transformers
- Set up or query vector stores: pgvector, Chroma, Qdrant, or Weaviate
- Build incremental embedding refresh pipelines (hash-based change detection, Airflow orchestration)
- Implement hybrid retrieval (dense vector + BM25/keyword + RRF fusion)
- Add cross-encoder re-ranking to improve retrieval precision
- Filter vector search results with metadata predicates
- Monitor retrieval quality (hit rate, MRR, latency) in production
RAG Architecture Overview
A production RAG system has two distinct flows: an offline indexing pipeline and an online query pipeline.
OFFLINE INDEXING PIPELINE
==========================
Raw Documents
(PDF, HTML, MD, DB rows)
|
v
[Document Loader] langchain DocumentLoaders / custom readers
|
v
[Chunker] fixed-size | recursive | semantic | structure-aware
|
v
[Deduplication / Hash] SHA-256 of chunk text → skip if already indexed
|
v
[Embedding Model] OpenAI / Cohere / sentence-transformers (batched)
|
v
[Vector Store Upsert] pgvector / Qdrant / Weaviate / Chroma
+ metadata (doc_id, (chunk_id as upsert key)
source, section,
date, hash)
|
v
[BM25 Index Update] Elasticsearch / OpenSearch / rank_bm25 (sparse)
ONLINE QUERY PIPELINE
=====================
User Query
|
+---> [Query Embedder] ------> [Vector (Dense) Search] ---+
| |
+---> [BM25 / Keyword Search] ---------------------------> +
|
[RRF Fusion]
|
[Cross-Encoder Reranker]
(optional, top-100→top-5)
|
[Top-k Chunks]
|
[LLM Generation]
(context + system prompt)
|
Final Answer
Chunking Strategies
1. Fixed-Size with Overlap
Simple baseline. Use when documents have no reliable structure.
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512, # tokens approximated by chars; tune to embedding model limit
chunk_overlap=64, # ~12% overlap prevents boundary cuts
length_function=len,
separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_text(document_text)
Rule of thumb: chunk_size 512–1024 chars, overlap 10–20% of chunk_size. Larger chunks increase recall; smaller chunks improve precision.
2. Recursive Character Splitting (recommended default)
Tries each separator in order; falls back to the next only when the chunk is still over the limit. Respects paragraph → sentence → word boundaries.
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=150,
separators=["\n\n", "\n", "(?<=\\. )", " ", ""],
)
docs = splitter.create_documents(
texts=[page_content],
metadatas=[{"source": "report_2024.pdf", "page": 3}],
)
3. Semantic Chunking (sentence-boundary)
Groups sentences until adding the next sentence would push cosine distance past a threshold. Best for narrative prose.
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
semantic_splitter = SemanticChunker(
embeddings=OpenAIEmbeddings(model="text-embedding-3-small"),
breakpoint_threshold_type="percentile", # or "standard_deviation"
breakpoint_threshold_amount=95, # split when similarity drops below 95th pct
)
docs = semantic_splitter.create_documents([document_text])
Custom implementation without LangChain dependency:
import re
import numpy as np
from sentence_transformers import SentenceTransformer
def semantic_chunk(text: str, model_name: str = "all-MiniLM-L6-v2", threshold: float = 0.75) -> list[str]:
model = SentenceTransformer(model_name)
sentences = re.split(r"(?<=[.!?])\s+", text.strip())
if len(sentences) <= 1:
return sentences
embeddings = model.encode(sentences, normalize_embeddings=True)
# cosine similarity between adjacent sentences
similarities = [
float(np.dot(embeddings[i], embeddings[i + 1]))
for i in range(len(embeddings) - 1)
]
chunks, current = [], [sentences[0]]
for i, sim in enumerate(similarities):
if sim < threshold:
chunks.append(" ".join(current))
current = [sentences[i + 1]]
else:
current.append(sentences[i + 1])
if current:
chunks.append(" ".join(current))
return chunks
4. Document-Structure-Aware Chunking
Splits on Markdown headers or HTML tags, preserving section context. Best for documentation, wikis, knowledge bases.
from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
# Step 1: split on Markdown headers to get sections with header metadata
header_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[
("#", "h1"),
("##", "h2"),
("###", "h3"),
],
strip_headers=False, # keep headers in chunk text for context
)
header_splits = header_splitter.split_text(markdown_text)
# Step 2: further split large sections with recursive splitter
char_splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=80)
final_chunks = char_splitter.split_documents(header_splits)
# Each chunk retains {"h1": "...", "h2": "...", "h3": "..."} metadata
# HTML-aware chunking
from langchain.text_splitter import HTMLHeaderTextSplitter
html_splitter = HTMLHeaderTextSplitter(
headers_to_split_on=[("h1", "h1"), ("h2", "h2"), ("h3", "h3")]
)
html_splits = html_splitter.split_text(html_content)
Embedding Models
OpenAI text-embedding-3-small / text-embedding-3-large
import time
import hashlib
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY from env
def embed_batch_with_retry(
texts: list[str],
model: str = "text-embedding-3-small", # 1536-dim; use "large" for 3072-dim
batch_size: int = 100,
max_retries: int = 5,
) -> list[list[float]]:
"""Batch embed with exponential backoff for rate limiting."""
all_embeddings = []
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
for attempt in range(max_retries):
try:
resp = client.embeddings.create(input=batch, model=model)
all_embeddings.extend([r.embedding for r in resp.data])
break
except Exception as e:
if attempt == max_retries - 1:
raise
wait = 2 ** attempt
time.sleep(wait)
return all_embeddings
Dimension considerations:
text-embedding-3-small: 1536 dims, $0.02/1M tokens — best cost/quality for most RAG use casestext-embedding-3-large: 3072 dims, $0.13/1M tokens — higher recall, larger index size- Both support Matryoshka truncation: pass
dimensions=256to get a shorter vector with acceptable quality loss
Cohere Embeddings
import cohere
co = cohere.Client() # reads COHERE_API_KEY
def embed_cohere(texts: list[str], input_type: str = "search_document") -> list[list[float]]:
# input_type: "search_document" for indexing, "search_query" for queries
response = co.embed(
texts=texts,
model="embed-english-v3.0", # 1024-dim
input_type=input_type,
embedding_types=["float"],
)
return response.embeddings.float
Local sentence-transformers
from sentence_transformers import SentenceTransformer
# BGE-large-en-v1.5: strong general-purpose, 1024-dim, runs on CPU
model = SentenceTransformer("BAAI/bge-large-en-v1.5")
def embed_local(texts: list[str], batch_size: int = 64) -> list[list[float]]:
embeddings = model.encode(
texts,
batch_size=batch_size,
normalize_embeddings=True, # required for cosine similarity via dot product
show_progress_bar=True,
)
return embeddings.tolist()
Embedding dimension guidance:
| Model | Dims | Notes |
|---|---|---|
| text-embedding-3-small | 1536 | Best commercial value |
| text-embedding-3-large | 3072 | Higher quality, larger storage |
| embed-english-v3.0 (Cohere) | 1024 | Requires asymmetric query/doc types |
| BAAI/bge-large-en-v1.5 | 1024 | Best open-source general purpose |
| all-MiniLM-L6-v2 | 384 | Fast CPU inference, lower quality |
Vector Stores
pgvector (PostgreSQL)
Ideal for teams already running PostgreSQL. Supports HNSW and IVFFlat indexes.
-- Enable extension
CREATE EXTENSION IF NOT EXISTS vector;
-- Document chunks table with metadata
CREATE TABLE document_chunks (
chunk_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
doc_id TEXT NOT NULL,
source TEXT NOT NULL, -- file path or URL
section TEXT, -- h1/h2 header context
chunk_index INT NOT NULL, -- position within document
content_hash TEXT NOT NULL, -- SHA-256 of chunk text for dedup
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
doc_date DATE, -- document publication date for recency filter
embedding vector(1536) -- match embedding model dims
);
-- HNSW index for cosine similarity (best for normalized embeddings)
CREATE INDEX ON document_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);
-- Composite index for filtered search — index on filter columns too
CREATE INDEX ON document_chunks (source, doc_date);
import psycopg
import numpy as np
def upsert_chunks(conn_str: str, chunks: list[dict]) -> None:
"""chunks: list of {chunk_id, doc_id, source, section, chunk_index,
content_hash, content, embedding, doc_date}"""
with psycopg.connect(conn_str) as conn:
with conn.cursor() as cur:
cur.executemany(
"""
INSERT INTO document_chunks
(chunk_id, doc_id, source, section, chunk_index,
content_hash, content, embedding, doc_date)
VALUES
(%(chunk_id)s, %(doc_id)s, %(source)s, %(section)s,
%(chunk_index)s, %(content_hash)s, %(content)s,
%(embedding)s::vector, %(doc_date)s)
ON CONFLICT (chunk_id) DO UPDATE SET
content = EXCLUDED.content,
content_hash = EXCLUDED.content_hash,
embedding = EXCLUDED.embedding,
section = EXCLUDED.section,
doc_date = EXCLUDED.doc_date
""",
chunks,
)
conn.commit()
def similarity_search(
conn_str: str,
query_embedding: list[float],
source_filter: str | None = None,
after_date: str | None = None, # ISO date string
top_k: int = 10,
) -> list[dict]:
conditions = []
params: dict = {"embedding": query_embedding, "k": top_k}
if source_filter:
conditions.append("source = %(source)s")
params["source"] = source_filter
if after_date:
conditions.append("doc_date >= %(after_date)s::date")
params["after_date"] = after_date
where_clause = ("WHERE " + " AND ".join(conditions)) if conditions else ""
sql = f"""
SELECT chunk_id, doc_id, source, section, content,
1 - (embedding <=> %(embedding)s::vector) AS cosine_score
FROM document_chunks
{where_clause}
ORDER BY embedding <=> %(embedding)s::vector
LIMIT %(k)s
"""
with psycopg.connect(conn_str) as conn:
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(sql, params)
return cur.fetchall()
pgvector metadata filtering note: From pgvector 0.8.0+, enable iterative HNSW scans for selective filters:
SET hnsw.iterative_scan = relaxed_order;
SET hnsw.ef_search = 100; -- increase for more accurate but slower search
Chroma (local development)
import chromadb
from chromadb.config import Settings
# In-process (dev) or HTTP client (staging)
client = chromadb.PersistentClient(path="./chroma_data")
# client = chromadb.HttpClient(host="localhost", port=8000)
collection = client.get_or_create_collection(
name="document_chunks",
metadata={"hnsw:space": "cosine"},
)
# Upsert
collection.upsert(
ids=[c["chunk_id"] for c in chunks],
embeddings=[c["embedding"] for c in chunks],
documents=[c["content"] for c in chunks],
metadatas=[
{"doc_id": c["doc_id"], "source": c["source"],
"section": c["section"], "doc_date": c["doc_date"]}
for c in chunks
],
)
# Search with metadata filter
results = collection.query(
query_embeddings=[query_embedding],
n_results=10,
where={"source": {"$eq": "handbook.md"}}, # Chroma filter syntax
include=["documents", "metadatas", "distances"],
)
Qdrant (production)
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance, VectorParams, PointStruct,
Filter, FieldCondition, MatchValue, Range,
)
client = QdrantClient(url="http://qdrant-host:6333", api_key="...")
# Create collection
client.recreate_collection(
collection_name="document_chunks",
vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
# For named vectors (multi-embedding): use dict of VectorParams
)
# Upsert
points = [
PointStruct(
id=c["chunk_id"], # must be UUID or uint64
vector=c["embedding"],
payload={
"doc_id": c["doc_id"], "source": c["source"],
"section": c["section"], "doc_date": c["doc_date"],
"content": c["content"],
},
)
for c in chunks
]
client.upsert(collection_name="document_chunks", points=points)
# Filtered similarity search
results = client.search(
collection_name="document_chunks",
query_vector=query_embedding,
limit=10,
query_filter=Filter(
must=[
FieldCondition(key="source", match=MatchValue(value="handbook.md")),
FieldCondition(key="doc_date", range=Range(gte="2024-01-01")),
]
),
with_payload=True,
)
Weaviate
import weaviate
import weaviate.classes as wvc
client = weaviate.connect_to_local() # or connect_to_weaviate_cloud(...)
# Create collection (schema)
client.collections.create(
name="DocumentChunk",
vectorizer_config=wvc.config.Configure.Vectorizer.none(), # bring-your-own vectors
properties=[
wvc.config.Property(name="doc_id", data_type=wvc.config.DataType.TEXT),
wvc.config.Property(name="source", data_type=wvc.config.DataType.TEXT),
wvc.config.Property(name="section", data_type=wvc.config.DataType.TEXT),
wvc.config.Property(name="content", data_type=wvc.config.DataType.TEXT),
wvc.config.Property(name="doc_date", data_type=wvc.config.DataType.DATE),
],
)
collection = client.collections.get("DocumentChunk")
# Batch upsert
with collection.batch.dynamic() as batch:
for c in chunks:
batch.add_object(
properties={
"doc_id": c["doc_id"], "source": c["source"],
"section": c["section"], "content": c["content"],
"doc_date": c["doc_date"],
},
vector=c["embedding"],
uuid=c["chunk_id"],
)
# Filtered vector search
response = collection.query.near_vector(
near_vector=query_embedding,
limit=10,
filters=wvc.query.Filter.by_property("source").equal("handbook.md"),
return_metadata=wvc.query.MetadataQuery(distance=True),
)
Incremental Embedding Refresh Pipeline
Change Detection
Use SHA-256 content hashes to detect which chunks actually changed. Avoid re-embedding unchanged content — can eliminate 60–80% of unnecessary API calls.
import hashlib
import json
from dataclasses import dataclass
from datetime import datetime
def compute_content_hash(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
@dataclass
class ChunkRecord:
chunk_id: str
doc_id: str
content_hash: str
embedded_at: datetime
def get_existing_hashes(conn_str: str, doc_id: str) -> dict[str, ChunkRecord]:
"""Return {chunk_id: ChunkRecord} for all chunks of a document."""
with psycopg.connect(conn_str) as conn:
with conn.cursor(row_factory=psycopg.rows.class_row(ChunkRecord)) as cur:
cur.execute(
"SELECT chunk_id, doc_id, content_hash, created_at AS embedded_at "
"FROM document_chunks WHERE doc_id = %s",
(doc_id,),
)
return {r.chunk_id: r for r in cur.fetchall()}
def compute_diff(
new_chunks: list[dict],
existing: dict[str, ChunkRecord],
) -> tuple[list[dict], list[str]]:
"""Returns (chunks_to_upsert, chunk_ids_to_delete)."""
new_ids = set()
to_upsert = []
for chunk in new_chunks:
cid = chunk["chunk_id"]
new_ids.add(cid)
if cid not in existing or existing[cid].content_hash != chunk["content_hash"]:
to_upsert.append(chunk)
to_delete = [cid for cid in existing if cid not in new_ids]
return to_upsert, to_delete
def delete_stale_chunks(conn_str: str, chunk_ids: list[str]) -> None:
if not chunk_ids:
return
with psycopg.connect(conn_str) as conn:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM document_chunks WHERE chunk_id = ANY(%s)",
(chunk_ids,),
)
conn.commit()
Airflow DAG for Incremental Refresh
import pendulum
from airflow.sdk import dag, task
from airflow.providers.standard.operators.python import PythonOperator
@dag(
dag_id="rag_embedding_refresh",
start_date=pendulum.datetime(2024, 1, 1, tz="UTC"),
schedule="0 2 * * *", # nightly at 02:00 UTC
catchup=False,
max_active_runs=1,
default_args={"retries": 2, "retry_delay": pendulum.duration(minutes=5)},
tags=["rag", "embeddings"],
)
def rag_embedding_refresh():
@task
def list_changed_documents() -> list[str]:
"""Return doc_ids that changed since the last run (timestamp or hash check)."""
from my_doc_store import get_documents_modified_since
from my_db import get_last_run_timestamp
last_run = get_last_run_timestamp(dag_id="rag_embedding_refresh")
changed = get_documents_modified_since(last_run)
return [d["doc_id"] for d in changed]
@task
def load_and_chunk(doc_id: str) -> list[dict]:
from my_doc_store import load_document
from my_chunker import chunk_document
doc = load_document(doc_id)
return chunk_document(doc)
@task
def embed_and_upsert(chunks: list[dict]) -> dict:
from my_embedder import embed_batch_with_retry
from my_vector_store import upsert_chunks, delete_stale_chunks, compute_diff, get_existing_hashes
import os
if not chunks:
return {"upserted": 0, "deleted": 0}
doc_id = chunks[0]["doc_id"]
existing = get_existing_hashes(os.environ["PGVECTOR_CONN"], doc_id)
to_upsert, to_delete = compute_diff(chunks, existing)
if to_upsert:
texts = [c["content"] for c in to_upsert]
embeddings = embed_batch_with_retry(texts)
for chunk, emb in zip(to_upsert, embeddings):
chunk["embedding"] = emb
upsert_chunks(os.environ["PGVECTOR_CONN"], to_upsert)
delete_stale_chunks(os.environ["PGVECTOR_CONN"], to_delete)
return {"upserted": len(to_upsert), "deleted": len(to_delete)}
doc_ids = list_changed_documents()
# Dynamic task mapping: one embed_and_upsert task per changed document
chunks = load_and_chunk.expand(doc_id=doc_ids)
embed_and_upsert.expand(chunks=chunks)
rag_embedding_refresh()
Hybrid Retrieval
Why Hybrid
Dense vector search excels at semantic similarity but misses exact token matches (error codes, product IDs, proper nouns). BM25 excels at keyword matching but misses paraphrase/synonym queries. Hybrid search combining both achieves ~91% recall@10 vs ~78% for either alone.
Reciprocal Rank Fusion (RRF)
RRF is score-scale agnostic — it operates on ranks, not raw scores (cosine similarity and BM25 scores are incompatible scales). Formula: score(d) = Σ 1 / (k + rank(d)) where k=60 is the standard constant.
from rank_bm25 import BM25Okapi
from typing import Any
def rrf_fuse(
dense_results: list[dict], # [{chunk_id, content, ...}, ...]
sparse_results: list[dict], # same structure
k: int = 60,
dense_weight: float = 1.0,
sparse_weight: float = 1.0,
) -> list[dict]:
"""Merge dense and sparse ranked lists with Reciprocal Rank Fusion."""
scores: dict[str, float] = {}
id_to_doc: dict[str, dict] = {}
for rank, doc in enumerate(dense_results, start=1):
cid = doc["chunk_id"]
scores[cid] = scores.get(cid, 0.0) + dense_weight / (k + rank)
id_to_doc[cid] = doc
for rank, doc in enumerate(sparse_results, start=1):
cid = doc["chunk_id"]
scores[cid] = scores.get(cid, 0.0) + sparse_weight / (k + rank)
id_to_doc[cid] = doc
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return [id_to_doc[cid] for cid, _ in ranked]
class BM25Retriever:
"""In-memory BM25 retriever. For production, use Elasticsearch/OpenSearch."""
def __init__(self, corpus: list[dict]):
self.docs = corpus
tokenized = [doc["content"].lower().split() for doc in corpus]
self.bm25 = BM25Okapi(tokenized)
def search(self, query: str, top_k: int = 20) -> list[dict]:
tokens = query.lower().split()
scores = self.bm25.get_scores(tokens)
ranked_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
return [self.docs[i] for i in ranked_idx[:top_k]]
def hybrid_search(
query: str,
query_embedding: list[float],
bm25_retriever: BM25Retriever,
vector_search_fn, # callable(embedding, top_k) -> list[dict]
top_k: int = 10,
candidate_k: int = 50,
) -> list[dict]:
dense = vector_search_fn(query_embedding, top_k=candidate_k)
sparse = bm25_retriever.search(query, top_k=candidate_k)
fused = rrf_fuse(dense, sparse)
return fused[:top_k]
Cross-Encoder Re-ranking
Reranking is the single most impactful component after retrieval — cross-encoders jointly encode query and document, providing fine-grained relevance scoring. MRR@3 typically jumps from ~0.43 to ~0.60.
from sentence_transformers.cross_encoder import CrossEncoder
# Use a dedicated cross-encoder, not a bi-encoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
# For production quality: "BAAI/bge-reranker-large" or Cohere Rerank API
def rerank(
query: str,
candidates: list[dict],
top_n: int = 5,
) -> list[dict]:
"""Re-score and re-order candidates with a cross-encoder."""
pairs = [(query, doc["content"]) for doc in candidates]
scores = reranker.predict(pairs, batch_size=32)
ranked = sorted(
zip(candidates, scores), key=lambda x: x[1], reverse=True
)
return [doc for doc, _ in ranked[:top_n]]
# Full two-stage retrieval
def retrieve_for_rag(
query: str,
query_embedding: list[float],
bm25_retriever: BM25Retriever,
vector_search_fn,
top_k_final: int = 5,
) -> list[dict]:
# Stage 1: hybrid retrieval, broad candidate pool
candidates = hybrid_search(
query, query_embedding, bm25_retriever, vector_search_fn,
top_k=100, candidate_k=50,
)
# Stage 2: cross-encoder reranking, narrow to final context
return rerank(query, candidates, top_n=top_k_final)
Metadata Filtering
Attach structured metadata to every chunk at index time. Use it to scope retrieval to the right subset before vector scoring.
Common metadata fields:
| Field | Type | Use Case |
|---|---|---|
doc_id | str/UUID | Delete all chunks of a doc |
source | str | Filter by document path / URL |
section | str | Filter by chapter / heading |
doc_date | date | Recency constraint (only docs after X) |
content_hash | str | Deduplication and change detection |
language | str | Multilingual collections |
access_level | str | Security-scoped retrieval |
pgvector filter example:
-- Retrieve only from specific source and last 90 days
SELECT chunk_id, content,
1 - (embedding <=> $1::vector) AS score
FROM document_chunks
WHERE source = 'internal_handbook'
AND doc_date >= CURRENT_DATE - INTERVAL '90 days'
ORDER BY embedding <=> $1::vector
LIMIT 10;
Qdrant filter example:
from qdrant_client.models import Filter, FieldCondition, MatchValue, Range
query_filter = Filter(
must=[
FieldCondition(key="access_level", match=MatchValue(value="public")),
],
should=[
FieldCondition(key="section", match=MatchValue(value="pricing")),
FieldCondition(key="section", match=MatchValue(value="billing")),
],
)
results = client.search(
collection_name="document_chunks",
query_vector=query_embedding,
query_filter=query_filter,
limit=10,
)
Production Considerations
Deduplication of Chunks
def deduplicate_chunks(chunks: list[dict]) -> list[dict]:
"""Remove exact-duplicate chunks by content hash before embedding."""
seen: set[str] = set()
unique = []
for chunk in chunks:
h = compute_content_hash(chunk["content"])
chunk["content_hash"] = h
if h not in seen:
seen.add(h)
unique.append(chunk)
return unique
Near-duplicate detection: use MinHash / SimHash for fuzzy dedup when slight reformatting creates near-duplicates across documents.
Monitoring Retrieval Quality
Track these metrics in production:
from dataclasses import dataclass, field
from statistics import mean
@dataclass
class RetrievalMetrics:
hit_rate: list[float] = field(default_factory=list) # 1 if answer in top-k
mrr: list[float] = field(default_factory=list) # mean reciprocal rank
latency_ms: list[float] = field(default_factory=list)
def record_query(
self,
ranked_chunks: list[dict],
relevant_chunk_ids: set[str],
latency_ms: float,
) -> None:
retrieved_ids = [c["chunk_id"] for c in ranked_chunks]
hit = any(cid in relevant_chunk_ids for cid in retrieved_ids)
self.hit_rate.append(float(hit))
rr = 0.0
for rank, cid in enumerate(retrieved_ids, start=1):
if cid in relevant_chunk_ids:
rr = 1.0 / rank
break
self.mrr.append(rr)
self.latency_ms.append(latency_ms)
def summary(self) -> dict:
return {
"hit_rate": round(mean(self.hit_rate), 4) if self.hit_rate else 0,
"mrr": round(mean(self.mrr), 4) if self.mrr else 0,
"p95_latency_ms": sorted(self.latency_ms)[int(len(self.latency_ms) * 0.95)]
if self.latency_ms else 0,
"n_queries": len(self.hit_rate),
}
A/B Testing Retrieval Strategies
import random
def route_retrieval_strategy(query: str, user_id: str) -> str:
"""Deterministic A/B split based on user_id hash."""
bucket = int(hashlib.md5(user_id.encode()).hexdigest(), 16) % 100
if bucket < 50:
return "dense_only" # control
else:
return "hybrid_rerank" # treatment
# Log each query with strategy for offline analysis
experiment_log = {
"query_id": query_id,
"strategy": strategy,
"user_id": user_id,
"retrieved_chunk_ids": [c["chunk_id"] for c in results],
"user_feedback": None, # filled in via thumbs up/down
}
Latency vs Quality Tradeoffs
| Component | Latency | Quality | When to Add |
|---|---|---|---|
| Dense-only retrieval | low (5–20ms) | medium | Dev / low-stakes |
| + BM25 hybrid + RRF | medium (+10–30ms) | high | Production default |
| + Cross-encoder rerank | high (+50–200ms) | very high | High-value queries, async flows |
| Semantic chunking | offline cost | better precision | Narrative documents |
| Matryoshka dims (256) | low (index 6x smaller) | slightly lower | High-scale, cost-sensitive |
Key thresholds (production baselines):
- Hit rate (answer in top-10): target >85% on evaluation set
- MRR@5: target >0.60 after reranking
- p95 online latency: <500ms end-to-end including LLM call
- Embedding refresh lag: <24h for mutable knowledge bases
Embedding Model Versioning
When you upgrade the embedding model, ALL existing vectors become stale (different embedding space).
# Store embedding_model_version in chunk metadata
chunk["embedding_model"] = "text-embedding-3-small-v1"
# Query: only search within same model version
results = similarity_search(
conn_str=CONN,
query_embedding=embed(query, model="text-embedding-3-small"),
model_version_filter="text-embedding-3-small-v1",
)
Run a background re-indexing job when upgrading models; maintain a model_version column and index; cut over queries atomically after the job completes.
Anti-Patterns
- Chunk size too large (>2000 chars): Dilutes the embedding signal; the model must represent too many topics in one vector. Use hierarchical chunking (section-level + chunk-level) instead.
- No chunk overlap: Sentences at boundaries lose context. Always use 10–15% overlap.
- Re-embedding all documents on every run: Compute content hashes and only re-embed changed chunks. Re-embedding is expensive and causes unnecessary API costs.
- Skipping deduplication: Near-duplicate chunks from repeated boilerplate (headers, footers, disclaimers) will saturate the context window with redundant text. Deduplicate by hash before indexing.
- Dense-only retrieval for technical queries: Exact token matches (error codes, CLI flags, product SKUs) require BM25. Always use hybrid for technical knowledge bases.
- RRF score normalization: Do not normalize BM25 and cosine scores before fusion — incompatible scales cause unpredictable results. Use RRF on ranks instead.
- Cross-encoder on full corpus: Cross-encoders are O(n) per query. Only apply them to the top-100 candidates from the first-stage retrieval.
- Ignoring filter selectivity with pgvector: Highly selective metadata filters + HNSW can return fewer results than
LIMIT. Enablehnsw.iterative_scan = relaxed_orderfor production filtered search. - Missing embedding model versioning: Upgrading the embedding model without re-indexing all chunks produces mixed embedding spaces. Results become unpredictably wrong for unchanged documents.
- No retrieval quality monitoring: Without hit-rate and MRR tracking against a labelled evaluation set, chunking and retrieval regressions go undetected after changes.
References to Consult When Needed
- pgvector HNSW tuning: https://github.com/pgvector/pgvector —
ef_construction,m,iterative_scan - Qdrant filtering docs: https://qdrant.tech/documentation/concepts/filtering/
- LangChain text splitters: https://python.langchain.com/docs/modules/data_connection/document_transformers/
- OpenAI embeddings: https://platform.openai.com/docs/guides/embeddings
- Cohere Rerank API: https://docs.cohere.com/reference/rerank
- sentence-transformers cross-encoders: https://www.sbert.net/docs/pretrained_cross-encoders.html
- BM25 Python: https://github.com/dorianbrown/rank_bm25
- RRF algorithm: Cormack, Clarke, Buettcher (2009) — "Reciprocal Rank Fusion outperforms Condorcet and individual rank learning methods"