Add exact-match mode to /lookup and /bulk-lookup, and run bulk lookups in parallel - #273
Merged
Conversation
Adds an `exact` parameter (label/synonyms/any) to /lookup and /bulk-lookup. When set, the eDisMax query is bypassed entirely in favour of a Solr filter query against the *_exactish fields (KeywordTokenizer + LowerCaseFilter). Filter queries are cached by Solr, so repeated exact lookups of the same term are fast after the first hit — the intended use case is NER pipelines doing bulk exact-string lookups. Also switches bulk_lookup() from a sequential for-loop to asyncio.gather(), sending all N Solr requests concurrently instead of one at a time. Closes #258 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Uses HP:0001300 (preferred_name="parkinsonian disorder", names=["Parkinsonian disease"]) as the test case, since its preferred name is absent from the names list — cleanly separating label-only vs. synonym-only matching. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
bulk_lookup() gathers its per-string lookups concurrently and NameResQuery.strings has no upper bound, so a large enough request would open one socket per string -- enough to exhaust this process's file descriptors and to stampede Solr. Before the lookups were parallelized this could not happen, because they ran strictly one at a time. Cap the in-flight queries with an asyncio.Semaphore, sized by the new SOLR_MAX_CONCURRENT_LOOKUPS environment variable (default 10). This keeps the latency win for ordinary request sizes without the unbounded fan-out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
API.md lists every /lookup parameter and shows a complete /bulk-lookup request body, so leaving `exact` out of it made the documentation wrong rather than merely incomplete. Adds an "Exact matching" section covering the three modes, the case-insensitive whole-string semantics of the *_exactish fields, why label and synonyms can disagree, and the change of sort order in the absence of a relevance score. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 was a conservative first guess. Solr can take considerably more strain than that, and large bulk requests -- the ones the limit actually binds on -- are exactly the case the parallelization was meant to speed up. Note that the bound is per request, not per process, so the queries in flight against Solr is this multiplied by the number of concurrent bulk lookups being served. Whether 100 is the right number therefore depends on the real request rate, which is being measured in TranslatorSRI/babel-validation#107. The concurrency test can no longer exceed the limit by sending real strings, since the test data has only 66 distinct labels, so it now patches the limit down instead. That works because bulk_lookup() builds its semaphore per request rather than at import. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 tasks
"Fuzzy" means edit-distance matching, which this service does not do: LowerTextField is a StandardTokenizer and a LowerCaseFilter with no stemming, stopwords, synonyms or ngrams, and lookup() escapes Solr's fuzzy `~` operator out of the query string along with the other special characters, so a caller cannot ask for it either. What the default search actually does is tokenize. A concept matches if its name or synonyms contain the search string's tokens, in any order and not necessarily adjacent; order and adjacency are rewarded by the phrase-field boost rather than required. With autocomplete=true the final token is additionally treated as a prefix. Calling that "fuzzy" invites callers to expect typo tolerance that isn't there. Replaces the term with "tokenized" throughout the exact-match documentation added by this branch, and describes the distinction explicitly in API.md. Adds two tests for the two halves of it, since it is a documented promise that a change to the query or to the schema's field types could quietly break. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three parameters interacted badly with exact matching, all silently: - autocomplete=true was ignored. It treats the final word as an incomplete prefix, which contradicts matching the whole string, so the combination is now a 400 rather than a request that quietly does something else. - The smart-quote rewrite (issue #176) was applied to the exact query too. The *_exactish fields are a KeywordTokenizer and a LowerCaseFilter with no punctuation folding, so the indexed value keeps whichever quote characters Babel emitted; folding the query therefore searched for a string the caller had not typed, and put any label containing a typographic quote permanently out of reach. Exact mode no longer folds. The default search is unaffected either way, since StandardTokenizer discards the punctuation anyway. - highlighting=true returned empty arrays. Solr had nothing to highlight: the query is *:* and the exactish fields are not stored, so hl.fl resolved to nothing. Since every exact match is a whole-value match, the highlighting is now synthesized from the returned document -- the entire matching name, in its own capitalisation, wrapped in the same <strong> tags the default path asks Solr for. Callers no longer have to care which mode produced the field. Also hardens two settings that the parallel bulk lookup made sharper: SOLR_MAX_CONCURRENT_LOOKUPS is clamped to at least 1, since 0 would build a semaphore nobody can acquire and hang every bulk request with no error and no log line; and Solr queries get a timeout (SOLR_TIMEOUT_SECONDS, default 60) instead of waiting forever, so one stalled connection cannot pin a bulk request that is otherwise complete while holding a semaphore slot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test_service.py had grown to 487 lines and 24 tests, half of them added by this branch. The repository already separates tests by topic (test_lookup_debug.py, test_status.py), so the exact-match cases get their own file on the same pattern. Only the tests that are about exact matching move. The concurrency-limit test stays in test_service.py and loses its "exact" prefix, because it is a property of bulk lookup rather than of exact mode -- it uses exact=label only to make each string's result definite -- and so do the two tests pinning the default search's tokenized-not-fuzzy behaviour and the one checking the Solr settings are sane. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Exact mode put a high-cardinality term into fq, which is what Solr's filterCache
is worst at. The cache is bounded by entry count -- solrconfig.xml sets 512 --
and the entries it holds are shared, reusable filters like types: and taxa: that
nearly every search benefits from. An exactish clause is the opposite: one
distinct entry per distinct search string.
So the workload exact mode was built for, an NER pipeline resolving large numbers
of *different* strings, would have evicted the whole cache on every request and
slowed down the ordinary search path as collateral damage, in exchange for a hit
rate near zero on its own entries. The 317K evictions recorded in the
queryResultCache comment in solrconfig.xml suggest these caches are already under
real pressure in production.
Adding {!cache=false} costs almost nothing: repeated identical exact lookups are
still served by the queryResultCache, which caches the whole (query, filters,
sort) result and is bounded by RAM rather than by entry count.
This also corrects the rationale given in the code comment and in API.md, which
claimed filter-query caching as the reason exact mode is fast. The real reason is
that there is nothing to score -- no eDisMax parsing, no phrase or field boosts,
just a term lookup against a single-token field.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CLAUDE.md gains a Gotchas section for three things that are not evident from
reading the code and cost time to work out: that the default search is tokenized
rather than fuzzy (and must not be described as fuzzy), that Solr's filterCache is
bounded by entry count so a per-query filter value needs {!cache=false}, and that
query-side string normalization must not reach the exact-match path. It also picks
up the two new environment variables and the new test file.
Three behaviours this branch changed had no test:
- The SOLR_MAX_CONCURRENT_LOOKUPS clamp. The existing test only asserted that the
current value is sane, not that a bad one gets corrected; this reloads the module
with the variable set to exercise the import-time clamp itself.
- Exact matching combined with the biolink_type, only_prefixes and exclude_prefixes
filters. The exactish clause shares a filter list with them, and filtering an
exact lookup to a known category is what an NER caller would actually do.
- The autocomplete/exact rejection through /bulk-lookup, where the exception is now
raised inside an asyncio.gather() and could plausibly have surfaced as a 500.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HP:0001300 is typed Disease, not PhenotypicFeature, despite the HP prefix, so the positive case was filtering the result away rather than keeping it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The OpenAPI service blurb advertised autocomplete but not exact matching, which is now an equally prominent way to query the lookup endpoints. CLAUDE.md pointed at `api/resources/.openapi.yml`, which does not exist -- the file has no leading dot -- and described api/server.py by a line count that was already 100 lines out of date and would only drift again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 31, 2026
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.
Adds an
exactparameter to/lookupand/bulk-lookup, so that callers who already know the string they are looking for can ask for whole-string matching instead of the default tokenized search, and sends the individual lookups behind/bulk-lookupconcurrently instead of one at a time. The motivating case is named entity recognition pipelines, which resolve large numbers of exact strings in bulk and pay for relevance ranking they never use.Closes #258.
What's here
Exact matching. When
exactis set, the eDisMax query is bypassed entirely in favour of a Solr filter query against the*_exactishfields. Those are indexed with a keyword tokenizer and a lowercase filter, so the whole string must match, case-insensitively —parkinsondoes not match the labelparkinsonian disorder, butParkinsonian Disorderdoes.exactlabelpreferred_name_exactish)synonymsnames_exactish)anylabelandsynonymscan genuinely disagree, because a concept's preferred name is not necessarily one of its synonyms — HP:0001300 ispreferred_name="parkinsonian disorder"withnames=["Parkinsonian disease"], and neither string appears in the other field. That is why the two modes are separate rather than one boolean, and it is also a data problem in its own right: NCATSTranslator/Babel#1073 proposes putting each clique's preferred name into its synonyms.Interaction with the other parameters. Three of them behaved badly with
exact, all silently, and all now do something defensible:autocomplete=truewas ignored. It treats the final word as an incomplete prefix, which contradicts matching the whole string, so the combination is now a400rather than a request that quietly does something else. The rejection lives inlookup(), so it applies to/bulk-lookuptoo.’don't work correctly #176) was applied to the exact query. The*_exactishfields do no punctuation folding, so the indexed value keeps whichever quote characters Babel emitted; folding the query searched for a string the caller had not typed, and put any label containing a typographic quote out of reach. Exact mode no longer folds. The default path is unaffected, because StandardTokenizer discards the punctuation anyway.highlighting=truereturned empty arrays, because the query is*:*and the exactish fields are not stored, so Solr had nothing to mark up. Since every exact match is a whole-value match, the highlighting is now synthesized from the returned document — the entire matching name, in its own capitalisation, wrapped in the same<strong>tags the default path asks Solr for. Callers no longer have to care which mode produced the field.Parallel bulk lookup.
bulk_lookup()issues its Solr queries withasyncio.gather()rather than awaiting them one at a time, bounded by a semaphore ofSOLR_MAX_CONCURRENT_LOOKUPS(default 100). The bound is not optional:NameResQuery.stringshas no upper limit, so an unbounded gather would open one socket per string and could exhaust the process's file descriptors and stampede Solr. Sequential execution used to make that impossible, so the parallelization is what introduces the hazard and the semaphore is what contains it.The limit applies per request rather than per process, so the queries in flight against Solr is that number multiplied by the number of bulk lookups being served at once. 100 is set deliberately high on the assumption that Solr can take the strain; sizing it against real traffic is being worked out in TranslatorSRI/babel-validation#107, and it is an environment variable so it can be retuned per deployment without a code change.
Two hazards that parallelism sharpened are closed off with it. The limit is clamped to at least 1, since
SOLR_MAX_CONCURRENT_LOOKUPS=0would build a semaphore nobody can acquire and hang every bulk request with no error and no log line. And Solr queries now have a timeout (SOLR_TIMEOUT_SECONDS, default 60) instead oftimeout=None: sequentially a stalled connection held up one query, but concurrently it can pin an otherwise-complete bulk request indefinitely while holding a semaphore slot.The default search is not "fuzzy". Documentation and parameter descriptions called it that; it never was.
LowerTextFieldis aStandardTokenizerand aLowerCaseFilterwith no stemming, stopwords, synonyms or ngrams, andlookup()escapes Solr's~operator out of the query string, so no caller can request edit-distance matching either. The default search is tokenized: it matches the query's tokens in any order, rewarding order and adjacency with score rather than requiring them, and treats the final token as a prefix whenautocomplete=true. Calling it fuzzy invited callers to expect typo tolerance the service has never had.Documentation.
documentation/API.mdgained an "Exact matching" section, theexactparameter and the notes above — that file enumerates every/lookupparameter and shows a complete/bulk-lookupbody, so omittingexactwould have made it wrong rather than merely incomplete.documentation/Deployment.mdgained the two new environment variables, the OpenAPI service blurb now mentions exact mode alongside autocomplete, andCLAUDE.mdgained a Gotchas section for the three things here that a reader cannot infer from the code.What it produces
Exact matching is cheaper than the equivalent tokenized search because there is nothing to score: no eDisMax parsing, no phrase or field boosts, just a term lookup against a field holding each name as a single token. Bulk requests of ordinary size now complete in roughly the time of their slowest lookup rather than the sum of all of them.
The exactish filter is marked
{!cache=false}on purpose. Solr's filterCache is bounded by entry count (512 insolrconfig.xml), and what it holds are the shared, reusable filters —types:,taxa:,curie:— that nearly every search benefits from. An exactish clause is one distinct entry per distinct search string, so caching it would let a single bulk NER request evict the whole cache and slow down the ordinary search path as collateral damage, for a hit rate near zero on its own entries. Repeated identical exact lookups are still served from the queryResultCache, which caches the whole result and is bounded by RAM.Because there is no relevance score to rank by in exact mode, results are sorted by
clique_identifier_count DESC, curie_suffix ASCinstead of the usualscore DESC, …. Thescorefield is still present in each result but carries no ranking information.What it deliberately does not do
strings. The semaphore throttles a large bulk request rather than rejecting it, so existing clients sending long lists keep working and simply queue.exactomitted the generated Solr query is what it was before; the only behavioural difference anywhere is the Solr client's timeout, which used to be unbounded.Tests
44, all against a live Solr in CI. Beyond the exact-match modes themselves, several encode promises that a change elsewhere could quietly break: that the default search ignores word order but does not tolerate misspellings, that the exactish filter still reaches Solr marked uncached, that exact matching composes with the
biolink_typeand prefix filters, that theautocomplete/exactrejection survives being raised insideasyncio.gather(), and that a bulk lookup carrying more strings than the concurrency limit still returns every one of them correctly keyed.The exact-match cases live in
tests/test_exact_mode.py, following the repository's existing habit of splitting tests by topic;tests/test_service.pykeeps the ones that are about the default search or about bulk lookup generally.Before merging
Nothing blocking.
Two things to pick up afterwards, both tracked outside this PR because neither can be settled from inside it:
SOLR_MAX_CONCURRENT_LOOKUPS = 100against the real request rate, specifically the peak number of concurrent/bulk-lookuprequests and the tail of thestrings-length distribution, whose product is what Solr actually sees. Needs log analysis, and the value is an environment variable, so it does not gate this.exact=synonymsandexact=anyconverge for most cliques, and the three-way split is worth revisiting then rather than pre-emptively now.History — rebased from the
cibranch ontomain, then three rounds of review. Kept for anyone tracing why a particular line looks the way it does; the durable conclusions are in the code, the docs and CLAUDE.md above.This PR originally targeted
ci, a long-lived branch that was never merged — its metrics work was reimplemented independently in v1.6.0 rather than ported. Retargeting tomaintherefore could not be a merge, since that would have dragged forty-odd unrelatedcicommits into the diff. Instead the two exact-mode commits were replayed ontomainwithgit rebase --onto.Anything from
cithat this branch used to sit on top of is deliberately gone as a result: theapi/solr.pyextraction, and thequery_log/perf_counter_nstiming. The code here usesmain'stime.time_ns()timing andmain'sdebugparameter. Do not try to re-port those fromci.The conflicts were almost entirely a collision of two additions in the same places:
mainhad added adebugparameter exactly where this branch addedexact. Both now coexist across/lookupGET and POST,lookup(), andNameResQuery, andmain'sdebughandling sits above theif exact:branch so that the two compose.The concurrency bound was added after review, not in the original implementation — the first version of the parallelization gathered without any limit, and the limit was 10 before being raised to 100.
A later review round found the filterCache problem, the three silent parameter interactions, and the unvalidated settings; each is one commit. An earlier revision of this PR claimed filter-query caching as the reason exact mode is fast, which was wrong in exactly the workload the feature exists for — the code comment,
documentation/API.mdand this description all said so until that round.The exact-match tests were then split out of
test_service.pyintotests/test_exact_mode.py, which had grown to 487 lines with half its tests belonging to this branch. The exact-plus-filters test failed on its first run because HP:0001300 is typedDisease, notPhenotypicFeature, despite the HP prefix.