@delali/narsil 0.1.8 → 0.1.10

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/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ ![Narsil, a distributed search engine](https://raw.githubusercontent.com/assetcorp/narsil/main/assets/banner.png)
2
+
1
3
  # Narsil
2
4
 
3
5
  [![CI](https://github.com/assetcorp/narsil/actions/workflows/ci.yml/badge.svg)](https://github.com/assetcorp/narsil/actions/workflows/ci.yml)
@@ -8,17 +10,48 @@
8
10
 
9
11
  Distributed search, reforged.
10
12
 
11
- Narsil is a distributed search engine with full-text, vector, hybrid, and geosearch. It auto-partitions large indexes across workers, serializes them into a cross-language binary format (.nrsl), and merges results back into a single ranked answer. The TypeScript package is the first implementation.
13
+ Narsil is a distributed search engine with full-text, vector, hybrid, and geosearch. One codebase runs in two contexts: embedded in your application process, where queries answer without a network hop, and as a standalone search server with a REST API, a write-ahead log, and bulk NDJSON ingest. Both contexts run the same engine and store indexes in the same cross-language binary format (.nrsl), so an index built in one loads in the other.
14
+
15
+ The engine partitions large indexes across workers and merges partition results into a single ranked answer. Its BM25 ranking matches the Anserini reference within 0.005 nDCG@10 on the BEIR datasets, and one node answers 1,020 keyword queries per second on BEIR SciFact ([benchmarks](https://github.com/assetcorp/narsil/blob/main/BENCHMARKS.md)). The TypeScript package is the first implementation.
12
16
 
13
17
  > *narsil* is the sword of Elendil in Tolkien's Lord of the Rings, shattered into shards and later reforged. The name maps to the architecture: data shatters into partitions, each shard is independently persisted, and every query reforges them into a unified result.
14
18
 
19
+ ## Contents
20
+
21
+ - [Install](#install)
22
+ - [Quick start](#quick-start)
23
+ - [Configuration](#configuration)
24
+ - [Indexes](#indexes)
25
+ - [Documents](#documents)
26
+ - [Batch operations](#batch-operations)
27
+ - [Search](#search)
28
+ - [Vector search](#vector-search)
29
+ - [Hybrid search](#hybrid-search)
30
+ - [Geosearch](#geosearch)
31
+ - [Embedding adapters](#embedding-adapters)
32
+ - [Persistence](#persistence)
33
+ - [Durability](#durability)
34
+ - [Snapshots and restore](#snapshots-and-restore)
35
+ - [Partitions and rebalancing](#partitions-and-rebalancing)
36
+ - [Workers](#workers)
37
+ - [Multi-instance invalidation](#multi-instance-invalidation)
38
+ - [Plugins](#plugins)
39
+ - [Events](#events)
40
+ - [Errors](#errors)
41
+ - [Memory reporting](#memory-reporting)
42
+ - [Language support](#language-support)
43
+ - [HTTP server](#http-server)
44
+ - [Distribution](#distribution)
45
+ - [Search quality](#search-quality)
46
+ - [Runtime support](#runtime-support)
47
+
15
48
  ## Install
16
49
 
17
50
  ```bash
18
51
  pnpm add @delali/narsil
19
52
  ```
20
53
 
21
- Requires Node.js >= 22.
54
+ Narsil requires Node.js 22 or newer. It also runs in Bun, Deno, and browsers; see [Runtime support](#runtime-support).
22
55
 
23
56
  ## Quick start
24
57
 
@@ -51,23 +84,28 @@ await narsil.insert('products', {
51
84
  const results = await narsil.query('products', {
52
85
  term: 'mechanical keyboard',
53
86
  filters: {
54
- inStock: { eq: true },
55
- price: { lte: 200 },
87
+ fields: {
88
+ inStock: { eq: true },
89
+ price: { lte: 200 },
90
+ },
56
91
  },
57
92
  boost: { title: 2.0 },
58
93
  limit: 10,
59
94
  })
60
95
  ```
61
96
 
97
+ Every hit carries the document, its id, and its BM25 score. `results.count` reports how many documents matched in total, and `results.elapsed` reports the query time in milliseconds.
98
+
62
99
  ## Configuration
63
100
 
64
101
  `createNarsil` accepts an optional `NarsilConfig` object. All fields are optional.
65
102
 
66
103
  ```ts
67
104
  import { createNarsil } from '@delali/narsil'
105
+ import { createFilesystemPersistence } from '@delali/narsil/adapters/filesystem'
68
106
 
69
107
  const narsil = await createNarsil({
70
- persistence: myAdapter,
108
+ persistence: createFilesystemPersistence({ directory: './narsil-data' }),
71
109
  workers: { enabled: true, count: 4 },
72
110
  flush: { interval: 5000, mutationThreshold: 100 },
73
111
  })
@@ -77,36 +115,37 @@ const narsil = await createNarsil({
77
115
 
78
116
  | Field | Type | Description |
79
117
  | --- | --- | --- |
80
- | `persistence` | `PersistenceAdapter` | Storage backend for durable indexes (filesystem, IndexedDB, or custom) |
81
- | `invalidation` | `InvalidationAdapter` | Cross-instance cache coordination adapter for multi-process or multi-tab setups |
82
- | `plugins` | `NarsilPlugin[]` | Lifecycle hooks for insert, remove, update, search, and index events |
83
- | `idGenerator` | `() => string` | Custom function for generating document IDs (defaults to UUID v7) |
84
- | `workers` | `WorkerConfig` | Worker thread configuration for parallel search |
85
- | `flush` | `FlushConfig` | Controls when dirty partitions persist to storage |
86
- | `eagerLoad` | `boolean` | When `true`, loads all persisted data into memory at creation time |
87
- | `embedding` | `EmbeddingAdapter` | Default adapter for auto-embedding text fields into vectors (see [Embedding adapters](#embedding-adapters)) |
118
+ | `persistence` | `PersistenceAdapter` | Sets the storage backend for persisted partitions. See [Persistence](#persistence). |
119
+ | `invalidation` | `InvalidationAdapter` | Coordinates cache eviction across processes or tabs. See [Multi-instance invalidation](#multi-instance-invalidation). |
120
+ | `plugins` | `NarsilPlugin[]` | Registers lifecycle hooks for document and search operations. See [Plugins](#plugins). |
121
+ | `idGenerator` | `() => string` | Replaces the default UUID v7 generator for document ids. |
122
+ | `workers` | `WorkerConfig` | Controls the worker thread pool for parallel search. See [Workers](#workers). |
123
+ | `flush` | `FlushConfig` | Controls when dirty partitions persist to storage. |
124
+ | `embedding` | `EmbeddingAdapter` | Sets the default adapter for auto-embedding text into vectors. See [Embedding adapters](#embedding-adapters). |
125
+ | `embeddingAdapters` | `Record<string, EmbeddingAdapter>` | Registers named adapters that index configs reference by name. Names persist in index metadata, so durability recovery can rebind them. |
126
+ | `durability` | `DurabilityConfig` | Enables write-ahead logging and snapshots. See [Durability](#durability). |
88
127
 
89
128
  ### WorkerConfig
90
129
 
91
130
  | Field | Type | Default | Description |
92
131
  | --- | --- | --- | --- |
93
- | `enabled` | `boolean` | `false` | Enables worker thread pool for search |
94
- | `count` | `number` | CPU count | Number of worker threads to spawn |
95
- | `promotionThreshold` | `number` | - | Document count per index that triggers auto-promotion to workers |
96
- | `totalPromotionThreshold` | `number` | - | Total document count across all indexes that triggers auto-promotion |
132
+ | `enabled` | `boolean` | `false` | Turns on the worker thread pool for search. |
133
+ | `count` | `number` | CPU cores minus one, clamped between 2 and 8 | Sets the number of worker threads to spawn. |
134
+ | `promotionThreshold` | `number` | `10000` | Sets the per-index document count that triggers promotion to workers. |
135
+ | `totalPromotionThreshold` | `number` | `50000` | Sets the document count across all indexes that triggers promotion. |
97
136
 
98
137
  ### FlushConfig
99
138
 
100
- | Field | Type | Default | Description |
101
- | --- | --- | --- | --- |
102
- | `interval` | `number` | - | Milliseconds between persistence flushes |
103
- | `mutationThreshold` | `number` | - | Number of mutations that triggers a flush |
139
+ | Field | Type | Description |
140
+ | --- | --- | --- |
141
+ | `interval` | `number` | Sets the milliseconds between persistence flushes. |
142
+ | `mutationThreshold` | `number` | Sets the mutation count that triggers a flush. |
104
143
 
105
144
  ### Tokenizer cache
106
145
 
107
- The stemmer normalization cache auto-sizes based on the runtime environment. On Node.js it reads container memory limits (via `process.constrainedMemory()`), in browsers it checks `navigator.deviceMemory`, and falls back to a sensible default elsewhere.
146
+ The stemmer normalization cache auto-sizes based on the runtime environment. On Node.js it reads container memory limits through `process.constrainedMemory()`, in browsers it checks `navigator.deviceMemory`, and it falls back to a fixed default elsewhere.
108
147
 
109
- This cache is process-global, shared across all Narsil instances in the same process. You can override the size by calling `configureNormalizationCache` once at startup, before creating any instances:
148
+ This cache is process-global and shared across all Narsil instances in the same process. Override the size by calling `configureNormalizationCache` once at startup, before creating any instances:
110
149
 
111
150
  ```ts
112
151
  import { configureNormalizationCache, createNarsil } from '@delali/narsil'
@@ -116,110 +155,202 @@ configureNormalizationCache(500_000)
116
155
  const narsil = await createNarsil()
117
156
  ```
118
157
 
119
- The value clamps to a floor of 50,000 and a ceiling of 2,000,000 entries. Invalid values (NaN, Infinity, negative numbers, zero) throw a `NarsilError` with code `CONFIG_INVALID`.
158
+ The value clamps to a floor of 50,000 and a ceiling of 2,000,000 entries. Invalid values (NaN, Infinity, negative numbers, and zero) throw a `NarsilError` with code `CONFIG_INVALID`.
120
159
 
121
- Four utility functions are available for cache management:
160
+ Four functions manage the cache:
122
161
 
123
- - `configureNormalizationCache(maxSize)` - set the maximum cache size
124
- - `clearNormalizationCache()` - drop all cached entries (reclaim memory after one-off indexing)
125
- - `resetNormalizationCache()` - clear the cache and reset the size to the auto-detected default
126
- - `getNormalizationCacheSize()` - return the current number of cached entries (useful for monitoring)
162
+ - `configureNormalizationCache(maxSize)` sets the maximum cache size.
163
+ - `clearNormalizationCache()` drops all cached entries, which reclaims memory after one-off indexing.
164
+ - `resetNormalizationCache()` clears the cache and resets the size to the auto-detected default.
165
+ - `getNormalizationCacheSize()` returns the current number of cached entries for monitoring.
127
166
 
128
- ## Features
167
+ ## Indexes
129
168
 
130
- ### Full-text search
169
+ `createIndex(name, config)` creates an index from a schema. The schema supports `string`, `number`, `boolean`, `enum`, `geopoint`, `vector[N]`, and the array variants `string[]`, `number[]`, `boolean[]`, and `enum[]`. Objects nest up to 4 levels deep. Narsil validates every document against the schema at insertion time.
131
170
 
132
- BM25 scoring with field boosting, fuzzy matching via bounded Levenshtein distance, exact phrase search, and configurable term matching policies. Specify how many query terms a document must match, set minimum score thresholds, and sort by any field.
171
+ ```ts
172
+ await narsil.createIndex('articles', {
173
+ schema: {
174
+ title: 'string',
175
+ body: 'string',
176
+ author: {
177
+ name: 'string',
178
+ verified: 'boolean',
179
+ },
180
+ publishedYear: 'number',
181
+ },
182
+ language: 'english',
183
+ required: ['title'],
184
+ })
185
+ ```
133
186
 
134
- ### Auto-partitioning
187
+ ### IndexConfig
135
188
 
136
- Indexes split into shards when they grow beyond a configurable threshold. Partition assignment uses FNV-1a hashing for deterministic routing. Background rebalancing redistributes documents across new partitions without blocking queries, using a write-ahead queue for zero-downtime resharding.
189
+ | Field | Type | Description |
190
+ | --- | --- | --- |
191
+ | `schema` | `SchemaDefinition` | Declares the fields and their types. This field is required. |
192
+ | `language` | `string` | Selects the language module for tokenization and stemming. The default is `english`. |
193
+ | `partitions` | `PartitionConfig` | Sets `maxDocsPerPartition` and `maxPartitions`. See [Partitions and rebalancing](#partitions-and-rebalancing). |
194
+ | `defaultScoring` | `'local' \| 'dfs' \| 'broadcast'` | Sets the scoring mode used when a query does not pass one. See [Scoring modes](#scoring-modes). |
195
+ | `bm25` | `BM25Params` | Overrides the BM25 `k1` and `b` parameters. |
196
+ | `stopWords` | `Set<string> \| (defaults: Set<string>) => Set<string>` | Replaces or transforms the language module's stop word set. |
197
+ | `tokenizer` | `CustomTokenizer` | Replaces the built-in tokenizer with your own `tokenize(text)` implementation. |
198
+ | `trackPositions` | `boolean` | Stores token positions for highlighting. The default is `true`. |
199
+ | `vectorPromotion` | `VectorIndexConfig` | Tunes the HNSW promotion threshold, graph parameters, and quantization. See [Vector search](#vector-search). |
200
+ | `strict` | `boolean` | Rejects documents that carry fields missing from the schema. |
201
+ | `embedding` | `EmbeddingFieldConfig` | Maps text fields to vector fields for auto-embedding. See [Embedding adapters](#embedding-adapters). |
202
+ | `required` | `string[]` | Lists fields a document must carry; inserts without them fail with `DOC_MISSING_REQUIRED_FIELD`. |
203
+
204
+ ### Index management
205
+
206
+ ```ts
207
+ const indexes = narsil.listIndexes()
208
+ // [{ name: 'articles', documentCount: 1204, partitionCount: 1, language: 'english' }]
137
209
 
138
- **Single-process overhead.** Partitioning pays off when shards live on separate processes or hosts. Inside one Node.js process, going from 1 to 20 partitions costs about 14% of insert throughput, 28% of median search latency, and 27% at p95, with no scaling upside. Keep `partitions.maxPartitions` low (or unset) for single-process deployments and raise it once you fan partitions out across workers or distributed nodes.
210
+ const stats = narsil.getStats('articles')
211
+ // { documentCount, partitionCount, estimatedMemoryBytes, language, schema }
139
212
 
140
- **Rebalance latency spikes.** While a partition reshape is running, worst-tick p95 latency can climb to about 25ms compared with around 11ms in steady state. Schedule large bulk inserts that trigger rebalancing during low-traffic windows, or pre-size the index with `partitions.maxDocsPerPartition` to avoid mid-load reshapes.
213
+ await narsil.clear('articles')
141
214
 
142
- ### Worker isolation
215
+ await narsil.dropIndex('articles')
216
+ ```
143
217
 
144
- Search operations move off the main thread through worker threads (Node.js, Bun) or Web Workers (browser, Deno). The engine starts in direct mode for small indexes and auto-promotes to workers once document counts cross a threshold. You keep using the same API throughout.
218
+ `clear` removes every document but keeps the index and its schema. `dropIndex` removes the index entirely, including its persisted data. Call `shutdown()` when the process is done with the engine; it stops workers, flushes durability state, and rejects further calls.
145
219
 
146
- ### Memory reporting
220
+ ## Documents
147
221
 
148
- Narsil reports memory at three levels: per index, per partition, and per runtime. `getStats(indexName)` returns `estimatedMemoryBytes`, a formula-based estimate for the index's main-thread partitions and vector structures.
222
+ ### Insert
223
+
224
+ `insert(indexName, document, docId?, options?)` resolves the document id in this order: the explicit `docId` argument wins, then a string `id` field on the document itself, and otherwise Narsil generates a UUID v7. The method returns the resolved id.
149
225
 
150
226
  ```ts
151
- const indexStats = narsil.getStats('products')
227
+ const generatedId = await narsil.insert('products', { title: 'Trackball Mouse' })
152
228
 
153
- console.log(indexStats.estimatedMemoryBytes)
229
+ const explicitId = await narsil.insert('products', { title: 'Split Keyboard' }, 'kb-042')
230
+
231
+ await narsil.insert('products', { id: 'kb-043', title: 'Tenkeyless Keyboard' })
154
232
  ```
155
233
 
156
- The estimate comes from document counts, posting lists, field indexes, and vector indexes. It excludes V8 object headers, allocator overhead, and other host runtime costs, so use it for comparing indexes inside one process.
234
+ Inserting an id that already exists fails with `DOC_ALREADY_EXISTS`, and `update` fails with `DOC_NOT_FOUND` when the id is missing, so an upsert checks `has()` first and picks the right call. The HTTP server's PUT endpoint packages that check as one request.
157
235
 
158
- `getPartitionStats(indexName)` returns the same estimate for each partition. The partition values sum to `getStats(indexName).estimatedMemoryBytes`.
236
+ ### Read
159
237
 
160
238
  ```ts
161
- const partitions = narsil.getPartitionStats('products')
239
+ const doc = await narsil.get('products', 'kb-042')
240
+ // the document, or undefined when the id is unknown
162
241
 
163
- for (const partition of partitions) {
164
- console.log(partition.partitionId, partition.estimatedMemoryBytes)
165
- }
242
+ const docs = await narsil.getMultiple('products', ['kb-042', 'kb-043'])
243
+ // Map<string, AnyDocument> holding only the ids that exist
244
+
245
+ const exists = await narsil.has('products', 'kb-042')
246
+
247
+ const count = await narsil.countDocuments('products')
166
248
  ```
167
249
 
168
- `getMemoryStats()` returns a runtime snapshot and worker reports. It is async because workers report their heap usage through the worker message channel.
250
+ ### Update and remove
251
+
252
+ `update` replaces the whole document under an id. Internally it removes the old document and inserts the new one, with a fast path when the change touches nothing the index depends on.
169
253
 
170
254
  ```ts
171
- const memory = await narsil.getMemoryStats()
255
+ await narsil.update('products', 'kb-042', { title: 'Split Ergonomic Keyboard' })
172
256
 
173
- console.log(memory.estimatedIndexBytes)
174
- console.log(memory.process?.heapUsed)
175
- console.log(memory.workers)
257
+ await narsil.remove('products', 'kb-042')
176
258
  ```
177
259
 
178
- `memory.estimatedIndexBytes` sums `estimatedMemoryBytes` across every index in this Narsil instance. `memory.process` comes from `process.memoryUsage()` when the runtime exposes it. It measures the whole host process, so two Narsil instances in one Node.js process report the same process numbers. Browser runtimes return `null` for `memory.process`.
260
+ Both methods throw `DOC_NOT_FOUND` for an unknown id.
261
+
262
+ ## Batch operations
263
+
264
+ `insertBatch`, `updateBatch`, and `removeBatch` process many documents in one call and return partial results. One bad document never aborts the batch: every failure is collected with its id and error, and every success is applied.
265
+
266
+ ```ts
267
+ const result = await narsil.insertBatch('products', [
268
+ { id: 'p1', title: 'USB-C Hub', price: 49 },
269
+ { id: 'p2', title: 'Laptop Stand', price: 89 },
270
+ { id: 'p3', title: 'Broken Doc', price: 'not-a-number' },
271
+ ])
272
+
273
+ // result.succeeded => ['p1', 'p2']
274
+ // result.failed => [{ docId: 'p3', error: NarsilError(DOC_VALIDATION_FAILED) }]
275
+
276
+ await narsil.updateBatch('products', [
277
+ { docId: 'p1', document: { title: 'USB-C Hub, 8 ports', price: 59 } },
278
+ ])
279
+
280
+ await narsil.removeBatch('products', ['p1', 'p2'])
281
+ ```
282
+
283
+ Batch inserts resolve ids from each document's `id` field and generate UUID v7 ids for the rest. Large batches process in chunks and yield the event loop between chunks, so searches stay responsive during a bulk load.
284
+
285
+ ## Search
179
286
 
180
- `memory.workers` lists `heapUsed`, `heapTotal`, and `external` for each active worker. The array is empty before worker promotion.
287
+ `query(indexName, params)` runs every search. The `mode` parameter selects `'fulltext'` (the default), `'vector'`, or `'hybrid'`.
181
288
 
182
- ### Distributed BM25 scoring
289
+ ### Full-text search
183
290
 
184
- Three scoring modes handle the partition-skew problem:
291
+ Full-text search scores with BM25. `fields` restricts the search to specific fields, and `boost` multiplies per-field scores.
185
292
 
186
- - **Local**: each partition scores with its own statistics (fastest, default)
187
- - **DFS (Distributed Frequency Statistics)**: a two-phase query collects global term statistics first, then scores with unified IDF values
188
- - **Broadcast**: instances share statistics through the invalidation adapter for pre-computed global scoring
293
+ ```ts
294
+ const results = await narsil.query('products', {
295
+ term: 'wireless keyboard',
296
+ fields: ['title', 'description'],
297
+ boost: { title: 2.0 },
298
+ limit: 10,
299
+ offset: 0,
300
+ })
301
+ ```
189
302
 
190
- ### Vector search
303
+ `limit` defaults to 10. Each hit has the shape `{ id, score, document, highlights?, scoreComponents? }`. Pass `includeScoreComponents: true` to receive per-term frequencies, field lengths, and IDF values for debugging a ranking.
191
304
 
192
- Store and query high-dimensional embeddings with cosine similarity, dot product, or Euclidean distance. Small vector sets use an exact brute-force scan. Once a field crosses 1,024 vectors, the engine builds an HNSW graph and switches the field to approximate search. The threshold is configurable per index via `vectorPromotion.threshold`.
305
+ ### Fuzzy matching
193
306
 
194
- ### Hybrid search
307
+ `tolerance` sets the maximum Levenshtein edit distance between a query term and an indexed term. It defaults to 0, which requires exact matches. `prefixLength` limits fuzzy candidates to terms sharing that many leading characters with the query term; it defaults to 2, and raising it makes fuzzy lookups faster and stricter. `exact: true` turns fuzzy expansion off for the whole query.
195
308
 
196
- Combine full-text BM25 scores with vector similarity scores using weighted alpha blending. A single query searches both text and embeddings, normalizes both score sets to [0,1], and returns a merged ranking.
309
+ ```ts
310
+ const results = await narsil.query('products', {
311
+ term: 'keybaord',
312
+ tolerance: 2,
313
+ prefixLength: 3,
314
+ })
315
+ ```
197
316
 
198
- ### Geosearch
317
+ ### Score and coverage thresholds
199
318
 
200
- Index latitude/longitude points and query by radius (Haversine or Vincenty distance) or polygon containment (ray casting). High-precision mode switches to Vincenty's iterative formula for long-distance accuracy.
319
+ `minScore` drops hits scoring below a floor. `termMatch` sets how many query terms a document must match: `'any'` (the default) accepts one term, `'all'` requires every term, and a number requires at least that many terms.
320
+
321
+ ```ts
322
+ const results = await narsil.query('products', {
323
+ term: 'mechanical gaming keyboard',
324
+ termMatch: 2,
325
+ minScore: 1.5,
326
+ })
327
+ ```
201
328
 
202
329
  ### Filters
203
330
 
204
- Filter on any indexed field using comparison operators (`eq`, `ne`, `gt`, `lt`, `gte`, `lte`, `between`), string operators (`in`, `nin`, `startsWith`, `endsWith`), array operators (`containsAll`, `matchesAny`, `size`), and presence checks (`exists`, `isEmpty`). Combine filters with explicit `and`, `or`, and `not` boolean combinators.
331
+ Filter on any indexed field with comparison operators (`eq`, `ne`, `gt`, `lt`, `gte`, `lte`, `between`), string operators (`in`, `nin`, `startsWith`, `endsWith`), array operators (`containsAll`, `matchesAny`, `size`), and presence checks (`exists`, `notExists`, `isEmpty`, `isNotEmpty`). Combine filter expressions with `and`, `or`, and `not`.
205
332
 
206
333
  ```ts
207
334
  const results = await narsil.query('products', {
208
335
  term: 'wireless',
209
336
  filters: {
210
337
  or: [
211
- { category: { eq: 'electronics' } },
212
- { category: { eq: 'accessories' } },
338
+ { fields: { category: { eq: 'electronics' } } },
339
+ { fields: { category: { eq: 'accessories' } } },
213
340
  ],
214
- price: { between: [10, 100] },
215
- tags: { containsAll: ['bluetooth'] },
341
+ fields: {
342
+ price: { between: [10, 100] },
343
+ tags: { containsAll: ['bluetooth'] },
344
+ },
216
345
  },
217
346
  })
218
347
  ```
219
348
 
220
- ### Faceted search
349
+ Field conditions live under `fields`, and the `and`, `or`, and `not` combinators nest whole filter expressions, so any boolean shape is expressible. Filters narrow the candidates a search scores: a full-text query needs a `term` to produce hits, and in vector and hybrid modes the filters restrict which documents the vector search considers.
350
+
351
+ ### Facets
221
352
 
222
- Get aggregate value counts alongside search results for building filter UIs.
353
+ Facets return value counts alongside the hits for building filter UIs. String and enum facets take a `limit` and a `sort` direction, and numeric facets take explicit `ranges`.
223
354
 
224
355
  ```ts
225
356
  const results = await narsil.query('products', {
@@ -229,16 +360,51 @@ const results = await narsil.query('products', {
229
360
  price: { ranges: [{ from: 0, to: 500 }, { from: 500, to: 1000 }, { from: 1000, to: 5000 }] },
230
361
  },
231
362
  })
232
- // results.facets.category.values => { electronics: 42, computers: 28, ... }
363
+
364
+ // results.facets => { category: { values: { electronics: 42, computers: 28 }, count: 70 }, ... }
365
+ ```
366
+
367
+ ### Sort
368
+
369
+ `sort` orders hits by field values instead of score. Multiple entries apply in order, so the second field breaks ties in the first.
370
+
371
+ ```ts
372
+ const results = await narsil.query('products', {
373
+ term: 'keyboard',
374
+ sort: { price: 'asc', title: 'asc' },
375
+ })
233
376
  ```
234
377
 
235
378
  ### Grouping
236
379
 
237
- Group results by field values with optional custom reducers.
380
+ `group` collapses hits that share field values. `maxPerGroup` caps how many hits each group keeps, and an optional reducer folds every grouped document into an accumulated value.
381
+
382
+ ```ts
383
+ const results = await narsil.query('products', {
384
+ term: 'keyboard',
385
+ group: {
386
+ fields: ['category'],
387
+ maxPerGroup: 3,
388
+ },
389
+ })
390
+
391
+ // results.groups => [{ values: { category: 'electronics' }, hits: [...] }, ...]
392
+
393
+ const withTotals = await narsil.query('products', {
394
+ term: 'keyboard',
395
+ group: {
396
+ fields: ['category'],
397
+ reduce: {
398
+ initialValue: () => 0,
399
+ reducer: (total, doc) => (total as number) + ((doc.price as number) ?? 0),
400
+ },
401
+ },
402
+ })
403
+ ```
238
404
 
239
- ### Match highlighting
405
+ ### Highlighting
240
406
 
241
- Get snippets with customizable pre/post tags marking where query terms appear in the original text.
407
+ `highlight` returns snippets with tags marking where query terms appear. Highlighting needs `trackPositions` left at its default of `true`.
242
408
 
243
409
  ```ts
244
410
  const results = await narsil.query('products', {
@@ -247,14 +413,208 @@ const results = await narsil.query('products', {
247
413
  fields: ['title', 'description'],
248
414
  preTag: '<mark>',
249
415
  postTag: '</mark>',
416
+ maxSnippetLength: 160,
417
+ },
418
+ })
419
+
420
+ // hit.highlights?.title.snippet => '<mark>Mechanical</mark> Keyboard'
421
+ ```
422
+
423
+ ### Pagination
424
+
425
+ Shallow pagination uses `limit` and `offset`. Deep pagination uses `searchAfter` cursors, which track a position per partition and keep latency flat at any depth. Every page's result carries a `cursor` string; pass it back as `searchAfter` to fetch the next page.
426
+
427
+ ```ts
428
+ const firstPage = await narsil.query('products', { term: 'keyboard', limit: 20 })
429
+
430
+ if (firstPage.cursor) {
431
+ const secondPage = await narsil.query('products', {
432
+ term: 'keyboard',
433
+ limit: 20,
434
+ searchAfter: firstPage.cursor,
435
+ })
436
+ }
437
+ ```
438
+
439
+ A cursor is only valid for the same query parameters it came from. A malformed cursor fails with `SEARCH_INVALID_CURSOR`.
440
+
441
+ ### Pinning
442
+
443
+ `pinned` places specific documents at fixed positions in the ranked results, which serves sponsored or editorial placements. Positions are zero-based.
444
+
445
+ ```ts
446
+ const results = await narsil.query('products', {
447
+ term: 'keyboard',
448
+ pinned: [{ docId: 'kb-editorial-pick', position: 0 }],
449
+ })
450
+ ```
451
+
452
+ ### Scoring modes
453
+
454
+ Three scoring modes handle the statistics-skew problem that appears when an index spans partitions or instances:
455
+
456
+ - `'local'` scores each partition with its own statistics. It is the fastest mode and the default.
457
+ - `'dfs'` runs a two-phase query that first collects global term statistics, then scores with unified IDF values.
458
+ - `'broadcast'` has instances share statistics through the invalidation adapter, so scoring uses pre-computed global values.
459
+
460
+ Set a per-index default with `defaultScoring` in the index config, or set `scoring` per query:
461
+
462
+ ```ts
463
+ const results = await narsil.query('products', {
464
+ term: 'keyboard',
465
+ scoring: 'dfs',
466
+ })
467
+ ```
468
+
469
+ ### Preflight
470
+
471
+ `preflight(indexName, params)` returns the match count for a query without materializing, ranking, or paginating hits. Use it to size a result set before running an expensive query.
472
+
473
+ ```ts
474
+ const { count, elapsed } = await narsil.preflight('products', { term: 'keyboard' })
475
+ ```
476
+
477
+ ### Suggestions
478
+
479
+ `suggest(indexName, params)` returns autocomplete candidates from the index's term dictionary. It tokenizes the input, takes the last word as the prefix, and ranks matching terms by document frequency.
480
+
481
+ ```ts
482
+ const suggestions = await narsil.suggest('products', { prefix: 'mech', limit: 5 })
483
+ // suggestions.terms => [{ term: 'mechan', documentFrequency: 12 }, ...]
484
+ ```
485
+
486
+ ## Vector search
487
+
488
+ Declare a `vector[N]` field in the schema and insert documents carrying arrays of that exact length. Small fields use an exact brute-force scan. Once a field reaches 1,024 vectors, the engine builds an HNSW graph in the background and switches the field to approximate search. The cutoff and graph parameters are configurable per index through `vectorPromotion`.
489
+
490
+ ```ts
491
+ await narsil.createIndex('docs', {
492
+ schema: {
493
+ title: 'string',
494
+ embedding: 'vector[768]',
495
+ },
496
+ vectorPromotion: {
497
+ threshold: 2048,
498
+ hnswConfig: { m: 16, efConstruction: 200, metric: 'cosine' },
499
+ quantization: 'sq8',
500
+ },
501
+ })
502
+
503
+ await narsil.insert('docs', {
504
+ title: 'Distributed consensus',
505
+ embedding: myPrecomputedVector,
506
+ })
507
+
508
+ const results = await narsil.query('docs', {
509
+ mode: 'vector',
510
+ vector: {
511
+ field: 'embedding',
512
+ value: myQueryVector,
513
+ metric: 'cosine',
514
+ similarity: 0.35,
515
+ efSearch: 100,
516
+ },
517
+ limit: 10,
518
+ })
519
+ ```
520
+
521
+ The `vector` parameter takes either a raw `value` array or a `text` string for auto-embedding, and passing both fails with `EMBEDDING_CONFIG_INVALID`. `metric` selects `'cosine'` (the default), `'dotProduct'`, or `'euclidean'`. `similarity` sets a score floor; hits below it drop before `limit` applies, so a page can come back short. For `euclidean`, the floor applies to the similarity mapping `1 / (1 + distance)`. `efSearch` raises HNSW recall at the cost of latency and has no effect while the field still uses the brute-force backend. A `value` whose length differs from the field's declared dimension fails with `VECTOR_DIMENSION_MISMATCH`.
522
+
523
+ Quantization mode `'sq8'` is the default; it stores scalar-quantized int8 vectors, which cuts vector memory roughly 4x. Set `quantization: 'none'` to keep full float32 vectors.
524
+
525
+ ### Vector maintenance
526
+
527
+ Removed and updated vectors leave tombstones in the HNSW graph, which slows queries as they accumulate. Two maintenance calls clean up, and a status call reports whether either is worth running:
528
+
529
+ ```ts
530
+ const status = narsil.vectorMaintenanceStatus('docs')
531
+ // [{ fieldName, tombstoneRatio, graphCount, bufferSize, building, estimatedCompactMs, estimatedOptimizeMs }]
532
+
533
+ await narsil.compactVectors('docs', 'embedding')
534
+
535
+ await narsil.optimizeVectors('docs', 'embedding')
536
+ ```
537
+
538
+ `compactVectors` drops tombstones without rebuilding the graph and runs synchronously. `optimizeVectors` rebuilds the graph from live vectors, which takes longer and restores full query speed. Omit the field name to run maintenance on every vector field in the index.
539
+
540
+ ## Hybrid search
541
+
542
+ Hybrid mode runs the full-text and vector searches in one query and fuses the two rankings.
543
+
544
+ ```ts
545
+ const results = await narsil.query('docs', {
546
+ mode: 'hybrid',
547
+ term: 'how do search engines scale',
548
+ vector: { field: 'embedding', text: 'how do search engines scale' },
549
+ hybrid: { strategy: 'rrf', k: 60 },
550
+ limit: 10,
551
+ })
552
+ ```
553
+
554
+ Two fusion strategies are available:
555
+
556
+ - `'rrf'` (the default) applies reciprocal rank fusion, which combines the two rankings by position instead of by score. `k` dampens the contribution of lower ranks and defaults to 60.
557
+ - `'linear'` normalizes both score sets to [0, 1] and blends them as `alpha * vector + (1 - alpha) * text`. `alpha` defaults to 0.5 and clamps to [0, 1].
558
+
559
+ ```ts
560
+ const weighted = await narsil.query('docs', {
561
+ mode: 'hybrid',
562
+ term: 'partition rebalancing',
563
+ vector: { field: 'embedding', text: 'partition rebalancing' },
564
+ hybrid: { strategy: 'linear', alpha: 0.7 },
565
+ })
566
+ ```
567
+
568
+ ## Geosearch
569
+
570
+ Declare a `geopoint` field and insert documents with `{ lat, lon }` values. Geo conditions are filters, so they compose with terms, other filters, facets, and every other query feature.
571
+
572
+ ```ts
573
+ await narsil.createIndex('stores', {
574
+ schema: {
575
+ name: 'string',
576
+ location: 'geopoint',
577
+ },
578
+ })
579
+
580
+ await narsil.insert('stores', {
581
+ name: 'Osu Night Market',
582
+ location: { lat: 5.5571, lon: -0.1824 },
583
+ })
584
+
585
+ const nearby = await narsil.query('stores', {
586
+ filters: {
587
+ fields: {
588
+ location: {
589
+ radius: { lat: 5.556, lon: -0.1969, distance: 5, unit: 'km' },
590
+ },
591
+ },
592
+ },
593
+ })
594
+
595
+ const inArea = await narsil.query('stores', {
596
+ filters: {
597
+ fields: {
598
+ location: {
599
+ polygon: {
600
+ points: [
601
+ { lat: 5.52, lon: -0.25 },
602
+ { lat: 5.52, lon: -0.15 },
603
+ { lat: 5.62, lon: -0.15 },
604
+ { lat: 5.62, lon: -0.25 },
605
+ ],
606
+ },
607
+ },
608
+ },
250
609
  },
251
610
  })
252
- // hit.highlights.title.snippet => '<mark>Mechanical</mark> Keyboard'
253
611
  ```
254
612
 
255
- ### Embedding adapters
613
+ Radius filters measure Haversine distance by default and accept `unit: 'km' | 'mi' | 'm'`. Set `highPrecision: true` to switch to Vincenty's iterative formula for long-distance accuracy. Polygon filters test containment with ray casting. Both filter shapes accept `inside: false` to invert the match and return documents outside the area.
256
614
 
257
- Auto-embed text fields into vectors on insert and query. Pass an `EmbeddingAdapter` at the instance level (shared default) or per-index. When a document is inserted without a pre-computed vector, Narsil concatenates the configured source text fields and calls the adapter to generate the embedding. On query, pass `text` instead of `value` to auto-embed the search phrase.
615
+ ## Embedding adapters
616
+
617
+ Embedding adapters turn text into vectors automatically, on insert and at query time. Configure a default adapter for the whole engine, register named adapters, or set one per index. Map each vector field to the text fields it embeds; multiple source fields concatenate before embedding.
258
618
 
259
619
  ```ts
260
620
  import { createNarsil } from '@delali/narsil'
@@ -262,7 +622,8 @@ import { createOpenAIEmbedding } from '@delali/narsil/embeddings/openai'
262
622
 
263
623
  const narsil = await createNarsil({
264
624
  embedding: createOpenAIEmbedding({
265
- apiKey: process.env.OPENAI_API_KEY,
625
+ baseUrl: 'https://api.openai.com/v1',
626
+ apiKey: process.env.OPENAI_API_KEY ?? '',
266
627
  model: 'text-embedding-3-small',
267
628
  dimensions: 1536,
268
629
  }),
@@ -292,16 +653,51 @@ const results = await narsil.query('articles', {
292
653
  })
293
654
  ```
294
655
 
295
- Two adapters ship with the project:
656
+ ### Named adapters
657
+
658
+ An adapter instance is a function and cannot be serialized, so an index that names its adapter survives durability recovery: the engine persists the name in index metadata and rebinds it on the next start. Register names through the config or at runtime, and reference them from the index config:
659
+
660
+ ```ts
661
+ import { createNarsil } from '@delali/narsil'
662
+ import { createOpenAIEmbedding } from '@delali/narsil/embeddings/openai'
663
+
664
+ const engine = await createNarsil({
665
+ embeddingAdapters: {
666
+ 'openai-small': createOpenAIEmbedding({
667
+ baseUrl: 'https://api.openai.com/v1',
668
+ apiKey: process.env.OPENAI_API_KEY ?? '',
669
+ model: 'text-embedding-3-small',
670
+ dimensions: 1536,
671
+ }),
672
+ },
673
+ durability: { directory: './narsil-data' },
674
+ })
675
+
676
+ await engine.createIndex('articles', {
677
+ schema: { title: 'string', embedding: 'vector[1536]' },
678
+ embedding: {
679
+ adapter: 'openai-small',
680
+ fields: { embedding: ['title'] },
681
+ },
682
+ })
683
+
684
+ engine.registerEmbeddingAdapter('openai-small', myReplacementAdapter)
685
+ ```
686
+
687
+ `registerEmbeddingAdapter` rebinds every index referencing that name, which lets you rotate credentials or swap models without recreating indexes.
688
+
689
+ ### Shipped adapters
296
690
 
297
691
  | Adapter | Package | Dependencies |
298
692
  | --- | --- | --- |
299
- | OpenAI | `@delali/narsil/embeddings/openai` | None (uses `fetch`) |
300
- | Transformers.js | `@delali/narsil-embeddings-transformers` | `@huggingface/transformers` (peer) |
693
+ | OpenAI | `@delali/narsil/embeddings/openai` | The adapter has no dependencies and uses `fetch`. |
694
+ | Transformers.js | `@delali/narsil-embeddings-transformers` | The adapter needs `@huggingface/transformers` as a peer dependency. |
695
+
696
+ The OpenAI adapter retries retryable failures with exponential backoff and jitter, chunks batches at 2,048 inputs per request, and accepts a timeout and a retry cap. `baseUrl` points at any OpenAI-compatible endpoint, and `apiKey` accepts a string or a function returning one, so short-lived credentials work. The Transformers.js adapter runs models locally with lazy pipeline initialization, supports WebGPU, WASM, and CPU backends, and handles asymmetric models such as E5 and BGE through `documentPrefix` and `queryPrefix`. Its own [README](../embeddings-transformers/README.md) documents every option.
301
697
 
302
- The OpenAI adapter includes retry logic with exponential backoff and jitter, batch chunking (up to 2,048 inputs per request), and configurable timeouts. The Transformers.js adapter runs models locally with lazy pipeline initialization, supports WebGPU/WASM/CPU backends, and handles asymmetric models through document/query prefixes.
698
+ ### Custom adapters
303
699
 
304
- Build a custom adapter by implementing the `EmbeddingAdapter` interface:
700
+ Build an adapter by satisfying the `EmbeddingAdapter` interface:
305
701
 
306
702
  ```ts
307
703
  interface EmbeddingAdapter {
@@ -312,51 +708,260 @@ interface EmbeddingAdapter {
312
708
  }
313
709
  ```
314
710
 
315
- If `embedBatch` is not provided, Narsil falls back to parallel `embed()` calls with a concurrency limit of 8.
711
+ When `embedBatch` is missing, Narsil falls back to parallel `embed()` calls with a concurrency limit of 8.
712
+
713
+ ## Persistence
316
714
 
317
- ### Term suggestions
715
+ Persistence stores serialized partitions through a pluggable adapter. Three adapters ship with the package:
318
716
 
319
- Get autocomplete suggestions from the index's term dictionary. The API tokenizes the input, extracts the last word, and matches it against stored terms across all partitions. Results are ranked by document frequency.
717
+ | Adapter | Import | Environment |
718
+ | --- | --- | --- |
719
+ | Memory | `@delali/narsil/adapters/memory` | The adapter works everywhere and is meant for tests. |
720
+ | Filesystem | `@delali/narsil/adapters/filesystem` | The adapter runs on Node.js, Bun, and Deno. |
721
+ | IndexedDB | `@delali/narsil/adapters/indexeddb` | The adapter runs in browsers. |
320
722
 
321
723
  ```ts
322
- const suggestions = await narsil.suggest('products', {
323
- prefix: 'mech',
324
- limit: 5,
724
+ import { createNarsil } from '@delali/narsil'
725
+ import { createFilesystemPersistence } from '@delali/narsil/adapters/filesystem'
726
+
727
+ const narsil = await createNarsil({
728
+ persistence: createFilesystemPersistence({ directory: './narsil-data' }),
729
+ flush: { interval: 5000, mutationThreshold: 100 },
325
730
  })
326
- // suggestions.terms => [{ term: 'mechan', documentFrequency: 12 }, ...]
327
731
  ```
328
732
 
329
- ### Cursor-based pagination
733
+ In the browser, `createIndexedDBPersistence({ dbName, storeName })` takes the same place, and both config fields are optional. Flushing is debounced: dirty partitions serialize on the `flush.interval` timer or after `flush.mutationThreshold` mutations, whichever fires first.
330
734
 
331
- Deep pagination using `searchAfter` cursors that track position across partitions. No offset-based performance degradation for deep result sets.
735
+ The serialization format is `.nrsl`, a 32-byte header followed by a MessagePack payload. The format is cross-language portable and specified in [`packages/spec`](../spec), so a Python or Rust implementation can read and write the same files.
332
736
 
333
- ### Results pinning
737
+ A custom backend satisfies the `PersistenceAdapter` interface: `save(key, data)`, `load(key)`, `delete(key)`, and `list(prefix)`, all returning promises.
334
738
 
335
- Inject specific documents at fixed positions in the result set, useful for sponsored or editorial content.
739
+ ## Durability
336
740
 
337
- ### Persistence
741
+ Debounced persistence can lose the writes made after the last flush. Durability closes that window with a write-ahead log: every mutation appends to the log before it is acknowledged, periodic checkpoints capture the index state, and recovery replays the log over the newest checkpoint. Enable it with a directory:
338
742
 
339
- Plug in any storage backend through the persistence adapter interface. Three built-in adapters ship with the package:
743
+ ```ts
744
+ const narsil = await createNarsil({
745
+ durability: {
746
+ directory: './narsil-data',
747
+ mode: 'sync',
748
+ },
749
+ })
750
+ ```
340
751
 
341
- | Adapter | Import | Environment |
752
+ `createNarsil` runs recovery before it resolves, so indexes, documents, and named embedding adapter bindings are back before the first call. `checkpoint(indexName)` forces a checkpoint outside the automatic schedule:
753
+
754
+ ```ts
755
+ await narsil.checkpoint('products')
756
+ ```
757
+
758
+ ### DurabilityConfig
759
+
760
+ | Field | Type | Default | Description |
761
+ | --- | --- | --- | --- |
762
+ | `directory` | `string` | none | Sets the root directory for the log and checkpoints. |
763
+ | `mode` | `'sync' \| 'async'` | `'sync'` | Selects the acknowledgement contract described below. |
764
+ | `flushIntervalMs` | `number` | `1000` | Sets how often the async mode flushes the log to disk. |
765
+ | `segmentMaxBytes` | `number` | `67108864` | Caps a log segment at this size (64 MiB) before rolling to a new one. |
766
+ | `checkpointIntervalMs` | `number` | `300000` | Sets the time between automatic checkpoints (5 minutes). |
767
+ | `checkpointMutationThreshold` | `number` | `100000` | Sets the mutation count that triggers a checkpoint early. |
768
+ | `compactionThreshold` | `number` | `12` | Sets the checkpoint segment count that triggers compaction. |
769
+
770
+ In `sync` mode a write is not acknowledged until it is on disk, so a crash never loses a write your caller saw succeed. In `async` mode writes acknowledge immediately while the log flushes on `flushIntervalMs`, which is faster but can lose the final interval on a hard crash. Durability failures surface through the `durabilityError` event; see [Events](#events).
771
+
772
+ ## Snapshots and restore
773
+
774
+ `snapshot(indexName)` serializes a whole index, including its documents, schema, and vector data, into one portable byte array. `restore(indexName, data)` rebuilds an index from those bytes, replacing the index if it already exists.
775
+
776
+ ```ts
777
+ import { readFile, writeFile } from 'node:fs/promises'
778
+
779
+ const bytes = await narsil.snapshot('products')
780
+ await writeFile('./products.nrsl', bytes)
781
+
782
+ const saved = await readFile('./products.nrsl')
783
+ await narsil.restore('products', new Uint8Array(saved))
784
+ ```
785
+
786
+ Snapshots use the same cross-language `.nrsl` envelope as persistence, so one engine's snapshot restores in another process, another machine, or another language implementation. Restoring bytes from an incompatible envelope version fails with `ENVELOPE_VERSION_MISMATCH`.
787
+
788
+ ## Partitions and rebalancing
789
+
790
+ An index starts with the partition count set by `partitions.maxPartitions`, which defaults to 1. Documents route to partitions by FNV-1a hash of their id, which keeps routing deterministic across processes and languages. `partitions.maxDocsPerPartition` caps capacity: when an index holds `maxDocsPerPartition * partitionCount` documents, further inserts fail with `PARTITION_CAPACITY_EXCEEDED`.
791
+
792
+ ```ts
793
+ await narsil.createIndex('logs', {
794
+ schema: { message: 'string' },
795
+ partitions: { maxPartitions: 4, maxDocsPerPartition: 250_000 },
796
+ })
797
+
798
+ await narsil.rebalance('logs', 8)
799
+
800
+ await narsil.updatePartitionConfig('logs', { maxDocsPerPartition: 500_000 })
801
+ ```
802
+
803
+ `rebalance(indexName, targetPartitionCount)` reshapes the index to a new partition count while it stays online. Writes arriving during the reshape buffer in a write-ahead queue and replay in order when the reshape completes, and queries keep answering throughout. `updatePartitionConfig` adjusts the caps at runtime; it rejects a new capacity below the current document count with `PARTITION_CAPACITY_EXCEEDED` and rejects changes while a rebalance is running with `PARTITION_REBALANCING_BACKPRESSURE`.
804
+
805
+ Two measured costs are worth knowing before raising partition counts:
806
+
807
+ **Single-process overhead.** Partitioning pays off when shards live on separate workers or hosts. Inside one Node.js thread, going from 1 to 20 partitions costs about 14% of insert throughput, 28% of median search latency, and 27% at p95, with no scaling upside. Keep `maxPartitions` low for single-process deployments and raise it once partitions fan out.
808
+
809
+ **Rebalance latency spikes.** While a reshape runs, worst-tick p95 latency can climb to about 25ms compared with around 11ms in steady state. Schedule reshapes during low-traffic windows, or pre-size the index with `maxDocsPerPartition` so mid-load reshapes never become necessary.
810
+
811
+ ## Workers
812
+
813
+ Search can move off the main thread through worker threads on Node.js and Bun, or Web Workers in browsers and Deno. With `workers.enabled: true`, the engine starts in direct mode and promotes itself to the worker pool once any index passes `promotionThreshold` documents or all indexes together pass `totalPromotionThreshold`. The API stays identical before and after promotion.
814
+
815
+ ```ts
816
+ const narsil = await createNarsil({
817
+ workers: {
818
+ enabled: true,
819
+ count: 4,
820
+ promotionThreshold: 10_000,
821
+ totalPromotionThreshold: 50_000,
822
+ },
823
+ })
824
+ ```
825
+
826
+ Promotion emits the `workerPromote` event, and a crashed worker emits `workerCrash`; see [Events](#events). Worker heap usage appears in `getMemoryStats()`.
827
+
828
+ ## Multi-instance invalidation
829
+
830
+ When several engine instances share one persistence backend, the invalidation adapter tells the others which partitions changed so they evict stale cache instead of serving old data. Two adapters ship with the package, and `@delali/narsil/invalidation/noop` stubs the interface for single-instance deployments:
831
+
832
+ | Adapter | Import | Use case |
342
833
  | --- | --- | --- |
343
- | Memory | `@delali/narsil/adapters/memory` | All |
344
- | Filesystem | `@delali/narsil/adapters/filesystem` | Node.js, Bun, Deno |
345
- | IndexedDB | `@delali/narsil/adapters/indexeddb` | Browser |
834
+ | Filesystem | `@delali/narsil/invalidation/filesystem` | The adapter coordinates processes on one machine through marker files. |
835
+ | BroadcastChannel | `@delali/narsil/invalidation/broadcast-channel` | The adapter coordinates browser tabs through a BroadcastChannel. |
836
+
837
+ ```ts
838
+ import { createNarsil } from '@delali/narsil'
839
+ import { createFilesystemPersistence } from '@delali/narsil/adapters/filesystem'
840
+ import { createFilesystemInvalidation } from '@delali/narsil/invalidation/filesystem'
841
+
842
+ const narsil = await createNarsil({
843
+ persistence: createFilesystemPersistence({ directory: './narsil-data' }),
844
+ invalidation: createFilesystemInvalidation({ directory: './narsil-data', pollInterval: 1000 }),
845
+ })
846
+ ```
847
+
848
+ The invalidation channel also carries partition statistics for the `broadcast` scoring mode; see [Scoring modes](#scoring-modes). A custom adapter satisfies the `InvalidationAdapter` interface: `publish(event)`, `subscribe(handler)`, and `shutdown()`.
849
+
850
+ ## Plugins
851
+
852
+ Plugins hook into the document and search lifecycle. A plugin is an object with a `name` and any of the optional hooks:
853
+
854
+ | Hook | Fires |
855
+ | --- | --- |
856
+ | `beforeInsert` / `afterInsert` | The hooks fire around every document insert, including each document of a batch. |
857
+ | `beforeUpdate` / `afterUpdate` | The hooks fire around every document update. |
858
+ | `beforeRemove` / `afterRemove` | The hooks fire around every document removal. |
859
+ | `beforeSearch` / `afterSearch` | The hooks fire around every query; `afterSearch` receives the results. |
860
+ | `onIndexCreate` / `onIndexDrop` | The hooks fire when an index is created or dropped. |
861
+
862
+ The interface also declares `onPartitionSplit` and `onWorkerPromote`, which are reserved for partition and worker lifecycle notifications; the engine does not fire them yet. Subscribe to the `partitionRebalance` and `workerPromote` [events](#events) for those signals today.
863
+
864
+ ```ts
865
+ import { createNarsil, type NarsilPlugin } from '@delali/narsil'
866
+
867
+ const auditLog: NarsilPlugin = {
868
+ name: 'audit-log',
869
+ async afterInsert(ctx) {
870
+ console.log(`indexed ${ctx.docId} into ${ctx.indexName}`)
871
+ },
872
+ async afterSearch(ctx) {
873
+ console.log(`query on ${ctx.indexName} returned ${ctx.results?.hits.length ?? 0} hits`)
874
+ },
875
+ }
876
+
877
+ const narsil = await createNarsil({ plugins: [auditLog] })
878
+ ```
879
+
880
+ Hooks can be async, and `before*` hooks run to completion before the operation applies, so a thrown error in `beforeInsert` rejects the insert. Errors thrown in `after*` hooks log a warning and never fail the operation that already succeeded.
881
+
882
+ ## Events
883
+
884
+ `on(event, handler)` subscribes to engine events and `off(event, handler)` unsubscribes. The payloads are typed through `NarsilEventMap`.
885
+
886
+ | Event | Payload | Meaning |
887
+ | --- | --- | --- |
888
+ | `persistenceError` | `{ indexName, partitionId, error, retriesExhausted }` | A partition flush failed; `retriesExhausted` reports whether the engine gave up. |
889
+ | `durabilityError` | `{ error }` | A write-ahead log or checkpoint operation failed. |
890
+ | `workerCrash` | `{ workerId, indexNames, error }` | A worker died; the engine reassigns its indexes. |
891
+ | `workerPromote` | `{ workerCount, reason }` | The engine moved search onto the worker pool. |
892
+ | `partitionRebalance` | `{ indexName, oldCount, newCount }` | A partition reshape completed. |
893
+
894
+ ```ts
895
+ narsil.on('persistenceError', payload => {
896
+ console.error(`flush failed for ${payload.indexName}:`, payload.error)
897
+ })
898
+ ```
899
+
900
+ Subscribe to `persistenceError` and `durabilityError` in any deployment that persists data; they are the engine's only channel for reporting background write failures.
901
+
902
+ ## Errors
903
+
904
+ Every failure throws a `NarsilError` carrying a stable string `code`, a human-readable `message`, and a `details` object with the values that produced the failure. The full set of codes is exported as `ErrorCodes`.
905
+
906
+ ```ts
907
+ import { ErrorCodes, NarsilError } from '@delali/narsil'
908
+
909
+ try {
910
+ await narsil.insert('products', { title: 42 })
911
+ } catch (err) {
912
+ if (err instanceof NarsilError && err.code === ErrorCodes.DOC_VALIDATION_FAILED) {
913
+ console.error(err.message, err.details)
914
+ }
915
+ }
916
+ ```
917
+
918
+ The codes you handle most often:
919
+
920
+ | Code | Thrown when |
921
+ | --- | --- |
922
+ | `INDEX_NOT_FOUND` / `INDEX_ALREADY_EXISTS` | An operation names an unknown index, or `createIndex` reuses a name. |
923
+ | `DOC_NOT_FOUND` / `DOC_ALREADY_EXISTS` | A read, update, or removal names an unknown id, or an insert reuses one. |
924
+ | `DOC_VALIDATION_FAILED` / `DOC_MISSING_REQUIRED_FIELD` | A document does not match the schema or omits a required field. |
925
+ | `SEARCH_INVALID_FIELD` / `SEARCH_INVALID_FILTER` / `SEARCH_INVALID_CURSOR` | A query names an unknown field, passes a malformed filter, or replays a bad cursor. |
926
+ | `VECTOR_DIMENSION_MISMATCH` | A vector's length differs from the field's declared dimension. |
927
+ | `EMBEDDING_FAILED` / `EMBEDDING_CONFIG_INVALID` | An adapter call failed, or the embedding configuration is contradictory. |
928
+ | `PARTITION_CAPACITY_EXCEEDED` / `PARTITION_REBALANCING_BACKPRESSURE` | An insert passes the capacity cap, or a config change collides with a running reshape. |
929
+ | `LANGUAGE_NOT_SUPPORTED` | An index config names a language module that was never imported. |
930
+ | `CONFIG_INVALID` | A configuration value is out of range or contradictory. |
931
+
932
+ ## Memory reporting
933
+
934
+ Narsil reports memory at three levels: per index, per partition, and per runtime. `getStats(indexName)` returns `estimatedMemoryBytes`, a formula-based estimate for the index's main-thread partitions and vector structures.
935
+
936
+ ```ts
937
+ const indexStats = narsil.getStats('products')
346
938
 
347
- Persistence uses debounced flushing: dirty partitions serialize on a timer or after a mutation count threshold, whichever fires first. The serialization format is `.nrsl`, a 32-byte header followed by a MessagePack payload. The format is cross-language portable, so a Python or Rust implementation can read and write the same files.
939
+ console.log(indexStats.estimatedMemoryBytes)
940
+ ```
348
941
 
349
- ### Multi-instance invalidation
942
+ The estimate comes from document counts, posting lists, field indexes, and vector indexes. It excludes V8 object headers, allocator overhead, and other host runtime costs, so use it for comparing indexes inside one process rather than for sizing host memory.
943
+
944
+ `getPartitionStats(indexName)` returns the same estimate for each partition. The partition values sum to `getStats(indexName).estimatedMemoryBytes`.
945
+
946
+ ```ts
947
+ const partitions = narsil.getPartitionStats('products')
350
948
 
351
- When multiple instances share the same persistence backend, the invalidation adapter coordinates cache eviction. A filesystem adapter uses marker files for multi-process deployments. A BroadcastChannel adapter handles cross-tab coordination in browsers.
949
+ for (const partition of partitions) {
950
+ console.log(partition.partitionId, partition.estimatedMemoryBytes)
951
+ }
952
+ ```
352
953
 
353
- ### Plugin system
954
+ `getMemoryStats()` returns a runtime snapshot and worker reports. It is async because workers report their heap usage through the worker message channel.
354
955
 
355
- Hook into the document and search lifecycle with plugins. Plugins can run before/after insert, remove, update, and search operations, and respond to index creation, partition splits, and worker promotions.
956
+ ```ts
957
+ const memory = await narsil.getMemoryStats()
356
958
 
357
- ### Schema validation
959
+ console.log(memory.estimatedIndexBytes)
960
+ console.log(memory.process?.heapUsed)
961
+ console.log(memory.workers)
962
+ ```
358
963
 
359
- Define typed schemas with support for `string`, `number`, `boolean`, `enum`, `geopoint`, `vector[N]`, and array variants (`string[]`, `number[]`, `boolean[]`, `enum[]`). Nested objects up to 4 levels deep. Documents are validated against the schema at insertion time.
964
+ `memory.estimatedIndexBytes` sums `estimatedMemoryBytes` across every index in this Narsil instance. `memory.process` comes from `process.memoryUsage()` when the runtime exposes it. It measures the whole host process, so two Narsil instances in one Node.js process report the same process numbers, and browser runtimes return `null`. `memory.workers` lists `heapUsed`, `heapTotal`, and `external` for each active worker, and the array is empty before worker promotion.
360
965
 
361
966
  ## Language support
362
967
 
@@ -382,38 +987,78 @@ import '@delali/narsil/languages/swahili'
382
987
  import '@delali/narsil/languages/twi'
383
988
  ```
384
989
 
385
- ## Search modes
990
+ Import the module before creating an index that names it; naming a language that was never imported fails with `LANGUAGE_NOT_SUPPORTED`. English is always available. `registerLanguage(module)` adds a language of your own: a module carries a name, a tokenizer, a stemmer, and a stop word set, and any of the shipped modules serves as a reference.
386
991
 
387
- | Mode | Method | Scoring |
388
- | --- | --- | --- |
389
- | Full-text | `mode: 'fulltext'` | BM25 with field boosting |
390
- | Vector | `mode: 'vector'` | Cosine similarity, dot product, or Euclidean distance |
391
- | Hybrid | `mode: 'hybrid'` | Weighted combination of BM25 + vector similarity |
992
+ ## HTTP server
392
993
 
393
- ## Search quality
994
+ `@delali/narsil/server` wraps an engine you build in a REST API. You own the engine and its configuration (durability, embedding adapters, workers), and the server shares it across requests. The HTTP layer runs on `uWebSockets.js`, an optional peer dependency:
394
995
 
395
- Ranking quality is measured against the [Cranfield Collection](https://ir-datasets.com/cranfield.html), the foundational information retrieval benchmark created in the 1960s. It contains 1,400 documents and 225 queries with exhaustive human relevance judgments, meaning every query-document pair was scored by domain experts. All engines in this comparison use identical stop words (Lucene English, 35 terms), Porter stemming, and default BM25 parameters.
996
+ ```bash
997
+ pnpm add -E uWebSockets.js@github:uNetworking/uWebSockets.js#v20.58.0
998
+ ```
396
999
 
397
- | Engine | nDCG@10 | P@10 | MAP | MRR |
398
- | --- | ---: | ---: | ---: | ---: |
399
- | **Narsil** | **0.3739** | **0.2458** | **0.2614** | **0.5638** |
400
- | Orama 3.1.18 | 0.2911 | 0.1836 | 0.1846 | 0.4821 |
401
- | MiniSearch 7.2.0 | 0.0077 | 0.0067 | 0.0027 | 0.0139 |
1000
+ ```ts
1001
+ import { createNarsil } from '@delali/narsil'
1002
+ import { createServer } from '@delali/narsil/server'
1003
+
1004
+ const engine = await createNarsil({ durability: { directory: './narsil-data' } })
1005
+
1006
+ const server = createServer(engine, {
1007
+ host: '127.0.0.1',
1008
+ port: 7700,
1009
+ })
1010
+
1011
+ await server.listen()
1012
+ ```
1013
+
1014
+ `ServerOptions` also accepts `cors`, an `onRequest` hook for authentication, `limits` for body-size and concurrency caps, `embeddingAdapters` that JSON index configs reference by name, a `taskStore` that keeps long-running task status across restarts, an `instanceId` for task recovery, and `allowInsecure` for trusted private networks. The server refuses to bind a non-loopback address without an `onRequest` hook, because the admin endpoints can destroy data.
1015
+
1016
+ The full surface:
1017
+
1018
+ | Method and path | Purpose |
1019
+ | --- | --- |
1020
+ | `GET /livez`, `GET /readyz`, `GET /health` | The probes report liveness and readiness without authentication. |
1021
+ | `GET /version` | The endpoint reports the build identity stamped at startup. |
1022
+ | `GET /stats/memory` | The endpoint returns `getMemoryStats()`. |
1023
+ | `POST /indexes`, `GET /indexes`, `DELETE /indexes/{name}` | The endpoints create, list, and drop indexes. |
1024
+ | `GET /indexes/{name}/stats`, `GET /indexes/{name}/partitions`, `GET /indexes/{name}/count` | The endpoints report index, partition, and document-count statistics. |
1025
+ | `POST /indexes/{name}/_clear` | The endpoint removes every document but keeps the index. |
1026
+ | `POST /indexes/{name}/documents` | The endpoint inserts one document. |
1027
+ | `GET`, `PUT`, `PATCH`, `DELETE /indexes/{name}/documents/{id}` | The endpoints read, upsert, update, and remove one document. |
1028
+ | `GET /indexes/{name}/documents/{id}/_exists` | The endpoint reports whether the id exists. |
1029
+ | `POST /indexes/{name}/documents/_batch` | The endpoint runs a batch insert, update, or delete with partial results. |
1030
+ | `POST /indexes/{name}/documents/_multi-get` | The endpoint fetches many documents by id. |
1031
+ | `POST /indexes/{name}/documents/_import` | The endpoint streams an NDJSON corpus in bounded batches. |
1032
+ | `POST /indexes/{name}/search`, `POST /indexes/{name}/search/preflight`, `POST /indexes/{name}/suggest` | The endpoints run queries, match counts, and autocomplete. |
1033
+ | `POST /indexes/{name}/_checkpoint`, `GET /indexes/{name}/snapshot`, `POST /indexes/{name}/restore` | The endpoints force a checkpoint, download a snapshot, and restore one. |
1034
+ | `GET /indexes/{name}/vector-maintenance`, `POST /indexes/{name}/vectors/_compact`, `POST /indexes/{name}/vectors/_optimize` | The endpoints report and run vector maintenance. |
1035
+ | `POST /indexes/{name}/_rebalance`, `POST /indexes/{name}/partition-config` | The endpoints reshape partitions and adjust partition caps. |
1036
+ | `GET /tasks`, `GET /tasks/{id}` | The endpoints report long-running task status. |
1037
+
1038
+ The [HTTP server example](examples/http-server/README.md) documents every endpoint with request and response bodies, curl walkthroughs, Docker packaging, and the environment-driven configuration of a production launcher.
1039
+
1040
+ ## Distribution
1041
+
1042
+ `@delali/narsil/distribution` holds the building blocks of Narsil's multi-node cluster mode: node roles, replication, coordinator adapters, and query routing. The distribution layer is under active development and highly experimental. It currently runs only in-process, its APIs change without notice, and it is not ready for production deployments. The design is specified in [`packages/spec/distribution`](../spec/distribution), and this section will grow into full documentation once the cluster mode is runnable.
1043
+
1044
+ ## Search quality
1045
+
1046
+ Ranking quality is measured against the [BEIR](https://github.com/beir-cellar/beir) SciFact corpus, 5,183 documents with 300 judged queries, where a human relevance judgment scores each query-document pair. Narsil runs in one process against Orama and MiniSearch, and every engine uses identical stop words (Lucene English, 35 terms), Porter stemming, and default BM25 parameters. Narsil takes the top nDCG@10 of the three.
402
1047
 
403
1048
  **What these metrics mean:**
404
1049
 
405
- - **nDCG@10** measures whether the most relevant documents appear near the top of the results. A score of 1.0 would mean perfect ranking; 0.0 means no relevant documents in the top 10.
406
- - **P@10** is the fraction of the top 10 results that are relevant. Narsil averages about 2.5 relevant results in every top-10 list.
407
- - **MAP** tracks precision at every rank where a relevant document appears. Higher MAP means relevant documents cluster near the top rather than being spread through the result set.
408
- - **MRR** measures how far a user scrolls to find the first relevant result. Narsil's 0.56 means the first relevant document typically appears at position 2.
1050
+ - **nDCG@10** measures whether the most relevant documents appear near the top of the results. A score of 1.0 means perfect ranking, and 0.0 means no relevant documents appear in the top 10.
1051
+ - **P@10** is the fraction of the top 10 results that are relevant.
1052
+ - **MAP** tracks precision at every rank where a relevant document appears. A higher MAP means relevant documents cluster near the top of the ranking.
1053
+ - **MRR** measures how soon the first relevant result appears. A higher value means the first relevant document sits closer to the top.
409
1054
 
410
- These scores fall within the expected range for BM25 on standard IR benchmarks (published BM25 baselines on BEIR datasets range from 0.24 to 0.63 nDCG@10). Narsil also runs a [Cranfield regression test](src/__tests__/relevance/cranfield.test.ts) in CI that fails the build if ranking quality drops below calibrated thresholds.
1055
+ A separate [SciFact regression test](src/__tests__/relevance/scifact.test.ts) runs in CI on the same corpus and fails the build if ranking quality drops below calibrated thresholds.
411
1056
 
412
- Reproduce: `pnpm -C packages/benchmarks bench --tiers quality`
1057
+ Reproduce these scores with `pnpm --filter benchmarks bench -- --tiers relevance`. The full quality, throughput, latency, and memory tables for all three engines are in [BENCHMARKS.md](https://github.com/assetcorp/narsil/blob/main/BENCHMARKS.md).
413
1058
 
414
1059
  Narsil also runs as a search server. On the BEIR information-retrieval datasets it is measured against Elasticsearch, OpenSearch, Qdrant, Weaviate, Typesense, and Meilisearch across keyword, vector, and hybrid retrieval. See [the full benchmarks](https://github.com/assetcorp/narsil/blob/main/BENCHMARKS.md) for those results.
415
1060
 
416
- ## Runtime support (TypeScript)
1061
+ ## Runtime support
417
1062
 
418
1063
  | Runtime | Concurrency | Persistence | Invalidation |
419
1064
  | --- | --- | --- | --- |
@@ -422,6 +1067,8 @@ Narsil also runs as a search server. On the BEIR information-retrieval datasets
422
1067
  | Deno | Web Workers | Filesystem | BroadcastChannel |
423
1068
  | Browser | Web Workers | IndexedDB | BroadcastChannel |
424
1069
 
1070
+ The [browser example](examples/browser/README.md) shows an embedded engine with IndexedDB persistence, and the [server app example](examples/server-app/README.md) shows a full search UI backed by the HTTP server.
1071
+
425
1072
  ## License
426
1073
 
427
1074
  Apache-2.0