Build RAG Without a Vector Database
16 Dec 2025If your document set fits in a few hundred MB, you don’t need a vector database. Postgres full-text search gets you most of the retrieval quality with none of the new infrastructure.
When this applies
This is for small-to-medium corpora — internal docs, a product’s help center, a few thousand support tickets. Not for “search across the entire web” or tens of millions of chunks, where approximate nearest-neighbor search actually earns its complexity. If you’re not sure which camp you’re in, you’re probably in this one.
The setup
Postgres has had full-text search since forever, via tsvector and tsquery. It does stemming, ranking, and phrase matching out of the box, no extension required.
ALTER TABLE documents ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX documents_search_idx ON documents USING GIN (search_vector);
That generated column keeps itself in sync on every insert and update — no separate embedding pipeline, no background job that can fall behind.
Query it with ts_rank for relevance ordering:
SELECT id, title, ts_rank(search_vector, query) AS rank
FROM documents, to_tsquery('english', 'deploy & rollback') AS query
WHERE search_vector @@ query
ORDER BY rank DESC
LIMIT 5;
Why this beats a vector DB for small sets
- No new service. You already run Postgres. A vector DB is another thing to deploy, monitor, back up, and pay for.
- No embedding step. Every vector search needs an embedding call per document at write time and per query at read time. That’s API cost and latency you don’t have here.
- Exact matches actually work. Semantic search is bad at exact terms — error codes, product SKUs, function names. Full-text search nails these because it’s literally matching text, not approximating meaning.
- It’s fast at this scale. A GIN index on a few hundred thousand rows returns in single-digit milliseconds. You will not notice a difference.
Where it falls short
Full-text search is lexical, not semantic. “How do I cancel a subscription” won’t match a document that says “terminate recurring billing” unless you’ve added synonyms. Two mitigations that cover most of the gap:
websearch_to_tsqueryinstead ofto_tsqueryfor user-typed queries — it handles quotes,OR, and-excludethe way people actually type search boxes.- A synonym dictionary. Postgres text search config supports custom thesaurus files. Map your domain’s common rewordings once — “cancel” → “cancel terminate stop” — and a lot of the semantic gap closes for free.
If after that you’re still missing real paraphrase matches, that’s the actual signal you need embeddings — not “vector databases are the modern way to do search.”
Here’s the decision path end to end:
flowchart TD
A["Document set size?"] -->|"Small-medium, fits in a few hundred MB"| B["Use Postgres full-text search"]
A -->|"Tens of millions of chunks"| C["Use a vector database"]
B --> D{"Missing paraphrase matches?"}
D -->|No| E["Done - FTS is enough"]
D -->|Yes| F["Add synonyms / websearch_to_tsquery"]
F --> G{"Still missing real paraphrases?"}
G -->|No| E
G -->|Yes| H["Add pgvector, combine with RRF"]
The migration path
If you do outgrow this, you don’t have to throw it away. Add pgvector to the same Postgres instance and run both: full-text for exact-term recall, vector similarity for paraphrase recall, and combine the two result sets with reciprocal rank fusion. You get hybrid search without introducing a second database, and you can make that jump exactly when you have evidence you need it, not before.
The actual failure mode to watch for
Full-text ranking (ts_rank) is not tuned out of the box for short documents — it can rank a page that mentions your term once above a page that’s actually about the topic, if the shorter page has a higher term-to-length ratio. If ranking looks off, try ts_rank_cd (cover density) instead, which rewards terms appearing close together, or add a manual boost for title matches over body matches.