← All posts
Engineering

We used Postgres full-text search instead of embeddings

The in-app assistant answers from a knowledge base. Everyone reaches for a vector index. For a corpus this size it was the wrong tool.

The assistant that sits behind cmd-J answers questions from a knowledge base. The reflex choice for retrieval is embeddings and a vector index. We used Postgres full-text search, and the deciding factor was not accuracy.

The corpus is small and curated

The knowledge base is a couple of dozen short, deliberately written articles. It is not a document dump, it does not grow on its own, and every entry has been read by a person.

At that size the retrieval problem is almost trivially easy. Which meant the decision came down to what each option costs to operate, not to what scores better on a benchmark nobody in this codebase was going to run.

What FTS does not need

Full-text search needs no extension to install, no embedding call on every write, and no re-index when a model provider changes underneath us. That last one mattered more than it looks. An embedded corpus is pinned to the model that embedded it: swap providers and you re-embed everything, or you live with a mixed index whose distances no longer mean one thing.

FTS has no such coupling. The index is derived from the text alone.

The escape hatch is deliberate too. Retrieval sits behind a single function, so moving to pgvector later means replacing that function and nothing else.

The bug worth writing down

The first implementation used websearch_to_tsquery, which ANDs every term in the query. That is fine until someone asks a question in English.

> How do credits work?

work is an ordinary word that the correct article did not happen to contain. One AND later, a perfect match scored zero and the assistant said it did not know — while the article sat right there in the table.

The fix was to rank with OR semantics and let ts_rank decide, rather than letting a single absent word veto the whole match. Requiring every term is a reasonable rule for a search box where people type keywords. It is a bad rule for a chat box where people type sentences.

When we would change our minds

If the corpus grows past what a person can curate, or starts covering questions phrased nothing like the source text, the tradeoff inverts and semantic retrieval earns its keep. It is one function away. Until then, the boring option is doing the job.

Keep reading