@delali/narsil 0.1.7 → 0.1.9
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 +800 -111
- package/dist/adapters/filesystem.d.ts +1 -1
- package/dist/adapters/filesystem.mjs +46 -2
- package/dist/adapters/indexeddb.d.ts +1 -1
- package/dist/adapters/indexeddb.mjs +1 -1
- package/dist/adapters/memory.d.ts +1 -1
- package/dist/{adapters-BbRkoiu9.d.ts → adapters-Bo2JQg8b.d.ts} +2 -1
- package/dist/chunk-ALB35PRQ.mjs +98 -0
- package/dist/chunk-KMMJMDUA.mjs +44 -0
- package/dist/chunk-RQLXC2YS.mjs +269 -0
- package/dist/chunk-S7RJJMHI.mjs +1086 -0
- package/dist/chunk-SVDEEMO6.mjs +7023 -0
- package/dist/{chunk-I24SGLFS.mjs → chunk-TW6BXTOL.mjs} +5 -1
- package/dist/{chunk-KHL4RPC3.mjs → chunk-VWF7RKSF.mjs} +2566 -2282
- package/dist/chunk-WISGW24C.mjs +455 -0
- package/dist/config-DX--2Owg.d.ts +472 -0
- package/dist/direct-executor-KC5O5D2R.mjs +4 -0
- package/dist/distribution/coordinator/etcd.d.ts +16 -0
- package/dist/distribution/coordinator/etcd.mjs +2 -0
- package/dist/distribution/coordinator/in-memory.d.ts +7 -0
- package/dist/distribution/coordinator/in-memory.mjs +1 -0
- package/dist/distribution.d.ts +122 -0
- package/dist/distribution.mjs +7410 -0
- package/dist/embeddings/openai.d.ts +1 -1
- package/dist/embeddings/openai.mjs +6 -1
- package/dist/index.browser.mjs +11750 -7532
- package/dist/index.d.ts +6 -450
- package/dist/index.mjs +7 -3236
- package/dist/invalidation/broadcast-channel.d.ts +1 -1
- package/dist/invalidation/filesystem.d.ts +1 -1
- package/dist/invalidation/noop.d.ts +1 -1
- package/dist/{language-ClUGLuoC.d.ts → language-ONQ3iVDf.d.ts} +1 -0
- package/dist/languages/arabic.d.ts +1 -1
- package/dist/languages/armenian.d.ts +1 -1
- package/dist/languages/bulgarian.d.ts +1 -1
- package/dist/languages/chinese.d.ts +1 -1
- package/dist/languages/dagbani.d.ts +1 -1
- package/dist/languages/danish.d.ts +1 -1
- package/dist/languages/dutch.d.ts +1 -1
- package/dist/languages/english.d.ts +1 -1
- package/dist/languages/english.mjs +1 -1
- package/dist/languages/ewe.d.ts +1 -1
- package/dist/languages/finnish.d.ts +1 -1
- package/dist/languages/french.d.ts +1 -1
- package/dist/languages/ga.d.ts +1 -1
- package/dist/languages/german.d.ts +1 -1
- package/dist/languages/greek.d.ts +1 -1
- package/dist/languages/hausa.d.ts +1 -1
- package/dist/languages/hindi.d.ts +1 -1
- package/dist/languages/hungarian.d.ts +1 -1
- package/dist/languages/igbo.d.ts +1 -1
- package/dist/languages/indonesian.d.ts +1 -1
- package/dist/languages/irish.d.ts +1 -1
- package/dist/languages/italian.d.ts +1 -1
- package/dist/languages/japanese.d.ts +1 -1
- package/dist/languages/nepali.d.ts +1 -1
- package/dist/languages/norwegian.d.ts +1 -1
- package/dist/languages/portuguese.d.ts +1 -1
- package/dist/languages/romanian.d.ts +1 -1
- package/dist/languages/russian.d.ts +1 -1
- package/dist/languages/sanskrit.d.ts +1 -1
- package/dist/languages/serbian.d.ts +1 -1
- package/dist/languages/slovenian.d.ts +1 -1
- package/dist/languages/spanish.d.ts +1 -1
- package/dist/languages/swahili.d.ts +1 -1
- package/dist/languages/swedish.d.ts +1 -1
- package/dist/languages/tamil.d.ts +1 -1
- package/dist/languages/turkish.d.ts +1 -1
- package/dist/languages/twi.d.ts +1 -1
- package/dist/languages/ukrainian.d.ts +1 -1
- package/dist/languages/yoruba.d.ts +1 -1
- package/dist/languages/zulu.d.ts +1 -1
- package/dist/narsil-B3jKyXCr.d.ts +72 -0
- package/dist/persistence/durability/checkpoint-worker.d.ts +48 -0
- package/dist/persistence/durability/checkpoint-worker.mjs +59 -0
- package/dist/schema-C3VhNvfw.d.ts +60 -0
- package/dist/serialization/crc32-worker.d.ts +20 -0
- package/dist/serialization/crc32-worker.mjs +61 -0
- package/dist/server.d.ts +218 -0
- package/dist/server.mjs +1184 -0
- package/dist/types-CTVVm4GG.d.ts +73 -0
- package/dist/vector/hnsw-build-worker.mjs +1 -1
- package/dist/workers/entry.mjs +1 -1
- package/package.json +38 -2
- package/dist/chunk-R2C2EQGO.mjs +0 -53
- package/dist/chunk-VVORJOXR.mjs +0 -759
- package/dist/direct-executor-LBJLGMJ2.mjs +0 -4
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
1
3
|
# Narsil
|
|
2
4
|
|
|
3
5
|
[](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.
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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:
|
|
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` |
|
|
81
|
-
| `invalidation` | `InvalidationAdapter` |
|
|
82
|
-
| `plugins` | `NarsilPlugin[]` |
|
|
83
|
-
| `idGenerator` | `() => string` |
|
|
84
|
-
| `workers` | `WorkerConfig` |
|
|
85
|
-
| `flush` | `FlushConfig` | Controls when dirty partitions persist to storage |
|
|
86
|
-
| `
|
|
87
|
-
| `
|
|
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` |
|
|
94
|
-
| `count` | `number` | CPU
|
|
95
|
-
| `promotionThreshold` | `number` |
|
|
96
|
-
| `totalPromotionThreshold` | `number` |
|
|
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 |
|
|
101
|
-
| --- | --- | --- |
|
|
102
|
-
| `interval` | `number` |
|
|
103
|
-
| `mutationThreshold` | `number` |
|
|
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
|
|
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
|
|
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,70 +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
|
|
160
|
+
Four functions manage the cache:
|
|
122
161
|
|
|
123
|
-
- `configureNormalizationCache(maxSize)`
|
|
124
|
-
- `clearNormalizationCache()`
|
|
125
|
-
- `resetNormalizationCache()`
|
|
126
|
-
- `getNormalizationCacheSize()`
|
|
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
|
-
##
|
|
167
|
+
## Indexes
|
|
129
168
|
|
|
130
|
-
|
|
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.
|
|
170
|
+
|
|
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
|
+
```
|
|
186
|
+
|
|
187
|
+
### IndexConfig
|
|
188
|
+
|
|
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' }]
|
|
209
|
+
|
|
210
|
+
const stats = narsil.getStats('articles')
|
|
211
|
+
// { documentCount, partitionCount, estimatedMemoryBytes, language, schema }
|
|
212
|
+
|
|
213
|
+
await narsil.clear('articles')
|
|
214
|
+
|
|
215
|
+
await narsil.dropIndex('articles')
|
|
216
|
+
```
|
|
217
|
+
|
|
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.
|
|
219
|
+
|
|
220
|
+
## Documents
|
|
221
|
+
|
|
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.
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
const generatedId = await narsil.insert('products', { title: 'Trackball Mouse' })
|
|
228
|
+
|
|
229
|
+
const explicitId = await narsil.insert('products', { title: 'Split Keyboard' }, 'kb-042')
|
|
230
|
+
|
|
231
|
+
await narsil.insert('products', { id: 'kb-043', title: 'Tenkeyless Keyboard' })
|
|
232
|
+
```
|
|
233
|
+
|
|
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.
|
|
235
|
+
|
|
236
|
+
### Read
|
|
131
237
|
|
|
132
|
-
|
|
238
|
+
```ts
|
|
239
|
+
const doc = await narsil.get('products', 'kb-042')
|
|
240
|
+
// the document, or undefined when the id is unknown
|
|
241
|
+
|
|
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')
|
|
248
|
+
```
|
|
249
|
+
|
|
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.
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
await narsil.update('products', 'kb-042', { title: 'Split Ergonomic Keyboard' })
|
|
256
|
+
|
|
257
|
+
await narsil.remove('products', 'kb-042')
|
|
258
|
+
```
|
|
133
259
|
|
|
134
|
-
|
|
260
|
+
Both methods throw `DOC_NOT_FOUND` for an unknown id.
|
|
135
261
|
|
|
136
|
-
|
|
262
|
+
## Batch operations
|
|
137
263
|
|
|
138
|
-
|
|
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.
|
|
139
265
|
|
|
140
|
-
|
|
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
|
+
])
|
|
141
279
|
|
|
142
|
-
|
|
280
|
+
await narsil.removeBatch('products', ['p1', 'p2'])
|
|
281
|
+
```
|
|
143
282
|
|
|
144
|
-
|
|
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.
|
|
145
284
|
|
|
146
|
-
|
|
147
|
-
- **DFS (Distributed Frequency Statistics)**: a two-phase query collects global term statistics first, then scores with unified IDF values
|
|
148
|
-
- **Broadcast**: instances share statistics through the invalidation adapter for pre-computed global scoring
|
|
285
|
+
## Search
|
|
149
286
|
|
|
150
|
-
|
|
287
|
+
`query(indexName, params)` runs every search. The `mode` parameter selects `'fulltext'` (the default), `'vector'`, or `'hybrid'`.
|
|
151
288
|
|
|
152
|
-
|
|
289
|
+
### Full-text search
|
|
153
290
|
|
|
154
|
-
|
|
291
|
+
Full-text search scores with BM25. `fields` restricts the search to specific fields, and `boost` multiplies per-field scores.
|
|
155
292
|
|
|
156
|
-
|
|
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
|
+
```
|
|
157
302
|
|
|
158
|
-
|
|
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.
|
|
159
304
|
|
|
160
|
-
|
|
305
|
+
### Fuzzy matching
|
|
306
|
+
|
|
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.
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
const results = await narsil.query('products', {
|
|
311
|
+
term: 'keybaord',
|
|
312
|
+
tolerance: 2,
|
|
313
|
+
prefixLength: 3,
|
|
314
|
+
})
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### Score and coverage thresholds
|
|
318
|
+
|
|
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
|
+
```
|
|
161
328
|
|
|
162
329
|
### Filters
|
|
163
330
|
|
|
164
|
-
Filter on any indexed field
|
|
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`.
|
|
165
332
|
|
|
166
333
|
```ts
|
|
167
334
|
const results = await narsil.query('products', {
|
|
168
335
|
term: 'wireless',
|
|
169
336
|
filters: {
|
|
170
337
|
or: [
|
|
171
|
-
{ category: { eq: 'electronics' } },
|
|
172
|
-
{ category: { eq: 'accessories' } },
|
|
338
|
+
{ fields: { category: { eq: 'electronics' } } },
|
|
339
|
+
{ fields: { category: { eq: 'accessories' } } },
|
|
173
340
|
],
|
|
174
|
-
|
|
175
|
-
|
|
341
|
+
fields: {
|
|
342
|
+
price: { between: [10, 100] },
|
|
343
|
+
tags: { containsAll: ['bluetooth'] },
|
|
344
|
+
},
|
|
176
345
|
},
|
|
177
346
|
})
|
|
178
347
|
```
|
|
179
348
|
|
|
180
|
-
|
|
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
|
|
181
352
|
|
|
182
|
-
|
|
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`.
|
|
183
354
|
|
|
184
355
|
```ts
|
|
185
356
|
const results = await narsil.query('products', {
|
|
@@ -189,16 +360,51 @@ const results = await narsil.query('products', {
|
|
|
189
360
|
price: { ranges: [{ from: 0, to: 500 }, { from: 500, to: 1000 }, { from: 1000, to: 5000 }] },
|
|
190
361
|
},
|
|
191
362
|
})
|
|
192
|
-
|
|
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
|
+
})
|
|
193
376
|
```
|
|
194
377
|
|
|
195
378
|
### Grouping
|
|
196
379
|
|
|
197
|
-
|
|
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
|
+
```
|
|
198
404
|
|
|
199
|
-
###
|
|
405
|
+
### Highlighting
|
|
200
406
|
|
|
201
|
-
|
|
407
|
+
`highlight` returns snippets with tags marking where query terms appear. Highlighting needs `trackPositions` left at its default of `true`.
|
|
202
408
|
|
|
203
409
|
```ts
|
|
204
410
|
const results = await narsil.query('products', {
|
|
@@ -207,14 +413,208 @@ const results = await narsil.query('products', {
|
|
|
207
413
|
fields: ['title', 'description'],
|
|
208
414
|
preTag: '<mark>',
|
|
209
415
|
postTag: '</mark>',
|
|
416
|
+
maxSnippetLength: 160,
|
|
210
417
|
},
|
|
211
418
|
})
|
|
212
|
-
|
|
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' })
|
|
213
475
|
```
|
|
214
476
|
|
|
215
|
-
###
|
|
477
|
+
### Suggestions
|
|
216
478
|
|
|
217
|
-
|
|
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
|
+
},
|
|
609
|
+
},
|
|
610
|
+
})
|
|
611
|
+
```
|
|
612
|
+
|
|
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.
|
|
614
|
+
|
|
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.
|
|
218
618
|
|
|
219
619
|
```ts
|
|
220
620
|
import { createNarsil } from '@delali/narsil'
|
|
@@ -222,7 +622,8 @@ import { createOpenAIEmbedding } from '@delali/narsil/embeddings/openai'
|
|
|
222
622
|
|
|
223
623
|
const narsil = await createNarsil({
|
|
224
624
|
embedding: createOpenAIEmbedding({
|
|
225
|
-
|
|
625
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
626
|
+
apiKey: process.env.OPENAI_API_KEY ?? '',
|
|
226
627
|
model: 'text-embedding-3-small',
|
|
227
628
|
dimensions: 1536,
|
|
228
629
|
}),
|
|
@@ -252,16 +653,51 @@ const results = await narsil.query('articles', {
|
|
|
252
653
|
})
|
|
253
654
|
```
|
|
254
655
|
|
|
255
|
-
|
|
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
|
|
256
690
|
|
|
257
691
|
| Adapter | Package | Dependencies |
|
|
258
692
|
| --- | --- | --- |
|
|
259
|
-
| OpenAI | `@delali/narsil/embeddings/openai` |
|
|
260
|
-
| Transformers.js | `@delali/narsil-embeddings-transformers` | `@huggingface/transformers`
|
|
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. |
|
|
261
695
|
|
|
262
|
-
The OpenAI adapter
|
|
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.
|
|
263
697
|
|
|
264
|
-
|
|
698
|
+
### Custom adapters
|
|
699
|
+
|
|
700
|
+
Build an adapter by satisfying the `EmbeddingAdapter` interface:
|
|
265
701
|
|
|
266
702
|
```ts
|
|
267
703
|
interface EmbeddingAdapter {
|
|
@@ -272,51 +708,260 @@ interface EmbeddingAdapter {
|
|
|
272
708
|
}
|
|
273
709
|
```
|
|
274
710
|
|
|
275
|
-
|
|
711
|
+
When `embedBatch` is missing, Narsil falls back to parallel `embed()` calls with a concurrency limit of 8.
|
|
712
|
+
|
|
713
|
+
## Persistence
|
|
276
714
|
|
|
277
|
-
|
|
715
|
+
Persistence stores serialized partitions through a pluggable adapter. Three adapters ship with the package:
|
|
278
716
|
|
|
279
|
-
|
|
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. |
|
|
280
722
|
|
|
281
723
|
```ts
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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 },
|
|
285
730
|
})
|
|
286
|
-
// suggestions.terms => [{ term: 'mechan', documentFrequency: 12 }, ...]
|
|
287
731
|
```
|
|
288
732
|
|
|
289
|
-
|
|
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.
|
|
290
734
|
|
|
291
|
-
|
|
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.
|
|
292
736
|
|
|
293
|
-
|
|
737
|
+
A custom backend satisfies the `PersistenceAdapter` interface: `save(key, data)`, `load(key)`, `delete(key)`, and `list(prefix)`, all returning promises.
|
|
294
738
|
|
|
295
|
-
|
|
739
|
+
## Durability
|
|
296
740
|
|
|
297
|
-
|
|
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:
|
|
298
742
|
|
|
299
|
-
|
|
743
|
+
```ts
|
|
744
|
+
const narsil = await createNarsil({
|
|
745
|
+
durability: {
|
|
746
|
+
directory: './narsil-data',
|
|
747
|
+
mode: 'sync',
|
|
748
|
+
},
|
|
749
|
+
})
|
|
750
|
+
```
|
|
300
751
|
|
|
301
|
-
|
|
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 |
|
|
302
833
|
| --- | --- | --- |
|
|
303
|
-
|
|
|
304
|
-
|
|
|
305
|
-
| 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. |
|
|
306
836
|
|
|
307
|
-
|
|
837
|
+
```ts
|
|
838
|
+
import { createNarsil } from '@delali/narsil'
|
|
839
|
+
import { createFilesystemPersistence } from '@delali/narsil/adapters/filesystem'
|
|
840
|
+
import { createFilesystemInvalidation } from '@delali/narsil/invalidation/filesystem'
|
|
308
841
|
|
|
309
|
-
|
|
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
|
|
310
851
|
|
|
311
|
-
|
|
852
|
+
Plugins hook into the document and search lifecycle. A plugin is an object with a `name` and any of the optional hooks:
|
|
312
853
|
|
|
313
|
-
|
|
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. |
|
|
314
861
|
|
|
315
|
-
|
|
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'
|
|
316
866
|
|
|
317
|
-
|
|
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
|
+
```
|
|
318
917
|
|
|
319
|
-
|
|
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')
|
|
938
|
+
|
|
939
|
+
console.log(indexStats.estimatedMemoryBytes)
|
|
940
|
+
```
|
|
941
|
+
|
|
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')
|
|
948
|
+
|
|
949
|
+
for (const partition of partitions) {
|
|
950
|
+
console.log(partition.partitionId, partition.estimatedMemoryBytes)
|
|
951
|
+
}
|
|
952
|
+
```
|
|
953
|
+
|
|
954
|
+
`getMemoryStats()` returns a runtime snapshot and worker reports. It is async because workers report their heap usage through the worker message channel.
|
|
955
|
+
|
|
956
|
+
```ts
|
|
957
|
+
const memory = await narsil.getMemoryStats()
|
|
958
|
+
|
|
959
|
+
console.log(memory.estimatedIndexBytes)
|
|
960
|
+
console.log(memory.process?.heapUsed)
|
|
961
|
+
console.log(memory.workers)
|
|
962
|
+
```
|
|
963
|
+
|
|
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.
|
|
320
965
|
|
|
321
966
|
## Language support
|
|
322
967
|
|
|
@@ -342,36 +987,78 @@ import '@delali/narsil/languages/swahili'
|
|
|
342
987
|
import '@delali/narsil/languages/twi'
|
|
343
988
|
```
|
|
344
989
|
|
|
345
|
-
|
|
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.
|
|
346
991
|
|
|
347
|
-
|
|
348
|
-
| --- | --- | --- |
|
|
349
|
-
| Full-text | `mode: 'fulltext'` | BM25 with field boosting |
|
|
350
|
-
| Vector | `mode: 'vector'` | Cosine similarity, dot product, or Euclidean distance |
|
|
351
|
-
| Hybrid | `mode: 'hybrid'` | Weighted combination of BM25 + vector similarity |
|
|
992
|
+
## HTTP server
|
|
352
993
|
|
|
353
|
-
|
|
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:
|
|
995
|
+
|
|
996
|
+
```bash
|
|
997
|
+
pnpm add -E uWebSockets.js@github:uNetworking/uWebSockets.js#v20.58.0
|
|
998
|
+
```
|
|
354
999
|
|
|
355
|
-
|
|
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
|
+
```
|
|
356
1013
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
|
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.
|
|
362
1047
|
|
|
363
1048
|
**What these metrics mean:**
|
|
364
1049
|
|
|
365
|
-
- **nDCG@10** measures whether the most relevant documents appear near the top of the results. A score of 1.0
|
|
366
|
-
- **P@10** is the fraction of the top 10 results that are relevant.
|
|
367
|
-
- **MAP** tracks precision at every rank where a relevant document appears.
|
|
368
|
-
- **MRR** measures how
|
|
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.
|
|
369
1054
|
|
|
370
|
-
|
|
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.
|
|
371
1056
|
|
|
372
|
-
Reproduce
|
|
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).
|
|
373
1058
|
|
|
374
|
-
|
|
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.
|
|
1060
|
+
|
|
1061
|
+
## Runtime support
|
|
375
1062
|
|
|
376
1063
|
| Runtime | Concurrency | Persistence | Invalidation |
|
|
377
1064
|
| --- | --- | --- | --- |
|
|
@@ -380,6 +1067,8 @@ Reproduce: `pnpm -C packages/benchmarks bench --tiers quality`
|
|
|
380
1067
|
| Deno | Web Workers | Filesystem | BroadcastChannel |
|
|
381
1068
|
| Browser | Web Workers | IndexedDB | BroadcastChannel |
|
|
382
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
|
+
|
|
383
1072
|
## License
|
|
384
1073
|
|
|
385
1074
|
Apache-2.0
|