Skip to content

Add exact-match mode to /lookup and /bulk-lookup, and run bulk lookups in parallel - #273

Merged
gaurav merged 13 commits into
mainfrom
add-exact-mode
Aug 31, 2026
Merged

Add exact-match mode to /lookup and /bulk-lookup, and run bulk lookups in parallel#273
gaurav merged 13 commits into
mainfrom
add-exact-mode

Conversation

@gaurav

@gaurav gaurav commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Adds an exact parameter to /lookup and /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-lookup concurrently 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 exact is set, the eDisMax query is bypassed entirely in favour of a Solr filter query against the *_exactish fields. Those are indexed with a keyword tokenizer and a lowercase filter, so the whole string must match, case-insensitively — parkinson does not match the label parkinsonian disorder, but Parkinsonian Disorder does.

exact Matches against
label The preferred name only (preferred_name_exactish)
synonyms The synonyms only (names_exactish)
any Either
(omitted) Nothing changes — the usual tokenized eDisMax search

label and synonyms can genuinely disagree, because a concept's preferred name is not necessarily one of its synonyms — HP:0001300 is preferred_name="parkinsonian disorder" with names=["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=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 rejection lives in lookup(), so it applies to /bulk-lookup too.
  • The smart-quote rewrite (Queries containing don't work correctly #176) was applied to the exact query. The *_exactish fields 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=true returned 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 with asyncio.gather() rather than awaiting them one at a time, bounded by a semaphore of SOLR_MAX_CONCURRENT_LOOKUPS (default 100). The bound is not optional: NameResQuery.strings has 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=0 would 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 of timeout=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. LowerTextField is a StandardTokenizer and a LowerCaseFilter with no stemming, stopwords, synonyms or ngrams, and lookup() 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 when autocomplete=true. Calling it fuzzy invited callers to expect typo tolerance the service has never had.

Documentation. documentation/API.md gained an "Exact matching" section, the exact parameter and the notes above — that file enumerates every /lookup parameter and shows a complete /bulk-lookup body, so omitting exact would have made it wrong rather than merely incomplete. documentation/Deployment.md gained the two new environment variables, the OpenAPI service blurb now mentions exact mode alongside autocomplete, and CLAUDE.md gained 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 in solrconfig.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 ASC instead of the usual score DESC, …. The score field is still present in each result but carries no ranking information.

What it deliberately does not do

  • No stemming, normalisation, or punctuation folding beyond lowercasing. Exact means exact; a caller who wants looser matching already has the default search.
  • No cap on strings. The semaphore throttles a large bulk request rather than rejecting it, so existing clients sending long lists keep working and simply queue.
  • No change to which cliques the default search returns. With exact omitted 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_type and prefix filters, that the autocomplete/exact rejection survives being raised inside asyncio.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.py keeps 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:

  • TranslatorSRI/babel-validation#107 — confirm SOLR_MAX_CONCURRENT_LOOKUPS = 100 against the real request rate, specifically the peak number of concurrent /bulk-lookup requests and the tail of the strings-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.
  • NCATSTranslator/Babel#1073 — put each clique's preferred name into its synonyms. If that lands, exact=synonyms and exact=any converge for most cliques, and the three-way split is worth revisiting then rather than pre-emptively now.
History — rebased from the ci branch onto main, 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 to main therefore could not be a merge, since that would have dragged forty-odd unrelated ci commits into the diff. Instead the two exact-mode commits were replayed onto main with git rebase --onto.

Anything from ci that this branch used to sit on top of is deliberately gone as a result: the api/solr.py extraction, and the query_log / perf_counter_ns timing. The code here uses main's time.time_ns() timing and main's debug parameter. Do not try to re-port those from ci.

The conflicts were almost entirely a collision of two additions in the same places: main had added a debug parameter exactly where this branch added exact. Both now coexist across /lookup GET and POST, lookup(), and NameResQuery, and main's debug handling sits above the if 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.md and this description all said so until that round.

The exact-match tests were then split out of test_service.py into tests/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 typed Disease, not PhenotypicFeature, despite the HP prefix.

gaurav and others added 2 commits August 31, 2026 14:28
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>
@gaurav
gaurav changed the base branch from ci to main August 31, 2026 18:29
@gaurav gaurav changed the title Add exact mode Add exact-match mode to /lookup and /bulk-lookup, and run bulk lookups in parallel Aug 31, 2026
gaurav and others added 4 commits August 31, 2026 14:36
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>
"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>
gaurav and others added 6 commits August 31, 2026 15:23
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>
@gaurav
gaurav merged commit 90fb7da into main Aug 31, 2026
1 check passed
@gaurav
gaurav deleted the add-exact-mode branch August 31, 2026 19:58
@github-project-automation github-project-automation Bot moved this from Backlog to Done in NameRes sprints Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Add an exact lookup mode

1 participant