← PRA / report example

Independent demo based on public GitHub data. PRA is not affiliated with or endorsed by Typesense, Inc.

Typesense, technical review for September 2026

Scope and sources

Repository typesense/typesense, branch v31, window [2026-09-01T00:00:00Z, 2026-10-01T00:00:00Z). Data snapshot 11 November 2026.

Changes were selected by merge date, taken from the committer date of the squash commit in the v31 history and checked against the pull request it closes. Author dates of branch commits were not used for selection, because a branch can carry September dates and still not be part of v31 at the cutoff.

Twelve commits were part of v31 at the cutoff and entered it inside the window: eleven pull requests and one direct commit by a maintainer. One further commit, b8363a3807, carries a 23 September committer date but was not in the branch on 30 September, so it is left out. Coverage of the window is complete against the branch history.

Tests were read in the diffs, not run. Where a pull request author reports a run, this review says so and names the author as the source. Code links are pinned to the merge revision, so behaviour on current v31 may differ.

The newest published release is v30.2 from 19 April 2026 (releases). No commit in this window is in a published release.

1. Union search returns every document it counts

Before: Union_KV entries were keyed by hash_combine(collection_id, seq_id), or (search_index, seq_id). hash_combine is not injective over small integers. The pull request gives (3, 1000) and (4, 932) as a colliding pair, and any two documents in neighbouring collections whose sequence ids differ by about 64 collide the same way. The topster treated a colliding pair as one document and kept one, while found was computed elsewhere and stayed correct. No cutoff flag was set, so the response gave no indication of loss. The curated skip inside Collection::do_union computed hash_combine(collection_id, distinct_key) independently and had the same collisions, which let a pinned document suppress an unrelated raw document from another collection.

After: both halves of the key are 32-bit, so they are packed into the 64-bit key rather than hashed. Union_KV::get_key is used for both insertion and the curated lookup.

Code: 245ebc7, include/topster.h and src/collection.cpp.

Tests read: UnionTest.ReturnsEveryHitAcrossCollections, UnionTest.CuratedHitShouldNotSuppressUnrelatedRawHit, UnionTest.DynamicFacetMinOccurrenceRatioShouldApplyAfterUnionMerge, plus test/topster_test.cpp.

Limits: the diff changes key construction and the tests cover selected cases. This review did not independently check every ranking, merge-order or facet case in a union.

2. Natural-language search uses the generated query

Before: the natural-language path wrote the user’s sentence into a hidden raw_query parameter, and collection_search_args_t::init preferred raw_query over q. Retrieval ran on the sentence, so a model-generated q="*" combined with a filter_by returned no hits. Issue #2910 reports that case. The existing test HighlightOriginalQuery asserted the buggy result.

After: raw_query and its RAW_QUERY constant are removed, and init reads q. The sentence is carried as original_nl_query and used only for highlighting. Its tokens are added as exact highlight candidates when they exist in the field index. When the generated q is empty or *, the sentence becomes the highlight text and infix matching is disabled for it.

Code: c0f2899, include/collection.h, src/collection.cpp, src/natural_language_search_model_manager.cpp.

Tests read: HighlightOriginalQuery was removed and replaced by NaturalLanguageSearchModelManagerTest.FilterOnlyQueryHighlightsOriginalText, .GeneratedQueryControlsRetrievalAndHighlightExpansion and .WildcardGeneratedQueryReturnsFilteredBooks.

Limits: highlighting of a filter-only query now comes from the sentence, which is a deliberate behaviour change. The pull request does not measure how often generated queries differ from the sentence in practice.

3. Schema prompt parameters are configurable

Before: generate_schema_prompt() held the facet limits as literals: 20 values fetched per facet field, 10 listed in the prompt, 20% sampling above 1000 documents, and all string facet fields included. The prompt cache was keyed by collection alone.

After: schema_prompt_params_t holds nl_max_facet_values, nl_schema_sample_values, nl_facet_sample_percent, nl_facet_sample_threshold and nl_facet_fields. They can be set per request, per multi_search item, or in a preset. Defaults equal the previous literals, so existing prompts are byte-identical. The cache is keyed by collection plus parameters and tracks the collection per entry, so clear_schema_prompt() drops every variant for that collection. schema_prompt_params_t::parse() rejects caps above a 10000 ceiling and rejects an nl_facet_fields entry that is missing, unfaceted or not a string.

The same pull request fixes preset reading, which is a separate defect. Presets were read expecting a value wrapper that put_upsert_preset() never writes, so nl_query and nl_model_id stored in a preset had no effect. A preset is now read as the stored search parameters object, and preset merging fills only parameters the request left unset, which restores the documented precedence of embedded over request over preset.

Code: 2d09752, include/natural_language_search_model_manager.h, src/natural_language_search_model_manager.cpp.

Tests read: SchemaPromptFacetValueCaps, SchemaPromptFacetFieldsSelection, SchemaPromptCachedPerPromptParams, SchemaPromptCacheExpiryOnCollectionAlter, GetNLQueryParamsFromPreset, RequestParamsWinOverPreset, InvalidPresetFacetLimit, NonScalarPresetValue, PresetFacetFieldsMissingFromCollection, MissingCollection.

Limits: a larger facet cap means a longer prompt and more tokens per call. The pull request documents the parameters and the ceiling, and does not measure answer quality at higher caps.

4. A lopsided conjunction is computed from its selective side

Before: compute_iterators() materialised both subtrees of an && and intersected the results, so the cost was bounded by the wider side regardless of what the conjunction matched. The construction-time estimate min(left, right) is the right size for the result, but says nothing about the cost of producing it.

After: when one side is at least AND_PROBE_RATIO times wider, the narrow side is materialised alone and the wide side is asked about each id it yields through the is_valid(id) call that and_filter_iterators() already uses to advance a lazy subtree. Ids come out in the same order, so the node’s result, its validity and approx_filter_ids_length are unchanged. computed_by_probe records which plan ran, exposed through _get_computed_by_probe() so a regression that quietly returns to materialising both sides fails a test.

The merged code sets AND_PROBE_RATIO to 32, asserted by ASSERT_EQ(32, AND_PROBE_RATIO) in the same diff. The pull request description still says 8 and describes a boundary test at 39 against 40 ids, while the merged test documents 159 against 160, which is 32 times a narrow side of 5. The description is stale relative to the merged code, and the code value is the one reported here.

Scope of the new plan: it needs the wide side to still be lazy when its parent && is initialised. That holds for any string leaf above string_filter_ids_threshold in every configuration, and for an integer or float leaf when enable_lazy_filter is on. A range-index, geo or id:* leaf has already materialised inside its own init, so probing it is correct but cannot recover a cost paid one level down. Both sides are still materialised when either subtree filters on a referenced collection, because intersecting drops an id whose reference results have nothing in common and probing has no equivalent step. The same holds when the node is an object filter root, and when the wide side is an operator rather than a leaf.

Timeouts: both children receive a copy of the budget before anything is computed, the counted check inside is_valid cuts the probe loop short, and a forced check after the loop covers a loop too short to reach the clock. A -1 from is_valid is not read as a timeout, because in a lopsided && it almost always means the wide side ran out of ids, so validity distinguishes the two cases.

Measurements reported by the author

Conditions, from the pull request description: Apple M1 Max, 10 cores, 64 GB, macOS 26.6.2, release builds via bazel build //:typesense-server with -O2, origin/v31 against the branch, the same on-disk index, single request, no concurrency. found is identical in every row.

On musicbrainz-1M-songs, the corpus the repository’s own benchmark/ directory downloads:

scenario found before after
filter_selective_and, added by the pull request 431 31 ms 1 ms
filter_complex, existing 1,258 8 ms 2 ms
filter_simple, single leaf, control 214,468 15 ms 15 ms
one broad leaf alone, control 960,372 61 ms 61 ms

On a synthetic set of two million documents with one field matching 100 documents and one matching 1,900,000:

filter_by found before after
selective && broad 95 92 ms 1 ms
broad && selective 95 92 ms 1 ms
broad leaf alone 1,900,000 137 ms 145 ms
ratio 8x between sides 1 0 ms 0 ms
ratio 1000x between sides 5 5 ms 0 ms
numeric wide side, default config 95 37 ms 38 ms

The author attributes the broad leaf’s 137 to 145 ms to run-to-run noise measured minutes apart, and notes that a single leaf never reaches the operator branch that changed. The numeric wide side under the default configuration is the scope limit stated above. These are the author’s measurements on one machine. They were not independently reproduced for this review.

Code: f91b4a5, include/filter.h, include/filter_result_iterator.h, src/filter_result_iterator.cpp.

Tests read: FilterTest.AndProbeSelectiveSide, .AndProbeRatioBoundary, .AndProbeNumericSide, .AndProbeKeepsReferences, .AndProbeTimeout, .InfixLazyEvaluation, CollectionFilteringTest.SelectiveAndDoesNotMaterializeWideSide, .MissingFilterLazyEvaluationSearchHits.

5. Phrase and infix searches keep an empty or timed-out filter

Before: Index::do_phrase_search applied the filter only when filter_result_iterator->validity was truthy. A filter that was provided but matched no documents, or that timed out, fell through to the branch where the phrase matches themselves became the filter, so the response contained documents the filter excluded. Index::do_infix_search had the same gap. Both paths are reachable when curation, facets or grouping stop the search from returning early.

After: the phrase path checks validity != invalid and, when the filter was provided but empty, drops the phrase ids and returns an empty result instead of falling back. A timed-out iterator is passed through add_phrase_ids so the combined iterator keeps the timeout state and fails closed. The infix path skips a field when the iterator is not valid and a filter was provided. Infix result ownership was reworked in the same commit, which had been mixing a borrowed pointer into infix_ids with an owned array from ArrayUtils::exclude_scalar.

Code: direct commit d4dbdae, src/index.cpp.

Tests read, from the commit itself and from the follow-up pull request #3051 (0e35bc0): CollectionSpecificTest.PinnedHitDoesNotAdmitPhraseMatchesWhenFilterMatchesNothing, .HiddenHitDoesNotAdmitPhraseMatchesWhenFilterMatchesNothing, .FacetsExcludePhraseMatchesWhenFilterMatchesNothing, .GroupedHitsExcludePhraseMatchesWhenFilterMatchesNothing and the four infix counterparts, plus UnionTest.SearchCutoffKeepsInfixMatchesFiltered.

Limits: the direct commit carries no message body, so the reasoning above comes from the diff and from the tests added with it and in #3051.

6. Reference facet identity survives pinned hits

Before: Index::do_facets mutated orig_index and reference_collection_name on the facet objects it was given. During pinned-hit processing that cleared the metadata identifying a joined reference facet, so the facet was misidentified and a search could hang or return incorrect results. Reported as #3083.

After: the facet is copied before those fields are set.

Code: 20e294c, src/index.cpp.

Tests read: CollectionJoinTest.PinnedHitsPreserveReferenceFacetIdentity, .PinnedReferenceFacetMetadataEdgeCases, .GroupByWithVectorQueryDoesNotLeakReferenceFacets, CollectionCurationTest.CurationIncludesPreservePinnedReferenceFacetIdentity, and an HTTP-level test in api_tests/tests/pinned_reference_facet.test.ts covering multi-search, direct search and a reference-only facet request.

7. A rejected alias swap keeps the previous target

Before: an alias upsert had no identity in BatchedIndexer, the queue that applies writes in order and replays them after a restart. On replay an alias upsert could run before the collections that reference the alias were created. With no reference metadata available, validation was skipped and an invalid target was persisted. The visible sequence was a swap to an incompatible collection that returned an error, appeared rolled back, and then pointed at the rejected target after a restart, which broke async-reference helper resolution and join queries.

After: alias upserts are queued by alias name consistently at enqueue, replay and completion. A scheduler dependency is added from the alias to the requested target collection, and the existing reverse-reference dependency tracking makes alias updates wait behind collections that reference the alias. Dependency state is refreshed from the committed alias target after the request completes, so a failed swap keeps the old target relationship.

Code: 1726f4b, include/batched_indexer.h, src/batched_indexer.cpp, src/collection_manager.cpp.

Tests read: BatchedIndexerAliasTest.ResolvedAliasSchemaStillOrdersFailedSwapAfterChildCreation, .FailedEarlierAliasRequestPreservesPendingTarget, .EarlierCompletionPreservesEveryPendingAliasTarget, .DeleteWaitsForBlockedUpsertAcrossSnapshotRestore, .DeleteCompletionPreservesLaterUpsertTarget, CollectionJoinTest.FailedAliasSwapKeepsAsyncReferenceTargetAfterRestart, and the HTTP reproduction in api_tests/tests/alias_async_reference_replay.test.ts.

The code calls an alias a symlink, as in CollectionManager::get_symlinks(), while the API calls it an alias.

8. A remote embedding field survives a restart

Before: init_collection validated a remote embedding endpoint on load and treated any validate_and_init_model failure as fatal for the field, so a restart while the endpoint was unreachable dropped the auto-embedding field from the persisted schema. Reported as #2988.

After: when the field’s dimensions are already persisted (num_dim > 0), the load-time ping guarantees nothing and the embedder it would register is identical to one registered without it, so remote embedders are registered directly and endpoint problems surface on the requests that use them. A malformed persisted config still fails construction and drops the field.

Code: 9c3d882, include/embedder_manager.h, src/embedder_manager.cpp, src/collection_manager.cpp, src/collection.cpp.

Tests read: CollectionManagerTest.RestoreRemoteEmbeddingFieldWithoutEndpointValidation, .RestoreAutoSchemaDocsOnRestart, .RestorePresetsOnRestart.

Limits: the load-time check is skipped only when dimensions are persisted. A field whose dimensions were never written still goes through validation on load.

9. A query embedding of the wrong size is rejected

The same pull request as section 8 added a size check where query embeddings are consumed, in all three places. A remote model that starts returning a different dimensionality, for instance because the model behind a fixed URL was swapped, now fails the search with a 400 rather than feeding mismatched vectors into the vector index.

Code: 9c3d882, src/collection.cpp.

10. Long synonym keys are checked in full

Before: the synonym index is an adaptive radix tree that stores only the first MAX_PREFIX_LEN bytes of a compressed intermediate node’s prefix. During fuzzy traversal, bytes beyond the stored prefix were treated as matching the query, so differences in the omitted part went undetected. The pull request names the visible cases: long synonym terms, synonym_num_typos: 0, multibyte scripts such as Thai, and several synonym terms sharing a long prefix.

After: the fix stays outside the generic radix tree and applies to synonym matching only. The tree still produces ordered candidate leaves, and long candidates are revalidated against their complete leaf keys using the same byte-level optimal string alignment distance, including adjacent transpositions. synonym_prefix matches are validated against candidate prefixes. Exact matches keep their fast path and short-only synonym nodes keep the previous behaviour. The existing cap of ten returned synonym children stands. When invalid long-prefix candidates fill the initial window, the window widens until ten valid matches are found, the candidates are exhausted, or every child has been considered. Per-node metadata avoids scanning every synonym child per query.

Code: 9214365, include/synonym_index.h, src/synonym_index.cpp.

Tests read: CollectionSynonymsTest.LongCompressedPrefixFullKeyValidation, .LongCompressedPrefixAndMultibyteValidation, .LongCompressedPrefixCandidateCap, .SynonymPrefix, .SynonymTypos, .SynonymResolutionPreferExactMatch.

11. Geopolygon queries shortlist only the polygons that share their cells

Before: GeoPolygonIndex looked S2 cell ids up through a NumericTrie. The $-prefixed S2 index terms parsed to cell id 0, which matched the trie root, so every query shortlisted every indexed record. Reported as #3056.

After: a termToSeqids map is keyed directly on S2 index terms, with per-term posting lists stored as ids_t compact or full lists and ids_t::destroy_list cleanup when a term’s list empties.

Code: f6f289c, include/geopolygon_index.h, src/geopolygon_index.cpp.

Tests read: GeoFilteringTest.GeoPolygonTestRealCoordinates. The pull request reports no measurement, so the effect is described as a change in which candidates are considered, with no latency figure.

12. Outbound calls reuse connections

Before: each outbound call created a curl handle and discarded it, so DNS resolution and the TLS handshake were repeated per request. The pull request also notes that the bundled curl 7.87 carried a per-connect stall of about one second introduced by the 7.86 and 7.87 connection-filter refactor.

After: the bundled curl moves to 8.22 and non-streaming calls lease handles from a LIFO pool. Handles return through curl_easy_reset, which keeps the live connection, the DNS cache and the TLS session on the handle. DNS and TLS sessions are shared process-wide through curl_share lock callbacks, while connections stay per handle because libcurl cannot share a connection cache across concurrent threads. HTTP/2 is negotiated with an HTTP/1.1 fallback on HTTPS, and h2c prior knowledge is kept on plain HTTP. URL schemes are now compared case-insensitively, so an HTTPS:// URL no longer skips the certificate authority and verification setup. A comment in the diff records that the streaming and server-sent-event paths keep their own short-lived handles, so the pool covers the synchronous path.

Shutdown drains the pool: it waits for in-flight transfers, counts unpooled handles in the drain, refuses new leases once the pool is down, logs a drain that times out, and the HTTP server is deleted before curl’s global cleanup. Stream waiters are notified when handle initialisation fails. Auto-detected brotli and zstd are disabled in the bundled build. SIGPIPE is blocked in the test server’s connection threads so an SSL_shutdown alert to a closed socket cannot terminate an isolated test.

Measurements reported by the author, five iterations per case with a packet capture per phase, against OpenAI text-embedding-3-small and gpt-4o-mini, comparing v31 with curl 7.87 and no pooling against the branch:

scenario median before median after new TCP connections before / after
embedding call 471 ms 179 ms 5 / 0
natural-language search 944 ms 778 ms 5 / 0
conversational SSE 1,208 ms 966 ms 5 / 5

The author also reports that outbound reconnects dominated natural-language search at about 1,851 ms per call before the change and about 355 ms after. The SSE row keeps its five connections, which matches the short-lived handles on that path, and its slowest run moved from 1,284 ms to 1,660 ms. The author states that the isolated test and all ten pool tests were run with uncached Bazel runs. All of this is read as an author report, not reproduced here.

Code: e28bcec, include/http_client.h, src/http_client.cpp, src/typesense_server_utils.cpp, test/http_client_pool_test.cpp.

Work in progress at the end of the period

This lists the branches that took commits in September and were not part of v31 on 30 September. Seventy-one commits in all: 67 on the branches of twenty pull requests that were open at the cutoff, and four on branches that had no open pull request on that date.

Author dates are used, which say when a commit was written and not when it was pushed. Merges of v31 into a branch are left out, so an initiative shows fewer commits here than GitHub does. Branch state was restored retrospectively from git history and pull request metadata, so exact draft or approval status on 30 September cannot be attributed. None of this counts towards the month’s results, and the list is not an estimate of readiness, a confirmed roadmap or a delivery commitment.

Ordered by the number of September commits.

#3072, A typed-judgment provider for natural-language search (14 commits)

Continues the theme of section 2. The branch adds a provider that assembles search parameters from typed model answers, a separate reranking step, facet value shortlisting, per-round telemetry and a total_timeout_ms budget. Opened 24 September. At the cutoff this was the branch with the most September commits.

#3050, Document visibility during bulk import (7 commits)

A document became searchable before it reached the store. The branch moves the store write ahead of publication, splits batch indexing into phases and restores the alter_mutex scope across all of them. One commit inside the branch reverts an earlier attempt at the same thing. Opened 15 September.

#3066, Failed collection loads instead of ghost documents (7 commits)

The branch tracks failures in failed_collection_loads, answers 503 for an unavailable collection, rejects an incomplete load before publication and rebuilds missing document indexes on a full upsert. Opened 23 September.

#3082, Stale async reference registrations (7 commits)

Continues the theme of section 7. The branch restores missing registrations on load, removes a registration together with its field and resolves the alias before comparing registrations. Opened 29 September.

#3063, Memory used by nested numeric range filters (5 commits)

The same iterator code as section 4. The branch exposes numeric range postings for reuse, defers nested range filters until final materialisation and bounds that materialisation while preserving timeout cancellation. Opened 22 September.

#3052, search_cutoff and 408 for union searches (4 commits)

The cutoff flag was reported from the last sub-search of a union rather than from all of them. The branch fixes that and answers 408 when a union is cut off with nothing to return. Opened 15 September.

#2993, An earlier attempt at skipping remote model validation (3 commits)

The same fix that reached v31 through #3058 on 20 September, described in section 8. Its branch was still open at the cutoff.

#3038, A Gemma tokenizer type (3 commits)

Support for EmbeddingGemma models: the tokenizer, truncation before copying pieces in GemmaTokenizer::Encode, and unit tests. Opened 6 September.

#3041, Token score rebuilding (3 commits)

Resumable rebuilds of maximum scores in the radix tree, and an API entry point to trigger them when the scores are stale. Opened 11 September.

#3064, Bundled OpenSSL from 3.0.5 to 3.5.8 (3 commits)

The version bump, a clear reason in the log when an HTTPS certificate or key is rejected, and listener tests with 2048-bit and 1024-bit keys. Opened 23 September.

#3039, A Qwen tokenizer type (2 commits)

Support for Qwen3-Embedding models: the tokenizer and a hard failure on a malformed merge. Opened 6 September.

#2320, Batching of single-document write requests (1 commit)

The oldest open branch in this set, opened 28 April 2025. In September it took one commit on durable async batching.

#2878, Snapshot race under write load (1 commit)

Opened 17 April 2026. One September commit: snapshot export layout and regression coverage.

#3043, A test for filter_curated_hits with group_by on a missing field (1 commit)

A test only, no behaviour change. Opened 12 September.

#3045, Resolving union collections by name (1 commit)

Union resolved collections by coll_id rather than by name. Opened 14 September.

#3068, Releasing a pending write when replication fails (1 commit)

The pending write counter was not decremented when a replicated entry failed to arrive. Opened 23 September.

#3073, max_completion_tokens for GPT-5 and later models (1 commit)

Opened 24 September.

#3075, The int32 -1 sentinel colliding with facet id 1 (1 commit)

The same kind of collision as sections 1 and 6, here in a facet identifier. Opened 24 September.

#3076, Approving build scripts for pnpm 10+ (1 commit)

The benchmark workspace: explicit approval of the esbuild and protobufjs build scripts. Opened 24 September.

#3080, Retrying the memory guard (1 commit)

The memory guard rejected a write without first trying to return jemalloc’s dirty pages to the system. Opened 28 September.

Branches with no open pull request at the cutoff (4 commits)

These commits were written in September, but on 30 September none of them had an open pull request, so they cannot be attached to an initiative above.

The first of them, b8363a38, also shows why the window is taken by merge date rather than by commit date: it carries a September committer date, but on 30 September it was not part of v31.

Verification gaps left open

Three September initiatives were closed without merging: #3053 (accent insensitive search, 11 commits on 16 and 17 September), #3081 (reference intersection logic) and #3089 (stale search results after deleting documents). The data collection does not fetch branches of pull requests closed without merging, so their content could not be read. None of them is merged work, so the month’s set of changes is unaffected, but the reason each was closed, and whether a successor exists, is not established here.

The figures in sections 4 and 12 are author reports. Reproducing either requires a build and is outside this review.

Everything in this review is bounded by the cutoff of 30 September. Later changes to any of it, including merges of the branches listed as work in progress, are not reflected here.

In section 4 the pull request description disagrees with the merged code on the ratio constant. The code value is reported, and the raw benchmark output behind the tables was not published, so the tables are reproduced as given.

Privacy

Typesense is a trademark of Typesense, Inc.