← All posts

One search across five surfaces - on Postgres, and when it falls over

How Rezee searches issues, docs, chat, PRs, and code paths with Postgres full-text search instead of a separate engine - measured GIN latency to 1,000,000 rows, the p99 tail that is the real fall-over signal, and why workspace-scoping keeps it flat.

Jun 30, 2026 · 8 min read · Kash Gohil

Rezee is one workspace for the whole product development lifecycle - code, planning, CI/CD, chat, and docs. The unified-shell promise includes one search across all of it: type a query, get issues, docs, chat messages, PRs, and code paths back together. The default instinct is to reach for a search engine - Meilisearch, Typesense, Elasticsearch - and run it alongside Postgres. We didn't, and this post is the measured case for why Postgres full-text search is enough for this job, plus the honest number for exactly when it wouldn't be. Everything here is reproducible against a real Postgres (scripts/bench/search-fts), out to a million rows.

What does "search on Postgres" actually mean?

One table, one column, one index. A search_index row per searchable thing across every surface, with a tsvector - Postgres's pre-parsed, stemmed representation of text - and a GIN index over it:

CREATE TABLE search_index (
  id           bigserial PRIMARY KEY,
  workspace_id int  NOT NULL,
  surface      text NOT NULL,   -- 'issue' | 'doc' | 'chat' | 'pr' | 'code'
  title        text NOT NULL,
  body         text NOT NULL,
  tsv          tsvector
);
CREATE INDEX search_tsv_gin ON search_index USING gin(tsv);

A query becomes a tsv @@ websearch_to_tsquery('english', 'deploy pipeline') - websearch_to_tsquery parses Google-style input (quoted phrases, or, -exclude) so users type what they'd type into any search box. Ranking is ts_rank. The whole search feature is a table and an operator, and the reason to prefer it isn't that it's clever - it's that it's already there: no second datastore to run, back up, secure, and keep in sync with the source of truth. A separate engine is a second copy of your data that can disagree with the first.

Does it actually hold up?

The question everyone asks about Postgres FTS is "sure, but does it scale," so we measured latency as the index grows from 10,000 to 1,000,000 rows. The median is boring in the best way - a GIN-indexed query stays sub-millisecond at every size, 0.32ms at 10k and 0.20ms at 1M. Median latency simply isn't where the story is. The tail is:

Search p99 latency vs corpus size (ms) Search p99 latency vs corpus size (ms) workspace-scoped (p99)global GIN (p99) 050100150200 1.23.177.9169.50.90.40.80.8 10k100k500k1M rows in the search index p99 ms

That orange line is the real finding. A GIN index answers the median query instantly at a million rows, but its p99 climbs from 1ms to 169ms as the corpus grows - because GIN returns a lossy bitmap that Postgres must recheck against the heap, and on a large index a query matching many rows does a lot of recheck work in its unlucky cases. The median hides it; the p99 is the number that decides whether search feels instant or occasionally janky. If we were building global cross-tenant search over a million rows, that tail is exactly the signal that says "time for a real engine."

But we're not, and that's the whole point.

Why the tail never actually happens

Rezee search is always workspace-scoped. You never search the whole table - you search your workspace's rows. Nobody queries a million rows; they query the few thousand in front of them. The purple line is the same benchmark with WHERE workspace_id = $1 AND tsv @@ ..., and it's flat: p99 stays under 1ms at every corpus size, from 10k total rows to 1M, because the scoped query only ever touches one workspace's ~5,000 rows no matter how large the global table gets.

This is the architectural fact that makes Postgres FTS the right call for Rezee specifically. The same workspace-scoping that isolates every tenant also bounds every search: the corpus that matters is one workspace, not the sum of all of them. The (workspace_id, tsv) filter turns "search a million rows" - where GIN's tail bites - into "search a few thousand" - where it never does. The largest customer's search performs like the smallest, because they're both searching their own workspace.

What does the index cost to maintain?

Search that's stale is worse than no search, so the write path matters as much as the read path:

GIN index build time and size vs corpus GIN index build time and size vs corpus index size (MB)build time (ms) 02.0k4.0k6.0k8.0k 48.20.9458.68.43.0k38.87.1k76.3 10k100k500k1M rows in the search index ms / MB

Building the GIN index from scratch scales from 48ms at 10k rows to 7.1s at 1M, and the index itself grows to 76MB for a million rows - both linear and both unremarkable for a database that's already holding the source data. The number that matters operationally isn't the full rebuild, though; it's the incremental update. A GIN index update on a single new or edited row is a small, localized write, so keeping the index live means updating one tsvector when an issue, doc, or message changes - a trigger or an application write on the same transaction as the edit, not a batch reindex. The append-mostly surfaces like chat only ever insert; the mutable ones (issues, docs) update one row's tsv. No surface produces index churn that a single-node Postgres can't absorb at team scale.

So when would you actually outgrow it?

The honest thresholds, because "just use Postgres" without the boundary is as bad as reaching for Elasticsearch on day one:

  • Cross-workspace / admin search over millions of rows. The moment a query legitimately spans the whole table - a platform-wide admin console, cross-tenant analytics - you're back on the orange line, and the p99 tail is real. That's a genuine reason to add a dedicated engine, for that feature, not for workspace search.
  • Relevance beyond ts_rank. Typo tolerance, synonyms, learned ranking, faceting, "did you mean" - Postgres FTS does lexical matching well and semantic relevance not at all. A workspace that needs Algolia-grade relevance needs Algolia; we need "find the issue that mentions the deploy," which ts_rank does.
  • Very high write churn on the indexed text. GIN updates are cheap per row but not free, and a workspace generating tens of thousands of edits per second to searchable text would pressure the index. Team-scale collaboration doesn't; a firehose might.

None of these is Rezee's search today, and each is a specific feature you'd bolt on - not a reason to run a second datastore for the search that workspace-scoping already makes fast.

FAQ

Why GIN and not GiST for the tsvector index?

GIN is the right default for text search: it's slower to build and update than GiST but much faster to query, and search is read-heavy - you index a document once and search it many times. GiST's advantage is cheaper updates and smaller size, which matters for extremely high-churn or huge indexes; at workspace scale the query speed wins, and the build/update costs (measured above) are comfortably affordable.

Why store a separate search_index table instead of indexing each surface's table directly?

Because search is unified - one query has to return issues, docs, chat, and PRs ranked together, and they live in different tables with different columns. A single search_index with a surface discriminator gives one place to query, one ranking scale, and one index to reason about, at the cost of a projection from each source table (written on the same transaction as the source edit). Querying five tables and merging results in the app would re-implement, worse, what one indexed table does.

Isn't a real search engine just better?

Better at search-engine things - typo tolerance, semantic relevance, faceting at massive scale - and worse at being one fewer moving part. For Rezee's actual need (fast lexical search within a workspace), the measurements say Postgres is not a compromise: workspace-scoped p99 under a millisecond at a million total rows is not "good enough for now," it's just good. We'd add an engine for a feature Postgres can't do, not to fix a speed problem it doesn't have.