# Query-path tuning — 2026-10-30

This follows the first native Elasticsearch comparison. The global Koha engine
remains Elasticsearch while direct Rust requests are measured on loopback.
The catalog and Rust index are unchanged; no relevance, matching, facet, holdings,
or visibility behavior is intentionally changed.

## Diagnosis

A 12-second ARM PMU profile at 199 Hz recorded 2,376 samples with no lost samples.
Most cycles were spent decompressing Tantivy facet dictionary blocks, chiefly
translating ordinals to labels during facet harvesting. The bibliographic index
has segment max-doc counts [70410, 8800, 99, 9, 1, 1]; most live documents are in a
single segment, limiting the benefit of per-segment search threads.

## Changes

The facet collector caches immutable ordinal-to-child dictionaries by SegmentId.
Each query still gathers current matching document IDs and fresh counters. Active
search snapshots retain their own Arc references; retained cache entries are
pruned to the current snapshot. Native Tantivy collection is retained as a
reference/control via RUSTSEARCH_FACET_CACHE=0. Regression tests compare native
counts for hierarchy, duplicate descendants, literal slash values, concurrent
queries, commits and deletions. The entire engine suite passed 40 tests.

The Koha adapter prefetches bibliography and metadata rows once per page, retaining
native legacy item embedding and OPAC suppression. Result relevance order is
restored from Rust hits rather than relying on database order. Missing records
and database failures remain explicit failures, not partial success. This is a
request-local prefetch, not a retained MARC or holdings cache.

A newer Koha embedding API was also profiled. Its output was not byte-identical,
so it was not adopted. The retained metadata prefetch gave byte-identical MARC in
20- and 100-result loader probes.

## Measurements

Raw API phases use A/B/A/B order with twenty samples per query in each phase,
three warmups, and complete canonical response SHA-256 fingerprints. The matrix
covers ordinary fields, zero matches, paging, 100 results, allowlists, ISBNs and
facet filters. A separate full-corpus request verifies parity without weighting
the common-query timing mix toward that adapter-unsupported request. Fingerprints
cover IDs, scores, counts, labels and facet counts. All matched across phases.
One/four-client checks use forty requests each and are bounded probes, not
production capacity or saturation tests.

A two-thread cached variant was tested. It helped some broad queries but regressed
narrow queries and reduced four-client throughput relative to one thread; one
thread is retained on this catalog. RUSTSEARCH_SEARCH_THREADS defaults to 1.

All query timings here include loopback HTTP and JSON decoding, excluding the
Koha adapter and formatter. Complete Koha-path measurements are kept separately;
raw-engine improvements must not be advertised as equivalent browser speedups.

## Results of this iteration

Pooled loopback API medians, 40 timed requests per query per variant (ms):

| Query | Native facets | Cached dictionary | Lower latency |
| --- | ---: | ---: | ---: |
| dogs | 79.35 | 7.42 | 90.6% |
| history | 204.00 | 16.55 | 91.9% |
| title | 19.98 | 2.00 | 90.0% |
| author | 15.66 | 1.25 | 92.0% |
| subject | 74.40 | 7.19 | 90.3% |
| no_match | 12.33 | 0.45 | 96.4% |
| page_two | 79.41 | 7.14 | 91.0% |
| deep_page | 205.58 | 16.91 | 91.8% |
| hundred | 80.21 | 7.90 | 90.1% |
| allowlist | 12.99 | 1.02 | 92.1% |
| isbn | 12.57 | 0.70 | 94.4% |
| facet_filter | 67.07 | 5.92 | 91.2% |

The metadata-only A/B holds the cached Rust engine fixed. For typical 20-record
pages, adapter medians improve by approximately 16–20%; complete shared-formatter
medians improve by approximately 5–7%. The 100-record check improves from 1,046
to 805 ms in the adapter and from 3,439 to 3,073 ms including formatting. All
complete MARC/score/facet fingerprints agree. Single-result pages retain the
original find path: the follow-up ISBN comparison is effectively unchanged
(50.9 versus 51.7 ms including formatting), avoiding generic batch setup cost.

### Targeted native three-engine follow-up

This is a **five-workload targeted probe**, 20 timed requests per engine per
workload (300 samples). It does not replace the earlier full 23-workload suite.
Times include the native adapter and shared Koha result formatter, excluding
browser rendering, HTTP routing and process startup. Engine matching, relevance,
and top-page contents differ; consult raw totals and IDs before interpreting
comparisons. All engines use the same 70,410-record catalog subset.

| Workload | Rust (ms) | Elasticsearch (ms) | Zebra (ms) |
| --- | ---: | ---: | ---: |
| keyword_dogs | 652.7 | 670.7 | 1293.7 |
| title_dogs | 638.3 | 510.1 | 855.1 |
| author_bridwell | 688.2 | 553.2 | 821.6 |
| accent_cafe | 399.5 | 589.7 | 967.7 |
| no_match | 13.9 | 122.6 | 18.6 |

Rust closes the keyword-dogs gap in this probe, while title and author remain
slower than Elasticsearch. No aggregate winner is asserted.

### Further investigation (not deployed)

A read-only staff-item batching prototype was measured
using the existing native Item2Marc function. It improves the isolated loader
substantially and matches complete MARC bytes on the sampled dogs pages. It has
not been adopted: broader custom-framework, multiple-item ordering and OPAC
policy validation are still needed. The newer metadata API experiment likewise
remains out of the implementation because its MARC bytes differ.

## Validation

40 native Rust tests, 1,227 Perl assertions and 134 Python tests passed. The candidate preserves matching, scores, facets, current MARC holdings and native OPAC policy. The deeper item-batching prototype is experimental and excluded from the downloadable candidate.

The direct-query comparison uses repeated A/B/A/B phases, 40 timed samples per variant per query, three warmups per phase, and complete response fingerprints. The full-path follow-up uses 20 trials per workload per engine, two warmups, and rotating engine order. Before/after full-path percentages compare separate snapshots and may include environmental effects.

## Full-path snapshot comparison

The earlier full-suite snapshot and this targeted follow-up used the same shared
catalog. These are separate runs, so environmental effects can contribute.

| Rust workload | Earlier median (ms) | Tuned median (ms) | Lower latency |
| --- | ---: | ---: | ---: |
| Keyword dogs | 760.3 | 652.7 | 14.2% |
| Title dogs | 688.4 | 638.3 | 7.3% |
| Author Bridwell | 771.8 | 688.2 | 10.8% |
| Accent cafe | 433.7 | 399.5 | 7.9% |
| No match | 25.9 | 13.9 | 46.2% |

## Bounded throughput follow-up

Forty mixed requests per engine and client count, native adapter plus shared
formatter, warm persistent workers. Phases run separately and include IPC
overhead. This does not establish sustained production capacity.

| Engine | Clients | Requests/s | Median / p95 (ms) |
| --- | ---: | ---: | ---: |
| RustSearch | 1 | 1.73 | 668.9 / 719.1 |
| Zebra | 1 | 1.10 | 892.8 / 1429.5 |
| Elasticsearch | 1 | 1.82 | 597.6 / 710.4 |
| RustSearch | 4 | 2.89 | 1597.8 / 1770.1 |
| Zebra | 4 | 1.90 | 2066.0 / 3932.3 |
| Elasticsearch | 4 | 2.92 | 1435.3 / 1833.5 |
