Skip to content
AI Full-Stack Wiki

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.

Difficulty: Advanced24 min readUpdated
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#

Retrieval-augmented generation (RAG) pipelineIndexing path: documents are split into chunks, embedded, and stored in a vector database. Query path: the question is embedded, similar chunks are retrieved and reranked, and the LLM answers from those chunks with citations.vectors + textnearest neighborstop 20INDEXING · OFFLINEQUERY · EVERY REQUESTDocumentsPDF, HTMLChunksplit + metadataEmbeddingmodelVector DBPostgres + pgvectorQuestionEmbeddingmodelVector searchtop-k similarRerankerre-scores pairsLLMcited answerAnswerwith citations

Hover or tap a component to see what it does.

RAG pipeline: the indexing path chunks and embeds documents into a vector database ahead of time; the query path embeds the question, retrieves and reranks similar chunks, and the LLM answers with citations.
Show 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:

lib/chunk.ts
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
Embeddings visualized: support questions projected to two dimensions cluster by meaning (billing, sign-in, shipping), and a new query lands next to the questions it is about, even with no shared keywords.
Show 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.

Sentences and their cluster
SentenceCluster
“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 questionsBilling

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.

lib/voyage.ts
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:

db/schema.sql
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);
lib/db.ts
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:

ingest.ts
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.

Hybrid search: vector and keyword results merged and rerankedThe query runs through vector search and keyword search in parallel; reciprocal rank fusion merges the two ranked lists; a reranker re-scores the merged candidates; the top few go to the LLM.top 20top 5QueryVector searchmeaningKeyword searchexact termsMerge (RRF)reciprocal rank fusionRerankertop 20 → top 5LLManswer with citations

Hover or tap a component to see what it does.

Hybrid search for RAG: vector search and keyword search run in parallel, reciprocal rank fusion merges the results, and a reranker picks the best passages for the LLM.
Show 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:

lib/search.ts
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:

answer.ts
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:

eval-retrieval.ts
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_type set 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

  1. Write four short Markdown files (refunds, payments, errors, invoices) with two or three headed sections each.
  2. Run the schema, ingest them, and ask three questions with answer.ts, checking that citations point to the right files.
  3. Write five retrieval eval cases, including one with an exact error code. Run eval-retrieval.ts with keyword search disabled (use only byMeaning), then with hybrid search, and compare recall@5.

Check your understanding

Why use hybrid search instead of vector search alone?
What does reciprocal rank fusion combine?
A user gets a wrong answer. What should you check first?

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_result blocks with citations enabled, and measure recall before tuning prompts.

Next steps#

Finished this article? Track it to see your progress on the roadmap.