Most RAG tutorials start by adding a vector database to your stack — another service, another client library, another bill, another thing to keep in sync with your real data. But if you’re on Postgres, you already own a vector database: the pgvector extension adds a vector column type and similarity indexes to the database you already back up, migrate, and query with your ORM. This boilerplate’s entire RAG feature — including chat-with-your-PDF — runs on one table.
One table, one index
The documents table stores the chunk text next to its embedding: a 1536-dimension vector column (sized for openai/text-embedding-3-small), a user id that cascades on delete, and an HNSW index with cosine ops. HNSW gives you approximate-nearest-neighbour search that stays fast as the table grows — without it, every query is a full scan.
embedding: vector("embedding", { dimensions: 1536 })
index().using("hnsw", table.embedding.op("vector_cosine_ops"))Ingest: chunk, embed once, insert
Long text is split by chunkText: paragraphs are kept intact and merged until a chunk reaches ~1,200 characters; only a single oversized paragraph gets split mid-text. Paragraph boundaries are where authors change topic, so chunks stay semantically coherent — which is what makes retrieval hit. All chunks then go through one embedMany call (one round trip, not one per chunk) and land in the table as plain rows.
Chat with your PDF, without blob storage
The PDF route parses uploads in-request with unpdf — pure JavaScript, no native dependencies, so it runs on serverless without a custom build step. Only the extracted text is chunked and stored; the original file is thrown away, which means there is no S3 bucket, no blob store, and no lifecycle policy in this feature at all. Uploads are capped at 4 MB (under Vercel’s request-body limit) and 500,000 extracted characters. If you want to keep the originals, the route has exactly one place to swap in Vercel Blob.
Retrieve and answer, with citations
A question is embedded with the same model, and retrieval is a regular Drizzle query: 1 - cosineDistance(embedding, queryVector) as the similarity, a floor of 0.2 to drop irrelevant matches, top 4 by similarity, scoped to the user’s own rows. The matches are numbered [1]…[4] and passed as context with a system prompt that demands inline citations and an honest “I don’t know” when the context doesn’t contain the answer. Grounded answers with sources the UI can display — that’s the whole trick.
Meter the ingest, not just the chat
The easy mistake in RAG billing is metering questions and giving ingestion away. Embeddings are real model spend — embedMany reports token usage exactly like a chat call, and a single large PDF can cost more to embed than a hundred questions cost to answer. So the ingest routes run the same billable recipe as everything else: rate limit, balance pre-check, then charge for the tokens the embedding call actually reported. A free tier with unmetered ingest is an invitation to embed a library on your card.
The whole module is about 130 lines plus one schema file. There is a scale where a dedicated vector database earns its place — tens of millions of vectors, cross-user search, heavy filtering. Until you’re there, the database you already run is the boring, correct answer.