Learning Postgres Search by Building It With Claude
I had two concepts sitting on a mental TODO list for months: Postgres full-text search with tsvector / tsquery, and vector similarity search with pgvector. I knew roughly what they did. I had never actually sat down and used them.
My usual way of learning something like this is read the docs, skim a couple of blog posts, maybe build a toy script, forget most of it in a week. This time I tried something different. I opened a Claude Code session and asked it to build a self-contained playground that used both, side by side, so I could compare them on the same queries.
An evening later I had a working app with a three-panel UI that runs the same search through tsvector, pgvector, and a hybrid blend, with an “explain” popup on every result that shows exactly what Postgres did. The repo is at github.com/gayanhewa/postgres-search.
The thing worth writing about isn’t the project though. It’s that the learning felt different.
The old loop
When I learn a new topic the usual way, there is a long gap between “I read something” and “I understand it.” The docs explain what ts_rank_cd does. A blog post shows a code snippet. I nod along. A week later someone asks me a specific question and I realize I don’t actually have working knowledge, I have a vague mental sketch.
The gap closes only when I sit down and build something that exercises the concept. That second step used to be expensive. Scaffolding a project, picking a stack, wiring up Postgres, writing the first seed script, getting docker compose right. All of it before I’ve even typed my first SELECT.
The expensive part isn’t the typing. It’s the overhead of starting. Most learning-project ideas die there.
The new loop
With Claude Code the scaffold is basically free. I described the project I wanted in the first message:
I want to experiment with tsquery/tsvector and pgvector. Create a self-contained project where I spin up Postgres in Docker, seed data with faker, and provide an API + search interface to use both. Use TS with Bun. UI can be EJS with Alpine. Include architecture guidelines. Use ports and adaptors style. Have tests.
Ten minutes later I had a running app. Docker was up, migrations applied, seed data in the table, a web UI responding to queries. I could have spent those ten minutes copying from a tutorial. Instead I spent them asking better questions.
Learning by poking
Once the playground was running, the feedback loop got tight:
- Type a query in the UI.
- Watch the three panels disagree.
- Ask Claude “why did tsvector put that doc first but pgvector put it fifth?”
- Get an answer grounded in the actual code it just wrote, the actual weights in the SQL, the actual embedding adapter. Not a generic doc quote.
- Change a weight, reload, type the query again.
I wasn’t reading about ts_rank_cd’s weight array {D, C, B, A}. I was changing the numbers in text-search.pg.ts, reloading the page, and watching the title-match boost disappear. I still couldn’t tell you the exact formula ts_rank_cd uses. I have a better wrong model of it than I did before, which is usually how learning actually works.
One specific moment that stuck. I typed pgvetor (deliberately misspelled) to see what would happen. The tsvector panel returned nothing. The pgvector panel returned hits, but none of them were about pgvector either. I asked Claude why, and got back an explanation of stemming, lexemes, and how our fake hashed embedding handles typos differently than the English parser would. Read it, poked at the adapter, moved on. Five minutes. That exchange would have been a two-hour rabbit hole six months ago.
Asking it to fact-check itself
After Claude wrote the explainers in the UI, I asked it to fact-check every claim against the official Postgres docs and the pgvector README and cite URLs.
Five claims needed fixing. The ts_rank_cd explanation oversimplified the formula. The IVFFlat lists = sqrt(rows) rule was for datasets over a million rows, not a universal starting point. A line about HNSW “being the better default” was something Claude had inferred, not something the pgvector README actually says.
If I’d read those same explanations in a blog post I probably would have trusted them. After the fact-check step I stopped treating Claude’s first pass as gospel, which felt important.
Asking dumb questions
The other thing that changed was that I could ask dumb questions without it being awkward.
At one point I typed: explain “the query string is turned into a 128-dim embedding by the DeterministicFakeEmbedding adapter (hashed bag-of-words plus bigrams, L2 normalized)” like I’m a complete noob.
What came back was a six-paragraph walkthrough that started with “pgvector compares documents by looking at numbers, not words, so we have to turn every piece of text into a list of numbers first. That list is called an embedding.” It walked through the hashing, the 128 slots, why L2 normalize exists, and what cosine similarity actually does with the result.
I’ve read dozens of explanations of embeddings. This one stuck because it was tailored to the specific code I was looking at, at the moment I was trying to understand it.
What surprised me
The ratio shifted from reading to doing. I used to spend most of my learning time reading docs and a small slice experimenting. Now it’s almost inverted. I read just enough to ask the next useful question, then poke at the running system.
I also ended up learning the weird details first. With a playground that shows every engine disagreeing, the edge cases are what you see first. Why does a typo kill tsvector but not pgvector. Why does changing the language config from english to simple make “indexing” stop matching “index”. Those questions are staring at you before you’ve even grasped the happy path.
And the project is reusable, which is rare for a “learning project.” It seeds Wikipedia articles, has a UI with explainers, and the architecture is clean enough that I can swap in a real embedding model without rewriting anything. I’ll come back to it next time I need to test a search idea.
Where I’m still unsure
I’m not convinced this workflow works as well for topics I don’t already have some taste for. With Postgres search I could tell when Claude was overreaching. I know roughly what a reasonable answer looks like, so when it said something confidently wrong about ranking weights I noticed. For a domain I know nothing about, I probably wouldn’t catch it. The fact-check step helps, but only if you can at least smell when a claim deserves fact-checking in the first place.
Related: the first draft of the UI explainers was fine. When I asked Claude to “add more detail,” the second draft was worse. More words, more abstraction, same information. I had to walk it back. More is not the right axis; concrete is.
What I’d do differently
Two things.
Start with the fact-checking rule from the beginning. Next time I’ll tell Claude up front that every technical claim in the final artifact needs a citation to primary sources. That cuts out the first draft, which sounded confident and was wrong in five places.
Ask for concrete examples up front. Claude’s first pass at explainers was technically correct but abstract. The versions that actually helped me learn were the ones I specifically asked to be written “like I’m new to this, with examples.” If I’d said that in the initial prompt the first draft would have been useful on the first try.
The repo is at github.com/gayanhewa/postgres-search if you want to clone it and poke at it yourself. It seeds from Wikipedia so the engines actually disagree on real queries.
Whether this workflow works for topics I don’t already have some instinct for is still an open question. Probably worth writing a second post about in six months.