Repository navigation
sqlitedb: make id, isa and regexp searches fast (doc_data search indexes, restricted AND terms) - #229
Merged
Merged
Conversation
An NDI id lookup - base.id exact_string AND base.session_id exact_string - took ~1 s in a database of 100,000 documents. The base.id term itself was already an index lookup; the cost was the AND: search_doc_ids searched each term over the whole branch and intersected the results in MATLAB, so the session term returned (and MATLAB converted and intersected) one row per document in the session. - search_doc_ids searches base.id lookups first, stops as soon as an AND term matches nothing, and once the result is down to at most maxSearchCandidates (1000) documents, checks the remaining terms against those documents only (docs.doc_id IN (...), with CROSS JOIN fixing the loop order so each candidate's doc_data rows are reached by index). - ORed base.id lookups (nested to any depth) are searched in one query per 1000 ids instead of one query per id. - New index doc_data_doc_idx_field_idx ON doc_data (doc_idx, field_idx), created with new databases and added when an older database (DID-matlab or DID-python) is opened. A file that cannot be written is left alone and searched exactly as before (canSearchCandidates is false then). - The index also speeds up unrestricted searches that scan doc_data per document (isa 2.4 s -> 0.34 s, hasfield 1.6 s -> 0.56 s, contains_string 0.6 s -> 0.16 s on a synthetic 100,000-document database), and the DELETE FROM doc_data WHERE doc_idx=? done when a document is removed. SQL-level timing on that synthetic database (3M doc_data rows): id AND session lookup 0.167 s -> 0.00014 s; building the index once: 0.9 s. Tests: TestBaseIdSearch (with did.test.helper.QueryLogSqlitedb, which records the row count of each query) - id lookups alone, ANDed with a session in either order, in the wrong session, unknown ids, ORed ids (including more than 1000), restricted vs full search agreement for every kind of term, and the index being added to an older database on open. Not run here: no MATLAB in this environment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv7SxVR8MCu3MKpUvwwUia
Contributor
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #229 +/- ##
==========================================
+ Coverage 65.71% 66.28% +0.56%
==========================================
Files 66 66
Lines 4579 4674 +95
==========================================
+ Hits 3009 3098 +89
- Misses 1570 1576 +6 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…erms last) 'isa' matches meta.class exactly OR runs a regex on the comma-joined meta.superclass list. That regex can't use an index, so over a whole branch SQLite built a temporary index over all of doc_data and ran the regex once per document: 3.0 s on a synthetic 100,000-document database (5.5 s measured in NDI for isa daqsystem AND base.session_id, returning 3 docs). - New index doc_data_field_idx_value ON doc_data (field_idx, value), next to doc_data_doc_idx_field_idx; both are listed in sqlitedb.searchIndexes, created with new databases and added when an older one is opened. canSearchCandidates is renamed hasSearchIndexes and is true when both exist. - Unrestricted, non-negated 'isa' runs the regex only on the DISTINCT superclass lists (read in order from the new index; LIMIT -1 keeps SQLite from pushing the regex back onto every row), then finds the documents holding a matching list through the same index: 41 regex calls instead of 100,000 in the synthetic database. - In an AND, base.session_id terms are searched last, so a selective term (isa daqsystem) narrows the candidates before the session term is checked. - A restricted query names doc_data_doc_idx_field_idx with INDEXED BY: with both indexes present the planner otherwise read the session's rows through (field_idx, value), 100,000 rows per candidate. Synthetic database, SQL only (Python sqlite3): isa daqsystem AND session (3 docs) 2.44 s -> 0.024 s isa element AND session (99,999) 2.92 s -> 0.62 s one-time build of both indexes ~2.5 s Results match the old SQL for isa/~isa, and restricted vs full searches match, for every kind of term checked. Tests: TestBaseIdSearch becomes TestSearchIndexes, adding isa AND session in either order (rows returned bounded by the isa matches), isa/~isa identical with and without the indexes, whole-name matching, and both indexes added to an older database. Not run here: no MATLAB in this environment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv7SxVR8MCu3MKpUvwwUia
… AND These operations test every value they see, so over a whole branch they cost a scan of every document (NDI measured +5.2 s for a base.name regexp matching everything, ANDed with isa and base.session_id). Ranking them after the other terms means they run on the few documents those terms left, through the candidate restriction, whatever order the query gave. Test: regexp AND isa AND session, in three orders, returns the right documents with no query returning more rows than isa matched. Not run here: no MATLAB in this environment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv7SxVR8MCu3MKpUvwwUia
DID-python has created doc_data_doc_idx ON doc_data(doc_idx) and doc_data_field_value ON doc_data(field_idx, value) on every open for some time, so databases it has touched already carry them. Creating the same indexes under other names (doc_data_doc_idx_field_idx, doc_data_field_idx_value) would have given such a database two copies of each. The search uses them the same way; INDEXED BY now names doc_data_doc_idx. On the synthetic 100,000-document database, checking a term against 1000 candidates through the single-column index takes 0.02-0.08 s; the NDI lookups (id or isa AND session) are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv7SxVR8MCu3MKpUvwwUia
stevewds
approved these changes
Oct 24, 2026
This was referenced Oct 24, 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.
What was slow
NDI's most common searches pair an id or class lookup with a session filter:
base.id exact_string IDANDbase.session_id exact_string SESSIONisa daqsystemANDbase.session_id exact_string SESSION, sometimes also AND aregexpMeasured in NDI on a ~100,000-document database:
isa daqsystem+ session took 5.5 s, returning 3 documents.base.name regexp (.*)term added +5.2 s.Three causes:
did.database/search_doc_idsran every ANDed term as its own query over the whole branch, then intersected the results in MATLAB. So the session term returned one row per document in the session, and a regexp tested every document.isa. It matchesmeta.classexactly, OR runsregex()on the comma-joinedmeta.superclasslist. The regex can't use an index. SQLite built a temporary index over all ofdoc_data, then ran the regex once per document: 3.0 s on a synthetic 100,000-document database.doc_data.doc_idx. A term could not be checked against just a few documents.What changed
Indexes
sqlitedb.searchIndexesdefines two indexes:doc_data_doc_idxon(doc_idx)doc_data_field_valueon(field_idx, value)DID-python's
_ensure_indexeshas created these with the same names and columns for some time. So a database touched by both libraries carries one copy of each.ensure_search_indexes).hasSearchIndexes()is then false, and searches run exactly as before.AND terms (
search_doc_ids)base.idlookupsisa)base.session_idregexp,contains_string,hasmember)docs.doc_id IN (...), aCROSS JOINorder, andINDEXED BY doc_data_doc_idx.So
isa daqsystem AND session AND regexpfinds the 3 daqsystem documents first. It then checks their session, then runs the regexp on their 3 names, whatever order the query was written in.isaand ORed idsisa(unrestricted, not negated) runs its regex only on the distinct superclass lists, read fromdoc_data_field_value.LIMIT -1stops SQLite from pushing the regex back onto every row.base.idlookups run as one query per 1000 ids (search_base_ids).Timings
SQL only (Python
sqlite3), on a synthetic copy of this schema: 100,000 docs, 3Mdoc_datarows.isa daqsystemAND session (3 docs)isa elementAND session (99,999 docs)base.idAND sessionhasfield0.34 s)The before figures leave out MATLAB converting and intersecting the large result sets, which adds to them in NDI.
On the synthetic database,
isa/~isareturn the same results under the old and new SQL. For each other kind of term checked, the restricted search returns the full search's results limited to the candidates.Tests
tests/+did/+unittest/TestSearchIndexes.m, with the helperdid.test.helper.QueryLogSqlitedb(it records each query's row count).isaAND session, in either order.regexpANDisaAND session, in three orders.isa/~isa: the same results with and without the indexes.CI on
83a2bda:main.The timings above have not been re-measured in NDI itself.
DID-python
The port is VH-Lab/DID-python#98. It uses the same index names, the same add-on-open behaviour and the same search order. Python's own timings,
main→ that branch:isa daqsystemAND session: 0.30 → 0.027 sIts bridge entries point at
83a2bdaon this branch. If this PR is squash-merged, they need re-pointing to the squashed commit.🤖 Generated with Claude Code
https://claude.ai/code/session_01Dv7SxVR8MCu3MKpUvwwUia