Skip to content

Use index-backed RDKit operators and rank similarity search results - #196

Merged
skearnes merged 4 commits into
mainfrom
speed-up-and-rank-search-queries
Jun 24, 2026
Merged

Use index-backed RDKit operators and rank similarity search results#196
skearnes merged 4 commits into
mainfrom
speed-up-and-rank-search-queries

Conversation

@skearnes

@skearnes skearnes commented Jun 24, 2026

Copy link
Copy Markdown
Member

Summary

Two related search improvements, both in ord_interface/api/queries.py.

1. Index-backed RDKit operators (speedup)

The component predicates were built as function callssubstruct(...), tanimoto_sml(...) >= t. PostgreSQL only uses the rdkit GiST indexes (mol_index, morgan_bfp_index) through the matching operators, so each substructure/SMARTS/similarity search sequentially scanned rdkit.mols, evaluating the RDKit function on every row. (The reaction-SMARTS query already used @>; the component queries just hadn't followed suit.)

predicate before after
substructure substruct(mol, %s) mol @> %s::mol
SMARTS substruct(mol, %s::qmol) mol @> %s::qmol
similarity tanimoto_sml(...) >= t morgan_bfp % morganbv_fp(%s)
chiral substructure substruct_chiral(...) @> + rdkit.do_chiral_sss

The operators can't take the similarity threshold or chirality flag inline, so those are supplied out of band via the rdkit.tanimoto_threshold / rdkit.do_chiral_sss session settings (set_config) before the query runs. Connections are per request, so the settings don't leak.

Verified with EXPLAIN that the operator forms use the GiST indexes where the function forms forced a seq scan. On a synthetic table up to 200k molecules, a selective substructure or similarity search runs ~15–50× faster, and the gap grows with table size (the function form is linear in row count).

2. Rank similarity results

Previously each predicate collapsed to an unordered set of reaction IDs. Now, when a query has exactly one similarity predicate, the matched reactions are scored by their greatest component Tanimoto similarity and ordered descending. The LIMIT is applied after scoring, so it selects the globally most-similar matches rather than the top of an arbitrary subset. fetch_reactions now preserves the order of its input IDs, so the ranking survives the final proto fetch (both the sync /query and async submit_query paths).

With zero or multiple similarity predicates, results are returned unordered (as before).

Tests

  • Existing component-query tests already pin the expected match counts, so they double as equivalence checks for the operator swap.
  • New test_similarity_ranking independently recomputes each reaction's max similarity and asserts the order is non-increasing and that LIMIT returns the true top-N of the full ranking.

All ord_interface/api tests pass; ty/ruff clean.

🤖 Generated with Claude Code

Greptile Summary

This PR improves component search in two ways: it replaces function-call predicates (substruct, tanimoto_sml >= t) with the matching RDKit operators (@>, %) that the PostgreSQL GiST indexes understand, and adds a two-phase similarity ranking — filter with the index operator, then score the filtered set with tanimoto_sml() and apply LIMIT after sorting.

  • Index-backed operators: chirality (rdkit.do_chiral_sss) and threshold (rdkit.tanimoto_threshold) are supplied via transaction-local set_config, so GUC values don't escape the request even with pooled connections.
  • Similarity ranking: run_queries detects a single similarity predicate, skips the early LIMIT, and delegates ranking to _rank_by_similarity using tanimoto_sml() directly. fetch_reactions now preserves input order so the ranking survives the final proto fetch.
  • Testing: test_similarity_ranking independently recomputes max similarities and asserts both non-increasing order and that limit=10 returns exactly the true top-10.

Confidence Score: 5/5

Safe to merge — the operator swap is semantically equivalent to the function-call forms, GUC settings are transaction-local, and the ranking/ordering logic is verified by the new test.

Both changes are well-contained: the index operator swap is validated by existing match-count tests, and the new ranking path is independently cross-checked by test_similarity_ranking. The fetch_reactions order-preservation fix is straightforward and the async Redis path correctly stores and restores the ranked ID list.

No files require special attention.

Important Files Changed

Filename Overview
ord_interface/api/queries.py Replaces function-call predicates with index-backed RDKit operators for substructure/similarity queries; adds similarity ranking via a two-phase filter-then-score approach; fetch_reactions now preserves input order. Logic is correct and well-structured.
ord_interface/api/queries_test.py Adds _max_similarities helper and test_similarity_ranking, independently verifying both the sort order and that LIMIT selects globally top-N matches. Test design is sound.

Reviews (3): Last reviewed commit: "Parametrize ranking test helper by targe..." | Re-trigger Greptile

The component search predicates were built as function calls (substruct(...),
tanimoto_sml(...) >= t). PostgreSQL only uses the rdkit GiST indexes through the
matching operators, so each substructure/SMARTS/similarity search sequentially
scanned rdkit.mols, evaluating the RDKit function on every row. Switch to the
operator forms, mirroring the reaction-SMARTS query that already used @>:

  - SUBSTRUCTURE: mol @> %s::mol
  - SMARTS:       mol @> %s::qmol
  - SIMILAR:      morgan_bfp % morganbv_fp(%s)

The similarity threshold and chirality flag can't be passed to those operators
inline, so they are supplied out of band via the rdkit.tanimoto_threshold and
rdkit.do_chiral_sss session settings (set_config) before the query runs;
connections are per request, so the settings don't leak. Verified with EXPLAIN
that the operator forms use the GiST indexes where the function forms could not.

Also rank similarity results. When a query has exactly one similarity predicate,
the matched reactions are scored by their greatest component Tanimoto similarity
and ordered descending, with the LIMIT applied after scoring so it selects the
most similar matches rather than an arbitrary subset. fetch_reactions now
preserves the order of its input IDs so the ranking survives the proto fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread ord_interface/api/queries.py Outdated
Comment thread ord_interface/api/queries_test.py Outdated
skearnes and others added 3 commits June 23, 2026 21:05
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…elper

set_config(local=true) scopes the similarity/chirality settings to the current
transaction (connections use autocommit=False, so the setting covers the query
and resets at transaction end), which is safe even if the connection is later
pooled. Rename the ranking test helper to _max_input_similarities to make its
input-component scoping explicit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a target parameter to _max_similarities (mirroring
ReactionComponentQuery._mols_join) so it generically recomputes INPUT- or
OUTPUT-component similarities, per reviewer feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@skearnes
skearnes merged commit f8d6b82 into main Jun 24, 2026
16 checks passed
@skearnes
skearnes deleted the speed-up-and-rank-search-queries branch June 24, 2026 01:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant