RAG in TypeScript: Embeddings, pgvector, Hybrid Search, and Citations
Build retrieval-augmented generation end to end: chunking, Voyage embeddings, Postgres with pgvector, hybrid search with rank fusion, reranking, and cited answers.
On this page· The two paths
Why this matters
Models know a lot, but not your documentation, your policies, or anything written after their training. Retrieval-augmented generation (RAG) finds the passages relevant to a question and puts them in the request, so the model answers from your content and can cite it. It is the most common pattern in production AI apps, and most of its quality comes from ordinary engineering: how you split documents, how you search, and how you measure.
The two paths#
Hover or tap a component to see what it does.
Show text descriptionHide text description
Retrieval-augmented generation has two paths that meet at the vector database.
Indexing path. Runs offline whenever documents change: load each document, split it into chunks, embed every chunk, and store the vectors with their text and metadata.
Query path. Runs on every request: embed the question, retrieve the nearest chunks, rerank them, and give the best few to the LLM, which answers with citations.
- Documents: Your source of truth: help-center articles, internal docs, tickets, PDFs. Keep each document's URL and title for citations.
- Chunk: Split documents into passages of a few hundred tokens, ideally on headings or paragraphs, with a small overlap. Attach the source, title, and section to each chunk.
- Embedding model: Turns each chunk into a vector: a list of numbers where similar meanings end up close together. Use the same embedding model for indexing and querying.
- Vector DB: Stores each chunk's text, metadata, and vector, with an index (such as HNSW) for fast nearest-neighbor search.
- Question: The user's question, for example: "How do I rotate an API key?"
- Embedding model: Embeds the question with the same model used for the documents, so the question and the chunks live in the same vector space.
- Vector search: Finds the chunks whose vectors are closest to the question's vector, usually 20 to 50 candidates. Combine with keyword search for exact terms (hybrid search).
- Reranker: A model that reads the question and each candidate together and scores relevance more precisely than vector similarity. Keep only the best few chunks.
- LLM: Receives the question plus the selected chunks, with instructions to answer only from them and cite the sources it used.
- Answer: The response shown to the user, with links to the source documents so they can verify it.
The numbers on the arrows are typical starting points, not rules: retrieve around 20 candidates, then keep the best 5 after reranking. Tune both with evals.
RAG has two halves that run at different times:
- Indexing (ahead of time, when documents change): split documents into chunks, compute an embedding for each, and store chunks, embeddings, and metadata in a database.
- Querying (per question): embed the question, retrieve similar chunks, rerank them, and send the best few to the model with instructions to answer from them and cite them.
You can skip half of this machinery when your content is small. If everything fits comfortably in a prompt (say under 100,000 tokens) and changes rarely, put it all in the system prompt with prompt caching. RAG pays off when the corpus is large, changes often, or needs per-user access control.
1. Chunking#
A chunk is the unit you retrieve. It should be small enough to be specific and large enough to make sense alone. For Markdown documentation, splitting on headings and packing paragraphs up to a size limit works well, and keeping the heading path as metadata gives each chunk context:
export interface Chunk {
source: string; // URL or path, used for citations
title: string; // "Billing > Refunds"
content: string;
}
const MAX_CHARS = 1500; // roughly 300 to 400 tokens of English
export function chunkMarkdown(source: string, markdown: string): Chunk[] {
const chunks: Chunk[] = [];
const headings: string[] = [];
let buffer: string[] = [];
const flush = () => {
const content = buffer.join("\n\n").trim();
if (content) chunks.push({ source, title: headings.filter(Boolean).join(" > "), content });
buffer = [];
};
for (const block of markdown.split(/\n{2,}/)) {
const heading = /^(#{1,3})\s+(.+)$/.exec(block.trim());
if (heading) {
flush();
const level = heading[1]!.length;
headings.length = level; // drop deeper headings from the previous section
headings[level - 1] = heading[2]!.trim();
continue;
}
if (buffer.join("\n\n").length + block.length > MAX_CHARS) flush();
buffer.push(block.trim());
}
flush();
return chunks;
}2. Embeddings#
An embedding is a list of numbers (a vector) that represents the meaning of a text. Texts with similar meaning have vectors that point in similar directions, so "How do I get my money back?" lands near a chunk about refunds even though they share no words.
- Billing
- Sign-in & accounts
- Shipping
Show text descriptionHide text description
Twelve support questions and one new query, embedded and projected to two dimensions for display. Real embeddings have hundreds or thousands of dimensions; the projection keeps neighbors close. Positions are illustrative.
| Sentence | Cluster |
|---|---|
| “How do I update my credit card?” | Billing |
| “Why was I charged twice this month?” | Billing |
| “Can I get an invoice for my subscription?” | Billing |
| “Refund for an annual plan” | Billing |
| “I can't log in after resetting my password” | Sign-in & accounts |
| “Two-factor code never arrives” | Sign-in & accounts |
| “How do I change the email on my account?” | Sign-in & accounts |
| “Locked out after too many attempts” | Sign-in & accounts |
| “Where is my package?” | Shipping |
| “Tracking number shows no updates” | Shipping |
| “Can I change my delivery address?” | Shipping |
| “Do you ship to Canada?” | Shipping |
| Query: “My payment failed, what now?” lands next to billing questions | Billing |
Anthropic does not offer an embeddings model; a common pairing is Voyage AI. Two details matter: embed in batches, and tell the model whether each text is a document or a query, because it embeds them slightly differently for better retrieval.
const API = "https://api.voyageai.com/v1";
const MODEL = "voyage-4"; // 1024 dimensions by default
const BATCH = 128;
async function post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${API}${path}`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${process.env.VOYAGE_API_KEY}` },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Voyage ${path} failed: ${res.status} ${await res.text()}`);
return (await res.json()) as T;
}
export async function embed(texts: string[], inputType: "document" | "query"): Promise<number[][]> {
const vectors: number[][] = [];
for (let i = 0; i < texts.length; i += BATCH) {
const { data } = await post<{ data: { embedding: number[]; index: number }[] }>("/embeddings", {
input: texts.slice(i, i + BATCH),
model: MODEL,
input_type: inputType,
});
vectors.push(...data.sort((a, b) => a.index - b.index).map((d) => d.embedding));
}
return vectors;
}
export async function rerank(query: string, documents: string[], topK: number): Promise<{ index: number; score: number }[]> {
const { data } = await post<{ data: { index: number; relevance_score: number }[] }>("/rerank", {
query,
documents,
model: "rerank-3",
top_k: topK,
});
return data.map((d) => ({ index: d.index, score: d.relevance_score }));
}3. Storage: Postgres with pgvector#
You do not need a separate vector database to start. The pgvector extension adds a vector type and fast approximate search to Postgres, so chunks live next to the rest of your data, with transactions, joins, and access control. A generated tsvector column adds keyword search in the same table:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
source text NOT NULL,
title text NOT NULL,
content text NOT NULL,
embedding vector(1024) NOT NULL,
-- Keyword search over title and content, kept up to date by Postgres.
tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || content)) STORED
);
CREATE INDEX chunks_embedding_idx ON chunks USING hnsw (embedding vector_cosine_ops);
CREATE INDEX chunks_tsv_idx ON chunks USING gin (tsv);import pg from "pg";
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
// pgvector accepts the text form "[0.1,0.2,...]".
export const toVector = (v: number[]) => JSON.stringify(v);Indexing then reads files, chunks them, embeds the chunks, and replaces each source's rows in a transaction, so re-running it after an edit never leaves a half-updated document:
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { chunkMarkdown } from "./lib/chunk.ts";
import { pool, toVector } from "./lib/db.ts";
import { embed } from "./lib/voyage.ts";
const dir = process.argv[2] ?? "docs";
for (const file of (await readdir(dir)).filter((f) => f.endsWith(".md"))) {
const chunks = chunkMarkdown(file, await readFile(path.join(dir, file), "utf8"));
// Embedding the title with the content helps short chunks match.
const vectors = await embed(chunks.map((c) => `${c.title}\n\n${c.content}`), "document");
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("DELETE FROM chunks WHERE source = $1", [file]);
for (const [i, c] of chunks.entries()) {
await client.query("INSERT INTO chunks (source, title, content, embedding) VALUES ($1, $2, $3, $4)", [
c.source, c.title, c.content, toVector(vectors[i]!),
]);
}
await client.query("COMMIT");
console.log(`${file}: ${chunks.length} chunks`);
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
await pool.end();4. Retrieval: hybrid search and reranking#
Vector search is good at meaning and weak at exact tokens: an error code like E_CARD_DECLINED, a product name, or an ID. Keyword search is the opposite. Hybrid search runs both and merges the rankings.
Hover or tap a component to see what it does.
Show text descriptionHide text description
Vector search understands meaning; keyword search catches exact terms like error codes, product names, and IDs. Combining them is one of the cheapest ways to improve retrieval quality.
- Query: For example: “error E1042 when exporting a PDF”.
- Vector search: Finds passages about failed exports even if they never use the word “error”.
- Keyword search: Postgres full-text search finds the exact code “E1042”, which embeddings often miss.
- Merge (RRF): Each result scores 1 / (k + rank) in each list it appears in; sum the scores. No score calibration needed.
- Reranker: Reads the query with each candidate and orders them by true relevance.
- LLM: Receives only the best passages.
The merge uses reciprocal rank fusion (RRF): each result scores 1 / (60 + rank) in each list it appears in, and the scores add up. It needs no tuning and works even though vector distances and keyword scores are on different scales. A reranker then reads the question and each candidate together, which is slower but more accurate than either search, and picks the final few:
import { pool, toVector } from "./db.ts";
import { embed, rerank } from "./voyage.ts";
export interface Hit {
id: number;
source: string;
title: string;
content: string;
}
const CANDIDATES = 20;
export function reciprocalRankFusion(lists: Hit[][], k = 60): Hit[] {
const scores = new Map<number, { hit: Hit; score: number }>();
for (const list of lists) {
list.forEach((hit, rank) => {
const entry = scores.get(hit.id) ?? { hit, score: 0 };
entry.score += 1 / (k + rank + 1);
scores.set(hit.id, entry);
});
}
return [...scores.values()].sort((a, b) => b.score - a.score).map((e) => e.hit);
}
export async function search(question: string, topK = 5): Promise<Hit[]> {
const [queryVector] = await embed([question], "query");
const [byMeaning, byKeyword] = await Promise.all([
// <=> is cosine distance; ORDER BY distance uses the HNSW index.
pool.query<Hit>(
"SELECT id, source, title, content FROM chunks ORDER BY embedding <=> $1 LIMIT $2",
[toVector(queryVector!), CANDIDATES],
),
pool.query<Hit>(
`SELECT id, source, title, content FROM chunks
WHERE tsv @@ websearch_to_tsquery('english', $1)
ORDER BY ts_rank_cd(tsv, websearch_to_tsquery('english', $1)) DESC LIMIT $2`,
[question, CANDIDATES],
),
]);
const fused = reciprocalRankFusion([byMeaning.rows, byKeyword.rows]).slice(0, CANDIDATES);
if (fused.length === 0) return [];
const ranked = await rerank(question, fused.map((h) => `${h.title}\n\n${h.content}`), topK);
return ranked.map((r) => fused[r.index]!);
}Access control belongs in these queries. If documents belong to tenants or users, add WHERE tenant_id = $3 to both searches. Filtering after retrieval is too late: it can leak content into logs, and it leaves you with fewer than topK results.
5. Generation with citations#
Retrieved chunks go to the model as search_result content blocks, each with a source and title. With citations enabled, the response's text blocks carry citations that point to the exact passages used, which you can render as links:
import Anthropic from "@anthropic-ai/sdk";
import { pool } from "./lib/db.ts";
import { search } from "./lib/search.ts";
const client = new Anthropic();
const question = process.argv[2] ?? "How long do refunds take?";
const hits = await search(question);
const message = await client.messages.create({
model: "claude-opus-5-5",
max_tokens: 4096,
system:
"Answer questions about Acme Billing using only the search results provided. If they do not contain the answer, say so plainly and suggest contacting support.",
messages: [
{
role: "user",
content: [
...hits.map((h): Anthropic.SearchResultBlockParam => ({
type: "search_result",
source: h.source,
title: h.title,
content: [{ type: "text", text: h.content }],
citations: { enabled: true },
})),
{ type: "text", text: question },
],
},
],
});
// Print the answer with numbered references.
const sources: string[] = [];
let output = "";
for (const block of message.content) {
if (block.type !== "text") continue;
output += block.text;
for (const c of block.citations ?? []) {
if (c.type !== "search_result_location") continue;
const ref = `${c.source}: ${c.title ?? ""}`;
if (!sources.includes(ref)) sources.push(ref);
output += ` [${sources.indexOf(ref) + 1}]`;
}
}
console.log(output);
sources.forEach((s, i) => console.log(`[${i + 1}] ${s}`));
await pool.end();The question goes after the search results: models answer long-context questions better when the question comes last.
Measure retrieval before tuning prompts#
Most "the model gave a wrong answer" bugs in RAG are retrieval bugs: the right chunk was never in the context. Measure retrieval on its own with a small set of questions and the source each one should find:
import { pool } from "./lib/db.ts";
import { search } from "./lib/search.ts";
const cases = [
{ question: "How long do refunds take?", expected: "refunds.md" },
{ question: "What does E_CARD_DECLINED mean?", expected: "errors.md" },
{ question: "Can I pay by bank transfer?", expected: "payments.md" },
];
let found = 0;
for (const { question, expected } of cases) {
const hits = await search(question, 5);
const ok = hits.some((h) => h.source === expected);
if (ok) found++;
console.log(ok ? "✓" : "✗", question, "→", hits.map((h) => h.source).join(", "));
}
console.log(`recall@5: ${((found / cases.length) * 100).toFixed(0)}%`);
await pool.end();Recall@5 (the share of questions whose correct source is in the top 5) is the number to improve first. Then change one thing at a time (chunk size, hybrid on/off, reranking) and watch it move.
Common mistakes#
- Chunks without context. A chunk saying "This takes 5 days" is useless without knowing it is about refunds. Keep heading paths and titles.
- Mixing embedding models or input types. Use the same model for documents and queries, with
input_typeset correctly. - Vector search only. Exact identifiers and error codes need keyword search.
- Too many chunks in the prompt. Twenty marginal passages dilute five good ones and cost more. Rerank and send the top few.
- Filtering permissions after retrieval. Filter in the query.
- Tuning prompts before measuring retrieval. If the right chunk is missing, no prompt will fix the answer.
Try it
Build and measure a small RAG index
- Write four short Markdown files (refunds, payments, errors, invoices) with two or three headed sections each.
- Run the schema, ingest them, and ask three questions with
answer.ts, checking that citations point to the right files. - Write five retrieval eval cases, including one with an exact error code. Run
eval-retrieval.tswith keyword search disabled (use onlybyMeaning), then with hybrid search, and compare recall@5.
Check your understanding
Key takeaways
- RAG has an indexing path (chunk, embed, store) and a query path (embed, retrieve, rerank, generate).
- Chunk on structure, keep titles and sources, and use the same embedding model with the right
input_type. - Postgres with pgvector handles vectors, keyword search, and access control in one place; hybrid search with RRF and a reranker gives strong retrieval.
- Send passages as
search_resultblocks with citations enabled, and measure recall before tuning prompts.
Next steps#
Finished this article? Track it to see your progress on the roadmap.
Related articles
- Databases for AI Apps: PostgreSQL, Drizzle, Redis, and pgvectorDesign an AI app's data layer with PostgreSQL, Drizzle, pgvector for embeddings, and Redis for caching and rate limits, with a full schema and runnable queries.
- Tool Use (Function Calling) with Claude in TypeScriptLet the model call your functions: define tools, run the tool-use loop by hand, handle errors and parallel calls, then use the SDK tool runner with Zod-typed tools.
- Project: Build a Docs Q&A Assistant with RAG, Citations, and EvalsBuild a documentation assistant: incremental ingestion with content hashes, hybrid search and reranking, streamed answers with source links, feedback, and evals.