fix(retain): bound entity-resolution candidate scoring (#3211) - #3213
Merged
Conversation
nicoloboschi
force-pushed
the
rios/3211-bound-entity-candidate-scoring
branch
from
August 6, 2026 16:11
0bb2ea9 to
8934161
Compare
Every fuzzy candidate was scored with difflib.SequenceMatcher in a synchronous loop on the event-loop thread, and candidate volume per query text was bounded only by what the trigram probe returned. On a bank whose index is polluted by many near-identical names, one resolution batch became minutes of uninterrupted CPU: /health could not answer, the orchestrator killed the worker mid-op, and the requeued op wedged the next one. Measured on a 100-mention batch (mock candidates, 10ms heartbeat task): 1M candidate rows took 10.6s at 99% CPU with the heartbeat getting zero turns; it now takes 0.38s with a max loop stall of 14ms. - Cap candidates per query text in SQL, ranked by the similarity score the probe already computes: LATERAL ... ORDER BY similarity() LIMIT on PG, ROW_NUMBER() OVER (PARTITION BY query_text) on Oracle. New HINDSIGHT_API_RETAIN_ENTITY_RESOLUTION_MAX_CANDIDATES (default 200). - Yield to the event loop every 256 scored candidates, so responsiveness does not depend on the cap being configured sanely. - Backstop truncation in _resolve_from_candidates with an O(1)-per- candidate ordering key, for sets built without a DB score (the "full" strategy's Python substring matching). The PG rewrite also drops DISTINCT ON (e.id), which deduplicated across query texts: an entity matching two mentions in the same batch was silently dropped as a candidate for one of them (on a 2-text fixture the old query returned 1004 + 997 of 2001 matches instead of 2001 each).
nicoloboschi
force-pushed
the
rios/3211-bound-entity-candidate-scoring
branch
from
August 7, 2026 00:15
8934161 to
a7224b2
Compare
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.
Fixes #3211.
The symptom
Every fuzzy candidate is scored with
difflib.SequenceMatcherin a synchronous loop on the event-loop thread, and candidate volume per query text was bounded only by what the trigram probe returned. On a bank whose index is polluted by many near-identical names, one resolution batch becomes minutes of uninterrupted CPU:/healthcannot answer, the orchestrator kills the worker mid-op, and the requeued op wedges the next one.Reproduction
A 100-mention batch driven through
_resolve_from_candidateswith mock candidates, alongside a 10 ms heartbeat task standing in for the health endpoint:Cost scales linearly with candidates, which is how a real polluted bank reaches minutes.
The fix
CROSS JOIN LATERAL (… ORDER BY similarity(…) DESC LIMIT $3)on PG,ROW_NUMBER() OVER (PARTITION BY q.query_text ORDER BY JARO_WINKLER …)on Oracle. NewHINDSIGHT_API_RETAIN_ENTITY_RESOLUTION_MAX_CANDIDATES, default 200._resolve_from_candidateswith an O(1)-per-candidate ordering key, for candidate sets built without a DB score (thefullstrategy's Python substring matching).The new PG query was verified against a real PostgreSQL + pg_trgm on a throwaway instance: the cap is honoured per query text and the best trigram match ranks first.
Behaviour change beyond the cap
The PG rewrite drops
SELECT DISTINCT ON (e.id), which deduplicated across query texts: an entity matching two mentions in the same batch was silently dropped as a candidate for one of them. On a 2-text / 2001-entity fixture the old query returned 1004 + 997 rows instead of 2001 each. TheLATERALform dedups per (query text, entity), which is what the grouping downstream expects.Resolution results are otherwise unchanged for any mention with ≤ 200 candidates. Above that, candidates ranked below the top 200 by trigram similarity are no longer scored — they could previously win on the co-occurrence/temporal boost, but a match that far down the similarity ranking is noise.
Tests
hindsight-api-slim/tests/test_entity_resolver_candidate_cap.py— scoring is capped at the configured maximum, truncation keeps the exact match, a concurrent task keeps getting scheduled during a wide batch (the regression the issue asks for), and both dialects' SQL carries the cap.