feat: per-result match confidence and tier on address search - #4
Merged
Conversation
Search computed a relevance score for every row, sent it over the wire, and
dropped it on the floor -- scanned into a local at address_service.go:340 and
never read. So a trigram rescue that barely cleared the 0.6 similarity
threshold came back indistinguishable from an address that matched every word
exactly, in one flat list. Anything matching addresses automatically has to
decide whether to accept a result, and that decision needs this.
Each result now carries:
"match": { "tier": "prefix", "confidence": 0.93 }
prefix the full-text prefix index matched every query word
fuzzy only the trigram fallback matched, so the query was misspelled
filter no text query; matched structured filters alone
Confidence is absent for filter matches. There is no text to have matched well
or badly there, and inventing a number would be worse than omitting one.
On the fuzzy pass the confidence is the word_similarity Postgres matched on,
not the relevance score. The score is 0 by construction for those rows -- the
trigram pass rescued them precisely because the literal substring the ILIKE
arms look for is absent -- so normalising it would report every typo match as
zero confidence. Measured: "Barendt" scores 1.000 on the prefix tier,
"barendtt" scores 0.778 on the fuzzy tier, which matches the documented
word_similarity for a doubled letter.
Confidence is 0..1 within a tier and deliberately not comparable across them:
a fuzzy 0.9 is a strong typo match, not a better answer than a prefix 0.7.
Absent on lookup by id, where there is nothing to have matched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2nhUJ4Zdsxf7cP9DapsY3
Code review found the first version reported a constant. The relevance score it
normalised was the sum of CASE arms whose first test is
`full_address ILIKE '%word%'` for 150, the maximum -- and full_address already
contains house number, street, unit, city, region and postcode. The prefix
predicate runs over to_tsvector('simple', full_address), so every row it admits
has every query word inside full_address. The top arm always fired, the lower
arms were unreachable, and the score was the literal constant
150 x len(queryWords).
Confirmed against seeded data: exactly one distinct score across every hit. So
confidence was always 1.0, and ORDER BY relevance_score DESC has been sorting
nothing this whole time, falling through to alphabetical by county.
Now ts_rank_cd over the same tsvector and the same tsquery the predicate used.
Normalisation flag 32 bounds it to 0..1 without a hand-maintained maximum that
can drift out of sync with the scoring arms. Measured on two rows matching the
same two words: 0.0476 where the words sit close together against 0.0123 where
they are spread apart.
Scoring the predicate's own expression also fixes a second defect: the CASE
interpolated the raw word while the predicate used the sanitized one, so
"Barendt." scored 0 on a row it had correctly matched, and a single-word query
with trailing punctuation reported 0.0 on the right answer.
Fuzzy confidence now aggregates per word with LEAST, matching a predicate that
applies `word <% full_address` independently per word. Scoring the joined
phrase looked for one contiguous ordered extent and reported confidences below
the threshold that admitted the row. Fuzzy results are also ordered by that
similarity -- they scored 0 on relevance_score by construction, so the top row
was the alphabetically-first county rather than the closest match.
A query whose words were all dropped as too short now reports tier "none"
rather than "filter"; no text predicate was applied at all, and calling it a
filter match invited callers to trust rows that matched nothing.
Test fixes: the fuzzy assertions were guarded by `if len(typo) > 0` and would
have vanished silently if the fallback broke, which is what they exist to
catch. The ordering test passed vacuously on an empty result. A comment named
a transposition the code does not use -- transpositions score below the
threshold and are documented as unrecovered.
OpenAPI schema updated; clients generated from it would have dropped the field.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2nhUJ4Zdsxf7cP9DapsY3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Search already knew how good each hit was and threw it away. The relevance
score was computed by Postgres, transferred over the wire, scanned into a local
at
address_service.go:340, and never read.The consequence: a trigram rescue that barely cleared the 0.6 similarity
threshold came back indistinguishable from an address that matched every query
word exactly. Anything matching addresses automatically — which is the main way
this API gets used — had no basis to decide whether to accept a result.
What callers get
prefixfuzzyfilterThree judgement calls
Fuzzy confidence is the real
word_similarity, not the relevance score.That score is 0 by construction for a fuzzy hit — the trigram pass rescued the
row precisely because the literal substring the ILIKE arms look for is absent.
Normalising it would have reported every typo match as zero confidence.
Measured:
Barendt→ prefix, 1.000.barendtt→ fuzzy, 0.778, which matchesthe documented
word_similarityfor a doubled letter.Filter-only matches carry no confidence at all. With no query text there is
nothing to have matched well or badly, and a fabricated 1.0 would be worse than
an absent field.
Confidence is not comparable across tiers, and the type says so. A fuzzy
0.9 is a strong typo match, not a better answer than a prefix 0.7.
Cost
None. The score was already being computed and returned; this stops discarding
it. The fuzzy pass adds one
word_similarityto the SELECT list, evaluatedonly on rows the index already matched, and only on the fallback path that runs
when the first pass returns nothing.
Verification
A test asserts the tiers are actually distinguishable, that fuzzy confidence is
greater than zero (which fails if the ILIKE score is used), that filter matches
omit confidence, and that confidence never increases as you walk down a
relevance-ordered result list. Full suite green against four probe databases.
🤖 Generated with Claude Code
https://claude.ai/code/session_01S2nhUJ4Zdsxf7cP9DapsY3