Indexing & Fuzzy Name Matching · How it works

71 min read
Senior22 min read
Rapid overview

How it works

Why a database does not read every row

A table is, physically, a pile of blocks on disk holding rows in roughly the order they were written. Answering WHERE passport_number = 'X12345' from that alone means reading every block and checking every row — a sequential scan. On 190 million rows that is tens of gigabytes of I/O for one answer, and the cost grows linearly with the table: double the data, double the time.

An index is a second, separate structure that stores a copy of one or more columns, kept in an order that makes lookup cheap, alongside a pointer back to the row. You pay for it twice — in storage, and on every write, because an insert must now update the table and every index on it — and you buy back the ability to find rows without reading them all.

The book-index analogy is the standard one and it is correct as far as it goes: you look up a term alphabetically at the back and it gives you page numbers, instead of reading the book. Where it breaks down matters more than where it holds:

  • A book has one index; a table has many, one per access pattern, each with its own write cost.
  • A book index is static; a database index is maintained on every write, which is the whole reason indexes are not free and why a table with twelve indexes has slow inserts.
  • A book index only does exact term lookup. A database B-tree also does ranges and prefixes, because it is sorted rather than hashed — and it still cannot do "similar to".
  • The book's page numbers are the answer. A database index usually gives a pointer, and the engine must then fetch the actual row — a second random read per row, which is why an index that matches ten million rows can be slower than a scan and why the planner will sometimes correctly ignore it.
Q: Why does adding an index sometimes make a query slower rather than faster?

A: Because an index lookup is two steps, not one — find the matching entries in the index, then fetch each matching row from the table — and the second step is a random read per row. Random reads are far more expensive per row than sequential ones, so there is a crossover point: if a predicate matches a small fraction of the table the index wins easily, but if it matches a large fraction the engine ends up doing millions of scattered single-row fetches, which is slower than streaming the whole table sequentially. Query planners model this with a selectivity estimate drawn from column statistics, and will deliberately choose a sequential scan when the estimated match fraction is high. The failure case is when those statistics are stale or the data is skewed, so the planner estimates a thousand rows, picks the index, and actually gets two million. Separately, the index still costs you on every insert, update and delete regardless of whether reads use it, so an index that is never chosen is pure overhead — which is why you check pg_stat_user_indexes for zero-scan indexes rather than adding indexes speculatively.

The B-tree, and the one thing it cannot do

The B-tree is the default index in essentially every relational database — Postgres, MySQL, SQL Server, Oracle. It is a balanced sorted tree: each node holds a sorted run of keys, and the children between those keys hold the values in that gap. Searching is a descent. At each node you binary-search the keys to pick one child, discarding everything under the others.

The arithmetic is the point. Each step reduces the remaining candidates by the tree's branching factor, so the depth is logarithmic in the row count. Taking the pessimistic binary case, log₂(190,000,000) ≈ 27.5 — about 27 comparisons to locate one row among 190 million. Real B-trees pack hundreds of keys per page rather than two, so the actual depth is 4 or 5 page reads, but "halving each step" is the intuition that matters: going from 190 million rows to 380 million adds one step.

Because it is sorted rather than hashed, a B-tree serves three query shapes:

ShapeExampleWorks?
Exact equalityname = 'RASHID'✅ one descent
Rangecreated_at BETWEEN a AND b✅ descend to a, walk right
Prefixname LIKE 'RASH%'✅ descend to RASH, walk right
Suffix / containsname LIKE '%SHID'❌ no contiguous range exists
Similar-toname ≈ 'RASHEED'no ordering encodes similarity

The last row is the load-bearing fact of this entire module. Sorting RASHEED puts it near RASHEEM and nowhere near RASHID, even though the second is far more likely to be the same person. The B-tree's ordering is lexicographic, and lexicographic distance is not similarity distance. There is no way to fix this with a better B-tree, because the structure's speed comes precisely from being able to discard whole subtrees — and to discard a subtree you must be certain nothing similar is in it, which sorted order cannot tell you.

Every other technique below — trigrams, blocking, phonetics, search engines — is a different answer to that single gap.

Q: A B-tree finds one row among 190 million in roughly 27 steps, so why can it not find a misspelling of that row at all?

A: Because the 27 steps come from discarding, at every level, every subtree the target cannot be in — and that decision is made purely on sort order. Sort order encodes lexicographic adjacency, not similarity: RASHEED and RASHID differ at the fourth character, so they sit in entirely different subtrees, while RASHEED and RASHEEZ — which are not the same person — sit adjacent. To find a misspelling the engine would have to descend into subtrees it cannot rule out, and since a single-character edit can occur at any position, including the first, it cannot rule out any subtree, which degenerates to a full scan of the index. This is why LIKE 'RASH%' is fast and LIKE '%SHID%' is not: a prefix pins the descent, a leading wildcard does not. The limitation is structural rather than an implementation gap, and the standard answer is a different index type that stores fragments of the value so that similar values genuinely share index entries.

Trigram indexes and GIN

A trigram is a three-character window over a string. RASHID decomposes into ras, ash, shi, hid (Postgres's pg_trgm also pads the ends, producing fragments for the word start, so short strings and word boundaries are represented).

The trick is that a misspelling shares most of its trigrams with the original:

RASHID   -> ras, ash, shi, hid
RASHEED  -> ras, ash, she, hee, eed
            ^^^^^^^^ two shared out of a small set
RASHIDD  -> ras, ash, shi, hid, idd
            ^^^^^^^^^^^^^^^^^^^ four shared out of five

Similarity is then defined as shared trigrams over total distinct trigrams — a Jaccard-style ratio. Postgres exposes it as similarity(a, b) and the % operator, which is true when similarity exceeds pg_trgm.similarity_threshold (default 0.3). Remember that default; it comes back as a failure mode below.

GIN — Generalised Inverted iNdex — is the index type that makes this searchable. An ordinary B-tree entry maps one key to one row. An inverted index maps one key to a posting list of many rows:

'ras' -> [row 12, row 88, row 401, row 9902, ...]
'ash' -> [row 12, row 55, row 401, ...]
'shi' -> [row 12, row 7734, ...]

A fuzzy query decomposes the search term into trigrams, fetches the posting list for each, and intersects or unions them to get candidate rows — which is exactly the "find things that share fragments" operation a B-tree cannot express. It is the same structure a search engine uses for words; a trigram index is an inverted index whose "words" are three-character fragments.

The costs follow directly from the shape:

  • Size. One row with a 20-character name produces roughly 20 index entries instead of 1. A GIN index on a text column is routinely larger than the column itself, sometimes larger than the table.
  • Write cost. An insert must append the row to ~20 posting lists. Postgres mitigates this with a fastupdate pending list that batches insertions and merges them later, which makes writes cheap but leaves the pending list to be scanned on every query until it is merged — so a write-heavy table can see query latency drift upward between merges.
  • Update amplification. Changing one character in a name rewrites several posting lists, not one entry.

pg_bigm is the same idea with two-character fragments (bigrams). Fewer distinct fragments means each posting list is longer and more rows share any given fragment, so it matches more aggressively — better recall on very short strings and on scripts where trigrams are sparse, but less selective: ra appears in a huge fraction of names, so the candidate set it returns is far larger and more of the work moves to the scoring stage. Trigrams are the usual default because three characters is selective enough to prune hard while still surviving a single typo.

Q: What is an inverted index and why is a GIN index expensive to keep current on writes?

A: An inverted index maps a single key to a list of every row containing it — a posting list — which inverts the usual direction of "row points at its values". That is what makes fragment search possible: you look up ras and immediately have every name containing it, without touching the table. The expense is a direct consequence: a B-tree insert adds one entry, whereas a GIN insert adds the row to as many posting lists as the value has fragments, which for a 20-character name is roughly 20 list mutations for one insert. Those lists are shared across rows, so concurrent writers contend on the same pages, and updating a value means removing the row from the old fragments' lists and adding it to the new ones. Postgres softens this with fastupdate, which appends new entries to an unsorted pending list and merges them into the main structure later — cheap writes, but every query must additionally scan the pending list until the merge happens, so query latency quietly degrades in proportion to write volume since the last merge. The storage cost is the same multiplication: an index holding roughly one entry per character of every row is frequently larger than the column it indexes.

Q: When would you choose pg_bigm over pg_trgm?

A: When the strings are short enough or the script sparse enough that trigrams stop producing useful overlap. A three-character name yields almost no interior trigrams, and CJK or other non-Latin text often has meaningful units shorter than three characters, so a trigram index can return nothing for inputs a human would call an obvious match. Bigrams generate more fragments per string and each fragment is shared by more rows, so recall rises. The cost is selectivity: two-character fragments are common, posting lists are long, and the candidate set handed to the scoring stage is much larger — which is fine if your second stage is cheap and correctly sized, and a latency problem if it is not. So the decision is really about which failure you can absorb: trigrams risk missing matches on short strings, bigrams risk flooding your scorer. In a two-stage design where recall is the thing you cannot recover from, bigrams on short-name-heavy data is a defensible trade; as a general default, trigrams prune far better.

Blocking, and blocking keys

Record linkage is deciding whether two records refer to the same real-world entity. The immediate obstacle is arithmetic: comparing every record against every other is O(n²). With 1 million records on each side that is 10¹² comparisons. At a million comparisons per second that is over eleven days for one run.

Blocking is the standard escape. You compute a short blocking key for every record such that records which are plausibly the same will produce the same key, then you only compare records that share a key. Comparison drops from "all pairs" to "all pairs within each block", which is orders of magnitude smaller.

Two worked examples:

Phonetic blocking key. Encode the surname with a phonetic algorithm and use that as the key.

RASHID   -> Soundex R230
RASHEED  -> Soundex R230     ✅ same block, will be compared
RACHID   -> Soundex R230     ✅ same block
ALI      -> Soundex A400     -- different block, never compared

Sorted-token blocking key. Split the name into words, lowercase, sort alphabetically, rejoin. This defeats word-order variation, which is endemic in international name data.

"Mohammed Al Rashid"   -> al|mohammed|rashid
"Rashid, Mohammed Al"  -> al|mohammed|rashid   ✅ same block
"Mohammad Al Rashid"   -> al|mohammad|rashid   ❌ DIFFERENT block

That last line is the honest part of the trade, and it is why blocking is dangerous in a way that is easy to under-state. Blocking is all-or-nothing. A pair that does not share a key is never compared — not compared and scored low, not surfaced as a near miss, not compared at all. There is no partial credit, no ranking, and critically no error: the query returns successfully, quickly, with a result set that silently omits the true match. This is a pure recall failure, and recall failures do not appear in logs, metrics, or exception trackers. You find them when someone notices the answer was wrong, or you never find them.

The standard mitigation is multiple independent blocking passes — block on phonetic surname, and on sorted tokens, and on date of birth, and union the candidate sets. A record must be missed by every pass to be lost, so independent passes multiply the recall rather than add to it. The cost is more candidates and more scoring work, which is the correct direction to err.

Both of the keys above have been measured, on a real 677,596-name sanctions corpus against 699 known-true matches, and the numbers are worth sitting with:

                       mean block size    true matches retained
trigram (baseline)          244                699 / 699  = 100%
sorted-token key              1                302 / 699  = 43.2%
phonetic surname            596                607 / 699  = 86.8%
surname + initial            48                503 / 699  = 72.0%
union of sorted + surname     -                619 / 699  = 88.6%

Three things fall out of that table, and none of them are obvious from the mechanism alone. First, the union beats both of its members (88.6% against 43.2% and 86.8%), which is the multi-pass argument confirmed on data rather than asserted. Second, it is still not enough — 88.6% recall means one true match in nine is never compared, and on a sanctions screen that is not a tuning detail. Third, and least intuitive: the phonetic key produced a mean block of 596 against the trigram baseline's 244. The "cheap narrow key" was 2.4x wider than the index it was supposed to replace. A blocking key is a bet that your key is more selective than your index, and that bet can simply lose.

These are full, unbudgeted blocks, so those recall figures are a ceiling, not a starting point — no amount of ranking or ORDER BY inside the block can retrieve a record the key never admitted.

Q: Why is a missed blocking key worse than a low similarity score?

A: Because a low score is visible and a missed block is not. If a pair is compared and scores 0.42 against a 0.6 threshold, that pair exists in your system: it can be logged, sampled, audited, put on a review queue, and used to tune the threshold — the failure is recoverable because you know it happened. If a pair never shares a blocking key it is never generated at all, so there is nothing to log, nothing to sample, and no signal anywhere that a match was possible. The query succeeds, latency is unchanged, and every monitor stays green while the system returns a wrong answer. That asymmetry means blocking should be tuned for recall and scoring for precision, never the reverse: it is safe to over-generate candidates because the scorer will discard them, and it is unsafe to under-generate because nothing downstream can recover a candidate that was never produced. The practical form of this is multiple independent blocking passes unioned together, since a record has to fall through every pass to be lost, plus periodic measurement against a labelled set to detect the misses that produce no error.

Fellegi-Sunter and probabilistic record linkage

Fellegi and Sunter's 1969 framework is the standard academic formalisation of "are these two records the same entity", and it is what most serious linkage tooling implements.

The idea: for each pair, compare a set of fields and record which agreed. For every field, estimate two probabilities from the data:

  • m = P(this field agrees | the pair IS a match). Less than 1, because real matching records still disagree through typos and stale data.
  • u = P(this field agrees | the pair is NOT a match). Small for a high-cardinality field like date of birth, much larger for something like gender.

The evidence contributed by one agreeing field is the ratio m/u, and because fields are treated as conditionally independent the log-ratios simply add up into a single match weight. A disagreement contributes (1-m)/(1-u), which is a negative weight — disagreement is evidence against, not merely absence of evidence.

The consequence worth internalising: agreement on a rare value is worth vastly more than agreement on a common one, because u is tiny for rare values. Two records agreeing on the surname "Kowalczyk" is strong evidence; two records agreeing on "Smith" is nearly none. That is the same insight as IDF weighting below, arrived at from probability rather than information theory.

Fellegi-Sunter then sets two thresholds, not one: above the upper one, declare a match; below the lower one, declare a non-match; in between, route to human review. Systems that collapse this to a single cut-off lose the ability to represent "we genuinely do not know", which in compliance and identity contexts is the answer that matters most.

Splink is the widely used open-source implementation, from the UK Ministry of Justice's analytical services team. Its documentation states plainly that "Splink's core linkage algorithm is based on Fellegi-Sunter's model of record linkage, with various customizations to improve accuracy", and describes itself as fast, accurate and scalable probabilistic data linkage for datasets lacking unique identifiers. It estimates m and u from your own data rather than requiring hand-set weights. — (verified)

Q: In Fellegi-Sunter, what are the m and u probabilities and why does the ratio between them matter more than either alone?

A: m is the probability that a field agrees given the pair really is a match, and u is the probability that the same field agrees given the pair is not a match — a coincidence rate. Neither is informative by itself: a high m just says the field is usually recorded consistently, and a low u just says values are spread out. What carries information is the ratio m/u, the likelihood ratio: how much more likely this observed agreement is under "same entity" than under "different entities". A date of birth agreeing has a small u because there are tens of thousands of plausible dates, so m/u is large and one agreement is strong evidence; gender agreeing has a u near 0.5, so m/u is close to 1 and the agreement is worth almost nothing even though it happens constantly. Because fields are combined as a product of ratios — a sum of log-weights — evidence accumulates naturally across fields, and disagreements contribute negative weight rather than merely failing to contribute positive weight. This is also why the model must estimate u per value rather than per field to handle names properly, since "Smith agrees" and "Kowalczyk agrees" have wildly different coincidence rates within the same column.

Q: Why does Fellegi-Sunter use two thresholds instead of one?

A: Because the score is a continuous measure of evidence and there are genuinely three outcomes, not two. Above an upper threshold the evidence is strong enough to declare a link automatically; below a lower threshold it is weak enough to discard automatically; between them the model is saying it does not know, and the correct action is to route the pair to human review rather than force it into a bucket. Collapsing to one cut-off makes every borderline pair either a false positive or a false negative depending on which side of the line it lands, and — worse — hides the fact that it was borderline at all, so the system reports the same confident output shape for a 0.97 and a 0.61. The two-threshold shape also makes the operating point tunable against a real cost model: in a compliance or identity setting a false negative and a false positive have very different consequences, so you set the thresholds from the review capacity you have and the relative cost of each error, and you measure the middle band as a first-class quantity because its size is what tells you whether the model or the data is degrading.

Phonetic algorithms

Phonetic algorithms map a string to a code approximating how it sounds, so that differently-spelled but similarly-pronounced names collide.

Soundex (1918, developed for US census work) keeps the first letter, maps consonants to six digit-classes, drops vowels, collapses repeats, and pads or truncates to four characters: R + 230 for both RASHID and RASHEED. It is crude, fixed-length, and over-collides — but it is present in almost every database as a built-in and is adequate as a blocking key.

Metaphone (1990) replaced the digit classes with rules over English letter combinations — PHF, TH → a single sound, silent letters dropped, GH handled contextually — producing a variable-length code that is markedly more accurate than Soundex.

Double Metaphone (2000) is the practical choice. It emits two codes per name — a primary and an alternate — precisely to handle names whose pronunciation depends on linguistic origin. Schmidt yields both a Germanic and an anglicised reading; a match on either code counts. That two-code design is what makes it usable on non-English name data at all.

Why Mohammed and Muhammad collide: both are transliterations of the same Arabic name, and every phonetic algorithm drops or neutralises vowels — which is exactly where the two spellings differ. The consonant skeleton M-H-M-D is identical, so they share a code. This is the mechanism, and it is also the limit.

Where they fail:

  • They encode English phonetics. The rules are about how English speakers pronounce letter combinations. Applied to Arabic, Chinese, Slavic or Indian names they encode a foreign speaker's guess at pronunciation, and their accuracy drops sharply.
  • Transliteration variance is not phonetic variance. Zhang / Chang and Yusuf / Youssef / Jusuf differ because of which romanisation system was used, not because of pronunciation drift, and English phonetic rules do not model that.
  • Soundex is anchored on the first letter, which it preserves verbatim — so Catherine and Katherine never collide, a failure on a pair any human would call identical.
  • They over-collide. Soundex's four-character output buys recall by producing false collisions in enormous numbers, which is acceptable for blocking and useless as a decision.

The correct framing: a phonetic code is a blocking key, never a match verdict. It belongs in stage one.

Q: Why do Mohammed and Muhammad produce the same phonetic code, and why is that not enough to declare a match?

A: They collide because phonetic algorithms deliberately discard vowels and keep the consonant skeleton, and those two spellings are transliterations of the same name differing only in vowels — M-H-M-D in both cases — so the codes are identical by construction. That collision is exactly what you want for retrieval: it puts both spellings in the same block so the pair is generated at all. It is not enough to decide, because collision is a very coarse equivalence: Soundex compresses names into a four-character space, so an enormous number of genuinely unrelated names share any given code, and the code carries no notion of degree — two names either collide or they do not, with no measure of how close they are. Worse, Mohammed is one of the most common given names in the world, so a phonetic collision on it is close to no evidence at all, which is precisely what rarity weighting exists to express. So the phonetic code is a stage-one blocking device that produces candidates cheaply, and the actual decision must come from stage-two scoring that weighs edit distance, the rarity of the tokens involved, and corroborating fields such as date of birth.

String similarity measures

Once you have candidates, you need a number. Three measures cover most cases and they are not interchangeable.

Levenshtein edit distance — the minimum number of single-character insertions, deletions or substitutions to turn one string into the other. RASHIDRASHEED is 2. It is symmetric, interpretable, and position-agnostic, and it is computed by a dynamic-programming table that costs O(m×n), which is fine on names and prohibitive on long text. Normalise it by the longer length to get a comparable ratio, and bound it: on names, a distance above 2 or 3 is almost never worth pursuing, and setting that bound lets the algorithm abandon early.

Jaro-Winkler — Jaro similarity counts matching characters within a sliding window and penalises transpositions, then Winkler adds a bonus scaled by the length of the common prefix (up to four characters). It returns 0–1. The prefix bonus is deliberate and empirical: it was designed for census person-names, where errors were observed to cluster toward the end of a name — typos, truncations, dropped suffixes — while the beginning is usually typed correctly. So Jonathon/Jonathan scores very high, and it is the standard first choice for given names and surnames. The same bias makes it wrong elsewhere: for identifiers, addresses or codes with a shared prefix (ACC-2024-0001 vs ACC-2024-9999), the bonus manufactures similarity that is not there.

Token-set overlap — split into words, compare as sets (Jaccard, or a token-sort/token-set ratio). It ignores word order and, depending on the variant, extra words entirely. This is the right tool when the tokens are stable and their arrangement is not: "Mohammed Al Rashid" vs "Al-Rashid, Mohammed" is a perfect token match and a poor character-level one. Handling multi-part names, missing middle names, honorifics and reordering is exactly its domain — and it is blind to typos within a token, which is why it is normally combined with a per-token character measure rather than used alone.

MeasureUse it forDo not use it for
Jaro-WinklerSingle given/family names, typo-level variationIdentifiers or strings with shared prefixes
LevenshteinShort codes, explainable edit counts, bounded checksLong text; word-order differences
Token-setMulti-word names, reordering, missing partsTypos inside a token
Q: Why does Jaro-Winkler reward matching beginnings, and when does that bias hurt you?

A: Because it was designed for person-name matching in census data, where errors were observed to concentrate toward the end of a name — mistyped or dropped final characters, truncation to a field width, missing suffixes — while the first few characters are usually entered correctly. Winkler added a bonus proportional to the length of the common prefix, up to four characters, so that pairs agreeing at the start are scored higher than the base Jaro similarity, matching how human name errors actually distribute. On names this is a genuine accuracy improvement and is why it remains the default choice. It becomes actively harmful whenever a shared prefix is structural rather than evidential: account numbers, reference codes, product SKUs, URLs and addresses routinely share long prefixes by design, so ACC-2024-000117 and ACC-2024-998823 receive a large bonus for agreement that carries no information about whether they are the same thing. The rule is that the prefix bonus is only valid where the prefix is discriminating, so for identifiers you want a plain edit distance, and for multi-word names you want token-level comparison with Jaro-Winkler applied per token rather than across the whole string.

IDF and rarity weighting

Without rarity weighting, every Mohammed matches every other Mohammed, every Smith matches every other Smith, and the top of your result list is permanently occupied by the most common names in the dataset — which are, definitionally, the least informative.

Inverse document frequency is the fix, borrowed from information retrieval. The weight of a token is inversely related to how many records contain it, conventionally idf = log(N / df) where N is the corpus size and df the number of records containing the token.

A concrete calculation over a 190-million-name corpus:

TokenRecords containing it (df)log(N/df)Weight
mohammed2,000,000log(95)≈ 4.6
al8,000,000log(24)≈ 3.2
kowalczyk400log(475,000)≈ 13.1

Agreement on kowalczyk carries roughly three times the weight of agreement on mohammed, derived from the data itself with no hand-maintained stopword list. That last point matters: a hardcoded list of common name-parts is a maintenance burden that is always wrong for the next dataset, whereas IDF is recomputed from the corpus and adapts automatically — including to the fact that al is common in one dataset and rare in another.

The direct consequence for matching: a query for "Mohammed Ali" against a corpus where both tokens are common should score low on token agreement alone and require corroboration — a date of birth, a nationality, a rare middle token — before it is treated as a match. This is the same principle as Fellegi-Sunter's u probability; IDF is its information-theoretic expression, and BM25 (below) is its production form in search engines.

Q: Why should a common name-word count for almost nothing in a match score?

A: Because the score is meant to measure evidence that two records are the same entity, and agreement on a value that millions of unrelated records also share is almost no evidence at all. If two million people in the corpus have the token mohammed, then two records agreeing on it narrows the space by a factor of ninety-five out of 190 million — whereas agreeing on a token held by four hundred records narrows it by a factor of half a million. Weighting them equally means the score is dominated by the most common tokens, so the ranking degenerates into "which record shares the most popular words", and every common name matches every other common name at the top of the list. IDF, log(N/df), expresses this directly and is computed from the corpus rather than maintained by hand, so it adapts when the data changes and does not require a stopword list that is wrong for the next dataset. Fellegi-Sunter reaches the same place through the u probability — the chance that a field agrees by coincidence — which is exactly what a high document frequency measures. Practically it means a rare-token agreement can carry a match on its own, while common-token agreement should require corroborating fields before crossing a threshold.

Partitioning

Partitioning splits one logical table into multiple physical tables — partitions — behind a single name. The database routes each query and write to the relevant partition(s) using a declared key, typically a date range or a hash.

CREATE TABLE screening_hits (
    id           BIGSERIAL,
    subject_id   UUID        NOT NULL,
    matched_at   TIMESTAMPTZ NOT NULL,
    score        NUMERIC     NOT NULL
) PARTITION BY RANGE (matched_at);

CREATE TABLE screening_hits_2026_08 PARTITION OF screening_hits
    FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');

Two properties make it worth the complexity:

Dropping a partition beats deleting rows. DELETE FROM screening_hits WHERE matched_at < '2025-01-01' on 40 million rows is a long-running transaction that writes a WAL record per row, leaves 40 million dead tuples for vacuum to reclaim, bloats the table and every index on it, and holds locks throughout. DROP TABLE screening_hits_2025_01 — or DETACH PARTITION first — unlinks files. It is effectively instant, generates no dead tuples, needs no vacuum, and releases the disk immediately. Retention policy stops being a maintenance operation and becomes a metadata change.

Index maintenance stays flat as history grows. This is the subtler and more valuable property, and it is the one that matters for the fuzzy-matching indexes above. A GIN trigram index over one unpartitioned table grows with total history; every insert updates posting lists inside a structure that is now hundreds of gigabytes, its upper levels no longer fit in the buffer pool, and write latency climbs year over year even though the write rate is unchanged. With range partitioning on time, only the newest partition takes writes, so the index being mutated is only as large as the current period — bounded, cache-resident, and the same size next year as this year. Older partitions' indexes are read-only, fully packed, and never touched by the write path.

The costs are real: the partition key must appear in queries for the planner to prune (a query without it touches every partition), unique constraints must include the partition key, and cross-partition queries fan out. Partition count also matters — thousands of partitions make planning itself slow.

Q: Why does partitioning keep index maintenance flat while history grows, and why does that matter most for a GIN index?

A: Because with range partitioning on time, writes land only in the current partition, so the index structure being modified is only ever as large as one period's data rather than all of history. Its upper levels stay in the buffer pool, page splits are cheap, and the cost of an insert next year is the same as this year even though the table as a whole is ten times bigger. Without partitioning the index grows monotonically, eventually exceeds memory, and every insert starts paying random I/O to fetch and dirty pages — so write latency degrades continuously with age, which is hard to diagnose because nothing about the workload changed. It matters most for GIN because GIN's per-insert cost is already multiplied: one row touches roughly one posting list per fragment of its text, so a 20-character name means about 20 list mutations, and those lists are shared across rows so writers contend on the same pages. Multiply an already-expensive write by an index that no longer fits in memory and the write path becomes the bottleneck. Confining that to a bounded, recent partition — while older partitions hold read-only, fully packed indexes the write path never touches — is what keeps a fuzzy-search system's ingestion rate stable over years.

Row store versus column store

A row store — Postgres, MySQL, SQL Server — keeps all the columns of one row physically adjacent. Reading one row is one contiguous read; reading one column across a million rows means touching every row's worth of bytes.

A column store — DuckDB, ClickHouse, and analytical formats like Parquet — keeps all the values of one column adjacent. Reading one column across a billion rows touches only that column's data, which compresses extremely well because adjacent values are homogeneous, and vectorises cleanly because the CPU processes a run of identical types.

Fetch one whole row by key✅ one read❌ one read per column, reassembled
Aggregate one column over a billion rows❌ reads every row✅ reads one column, compressed
Point update of one field✅ in place❌ typically rewrite or merge-on-read
Fuzzy lookup by inverted index✅ GIN / trigram available❌ generally not the model
Transactional writes at high rate❌ built for bulk load

Column stores are optimised for scanning many rows to produce few numbers. They are not designed for retrieving specific rows by a fuzzy key, and they do not generally offer the inverted-index machinery that fuzzy retrieval requires.

Which is why listing ClickHouse beside OpenSearch as "the search tier" is a category error, not merely a suboptimal choice. They answer different questions:

  • OpenSearch answers "which documents best match this messy string, ranked by relevance?" — an inverted-index, per-document-scoring question.
  • ClickHouse answers "across these billion rows, what is the count/sum/percentile grouped by these dimensions?" — a scan-and-aggregate question.

A design that names them as alternatives has not identified which question it is asking. The correct pairing is usually both, for different jobs: OpenSearch for candidate retrieval and relevance, ClickHouse for analytics over the resulting events. Asking ClickHouse to do fuzzy name retrieval means scanning, because there is no inverted index to prune with — which is precisely the sequential scan this whole module exists to avoid.

Q: Why is listing ClickHouse and OpenSearch together as "a search tier" a category error?

A: Because they are not two implementations of one capability, they are engines for two different question shapes, and choosing between them implies a question that has not been stated. OpenSearch is an inverted-index engine: it decomposes text into terms, keeps a posting list per term, and scores each candidate document with a relevance function, which is what "find the documents that best match this messy input" requires. ClickHouse is a columnar analytical engine: it stores each column contiguously and compressed so it can scan enormous row counts to compute aggregates fast, which is what "count and group these billion events" requires. Neither is a degraded version of the other. Asking ClickHouse for fuzzy name retrieval means a full scan with a similarity function applied per row, since it has no inverted index to prune candidates with — exactly the cost the index exists to eliminate. Asking OpenSearch for large-scale grouped aggregation is possible but wasteful and memory-hungry compared to a column store built for it. In a real system they are complementary rather than alternative: OpenSearch retrieves and ranks candidates, ClickHouse analyses the resulting decisions over time.

Search engines, and the explainability myth

OpenSearch and Elasticsearch (same Lucene lineage, forked in 2021), and the lighter Typesense and Meilisearch, add three things a relational database does not have natively:

Analyzers — a configurable pipeline applied identically at index time and query time: tokenise, lowercase, strip accents, apply a stemmer, expand synonyms, optionally emit character n-grams. "Müller-Schmidt" can be normalised to the tokens muller and schmidt before it is ever indexed, so the messiness is resolved in the index rather than in every query.

Typo tolerance — fuzzy matching over the term dictionary using a bounded edit distance, implemented with a Levenshtein automaton so it does not degenerate into scanning the dictionary. Typesense and Meilisearch enable this by default; Lucene engines expose it per query (fuzziness).

BM25 relevance scoring — the default ranking function. It scores a document against a query by combining three factors: term frequency with saturation (the tenth occurrence of a word adds much less than the second), inverse document frequency (the rarity weighting above), and field-length normalisation (a match in a short title counts for more than the same match buried in a long body).

And then the part that is routinely got wrong. It is a common assumption that a search engine's ranking is a black box you must accept on faith. It is not. OpenSearch exposes an Explain API at GET /{index}/_explain/{id} (also POST /{index}/_explain/{id}), documented as returning "detailed information about why a specific document matches or does not match a query". The response is a nested tree of scoring contributions built on Lucene's TF-IDF/BM25 framework, with the individual inputs named — boost, idf (documented as "the inverse document frequency", measuring how rare a term is), tf, and the field-length inputs dl and avgdl — each with its computed value and a human-readable description, composed into the final score. — (verified)

So "we cannot use a search engine because we could not explain the score to an auditor" is false as stated. The per-document breakdown is a first-class API. What is true is that the explanation is expressed in retrieval terms — term rarity, frequency, field length — rather than domain terms, and it explains ranking, not a decision. Which is another argument for the two-stage pattern: let the engine rank, and make the decision in your own scorer, whose weights you chose and can defend.

Q: Is a search engine's relevance score inherently unexplainable?

A: No, and assuming so is one of the more common wrong reasons to reject one. OpenSearch and Elasticsearch both expose an Explain API — GET /{index}/_explain/{id} in OpenSearch — which returns why a specific document matched a query and how its score was assembled. The response is a nested tree in which each node names a contribution, gives its numeric value and a description, and the leaves are the actual BM25 inputs: the term frequency, the inverse document frequency measuring how rare the term is, the boost, and the field-length terms dl and avgdl. That is a complete arithmetic account of the score, reproducible and auditable. The genuine limitation is different and worth stating precisely: the explanation is in information-retrieval vocabulary rather than domain vocabulary, so it tells you a term was rare and appeared in a short field, not that "the surname is uncommon and the date of birth corroborates". And it explains a ranking, which is not the same as justifying a decision. The clean resolution is the two-stage pattern — use the engine to retrieve and rank candidates, and make the actual decision in a scorer whose features and weights you own and can present in the language of the domain.

Why embeddings and vector search are wrong for name matching

This is the item most worth internalising, because the instinct to reach for a model is strong and the reason it fails is genuinely non-obvious.

An embedding maps text to a vector such that texts with similar meaning land near each other. That is the property the model is trained for: "physician" and "doctor" are close because they mean the same thing, despite sharing few characters. Vector search then finds nearest neighbours in that space, usually approximately (HNSW, IVF).

Names have no meaning to capture. John Smith and Jon Smyth should match because they share characters — because one is plausibly a misspelling or transliteration of the other — not because they denote similar concepts. Semantic similarity is simply the wrong metric for the task, and the failure is not a tuning problem:

  • The model normalises away the signal you need. Tokenizers and embeddings are built to be robust to surface variation so that meaning survives it. Name matching is entirely about surface variation. The model discards exactly the information you are trying to measure.
  • Semantic neighbours are the wrong neighbours. In embedding space John Smith sits near other common Anglo names and generic person-name patterns, because that is what "similar meaning" amounts to for a name. Jon Smyth, a rare spelling, may embed some distance away.
  • Rarity is inverted. Everything above says a rare token should dominate the score. An embedding model has seen rare names least, so its representations of them are the least reliable — the model is weakest exactly where the matching signal is strongest.
  • Non-determinism and drift. The same input embeds differently across model versions, so re-embedding on an upgrade silently changes which pairs match, with no diff you can review. Character-based similarity is deterministic and reproducible years later, which matters in any audited context.
  • It cannot be explained in the terms that matter. "Cosine similarity 0.87 in a 768-dimensional space" is not an account of why two people are the same person. "Surname edit distance 1, given names token-match, date of birth agrees, surname occurs in 400 of 190 million records" is.

The correct tool for names is character-level and token-level: trigrams and phonetics for retrieval, edit distance, token overlap and rarity weighting for scoring. Embeddings are the right tool nearby — for adverse-media text, for job titles, for descriptions, for anything where meaning is genuinely the signal. The error is applying them to the one field in the record that carries no semantics at all.

Q: Why are embeddings the wrong tool for matching names, when they work well for matching text?

A: Because an embedding encodes meaning, and a name does not have meaning to encode. Embeddings work on text precisely because they abstract away surface form to capture sense, which is why "physician" and "doctor" land close together despite sharing almost no characters. Name matching needs the opposite: John Smith and Jon Smyth should match because of character-level proximity — one is a plausible misspelling or transliteration of the other — and the model is specifically trained to discard that surface information as noise. The nearest neighbours it returns for a name are therefore other names that are typologically similar, common Anglo-Saxon names for instance, rather than variant spellings of that individual. Rarity makes it worse: rare surnames carry the most matching evidence and are exactly the strings a language model has seen least, so representation quality is lowest where the signal is highest. There are also operational problems — embeddings shift between model versions, so an upgrade silently changes which pairs match with nothing to diff, and a cosine distance cannot be presented to an auditor as a reason two records are the same person. Embeddings remain correct for the semantic parts of the same pipeline, such as classifying adverse-media articles, but for the name field itself the right tools are character n-grams, phonetic codes, edit distance, token overlap and rarity weighting.

The two-stage pattern

Everything above assembles into one architecture:

STAGE 1 — RETRIEVE (cheap, wide, recall-oriented)
   trigram / GIN, phonetic blocking key, sorted-token key,
   search-engine query — union of several independent passes
   -> a few hundred candidates
                    |
                    v
STAGE 2 — SCORE (expensive, narrow, precision-oriented)
   Jaro-Winkler per token, Levenshtein, token-set overlap,
   IDF rarity weights, corroborating fields (DOB, nationality),
   Fellegi-Sunter weights -> one number per candidate
                    |
                    v
DECIDE — two thresholds: auto-match / review / auto-discard

The rule that governs it: retrieval must be wider than the decision. Stage one should return candidates that stage two will confidently reject, and if it never does, stage one is too narrow. The asymmetry is the whole point — stage two can discard a bad candidate, but nothing downstream can recover a candidate stage one never produced.

The classic failure is collapsing the two: deciding at retrieval time. It looks efficient and is always tempting, because filtering earlier means less work. It shows up as a similarity threshold applied inside the SQL WHERE clause, as a phonetic key treated as a match verdict, as LIMIT 10 on the retrieval query because ten results is what the UI shows. In every form the effect is the same: the decision is being made by the cheap, coarse, recall-oriented mechanism instead of the precise one, and every pair it drops is invisible — never scored, never logged, never reviewable, no error. The two failure modes below are both instances of exactly this.

Q: What is the two-stage retrieve-then-score pattern and why must retrieval be wider than the decision?

A: Stage one uses cheap, coarse structures — trigram indexes, phonetic blocking keys, sorted-token keys, a search-engine query — to reduce the corpus from millions of rows to a few hundred candidates, and it is tuned for recall: several independent passes unioned together so a record has to be missed by all of them to be lost. Stage two applies expensive, precise scoring to just those candidates — per-token Jaro-Winkler, edit distance, token-set overlap, rarity weighting, corroborating fields — producing a number that a two-threshold rule turns into match, review, or discard. Retrieval must be wider than the decision because the errors are asymmetric: a candidate that stage one produces and stage two rejects costs a few microseconds of scoring and is fully visible, so it can be logged, sampled and audited, whereas a candidate stage one never produces cannot be recovered by anything downstream, generates no error, and moves no metric. So a stage one that never surfaces anything stage two rejects is not efficient, it is under-retrieving, and the visible rejections are the evidence that the width is adequate. Collapsing the two stages puts the decision in the coarse mechanism, which is what makes the resulting misses silent.

Measured: what this costs on a real system

Everything above is mechanism, and the corpus sizes used to illustrate it are hypothetical. This section is not: the figures below are measurements from a production sanctions-screening system, and they are worth reading because the mechanism shows up clearly at a corpus far smaller than the 190-million-row examples used above.

The corpus and the index.

~474,000 names across ~273,000 entities
  names table        ~300 MB
  GIN trigram index   ~88 MB   (~29% of the table it indexes)
  whole database     ~655 MB
  trigram match       p50 53 ms   p90 289 ms

The index-to-table ratio is the number to carry: a GIN trigram index is a large fraction of the data it indexes, because it stores an entry per fragment rather than per row. That is the storage side of the same fact that halves ingestion throughput.

Where the time actually goes. Stage one — candidate retrieval — was 95% of total screening time. Not the scoring, not the decision logic, not the network: the retrieval. Warm per-name latency varied by an order of magnitude across four real queries:

Lindqvist    344 -  864 ms      <- rare name, small candidate set
Putin        413 -  438 ms
al-Assad    2012 - 2278 ms
Kony        2308 - 2536 ms      <- common fragments, huge candidate set

That spread is the whole lesson. The corpus is identical for all four queries; what differs is how many rows share trigrams with the query. Retrieval cost tracks candidate-set size, not corpus size — which is why a system can pass every benchmark on rare names and fall over on common ones, exactly where matching is hardest.

Why the common name is slow. A cold first hit for Kony took 2.7 seconds, and the query plan says why: a bitmap heap scan fetching roughly 6,000 heap blocks in order to re-check similarity() over a 9,494-row candidate superset. A GIN index returns row locations, not answers — the trigram match is lossy, so every candidate must be visited in the heap and re-checked. The index prunes 474,000 rows to 9,494; the remaining 9,494 random reads are the bill.

One measured strategy comparison. A single inbound name fans out to four to eight spelling variants that all have to be retrieved. Issuing them as four sequential trigram queries cost 1,312 ms of database time; issuing the same four as one CROSS JOIN LATERAL over the variant list cost 488 ms — 2.7x faster, with the candidate sets verified identical (zero rows differing in either direction). Nothing about the index, the threshold or the data changed. How you issue retrieval can matter as much as which index you built, because per-statement overhead is paid per variant and the variants are not optional.

Restructuring retrieval on that finding produced, warm:

NamecandidateMatch beforeafterfactor
Lindqvist344 - 864 ms38 - 43 ms~10x
Putin413 - 438 ms98 - 118 ms~4x
al-Assad2012 - 2278 ms479 - 535 ms~4x
Kony2308 - 2536 ms742 - 878 ms~3x

Note the shape: the rare name improved most and the common name least, because the common name's cost is dominated by the heap re-check, which restructuring the query does not remove. Fixing the wrong stage would have moved these numbers barely at all.

Thresholds come from labelled data, not taste. A labelled set of 1,736 names with known verdicts moved the default similarity threshold from 0.85 to 0.70 — a change nobody could have justified by inspection. A larger benchmark of 1,536 labelled positives put recall at 75%, with an estimate of plus or minus 4.3 points at 95% confidence, and common-name precision at 14%. That last pair is the honest summary of this problem domain: recall is moderate, precision on common names is poor, and the two are traded against each other deliberately rather than accidentally.

What was labelled unmeasured — and what happened when it was measured. The same programme evaluated a blocking key as an alternative retrieval strategy and wrote, verbatim, that "nothing in this table is measured on our data", marking the blocking-key option "size not measured" and "test this first". The quoted figure that a computed key "cuts the field to about a hundred candidates" was derived arithmetic, not an observation.

It was then tested, and the derived figure was wrong in direction: the measured mean block was 596, against a trigram baseline of 244. Not "a hundred rather than a few hundred" — 2.4x larger than the thing it was proposed to replace, at 86.8% recall where the criterion was to match today's candidate sets exactly. The proposal did not survive contact with the corpus.

Two lessons, and the second is the one people miss. The obvious one is that the labelling saved the project: because the estimate was marked derived, the number was testable rather than load-bearing, and an unlabelled estimate becomes indistinguishable from a measurement once it has been copied twice. The subtler one is about the kind of error — a derived estimate is not simply imprecise, it can be inverted, because it is produced by a model of the data rather than by the data. The arithmetic assumed keys partition the corpus roughly evenly; real name distributions are savagely skewed, so the common blocks that dominate the mean are exactly the ones the estimate cannot see. Whenever an estimate depends on a distribution you have not looked at, treat its direction as unknown, not just its magnitude.

Q: Retrieval latency for the same corpus varied from 344 ms to 2,536 ms across four names, so what does that spread tell you?

A: That retrieval cost is driven by candidate-set size rather than corpus size. All four queries ran against the same roughly 474,000-name corpus with the same index and the same threshold, so the only variable is how many stored names share trigrams with the query. A rare surname like Lindqvist produces a small candidate superset and returns in a few hundred milliseconds; a name built from common fragments produces a very large one and takes a full order of magnitude longer. The mechanism behind the cost is the GIN re-check: a trigram index match is lossy, so the index yields row locations and every candidate must then be visited in the heap to compute the real similarity — one measured cold query fetched around 6,000 heap blocks to re-check a 9,494-row superset. The practical consequences are that benchmarking on rare names tells you nothing about your worst case, that a p50 is close to meaningless here and p90 or p99 is the number to hold, and that capacity planning has to be driven by the distribution of name commonality in real traffic rather than by row count. It is also why common names are the dangerous case in both directions at once: they are the slowest to retrieve and the hardest to score precisely, since common tokens carry almost no discriminating evidence.

Q: Four spelling variants issued as four sequential trigram queries cost 1,312 ms and as one lateral join cost 488 ms, so what is the general lesson?

A: That per-statement overhead is multiplied by variant fan-out, so the shape of the retrieval call is a design decision on the same footing as the choice of index. A single inbound name expands to roughly four to eight spelling variants that all have to be retrieved, and issuing them one at a time pays planning, round-trip and setup cost for each, with none of that work shared. Collapsing them into one statement — a CROSS JOIN LATERAL over the variant list — lets the database plan once and execute the scans together, which measured 2.7 times faster while returning candidate sets verified identical in both directions, so it is a structural win with no recall trade. The general lesson is that "which index" is only half the retrieval question and "how many statements" is the other half, and the second half is invisible in schema review because the index definition looks correct either way. It also shows why the identity check matters: a retrieval optimisation that silently narrows the candidate set is a recall regression wearing a performance win's clothes, so the change is only safe once you have proved the candidate sets did not move.

Q: After restructuring retrieval, a rare name improved 10x but the most common name improved only 3x, so why?

A: Because the two names were bottlenecked on different things and the change addressed only one of them. Collapsing several sequential variant queries into one statement removes per-statement overhead, a fixed cost paid per variant regardless of how many rows come back — so for a rare name, where the candidate superset is small, that overhead is most of the total and removing it is close to the whole win. For a common name the dominant cost is the GIN re-check: thousands of candidate rows must each be visited in the heap to compute real similarity, and that work is proportional to candidate-set size and completely untouched by how the query was issued, so the common name keeps its floor. The discipline this illustrates is to identify which stage dominates before optimising, because the same change produced both a 10x and a 3x result on one system, and measuring only the rare name would have badly overstated the improvement. Pushing the common name further needs a different lever — narrowing the candidate superset with a more selective retrieval pass, or reducing the per-candidate re-check cost — not a further refinement of statement batching.

Q: A phonetic blocking key was expected to cut the candidate field to ~100 but measured 596 against a trigram baseline of 244, so what went wrong?

A: The estimate modelled the data instead of measuring it, and the model assumed something names do not do. Dividing a corpus by the number of distinct keys gives an average block size, which is only meaningful if the keys partition the corpus roughly evenly — and real name distributions are savagely skewed, so a handful of phonetic codes covering the most common surnames absorb an enormous share of the rows. Those heavy blocks dominate the mean, and they are precisely the region the arithmetic cannot see, so the derived figure was not merely imprecise, it was wrong in direction: the key was 2.4 times wider than the index it was proposed to replace. The deeper point is that a blocking key is a bet that your key is more selective than your existing index, and that bet can lose outright, because a trigram index adapts its selectivity to the actual query string while a fixed key applies the same coarse partition to every name. It is also why the median matters more than the mean here — the same key had a median block of 344, so this is not one pathological outlier dragging an otherwise good average, it is the whole distribution sitting high. The practical rule is that any estimate resting on a distribution you have not inspected should be treated as having unknown direction, not just unknown magnitude, and that the cheap experiment is to compute the key over the real corpus and look at the block-size histogram before designing anything around it.

Q: Three blocking keys measured 43.2%, 86.8% and 72.0% recall, and their union 88.6%, so what do you conclude?

A: That the multi-pass argument is correct and still insufficient, which are two separate conclusions and both matter. The union beating every individual key — 88.6% against a best single key of 86.8% — is the theory confirmed on data: passes fail independently, so a record has to be missed by all of them to be lost, and unioning compounds recall rather than averaging it. But 88.6% on a sanctions screen means roughly one true match in nine is never compared to anything, and because these are full unbudgeted blocks that figure is a ceiling rather than a starting point: no ranking, threshold or ORDER BY applied inside a block can recover a record the key never admitted, so there is no downstream fix. The conclusion is therefore that these particular keys are not viable as a replacement for the existing retrieval, which measured 100% on the same probe set, and the honest report is that the proposal failed its own success criterion rather than that it needs tuning. It also shows why the criterion has to be fixed in advance — "reproduce today's candidate sets" is falsifiable and was falsified, whereas "improve retrieval" would have let 86.8% be presented as a good result by quoting the latency win and omitting the recall loss.

Q: A design document reports throughput figures for six retrieval options and states that none of them are measured, so is that a weakness?

A: No, it is the document's strongest property. The six options were compared on cost, risk and applicability using reasoning and vendor documentation rather than a benchmark on the real corpus, and saying so explicitly is what lets a reader weight each number correctly — the blocking-key option is annotated "size not measured" and "test this first", which converts a claim into a proposed experiment with a named next step. The failure mode this avoids is specific and common: an estimate that is not labelled becomes indistinguishable from a measurement after it has been quoted twice, at which point a real design decision rests on a number nobody ever took, and nobody can tell, because the provenance was lost at the first copy rather than at the decision. A figure such as "a computed key cuts the field to about a hundred candidates" is derived arithmetic and behaves very differently from the 9,494-row candidate superset that was actually observed, even though the two look identical once written into a slide. The professional habit is to mark every quantity as measured, derived or assumed, and to keep that marking attached as the number moves between documents.

Failure mode 1 — a fixed candidate budget over a growing pool

The design: retrieval returns the top 50 candidates per query, which stage two then scores precisely. Fifty was chosen when the reference dataset held 5 million names, where it was comfortably generous.

The dataset grows to 190 million. Nothing in the code changes.

  5M names: query "MOHAMMED AL RASHID" -> ~180 rows share a blocking key
            top 50 taken -> true match is at rank 31     ✅ found
190M names: same query                 -> ~6,800 rows share a blocking key
            top 50 taken -> true match is at rank 1,190  ❌ never scored

The number of names competing for those 50 slots grew 38×; the slots did not. Recall falls continuously, and the degree of the fall depends on how common the queried name is — so it is worst exactly where matching is hardest.

What makes this severe is what it does not produce:

  • No error. The query succeeds and returns 50 well-formed rows.
  • No latency change. Stage two does exactly as much work as before — the cost is capped by the budget, which is the entire reason it was introduced.
  • Nothing in monitoring. Throughput, error rate, p99, CPU, memory: all flat. Match count may even look stable, because common names still produce matches — just the wrong ones.

A fixed budget over a growing pool degrades silently, and monitoring built on errors and latency cannot see it. The only signals that can are ones you must deliberately build:

  1. Measure recall against a labelled set on a schedule, not once at launch — this is the only direct measurement, and the number moving is the alarm.
  2. Alert on truncation, not on failure. Record when retrieval returns exactly the budget, meaning it was capped. A rising truncation rate is the leading indicator, and it is available for free.
  3. Track the score of the lowest-ranked candidate. If the 50th candidate's score is high, good candidates are being cut off below it; if it is low, the budget is genuinely sufficient.
  4. Make the budget adaptive — derive it from the block size, or keep expanding until the marginal candidate falls below a score floor, rather than pinning a constant.
Q: A matching system caps retrieval at 50 candidates per query and the dataset grows 38x, so what breaks and why does no alert fire?

A: Recall breaks, continuously and invisibly. The 50 slots are now contested by roughly 38 times as many similarly-blocked names, so the true match that used to rank 31st is now ranked well past the cut-off and is never handed to the scoring stage at all — it is not scored low, it is not scored. No alert fires because every signal monitoring watches is unaffected by design: the query still succeeds, so there is no error; stage two still scores exactly 50 candidates, so latency and CPU are unchanged, since capping the cost was the whole purpose of the budget; and the system still returns matches for common names, so match volume looks plausible. The failure is a recall failure, and recall is the one property that produces no runtime symptom, because a result set that is missing rows is indistinguishable from a correct one unless you already know the answer. Detecting it requires measurement you build on purpose: periodic recall evaluation against a labelled set, an alert on the truncation rate — how often retrieval returns exactly the budget, meaning it was capped — and tracking the score of the last candidate that made the cut, since a high score at the boundary proves good candidates are being discarded below it. The structural fix is to make the budget adaptive, derived from block size or extended until the marginal candidate's score drops below a floor, rather than a constant chosen against a dataset size that no longer exists.

Failure mode 2 — a threshold that only works in one direction

The design: retrieval uses Postgres pg_trgm's % operator, then survivors are re-checked in application code against a configurable MinimumSimilarity.

-- Stage 1, in SQL: prunes at pg_trgm.similarity_threshold (default 0.30)
SELECT id, name FROM subjects WHERE name % :query;
// Stage 2, in code: re-checks against the configured floor
var hits = rows.Where(r => Similarity(r.Name, query) >= config.MinimumSimilarity);

Turn MinimumSimilarity up to 0.5 and results narrow, exactly as documented. The knob works.

Turn it down to 0.2 to widen recall, and nothing happens. Not "less than expected" — nothing. Every row between 0.2 and 0.3 was already removed by the SQL % operator, which pruned at the database's own default of 0.30 before the application ever saw a row. The application filter can only remove rows from a set that is already floored at 0.30; it can never add one back.

The knob appears to work because raising it does what you expect, and one-directional tests are the normal case — you tune a threshold by tightening it until precision looks right. The bug is only visible when someone tries to widen recall, and even then it presents as "lowering it didn't help much", which reads as a modelling limitation rather than an inert control.

The general shape, worth recognising anywhere: a filter applied twice, where the first application uses a default the second does not know about. The effective threshold is max(database_default, configured_value), and the configured value is only in force when it exceeds the default. Related instances: a client-side page size larger than a server-side cap, a retry count above a gateway timeout, a log level set below a sink's own minimum.

The fixes are all about making the two applications agree:

  • Set the database threshold from the same config valueSET LOCAL pg_trgm.similarity_threshold = :minSimilarity in the same transaction, so there is one number.
  • Use the explicit formsimilarity(name, :query) >= :minSimilarity instead of %, so the threshold is in the SQL rather than in a session setting. Note this can forfeit index usage, which is often why % was used in the first place; the config-driven SET LOCAL is usually the better answer.
  • Assert the invariant at startup — refuse to boot if the configured floor is below the database default, so the impossible configuration is a startup error rather than a silent no-op.
  • Test both directions. The test that would have caught this is "lower the threshold and assert the result count increases".
Q: A similarity threshold narrows results when raised but does nothing when lowered, so what is the mechanism?

A: The filter is being applied twice with two different floors, and the first one is not the one being configured. Retrieval prunes in SQL using pg_trgm's % operator, which compares against the session setting pg_trgm.similarity_threshold — default 0.30 — so the result set handed to the application is already floored at 0.30. The application then re-checks survivors against the configured MinimumSimilarity. Raising the configured value above 0.30 removes additional rows and behaves exactly as documented, which is why the knob looks functional. Lowering it below 0.30 does nothing at all, because the rows in that band were discarded by the database before the application saw them, and a downstream filter can only remove rows, never restore them. The effective threshold is max(database_default, configured_value). It survives review because thresholds are normally tuned in the tightening direction, so the broken direction is never exercised, and when it finally is, the symptom reads as a modelling ceiling rather than an inert control. The fix is to make one number govern both stages — set the session setting from the same config value inside the transaction — and to assert at startup that the configured floor is not below the database default, plus a test that lowers the threshold and asserts the candidate count increases.

Keeping index maintenance off the customer-facing path

There are two lessons in this section and the second is the rarer one. The first is the mechanism by which an optimisation aimed at writes turns into a read problem on a GIN index. The second is how to settle a design question honestly when the measurement that would settle it does not exist yet and cannot be taken — deciding on shape rather than on a benchmark, and knowing when that is legitimate.

The index in question is a Postgres trigram GIN index over normalized watchlist names: CREATE INDEX ix_watchlist_names_trgm ON "WatchlistNames" USING gin ("NormalizedName" gin_trgm_ops), created in PROOViD/AMLService/AMLService/Migrations/20260617062152_AddWatchlists.cs:83-88, and the candidate-retrieval query that rides it is in PROOViD/AMLService/AMLService/Infrastructure/Screening/LocalWatchlistProvider.cs:167. Everything below concerns the maintenance cost of that one index.

The question as it was actually posed: how do we keep the cost of writing to the index from hurting the customer-facing screening path? Ingest is bulk, machine-driven and tolerant of delay; screening is a synchronous request with a customer waiting on it. Three options were on the table:

  • A — monthly partitions, an index per partition, a bulk build for the backfill. Expiry becomes dropping a partition.
  • B — one table, GIN fastupdate enabled, so index writes are deferred into a pending list.
  • C — one table, ordinary inserts, accept the cost.

A was chosen. The recorded rationale was not about write throughput at all: partitions make the expiry question a drop rather than a mass delete, which is the expensive half of retention. Revisit once the first real index exists; that is a build-time gate, not a reason to wait now.

Why B is a trap

GIN is an inverted index: one row contributes one posting-list entry per distinct key it contains, so a name yields as many index mutations as it has trigrams. fastupdate makes that cheap by not doing it — new entries are appended to an unsorted pending list instead of being merged into the tree. Writes get faster because the expensive part has been postponed, not removed.

The postponed work reappears on the read side, twice over, and the read side is the customer-facing one.

  • Every search must scan the pending list linearly in addition to descending the tree. The pending list is unsorted, so there is nothing to prune with; the scan is proportional to how far ingest has run ahead of the merge. Read cost therefore grows with the ingest backlog — which is precisely the coupling the whole question was trying to break.
  • The flush is not scheduled on a maintenance thread. It happens during autovacuum, or when the pending list exceeds gin_pending_list_limit — and in that second case the query that trips the threshold performs the merge itself. That query is an arbitrary customer screening request, chosen by nothing but timing.

So B converts a steady, predictable, known write cost into an unpredictable tail-latency spike on the exact path the change was meant to protect, and it does it in a way that is invisible in averages and shows up only in the tail, sporadically, on whichever request was unlucky. "Trades a write problem for a read problem" is not a figure of speech here; it names the mechanism exactly.

Why A works

Under partitioning each partition carries its own, smaller index. Three consequences follow structurally, without needing a number attached to any of them:

  • Only the current partition takes live writes. Index maintenance is bounded by the size of one period's data rather than by the whole accumulated history, so the marginal cost of an insert stops growing as history grows.
  • The backfill can be built per partition in bulk — load the rows first, build the index afterwards. Building an index once over a static set is a fundamentally different operation from maintaining it incrementally across every row insert, and it is the cheaper one.
  • Partition pruning keeps reads off partitions the query cannot match, so a query constrained on the partition key never opens their indexes at all.

The retention half, which is what actually decided it

This is the argument that carried the decision, and it is about deletes, not inserts.

Under one big table, expiry is a mass DELETE. In Postgres a delete does not remove anything immediately: it writes a tombstone, marking the row dead but leaving it and its index entries in place. The index bloats with entries pointing at dead rows, reads pay to traverse them, and the space comes back only when VACUUM runs. That vacuum is heavy I/O contending with the read path — so retention reintroduces the same customer-facing latency spike, through a different door than B did.

Under partitions, expiry is DROP TABLE on the oldest partition: a catalog change plus a file unlink. No tombstones, no bloat, no vacuum debt, and no per-row work at all — the cost is independent of how many rows the partition held. That asymmetry is what made A the answer even before anything about write throughput was known, because retention was a hard requirement and mass deletion was going to be its most expensive operation regardless of how the write side turned out.

The price of A — state it, or the recommendation is not honest

  • Partition creation must be automated, or it silently stops. A missing future partition means inserts start failing at a boundary nobody was watching. This is a new operational obligation, and it fails on a date rather than on a code change, which is the hardest kind of failure to attribute.
  • Partition count is not free. The planner carries per-partition overhead, so a fine-grained scheme trades query-planning cost for maintenance granularity.
  • Cross-partition queries get harder, and global uniqueness is genuinely harder — a unique constraint that does not include the partition key cannot be enforced across partitions.
  • More objects to manage: one index per partition means more things to build, monitor, reindex and get wrong.

The part that matters most: this was decided on shape, not on a benchmark

Nothing in this decision was measured, and it could not have been. Settling it empirically would require an index built at one million, ten million and fifty million rows, and those builds were gated by a different open question that was still open. There is no latency figure, no throughput table, and no "N times faster" here — and the absence is deliberate. An invented number in a design record is indistinguishable from a real one six months later, and it will be quoted as if it had been taken.

What made deciding anyway legitimate was that the argument turned on directions and mechanisms, not magnitudes. "A mass delete leaves tombstones and needs a vacuum; a partition drop is a file unlink" is true at every scale, so no benchmark could reverse it. "A query that trips the pending-list threshold performs the merge itself" is a property of the implementation, not of the data volume. Reasoning from shape is sound exactly when the options differ in kind, and when the option you pick is also the one that is easier to reverse.

It would not have been legitimate if the choice had come down to magnitude — if both options had the same shape and the question were whether one is fast enough. "Is a per-partition GIN build fast enough to fit the maintenance window?" is a benchmark question and no amount of reasoning substitutes for running it. The test is whether flipping the unknown quantity could flip the answer: if it could, measure; if it cannot, decide and record why.

Finally, "revisit once the first real index exists" is a build-time gate, not a deferral. A deferral names no trigger and therefore never fires. A gate names the concrete event that makes the missing evidence available, and commits to re-examining then. That distinction is the difference between deciding under uncertainty and postponing a decision while pretending to have made one.

Q: Enabling GIN fastupdate makes ingest cheaper, so why can it hurt the customer-facing read path?

A: Because fastupdate does not remove the index-maintenance work, it postpones it, and the postponement lands on readers. New entries are appended to an unsorted pending list instead of being merged into the GIN tree, so writes get cheap. But the pending list is unsorted, which means there is nothing to prune with, so every subsequent search must scan it linearly in addition to descending the tree. Read cost therefore grows with how far ingest has run ahead of the merge — the read path becomes coupled to ingest volume, which is exactly the coupling you were trying to break. On top of that the merge is not scheduled on a background maintenance thread: it happens under autovacuum, or when the pending list exceeds gin_pending_list_limit, and in that second case the query that trips the threshold pays for the whole flush itself. That query is an arbitrary customer screening request selected by nothing but timing. So a steady, predictable write cost has been converted into a sporadic, unpredictable tail-latency spike on the path that was meant to be protected, and it hides in the averages.

Q: Under GIN fastupdate, who pays to flush the pending list, and why does that detail change the design?

A: Whichever query happens to be running when the pending list crosses gin_pending_list_limit — otherwise autovacuum, whenever it next gets there. That detail is the whole objection, because it decides where the cost lands rather than how large it is. If the flush were performed by a dedicated maintenance process, deferring index writes would be a straightforward win: cost moves off the request path onto a background one, which is the normal shape of a good deferral. Because the flush can instead be performed inline by an ordinary reader, the deferral moves cost onto the request path and randomises which request wears it. That makes the symptom a tail-latency spike with no stable reproduction — the same query is fast a thousand times and slow once — which is among the hardest performance problems to attribute. The general lesson transfers well beyond GIN: when evaluating any "defer the work" setting, the first question is not how much work is deferred but which thread eventually performs it, because a deferral that can be collected on the critical path is not a deferral at all, it is a redistribution into the tail.

Q: Why did retention, rather than write throughput, decide the partitioning question?

A: Because retention is where the two shapes differ irreversibly, and it was a hard requirement rather than an optimisation. Under a single table, expiring old rows is a mass DELETE, and in Postgres that does not free anything at the time: it writes tombstones, leaves the dead rows and their index entries in place, bloats the index so reads traverse dead entries, and defers reclamation to VACUUM — heavy I/O that competes with the read path and therefore reintroduces the same customer-facing spike you were avoiding, just through a different door. Under partitioning, expiry is DROP TABLE on the oldest partition: a catalog change plus a file unlink, with no tombstones, no bloat, no vacuum debt, and a cost independent of how many rows the partition held. That is a difference in kind, not degree, so it holds at any data volume and no benchmark could reverse it — which is why it was decidable without one. The write-side benefits of partitioning are real but secondary; they are about degree, and degree is precisely what had not been measured.

Q: What does partitioning actually do for index maintenance, mechanically?

A: It bounds the size of the structure being mutated and narrows which structures a read has to consider. Each partition carries its own index, so index maintenance on an insert touches the current partition's index only, whose size is bounded by one period of data rather than by all accumulated history — the marginal cost of a write stops growing as history grows. Historical partitions become effectively read-only, so their indexes are never mutated after their period closes. The backfill can then be loaded per partition and indexed afterwards, and building an index once over a static set is a different and cheaper operation than maintaining it incrementally across every row insert. On the read side, partition pruning lets the planner eliminate partitions the query's constraints cannot match, so those indexes are never opened. Note what this does not claim: no factor, no throughput figure. The claims are all directional — bounded rather than growing, built once rather than maintained per row — and directional claims are the ones that survive without a benchmark.

Q: What does choosing monthly partitions cost you, and why must that be in the decision record?

A: Four things, and they belong in the record because a recommendation that lists only benefits is a sales pitch rather than a decision. First, partition creation has to be automated or it silently stops: a missing future partition makes inserts fail at a boundary date, a failure triggered by the calendar rather than by a deploy, which is the hardest kind to attribute. Second, partition count is not free — the planner carries per-partition overhead, so finer granularity buys maintenance convenience with planning cost. Third, cross-partition queries get harder and global uniqueness genuinely harder, since a unique constraint that does not include the partition key cannot be enforced across partitions. Fourth, one index per partition means more objects to build, monitor and reindex. None of this outweighs turning retention into a file unlink, but stating it is what lets a later reader re-evaluate the decision rather than inherit it — and if the operational costs are the ones that eventually bite, the record shows they were known and accepted, not missed.

Q: This decision was made without any benchmark, so why was that legitimate here, and when would it not be?

A: It was legitimate because the options differed in kind rather than in magnitude, and every argument that carried the decision was directional. "A mass delete writes tombstones and needs a vacuum, a partition drop is a file unlink" is true at a million rows and at fifty million; "the query that trips the pending-list limit performs the merge itself" is a property of the implementation, not of the data volume. When the unknown quantity cannot flip the answer no matter what value it takes, waiting for it is not rigour, it is delay. It also mattered that the chosen option was the more reversible one and that measuring was not merely expensive but blocked — settling it empirically needed index builds at several scales, which were gated by a separate open question. It would not have been legitimate if the question had been one of degree — whether a per-partition index build fits the maintenance window, or whether the write cost is tolerable at current ingest rates. Those are benchmark questions and reasoning cannot substitute for running them. The test to apply is simple: ask whether any plausible value of the missing number would change the choice. If yes, measure. If no, decide, and record the reasoning so the next reader can check the premise instead of re-litigating the conclusion. What is never acceptable is closing the gap by inventing a plausible-looking figure, because an unlabelled estimate becomes indistinguishable from a measurement the moment it has been quoted twice.

Q: What is a build-time gate, and how is it different from "we will revisit this later"?

A: A gate names the concrete event that will make the missing evidence available and commits to re-examining when that event occurs; a deferral names no trigger and therefore never fires. "Revisit once the first real index exists" is a gate: the triggering event is unambiguous, it will happen as a normal consequence of the work rather than requiring someone to remember, and it is precisely the event that unblocks the measurement that could not be taken at decision time. "Revisit later" or "revisit if it becomes a problem" are deferrals dressed as gates — the first has no trigger at all, and the second triggers on a production incident, which means the plan is to find out from customers. The practical value is that a gate converts an unmeasured decision from a liability into a scheduled question, and it gives the next engineer permission to change the answer without treating the original decision as a mistake: the record says what was known, what was not, and what event would make the difference.

See also