Production RAG (Retrieval-Augmented Generation) patterns — document chunking strategies, embedding model selection, vector store integration, hybrid search, reranking, and hallucination prevention. Includes LangChain and direct API approaches.
You are an AI engineer building production RAG systems. Follow these patterns for retrieval-augmented generation.
Documents → Chunk → Embed → Store in Vector DB
↓
User Query → Embed → Search Vector DB → Retrieve Top-K → Rerank → LLM Generate
Chunk size determines retrieval quality. Too small = missing context. Too large = diluted relevance.
interface Chunk {
id: string;
content: string;
metadata: {
source: string;
page?: number;
section?: string;
tokenCount: number;
};
}
function chunkByParagraph(text: string, maxTokens = 512, overlap = 50): Chunk[] {
const paragraphs = text.split(/\n\n+/);
const chunks: Chunk[] = [];
let current = '';
let tokenCount = 0;
for (const para of paragraphs) {
const paraTokens = estimateTokens(para);
if (tokenCount + paraTokens > maxTokens && current) {
chunks.push(createChunk(current, tokenCount));
// Keep overlap from end of previous chunk
const words = current.split(' ');
current = words.slice(-overlap).join(' ') + '\n\n' + para;
tokenCount = estimateTokens(current);
} else {
current += (current ? '\n\n' : '') + para;
tokenCount += paraTokens;
}
}
if (current) chunks.push(createChunk(current, tokenCount));
return chunks;
}
// For code: chunk by function/class boundaries
function chunkCode(code: string, language: string): Chunk[] {
const patterns: Record<string, RegExp> = {
typescript: /^(export\s+)?(async\s+)?function\s+|^(export\s+)?class\s+|^(export\s+)?const\s+\w+\s*=/gm,
python: /^(async\s+)?def\s+|^class\s+/gm,
java: /^(public|private|protected)\s+(static\s+)?\w+\s+\w+\s*\(/gm,
};
// Split at function/class boundaries
// Each chunk = one function or class with its docstring
}
import Anthropic from '@anthropic-ai/sdk';
const anthropic = new Anthropic();
async function embedTexts(texts: string[]): Promise<number[][]> {
// Using Anthropic's voyage embeddings or OpenAI
const response = await fetch('https://api.voyageai.com/v1/embeddings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.VOYAGE_API_KEY}`,
},
body: JSON.stringify({
model: 'voyage-3',
input: texts,
input_type: 'document',
}),
});
const data = await response.json();
return data.data.map((d: { embedding: number[] }) => d.embedding);
}
async function embedQuery(query: ): <[]> {
response = (, {
: ,
: {
: ,
: ,
},
: .({
: ,
: [query],
: ,
}),
});
data = response.();
data.[].;
}
// Using pgvector with PostgreSQL
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
async function storeChunks(chunks: Chunk[], embeddings: number[][]) {
const client = await pool.connect();
try {
await client.query('BEGIN');
for (let i = 0; i < chunks.length; i++) {
await client.query(
`INSERT INTO documents (id, content, embedding, metadata)
VALUES ($1, $2, $3::vector, $4)
ON CONFLICT (id) DO UPDATE SET content = $2, embedding = $3::vector`,
[chunks[i].id, chunks[i].content, JSON.stringify(embeddings[i]), chunks[i].metadata]
);
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
async (): <[]> {
result = pool.(
,
[.(queryEmbedding), topK]
);
result.;
}
Combine semantic search with keyword matching for better recall.
async function hybridSearch(query: string, topK = 10): Promise<Chunk[]> {
const queryEmbedding = await embedQuery(query);
const results = await pool.query(
`WITH semantic AS (
SELECT id, content, metadata,
1 - (embedding <=> $1::vector) AS semantic_score
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT $2
),
keyword AS (
SELECT id, content, metadata,
ts_rank(to_tsvector('english', content), plainto_tsquery('english', $3)) AS keyword_score
FROM documents
WHERE to_tsvector('english', content) @@ plainto_tsquery('english', $3)
LIMIT $2
)
SELECT COALESCE(s.id, k.id) AS id,
COALESCE(s.content, k.content) AS content,
COALESCE(s.metadata, k.metadata) AS metadata,
COALESCE(s.semantic_score, 0) * 0.7 + COALESCE(k.keyword_score, 0) * 0.3 AS combined_score
FROM semantic s
FULL OUTER JOIN keyword k ON s.id = k.id
ORDER BY combined_score DESC
LIMIT $2`,
[JSON.stringify(queryEmbedding), topK, query]
);
return results.rows;
}
async function generateAnswer(query: string, context: Chunk[]): Promise<string> {
const contextText = context
.map((c, i) => `[Source ${i + 1}] ${c.content}`)
.join('\n\n---\n\n');
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
system: `You are a helpful assistant that answers questions based on the provided context.
Rules:
- Only answer based on the provided context. If the context doesn't contain the answer, say so.
- Cite sources using [Source N] notation.
- Be concise and direct.
- Never make up information not present in the context.`,
messages: [{
role: 'user',
content: `Context:\n${contextText}\n\nQuestion: ${query}`,
}],
});
return response.content[0].type === 'text' ? response.content[0].text : '';
}
async function answerWithVerification(query: string): Promise<{
answer: string;
sources: Chunk[];
confidence: 'high' | 'medium' | 'low';
}> {
const chunks = await hybridSearch(query, 8);
// Filter chunks by minimum similarity threshold
const relevant = chunks.filter((c) => c.similarity > 0.7);
if (relevant.length === 0) {
return {
answer: "I don't have enough information to answer this question.",
sources: [],
confidence: 'low',
};
}
const answer = await generateAnswer(query, relevant);
// Verify: check if the answer is grounded in sources
const verification = await anthropic.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 100,
messages: [{
role: 'user',
: ,
}],
});
verificationText = verification.[]. === ? verification.[]. : ;
{
answer,
: relevant,
: verificationText.() && !verificationText.()
?
: verificationText.() ? : ,
};
}
async function ingestDocument(filePath: string, source: string) {
// 1. Extract text
const text = await extractText(filePath);
// 2. Chunk
const chunks = chunkByParagraph(text, 512, 50);
chunks.forEach((c) => { c.metadata.source = source; });
// 3. Embed in batches (API limits)
const batchSize = 64;
for (let i = 0; i < chunks.length; i += batchSize) {
const batch = chunks.slice(i, i + batchSize);
const embeddings = await embedTexts(batch.map((c) => c.content));
await storeChunks(batch, embeddings);
}
console.log(`Ingested ${chunks.length} chunks from ${source}`);
}
| Decision | Recommendation | Why |
|---|---|---|
| Chunk size | 256-512 tokens | Balances context and precision |
| Overlap | 10-20% of chunk size | Prevents information loss at boundaries |
| Top-K retrieval | 5-10 chunks | More chunks = more context but higher cost |
| Embedding model | voyage-3 or text-embedding-3-small | Best quality/cost ratio |
| Vector DB | pgvector for small-medium, Pinecone for large scale | pgvector avoids another service |
| Similarity threshold | 0.7+ for strict, 0.5+ for broad | Filter irrelevant results |