fix

The Cost Was Never the Text Match

Search benchmarks usually compare how fast an engine finds documents matching a term. The more interesting question, and the one ParadeDB's latest post is really about, is what happens after the match: how many candidates the engine materialises, and how many it throws away.

The post is a response to a comparison with TIN, PlanetScale's Postgres search index. On a plain Wikipedia COUNT query, ParadeDB comes out about 2x faster. On queries that also filter by a numeric column or sort by time, the gap is 10x, 11.6x, and 21.7x. The optimizations that produced the first number are clever. The architecture argument behind the other three is the useful part.

Three optimizations, one theme

Visibility checking dominated. For a COUNT(*) on a term, Postgres has to confirm each matching row is visible to the current snapshot, which means fetching CTIDs and consulting the visibility map. That was 81% of page accesses — 1,382 of 1,707. The fix is segment partitioning: split the index by ctid ranges so each segment covers a disjoint range of heap pages. Then check the visibility map for the whole range up front and skip the segment entirely if every page is all-visible. Visibility-related page accesses dropped from 1,382 to 30.

The general principle: make skipping possible by making ranges disjoint. With naive size-tiered compaction, one updated page can poison the shortcut for most segments.

Invert the map. When a segment does need checking, the old approach fetched a CTID for every match. The new one stores, for each heap page, the first document ID on that page. Since segments are sorted by ctid, documents on the same page have consecutive IDs, so the engine checks the visibility map first and then looks up document ranges only for the pages that need it. After updating 1,000 rows, buffer accesses went from over 7,500 to under 400 — a 95% reduction.

Bitmaps for dense terms. Tantivy's posting lists use BP128 blocks, which are excellent for sparse terms and for block-max pruning. For terms matching more than 10% of documents, bitwise AND/OR over 64 documents at a time beats walking arrays. Introducing bitmaps above that density threshold halved p99 latency.

Each of these is a case of the same idea: stop doing per-row work when the data layout lets you do per-range work.

The architecture argument

The filtered and sorted queries are where the gap becomes an order of magnitude, and the reason isn't a faster loop. It's where the filtering happens.

TIN is a text-only index. Each index covers one text column; numeric filters and time sorts are left to Postgres B-trees. For a query like "posts containing 'database' with more than 10 points," the text index produces all its matches, the B-tree produces all its matches, and then they're intersected. The B-tree doesn't know which documents matched the text, so it can collect millions of rows that the intersection discards.

ParadeDB indexes the text alongside the filter and sort columns in one index, so matching, filtering, and ordering can be coordinated as the scan proceeds. For time-sorted top-k, the engine can track the newest ten matches while scanning instead of materialising everything and sorting afterwards.

That's the real difference: not a constant factor, but whether the query planner can prune using all the predicates at once.

The benchmark honesty

The most credible part of the post is the methodology section.

They started with their own k6-based benchmarker and found that, at the sub-4ms latencies involved, network time on one system's path materially changed the result. So they stopped using their own tool for this comparison and wrote a server-side PL/pgSQL harness to remove the network entirely — even though that made their numbers less flattering in some places. They published the harness and admitted they're unlikely to maintain it.

Then they say the thing vendors rarely say: even though they're faster on these queries, it doesn't establish that ParadeDB is the fastest search engine on Postgres. No single benchmark can, especially one written by the team building the product. They explicitly apply that to their own Hacker News benchmark as much as to PlanetScale's.

That's the correct posture, and it's also the practical advice: test your own queries on your own data, because the query shapes that matter to you are the ones that decide.

What to take away

Three things, if you're building anything with an index.

Measure where the time goes, not where you assume. The bug wasn't the text matching; it was visibility checking, which nobody benchmarks.

Design for skipping. Disjoint ranges and up-front checks let you avoid entire segments; overlapping ranges make every update expensive.

Push predicates into the index. If your text index can't see the filter column, you're paying to materialise candidates you'll discard.

The 2x headline is the least interesting number in the post. The 21.7x is the one that tells you something about architecture.

Source: Making COUNTs and the Rest of Search Really Fast (Part II) — Ming Ying, ParadeDB.

#databases#search#performance#engineering