@yoch/frozenminisearch 1.2.2 → 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v1.2.4 — `@yoch/frozenminisearch`
6
+
7
+ Patch release: faster frozen search and autoSuggest finalization, simplified AND gate heuristics, and small public exports for advanced callers. No MSv5 wire-format changes.
8
+
9
+ ### Added
10
+
11
+ - **Public finalize/suggest helpers** — export `finalizeRawSearchResults`, `finalizeSearchResults`, `suggestFromRawResults`, and `suggestFromSearchResults` from the package entry.
12
+
13
+ ### Improved
14
+
15
+ - **Tied-score finalization** — skip result sorting when every hit shares the same final score (search and suggestions).
16
+ - **Frozen search finalize** — copy stored fields in place via `assignStoredFields` (no per-document row allocation for single-column layouts).
17
+ - **AutoSuggest without `filter`** — aggregate suggestions from raw query hits instead of materializing full `SearchResult` objects.
18
+ - **AND gate heuristics** — pass selective gates as `allowedDocs` consistently; keep prefix/fuzzy on sequential gating via a cheap two-phase posting estimator.
19
+ - **CPU-only benchmarks** — `benchmark:finalize` and `benchmark:autosuggest` scripts; clearer reporting when benchmark payloads omit structural metrics.
20
+
21
+ ## v1.2.3 — `@yoch/frozenminisearch`
22
+
23
+ Patch release: broad-first exact AND / AND_NOT paths, seek-based gated doc-id collection, and README benchmark copy refresh. No API or MSv5 wire-format changes.
24
+
25
+ ### Improved
26
+
27
+ - **Broad-first exact AND** — on exact-only combined queries where the first branch posting is large and a later branch is selective enough, collect the final doc-id gate by estimated posting length, then score branches in query order (parity with naive score-then-intersect unchanged).
28
+ - **Broad-first AND_NOT** — when the positive branch is large and a negated branch is comparably large, collect exclusions first and score the positive branch only on survivors.
29
+ - **Gated doc-id collection** — `DocIdGate` lazy views and seek over sorted postings when the gate is much smaller than the posting list (`scoring.ts`, `frozenPostings.ts`).
30
+ - **AND gate posting estimate** — skip upfront posting-length estimation on prefix/fuzzy AND branches; keep absolute-gate skip on the sequential path so Divina `AND+fuzzy` stays fast.
31
+
5
32
  ## v1.2.2 — `@yoch/frozenminisearch`
6
33
 
7
34
  Patch release: faster frozen AND scoring on large posting lists (gated seek + posting-ratio gate) and BM25 segment hoisting. No API or MSv5 wire-format changes.
package/README.md CHANGED
@@ -3,67 +3,44 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@yoch/frozenminisearch.svg)](https://www.npmjs.com/package/@yoch/frozenminisearch)
4
4
  [![coverage](https://codecov.io/gh/yoch/frozenminisearch/graph/badge.svg)](https://codecov.io/gh/yoch/frozenminisearch)
5
5
  [![CI](https://github.com/yoch/frozenminisearch/actions/workflows/main.yml/badge.svg)](https://github.com/yoch/frozenminisearch/actions/workflows/main.yml)
6
- [![Socket Badge](https://socket.dev/api/badge/npm/package/@yoch/frozenminisearch)](https://socket.dev/npm/package/%40yoch%2Ffrozenminisearch)
7
6
 
8
7
  [API documentation](https://yoch.github.io/frozenminisearch/)
9
8
 
10
- **Memory-optimized, read-only full-text search for Node.js** the same BM25, prefix/fuzzy, and `autoSuggest` API as [MiniSearch](https://github.com/lucaong/minisearch), with **up to ~98% less index RAM** on real corpora and compact binary snapshots you ship instead of JSON.
9
+ **Memory-optimized, read-only full-text search for Node.js.** FrozenMiniSearch keeps the serving API close to [MiniSearch](https://github.com/lucaong/minisearch) while using compact, immutable indexes for fixed corpora.
11
10
 
12
- **Why it exists:** [MiniSearch](https://github.com/lucaong/minisearch) optimizes for a mutable in-memory index. FrozenMiniSearch optimizes for **retained heap, disk footprint, and cold load** once the corpus is fixed packed radix postings, columnar `storeFields`, typed-array layouts, and MSv5 binary wire format instead of per-document JS objects.
11
+ Use it when your documents are built offline, shipped to production, and queried many times. In that shape, frozen indexes use **~98-99% less index RAM** in the main benchmark set, save to compact binary snapshots, and load faster than MiniSearch JSON.
13
12
 
14
- **Design goal:** migrate with minimal code change package name and index construction only; serving code stays the same. Build with `fromDocuments`, the incremental builder, or `fromJson`; no mutable `MiniSearch` class is published here.
13
+ If you need live `add`, `remove`, or `discard`, use MiniSearch. If the corpus is fixed, this package is designed to keep the search experience familiar while making each serving replica much smaller.
15
14
 
16
15
  ---
17
16
 
18
- ## Why frozen instead of MiniSearch?
17
+ ## Why FrozenMiniSearch?
19
18
 
20
- Choose **mutable MiniSearch** when documents change at runtime (`add`, `remove`, `discard`). Choose **frozen** when memory and snapshot size matter: fixed corpus, deploy from binary, many replicas loading the same index. Search semantics stay the same — BM25, prefix/fuzzy, `autoSuggest`, wildcard, `AND` / `OR` / `AND_NOT` — with parity vs MiniSearch 7 validated in `dev/parity/` (scores `toBeCloseTo` precision 6).
19
+ FrozenMiniSearch is for the common production path where search data changes elsewhere, not inside the web process:
21
20
 
22
- ### Memory-first design
21
+ - Build or import the index offline.
22
+ - Save it as a compact binary snapshot.
23
+ - Load it in many read-only Node.js processes.
24
+ - Query with MiniSearch-style `search`, `autoSuggest`, filters, boosts, prefix/fuzzy search, wildcard, and `AND` / `OR` / `AND_NOT`.
23
25
 
24
- | Technique | What it saves |
25
- |-----------|---------------|
26
- | **Packed radix tree + flat postings** | Term dictionary and posting lists without per-entry JS wrappers |
27
- | **Columnar `storeFields`** | One dense column per field instead of a `Record` per document (~75% less heap for a single stored field) |
28
- | **MSv5 binary snapshots** | ~73–94% smaller on disk than MiniSearch JSON; faster cold load |
29
- | **Read-only freeze** | No mutation bookkeeping — layouts sized for serve-time, not incremental edit |
30
- | **Incremental builder** | Typed-array accumulators during build; lower peak heap than materializing `number[][]` per term |
26
+ Internally it replaces mutable JavaScript object graphs with packed radix postings, typed arrays, and columnar stored fields. The result is less flexible than MiniSearch, but much cheaper to keep resident.
31
27
 
32
28
  <!-- vs-reference:start — npm run bench:readme -->
33
- ### Measured vs lucaong MiniSearch (reference baseline)
29
+ ### Measured vs MiniSearch
34
30
 
35
- Same BM25 queries on identical corpora. **Frozen wins on what we optimize for**: RAM, disk, cold load, and search throughput on real workloads.
31
+ Same corpora, same BM25-style queries, MiniSearch 7.2.0 as the reference.
36
32
 
37
- | Scenario | Docs | Index RAM¹ | Disk (binary vs JSON)² | Cold load³ | Search p50 |
38
- |----------|-----:|------------|------------------------:|-----------:|------------:|
39
- | Divina with storeFields | 14,097 | 0.3 vs 16.1 MB (~98% less) | ~73% less | ~65% faster | ~21% faster |
40
- | Divina index only | 14,097 | 0.2 vs 14.9 MB (~99% less) | ~77% less | ~85% faster | ~17% faster |
41
- | high-frequency terms (10k docs) | 10,000 | 0.1 vs 7.4 MB (~99% less) | ~94% less | ~90% faster | ~38% faster |
42
- | Dense numeric ids (100k, identity lookup) | 100,000 | 0.9 vs 91.3 MB (~99% less) | ~88% less | ~90% faster | ~27% faster |
43
- | Doc id Uint16 boundary (65535 docs) | 65,535 | 0.6 vs 58.6 MB (~99% less) | ~91% less | ~93% faster | ~44% faster |
33
+ | Scenario | Docs | Index RAM | Binary size | Load time | Search p50 |
34
+ |----------|-----:|-----------|------------:|----------:|-----------:|
35
+ | Divina, with stored text | 14,097 | 0.3 vs 16.0 MB (~98% less) | ~73% less | ~69% faster | ~20% faster |
36
+ | Divina, index only | 14,097 | 0.2 vs 14.9 MB (~99% less) | ~77% less | ~84% faster | ~19% faster |
37
+ | High-frequency terms | 10,000 | 4.4 vs 7.4 MB (~40% less) | ~94% less | ~89% faster | ~43% faster |
38
+ | Dense numeric ids | 100,000 | 0.9 vs 91.3 MB (~99% less) | ~88% less | ~90% faster | ~33% faster |
39
+ | Uint16 doc id boundary | 65,535 | 0.6 vs 58.6 MB (~99% less) | ~91% less | ~94% faster | ~59% faster |
44
40
 
45
- **Headline:** 26/27 query benchmarks favor frozen (paired **hrtime** protocol v2). Divina `inferno` (exact, paired p50): mutable 15.7 µs → frozen 13.4 µs (**-2 µs**, ratio 0.80).
41
+ Across this full run, frozen is faster on **26/27** search cases. Divina `inferno` (exact, paired p50): mutable 15.0 µs → frozen 11.3 µs (**-4 µs**, ratio 0.74).
46
42
 
47
- Decomposition (Divina exact): L0 lookup ~300 ns frozen, L1 `executeQuery` ~6.6 µs, L2 full `search` ~10.1 µs (finalize 3 µs).
48
-
49
- | | lucaong `minisearch` | `@yoch/frozenminisearch` |
50
- |---|------------------------|---------------------------|
51
- | **Sweet spot** | Live index mutations | Fixed corpus, deploy from binary |
52
- | **Production path** | `addAll` → `toJSON` | `fromDocuments` / `fromMiniSearch` → `saveBinarySync` → `loadBinarySync` |
53
- | **Typical trade-off** | Higher RAM, JSON snapshots | One-time freeze, then compact binary |
54
-
55
- <details>
56
- <summary><strong>How to read these numbers (limits &amp; protocol)</strong></summary>
57
-
58
- - **Captured:** 2026-06-18 · commit `d05d8e9` · Node v24.16.0 · minisearch **7.2.0** · **3** run(s)/scenario · protocol **v2** (hrtime-paired, batch target 3 ms).
59
- - ¹ **Index RAM** — `measureHeap` with `--expose-gc`, one index alive. V8 overhead is extra; treat as **trend**, not accounting. Sporadic outliers happen (e.g. index-only Divina).
60
- - ² **Disk** — `JSON.stringify(mutable)` vs `saveBinarySync`.
61
- - ³ **Cold load** — median wall time to searchable index after read from disk format.
62
- - ⁴ **Search p50** — paired mutable/frozen samples per iteration; sub-0.1 ms baselines reported in **µs** in full reports. Fast queries use **50** iterations, others **20**.
63
- - **Not shown:** mutable `add`/`remove` (frozen is read-only by design). Freeze time is offline — see full suite for build metrics.
64
- - **Reproduce:** `npm run bench -- run --profile=vs-reference` · **Update this block:** `npm run bench:readme` after refreshing `benchmarks/baselines/reference.json`.
65
-
66
- </details>
43
+ Numbers are from `benchmarks/baselines/reference.json`, captured 2026-06-20 on Node v24.16.0, 3 runs per scenario. Heap is measured with one index alive and should be read as a trend, not exact accounting.
67
44
  <!-- vs-reference:end -->
68
45
 
69
46
  ---
@@ -74,8 +51,6 @@ Decomposition (Divina exact): L0 lookup ~300 ns frozen, L1 `executeQuery` ~6.6
74
51
  npm install @yoch/frozenminisearch
75
52
  ```
76
53
 
77
- **Build from documents:**
78
-
79
54
  ```javascript
80
55
  import FrozenMiniSearch from '@yoch/frozenminisearch'
81
56
 
@@ -89,7 +64,7 @@ const buf = index.saveBinarySync()
89
64
  const loaded = FrozenMiniSearch.loadBinarySync(buf, options)
90
65
  ```
91
66
 
92
- **Incremental builder:**
67
+ For larger imports, use the incremental builder:
93
68
 
94
69
  ```javascript
95
70
  import FrozenMiniSearch, {
@@ -106,21 +81,11 @@ ESM and CommonJS are both supported (`main` → CJS, `module` → ESM).
106
81
 
107
82
  ---
108
83
 
109
- ## Drop-in
110
-
111
- For **fixed corpora** (build once, serve read-only), treat this package as a drop-in replacement for MiniSearch on the serving path — same queries, far less memory per replica.
112
-
113
- **Change only:**
114
-
115
- | What | Before | After |
116
- |------|--------|-------|
117
- | Package | `minisearch` | `@yoch/frozenminisearch` |
118
- | Construction | `new MiniSearch(opts).addAll(docs)` | `FrozenMiniSearch.fromDocuments(docs, opts)` or `fromMiniSearch(mutable, opts)` |
119
- | JSON snapshot | `toJSON()` / `loadJSON()` wire format | `FrozenMiniSearch.toJSON()` / `fromJson(json, opts)` or `fromMiniSearchSnapshot(obj)` — no runtime dependency on `minisearch` |
84
+ ## Migration
120
85
 
121
- **Keep unchanged** after load: `search`, `autoSuggest`, `has`, `getStoredFields`, query options (`prefix`, `fuzzy`, `AND` / `OR` / `AND_NOT`, filters, boosts). Parity vs MiniSearch 7 is enforced in `dev/parity/`.
86
+ For fixed corpora, most serving code can stay the same. Change how the index is built or loaded, then keep calling `search`, `autoSuggest`, `has`, and `getStoredFields`.
122
87
 
123
- **Imports** — default and named both work (ESM and CJS):
88
+ Default and named imports both work:
124
89
 
125
90
  ```javascript
126
91
  // ESM
@@ -132,38 +97,28 @@ const FrozenMiniSearch = require('@yoch/frozenminisearch')
132
97
  const { FrozenMiniSearch } = require('@yoch/frozenminisearch')
133
98
  ```
134
99
 
135
- **Intentionally not drop-in:** live `add` / `remove` / `discard` (frozen is read-only); browser builds; custom `tokenize` / `processTerm` are not stored in JSON or binary snapshots — pass the same functions at load when you customized them.
100
+ Build directly:
136
101
 
137
- ---
102
+ ```javascript
103
+ import FrozenMiniSearch from '@yoch/frozenminisearch'
138
104
 
139
- ## Migration
105
+ const frozen = FrozenMiniSearch.fromDocuments(documents, options)
106
+ ```
140
107
 
141
- ### From MiniSearch JSON
108
+ Or freeze an existing MiniSearch index:
142
109
 
143
110
  ```javascript
144
- import MiniSearch from 'minisearch' // build-time only
111
+ import MiniSearch from 'minisearch'
145
112
  import FrozenMiniSearch from '@yoch/frozenminisearch'
146
113
 
147
114
  const mutable = new MiniSearch(options)
148
115
  mutable.addAll(documents)
149
116
 
150
- // Option A — live instance
151
117
  const frozen = FrozenMiniSearch.fromMiniSearch(mutable, options)
152
-
153
- // Option B — serialized index (offline / ETL)
154
- const json = JSON.stringify(mutable)
155
- const frozen2 = FrozenMiniSearch.fromJson(json, options)
118
+ const fromJson = FrozenMiniSearch.fromJson(JSON.stringify(mutable), options)
156
119
  ```
157
120
 
158
- `options.fields` must match the indexed fields in the snapshot when provided.
159
-
160
- ### From MiniSearch (mutable → frozen)
161
-
162
- | Before (mutable) | After (`@yoch/frozenminisearch`) |
163
- |------------------|----------------------------------|
164
- | `new MiniSearch(opts).addAll(docs)` then serve | `FrozenMiniSearch.fromDocuments(docs, opts)` or `fromMiniSearch(mutable, opts)` |
165
- | MiniSearch JSON snapshot | `FrozenMiniSearch.fromJson(json)` or `fromMiniSearchSnapshot(obj)` |
166
- | `import MiniSearch from 'minisearch'` | `import FrozenMiniSearch from '@yoch/frozenminisearch'` (+ `minisearch` only if you still build mutable indexes) |
121
+ MiniSearch is only needed if you still build mutable indexes. Frozen instances do not support live `add`, `remove`, or `discard`.
167
122
 
168
123
  ---
169
124
 
@@ -174,13 +129,13 @@ const frozen2 = FrozenMiniSearch.fromJson(json, options)
174
129
  - `has(id)`, `getStoredFields(id)`
175
130
  - `saveBinarySync` / `loadBinarySync` / async variants
176
131
 
177
- Indexing is **not** available on a frozen instance use `fromDocuments`, the builder, `fromMiniSearch*`, or `loadBinary*`.
132
+ Custom `tokenize` and `processTerm` functions are not stored in snapshots; pass the same functions again when loading.
178
133
 
179
134
  ---
180
135
 
181
136
  ## Binary snapshots
182
137
 
183
- The primary way to **persist and ship a memory-compact index** — smaller than MiniSearch JSON and faster to load into a low-RAM serving process.
138
+ Binary snapshots are the preferred production format.
184
139
 
185
140
  ```javascript
186
141
  const buf = index.saveBinarySync()
@@ -188,12 +143,8 @@ const loaded = FrozenMiniSearch.loadBinarySync(buf, {}) // field names embedded
188
143
  ```
189
144
 
190
145
  - **Node ≥ 20**
191
- - Default snapshot compression (`compression: 'auto'`, one pass):
192
- - payloads under 64 B stay raw
193
- - `zstd` on Node 22.15+ when it strictly shrinks the payload
194
- - otherwise `zlib` on Node 20–22.14 when it strictly shrinks the payload
195
- - otherwise `raw` (uncompressed)
196
- - Explicit snapshot compression always writes the chosen codec, even when compression would not shrink the payload (useful for portability):
146
+ - `compression: 'auto'` chooses `zstd` on Node 22.15+, otherwise `zlib`, and falls back to raw when compression does not help.
147
+ - Use explicit compression when you need a portable artifact:
197
148
 
198
149
  ```javascript
199
150
  const portable = index.saveBinarySync({ compression: 'zlib' })
@@ -201,11 +152,7 @@ const uncompressed = index.saveBinarySync({ compression: 'raw' })
201
152
  const bestRatio = index.saveBinarySync({ compression: 'zstd' }) // Node 22.15+
202
153
  ```
203
154
 
204
- - Snapshot readability depends on the embedded codec:
205
- - `raw` and `zlib` snapshots load on Node 20+
206
- - `zstd` snapshots require Node 22.15+
207
- - Snapshots produced by this package version are forward-compatible; re-build from MiniSearch JSON if an older binary fails to load
208
- - `tokenize` / `processTerm` are not stored — pass the same functions at load when customized
155
+ Raw and zlib snapshots load on Node 20+. zstd snapshots require Node 22.15+.
209
156
 
210
157
  ---
211
158
 
@@ -215,8 +162,8 @@ See [benchmarks/README.md](benchmarks/README.md).
215
162
 
216
163
  ```bash
217
164
  npm run bench -- run --profile=vs-reference # compare frozen vs minisearch
218
- npm run bench:diff # regression vs reference.json
219
- npm run bench:readme # refresh comparison table above
165
+ npm run bench:diff # regression vs reference.json
166
+ npm run bench:readme -- --from=benchmarks/baselines/latest.json
220
167
  ```
221
168
 
222
169
  ---
@@ -230,9 +177,7 @@ yarn build
230
177
  node scripts/verify-npm-pack.cjs
231
178
  ```
232
179
 
233
- Parity tests import `minisearch` as a devDependency (reference). Optional upstream clone: `git submodule update --init vendor/minisearch`.
234
-
235
- Design notes (freq adaptive, AND gating): [dev/docs/README.md](dev/docs/README.md).
180
+ Parity tests compare against MiniSearch 7. Longer notes and performance work live under [dev/docs/README.md](dev/docs/README.md) and [benchmarks/README.md](benchmarks/README.md).
236
181
 
237
182
  ---
238
183
 
@@ -241,6 +186,6 @@ Design notes (freq adaptive, AND gating): [dev/docs/README.md](dev/docs/README.m
241
186
  See [CHANGELOG.md](./CHANGELOG.md).
242
187
 
243
188
  - **MiniSearch** — [Luca Ongaro](https://github.com/lucaong/minisearch) (MIT)
244
- - **@yoch/frozenminisearch** — memory-optimized frozen indexes, packed radix tree, compact binary snapshots
189
+ - **@yoch/frozenminisearch** — memory-optimized frozen indexes and compact binary snapshots
245
190
 
246
191
  Upstream docs: [MiniSearch](https://lucaong.github.io/minisearch/)