DEV Community

Cover image for pgvector is enough. Here are the two bugs you'll ship anyway.
Max S.
Max S.

Posted on AI-assisted

pgvector is enough. Here are the two bugs you'll ship anyway.

"Do we need Pinecone for this?"

If you're adding AI search to an app in 2026, someone on your team has asked this. The internet's answer is pretty settled by now: no, keep your embeddings in Postgres with pgvector.

I agree. I also work at Nhost, a backend built on Postgres, so yes, I'm biased.

But most pgvector tutorials I've read stop at the same spot. Add a vector column, insert embeddings, run a similarity query, ship it. So I built a support ticket search and kept going past that spot. Two bugs showed up almost right away.

TL;DR: pgvector handles the search fine. It won't keep your embeddings fresh when the data changes, and it won't make sure search results respect who's allowed to see what. Both fail silently. At the end I'll show how Nhost Graphite takes care of both.

Bug 1: stale embeddings

Support tickets change. A customer adds details. An agent fixes the title. Someone finally pastes the actual error message.

Here's the usual setup:

// creating a ticket
const embedding = await embed(ticket.body);
await db.query(
  'INSERT INTO tickets (customer_id, title, body, embedding) VALUES ($1, $2, $3, $4)',
  [ticket.customerId, ticket.title, ticket.body, embedding]
);

// updating a ticket, somewhere else in the codebase
await db.query('UPDATE tickets SET body = $1 WHERE id = $2', [newBody, ticketId]);
Enter fullscreen mode Exit fullscreen mode

The insert creates an embedding. The update doesn't.

What happens: nothing. No errors, no failing tests. Search quietly gets worse, because it's matching against what your tickets said last week.

The fix is some kind of sync job. Mark changed rows (a trigger, an updated_at column, an outbox table), then have a background worker pick them up, call the embeddings API, retry failures and write the new vectors back. That's a sync pipeline. You skipped the second database to avoid exactly this, and now you're building it anyway, just inside Postgres.

Bug 2: search that ignores permissions

Your ticket list endpoint filters by customer. Of course it does:

SELECT id, title, status FROM tickets
WHERE customer_id = $1;
Enter fullscreen mode Exit fullscreen mode

Then someone adds search a month later:

SELECT id, title FROM tickets
ORDER BY embedding <=> $1
LIMIT 5;
Enter fullscreen mode Exit fullscreen mode

The result: customer A types "billing problem" and gets customer B's refund dispute.

One missing WHERE clause. Postgres row-level security can catch it, but only if your setup passes the current user to the database on every request. Some platforms do that for you. Plenty of hand-rolled backends don't, and most pgvector tutorials skip the topic.

How Graphite handles both

Graphite is the AI service in Nhost. It keeps embeddings in your Postgres table with pgvector, so this is still the "just use Postgres" setup. The difference is who does the boring parts.

Graphite's API is evolving, so check the Nhost docs for the current setup. The results below come from a test project in October 2026.

Fresh embeddings

You add a vector column to the table and point Graphite at it with one config call: which table, which columns to embed and which model to use. You also add a small trigger (the docs have an example) that marks a ticket as outdated when its title or body changes. Graphite picks up those rows on its next sync, calls the embeddings API and writes the new vectors back. The sync pipeline I described for stale embeddings still exists, but Graphite runs it, not you.

To test it, I edited one of user A's tickets. "Feature request: export to CSV" became "Charts invisible in dark mode." Then I searched for "graphs can't be seen with the dark theme."

This is a small demo: 36 tickets, two users, OpenAI's text-embedding-3-small. It shows the behavior, not performance at scale.

Right after the edit, the ticket ranked 5th. The title was new, but the embedding was still the old one. After the next sync, it ranked 1st.

Sync runs on a schedule, every 5 minutes in my project, which is plenty for support tickets. Apart from that one trigger, I didn't write any sync code.

Search that knows who's asking

Permissions in Nhost live on the table. Here's the rule for regular users:

{ "filter": { "customer_id": { "_eq": "X-Hasura-User-Id" } },
  "columns": ["id", "customer_id", "title", "body", "status", "created_at"] }
Enter fullscreen mode Exit fullscreen mode

The same rule in the Nhost dashboard: customer_id equals X-Hasura-User-Id

Graphite adds a search query to the GraphQL API:

query Search($query: String!) {
  graphiteSearchTickets(args: { query: $query, amount: 50 }, limit: 5) {
    id customer_id title
  }
}
Enter fullscreen mode Exit fullscreen mode

That query goes through the same permission rule as everything else. I ran one search, "I can't log in to my account," three times:

# Admin (no rules) User A User B
1 [B] Unable to sign in Can't log in after update Unable to sign in
2 [A] Can't log in after update Locked out of my account Google login stopped working
3 [A] Locked out of my account Two-factor code not accepted Logged out every few minutes
4 [A] Two-factor code not accepted Login fails on mobile app Password reset page shows error
5 [B] Google login stopped working Reset link says it expired Forgot password, no reset email

Searching as admin: results from both customers

Searching as Demo Customer A: every result has the same customer_id

As admin, the top 5 mixes both customers. As A or B, every result belongs to the person asking. The rule is defined once, on the table, and search follows it like any other query. Nobody had to remember a WHERE clause in the search code.

When you should use a dedicated vector DB

Fair is fair. pgvector has limits.

Most 2026 write-ups I found put the comfortable range at around 10 million vectors for typical 768 to 1536 dimension embeddings. Your number will depend on dimensions, RAM and how much you filter. Above that you're tuning memory and indexes, or adding pgvectorscale. A dedicated vector DB starts making sense with:

  • Hundreds of millions or billions of vectors
  • Sub-20ms p99 latency targets at high query volume
  • Heavy re-embedding, which leaves Postgres with a lot of dead rows to vacuum
  • A search product with its own team and needs like multi-vector retrieval

None of that describes your app? Keep the vectors next to the data.

So, do you need Pinecone?

Probably not. You do need a plan for stale embeddings and permissions. You can build that plan yourself, or let Graphite handle it.

How are you keeping embeddings in sync with your data right now? Triggers, cron jobs, hoping for the best? Tell me in the comments.

Top comments (0)