@delali/narsil 0.2.4-next.51 → 0.2.4-next.54
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 +33 -27
- package/dist/adapters/filesystem.mjs +1 -1
- package/dist/adapters/indexeddb.mjs +1 -1
- package/dist/{capabilities-BWm4IITU.d.ts → capabilities-DlYw0Ov0.d.ts} +13 -2
- package/dist/{chunk-KMMJMDUA.mjs → chunk-3L4U6EJQ.mjs} +15 -10
- package/dist/chunk-4NVPQ6S5.mjs +366 -0
- package/dist/{chunk-CMVG2UEF.mjs → chunk-5T6AOR37.mjs} +56 -518
- package/dist/{chunk-ND34I3SV.mjs → chunk-BH5SYB5W.mjs} +3295 -2149
- package/dist/{chunk-XE6XR2OE.mjs → chunk-D2S642IV.mjs} +4 -2
- package/dist/{chunk-P4ELXVJQ.mjs → chunk-FRQVMFY7.mjs} +7 -1
- package/dist/chunk-IFBKLBGB.mjs +7 -0
- package/dist/chunk-L225SHNB.mjs +513 -0
- package/dist/{chunk-MHE7YWAU.mjs → chunk-NTZYA3SJ.mjs} +3 -3
- package/dist/{chunk-WYJXSLER.mjs → chunk-POWOTUKJ.mjs} +7 -1
- package/dist/{chunk-CV5H7XL7.mjs → chunk-RGS7FMCB.mjs} +53 -10
- package/dist/{chunk-ZHUQKZBE.mjs → chunk-TVT5V4E7.mjs} +101 -34
- package/dist/chunk-U2AL3SON.mjs +4198 -0
- package/dist/chunk-UTOFOJOR.mjs +135 -0
- package/dist/{chunk-BEIK3EOZ.mjs → chunk-XNQAIO74.mjs} +5523 -3847
- package/dist/chunk-Y3L5MDRV.mjs +96 -0
- package/dist/chunk-ZJ4CSAB2.mjs +13 -0
- package/dist/{client-B_X6_AbT.d.ts → client-CQUqt9Ir.d.ts} +13 -4
- package/dist/client.d.ts +8 -8
- package/dist/client.mjs +20 -14
- package/dist/{config-Czh2YNDi.d.ts → config-CNas6wJH.d.ts} +2 -2
- package/dist/{config-OON7IJ_B.d.ts → config-bXxnJrQc.d.ts} +80 -35
- package/dist/direct-executor-RBA5O7EN.mjs +12 -0
- package/dist/distribution/coordinator/etcd.d.ts +2 -2
- package/dist/distribution/coordinator/etcd.mjs +1 -1
- package/dist/distribution/coordinator/in-memory.d.ts +2 -2
- package/dist/distribution/transport/grpc.mjs +1 -1
- package/dist/distribution.d.ts +8 -8
- package/dist/distribution.mjs +32 -17
- package/dist/embeddings/openai.mjs +1 -1
- package/dist/{engine-9PtFY50q.d.ts → engine-Dj4I91Jv.d.ts} +21 -8
- package/dist/index.browser.mjs +12962 -7786
- package/dist/index.d.ts +9 -21
- package/dist/index.mjs +14 -13
- package/dist/{memory-Bzd5xcEW.d.ts → memory-xrXOZMbw.d.ts} +68 -13
- package/dist/persistence/durability/checkpoint-worker.d.ts +138 -3
- package/dist/persistence/durability/checkpoint-worker.mjs +32 -18
- package/dist/react.d.ts +11 -11
- package/dist/react.mjs +16 -4
- package/dist/{schema-BqRx9t4E.d.ts → schema-mLpXZBEh.d.ts} +43 -12
- package/dist/serialization/crc32-worker.mjs +2 -1
- package/dist/server.d.ts +13 -10
- package/dist/server.mjs +136 -47
- package/dist/simd-DW5AtJ6B.d.ts +41 -0
- package/dist/{tasks-DT7L1p6w.d.ts → tasks-Df73Tjgq.d.ts} +9 -5
- package/dist/{thread-PZLEO6JL.mjs → thread-5UP6MGED.mjs} +131 -43
- package/dist/{types-DXPxaXPO.d.ts → types-BTIVO8DI.d.ts} +1 -1
- package/dist/vector/search-worker.d.ts +292 -128
- package/dist/vector/search-worker.mjs +70 -54
- package/dist/workers/entry.mjs +30 -14
- package/package.json +52 -31
- package/dist/brute-force-DbHYCTc3.d.ts +0 -8
- package/dist/chunk-CBLS46IE.mjs +0 -10
- package/dist/chunk-PIWKZPP7.mjs +0 -64
- package/dist/chunk-PMBHH44R.mjs +0 -492
- package/dist/chunk-RP23PWMV.mjs +0 -11
- package/dist/chunk-X6YZO4MQ.mjs +0 -1572
- package/dist/direct-executor-OREXU72C.mjs +0 -12
- package/dist/vector/hnsw-build-worker.d.ts +0 -105
- package/dist/vector/hnsw-build-worker.mjs +0 -123
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-

|
|
2
2
|
|
|
3
3
|
# Narsil
|
|
4
4
|
|
|
@@ -8,15 +8,19 @@
|
|
|
8
8
|
[](https://www.npmjs.com/package/@delali/narsil)
|
|
9
9
|
[](https://github.com/assetcorp/narsil/blob/main/LICENSE)
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
The search engine that scales with your data.
|
|
12
12
|
|
|
13
|
-
Narsil is
|
|
13
|
+
Narsil is an open source search engine with full-text, vector, hybrid, and geosearch on one typed index. You can embed Narsil in your application process, where the engine searches without a network hop, or deploy it as a standalone search server with a REST API, a write-ahead log, and bulk NDJSON ingest. Both forms contain the same engine, which stores indexes in one cross-language binary format (`.nrsl`), so you can load an index from one form into the other.
|
|
14
14
|
|
|
15
|
-
The engine partitions large indexes across workers and merges
|
|
15
|
+
The engine partitions large indexes across workers and merges the results of the partitions into a single ranked list. This TypeScript package is the reference implementation of the `.nrsl` format, so a second implementation in Go or Rust is the headline item on the [roadmap](https://github.com/assetcorp/narsil/blob/main/ROADMAP.md).
|
|
16
|
+
|
|
17
|
+
<!-- BENCH:headline START -->
|
|
18
|
+
On BEIR SciFact, Narsil's BM25 ranking scores 0.681 nDCG@10, which is within 0.002 of the Anserini reference of 0.679. Elasticsearch scores 0.679 and OpenSearch scores 0.679 on the same queries. On SciFact, Narsil answers 958 keyword queries a second at its peak, and OpenSearch follows with 878. On SciFact with every engine held at 0.99 recall, Narsil answers 259 vector queries a second at its peak, while OpenSearch leads with 730. Narsil has the highest peak throughput in 2 of the 6 keyword, vector, and hybrid comparisons in the newest recorded run, so read each figure beside its table. The harness recorded these figures in run `20260804T184221Z` on 2026-08-04, on GCP c3-standard-8, us-central1-a. You'll find every dataset, every engine's settings, and the full method in [`BENCHMARKS.md`](https://github.com/assetcorp/narsil/blob/main/BENCHMARKS.md).
|
|
19
|
+
<!-- BENCH:headline END -->
|
|
16
20
|
|
|
17
21
|
Try it in your browser at [narsil.sondelali.com/demo](https://narsil.sondelali.com/demo). Read the full documentation at [narsil.sondelali.com/docs](https://narsil.sondelali.com/docs).
|
|
18
22
|
|
|
19
|
-
> *narsil* is the sword of Elendil in Tolkien's Lord of the Rings, shattered into shards and later reforged. The name
|
|
23
|
+
> *narsil* is the sword of Elendil in Tolkien's Lord of the Rings, shattered into shards and later reforged. The name fits the design, because the engine splits your data into partitions that it persists one by one, then merges their results into one ranked list for each query.
|
|
20
24
|
|
|
21
25
|
## Contents
|
|
22
26
|
|
|
@@ -36,7 +40,9 @@ Try it in your browser at [narsil.sondelali.com/demo](https://narsil.sondelali.c
|
|
|
36
40
|
pnpm add @delali/narsil
|
|
37
41
|
```
|
|
38
42
|
|
|
39
|
-
Narsil
|
|
43
|
+
Narsil works in Node.js 22 or newer, and in Bun, Deno, and browsers. The [Runtime support](#runtime-support) table covers each runtime.
|
|
44
|
+
|
|
45
|
+
On an arm64 or x64 machine under macOS, Linux, or Windows, the package manager also installs Narsil's native search core as an optional dependency, and the engine searches vector graphs through it. If you omit optional dependencies, Narsil searches those graphs through WebAssembly, with the same results, as it always does in a browser.
|
|
40
46
|
|
|
41
47
|
## Quick start
|
|
42
48
|
|
|
@@ -79,21 +85,21 @@ const results = await narsil.query('products', {
|
|
|
79
85
|
})
|
|
80
86
|
```
|
|
81
87
|
|
|
82
|
-
Every hit holds the document, its id, and its BM25 score. `results.count`
|
|
88
|
+
Every hit holds the document, its id, and its BM25 score. Beside the hits, `results.count` holds the total number of matching documents, `results.elapsed` holds the query time in milliseconds, and `results.coverage` counts the partitions that the search covered, so you can tell when a cluster returns results from part of its data.
|
|
83
89
|
|
|
84
90
|
## Features
|
|
85
91
|
|
|
86
|
-
**Search.** [
|
|
92
|
+
**Search.** For [full-text search](https://github.com/assetcorp/narsil/blob/main/docs/full-text-search.md), the engine scores with BM25 and supports field boosting, fuzzy matching through bounded Levenshtein distance, search as you type through last-word prefix matching, and term-coverage and score thresholds. You can combine a query with [filters, facets, sorting, grouping, cursor pagination, and pinned results](https://github.com/assetcorp/narsil/blob/main/docs/filters-facets-and-pagination.md).
|
|
87
93
|
|
|
88
|
-
**Vector and hybrid retrieval.** [
|
|
94
|
+
**Vector and hybrid retrieval.** For [vector search](https://github.com/assetcorp/narsil/blob/main/docs/vector-search.md), the engine compares vectors by cosine similarity, dot product, or Euclidean distance. It scans a field exactly until the field holds 1,024 vectors, a count that you can change, after which it builds an HNSW graph with optimised scalar quantization on by default. Outside a browser, on an arm64 or x64 machine under macOS, Linux, or Windows, the engine searches that graph through a [native core in C](https://github.com/assetcorp/narsil/blob/main/docs/vector-search.md#native-search-core), which returns the same results as its WebAssembly search. For [hybrid search](https://github.com/assetcorp/narsil/blob/main/docs/hybrid-search.md), the engine fuses BM25 and vector rankings through reciprocal rank fusion or linear blending. [Embedding adapters](https://github.com/assetcorp/narsil/blob/main/docs/embedding-adapters.md) turn text into vectors on insert and query, through OpenAI, local Transformers.js models, or an adapter of your own.
|
|
89
95
|
|
|
90
|
-
**Geosearch.** [Geo filters](https://github.com/assetcorp/narsil/blob/main/docs/geosearch.md) match by radius, using Haversine or Vincenty distance, or by polygon containment
|
|
96
|
+
**Geosearch.** [Geo filters](https://github.com/assetcorp/narsil/blob/main/docs/geosearch.md) match documents by radius, using Haversine or Vincenty distance, or by polygon containment. You can combine a geo filter with every other query feature.
|
|
91
97
|
|
|
92
|
-
**Storage.** [Persistence adapters](https://github.com/assetcorp/narsil/blob/main/docs/persistence-and-durability.md)
|
|
98
|
+
**Storage.** [Persistence adapters](https://github.com/assetcorp/narsil/blob/main/docs/persistence-and-durability.md) store indexes on the filesystem, in IndexedDB, in memory, or in a backend of your own. With durability on, the engine keeps a write-ahead log with periodic checkpoints and recovers on its own after a crash. A snapshot captures a whole index as one portable byte array.
|
|
93
99
|
|
|
94
|
-
**Scale.** [
|
|
100
|
+
**Scale.** The engine routes each document to a [partition](https://github.com/assetcorp/narsil/blob/main/docs/partitions-and-workers.md) by a deterministic hash. When you call `rebalance()`, the engine reshapes the partitions online and buffers incoming writes in a write-ahead queue. Once an index holds 1,000 documents, the engine serves keyword queries from worker copies on half its worker threads, while the HTTP server receives requests on the threads that hold those copies.
|
|
95
101
|
|
|
96
|
-
**Operations.** The [HTTP server](https://github.com/assetcorp/narsil/blob/main/docs/http-server.md) subpath wraps an engine in a REST API with health probes, bulk NDJSON import, snapshot and restore endpoints, and task-based long operations. The [client](https://github.com/assetcorp/narsil/blob/main/docs/client.md) subpath
|
|
102
|
+
**Operations.** The [HTTP server](https://github.com/assetcorp/narsil/blob/main/docs/http-server.md) subpath wraps an engine in a REST API with health probes, bulk NDJSON import, snapshot and restore endpoints, and task-based long operations. The [client](https://github.com/assetcorp/narsil/blob/main/docs/client.md) subpath calls every one of those routes from a browser or from Node under the engine's own method names. The [React](https://github.com/assetcorp/narsil/blob/main/docs/react.md) subpath exposes those methods to components as hooks. For observability, you get [events, plugins, and memory reporting](https://github.com/assetcorp/narsil/blob/main/docs/observability.md). [Language modules](https://github.com/assetcorp/narsil/blob/main/docs/language-support.md) cover 107 languages as separate entry points, including 20 African languages.
|
|
97
103
|
|
|
98
104
|
## Documentation
|
|
99
105
|
|
|
@@ -103,7 +109,7 @@ Every hit holds the document, its id, and its BM25 score. `results.count` report
|
|
|
103
109
|
| [Indexes and documents](../../docs/indexes-and-documents.md) | Schemas, index management, inserts, reads, updates, removals, and batch operations |
|
|
104
110
|
| [Full-text search](../../docs/full-text-search.md) | Term queries, fuzzy matching, prefix completion, thresholds, highlighting, scoring modes, and suggestions |
|
|
105
111
|
| [Filters, facets, and pagination](../../docs/filters-facets-and-pagination.md) | Field, array, presence, and geo filters, facet counts, sorting, grouping, cursors, and pinning |
|
|
106
|
-
| [Vector search](../../docs/vector-search.md) | Vector fields, distance metrics, HNSW promotion, quantization, and graph maintenance |
|
|
112
|
+
| [Vector search](../../docs/vector-search.md) | Vector fields, distance metrics, HNSW promotion, quantization, the native search core, and graph maintenance |
|
|
107
113
|
| [Hybrid search](../../docs/hybrid-search.md) | Reciprocal rank fusion and linear blending of text and vector rankings |
|
|
108
114
|
| [Geosearch](../../docs/geosearch.md) | Radius and polygon filters, and the two distance formulas |
|
|
109
115
|
| [Embedding adapters](../../docs/embedding-adapters.md) | Automatic embedding on insert and query, named adapters, the bundled ones, and custom ones |
|
|
@@ -111,42 +117,42 @@ Every hit holds the document, its id, and its BM25 score. `results.count` report
|
|
|
111
117
|
| [Partitions and workers](../../docs/partitions-and-workers.md) | Partition routing, online rebalancing, worker copies, and multi-instance invalidation |
|
|
112
118
|
| [Language support](../../docs/language-support.md) | The 107 language modules, analysis revisions and rebuilds, and named tokenizers and stop words |
|
|
113
119
|
| [HTTP server](../../docs/http-server.md) | Wrapping an engine in a REST API, every route it serves, and long-running tasks |
|
|
114
|
-
| [Cluster mode](../../docs/cluster.md) | Multi-node indexes: nodes and roles, replication, routed writes, distributed searches and reads, and
|
|
120
|
+
| [Cluster mode](../../docs/cluster.md) | Multi-node indexes: nodes and roles, replication, routed writes, distributed searches and reads, and the calls that fail in a cluster |
|
|
115
121
|
| [Client](../../docs/client.md) | Reaching a server from a browser or Node, following a task, and the codes it raises |
|
|
116
122
|
| [React](../../docs/react.md) | The hooks over the client, one shared request per key, and loading a corpus from a component |
|
|
117
123
|
| [Observability](../../docs/observability.md) | Plugin hooks, engine events, and memory reporting |
|
|
118
124
|
| [Errors](../../docs/errors.md) | Every error code and what throws it |
|
|
119
125
|
|
|
120
|
-
The [specification](../spec/) defines the `.nrsl` format, the analysis pipeline, and the replication invariants that every implementation
|
|
126
|
+
The [specification](../spec/) defines the `.nrsl` format, the analysis pipeline, and the replication invariants that every implementation must uphold.
|
|
121
127
|
|
|
122
128
|
## Examples
|
|
123
129
|
|
|
124
130
|
| Example | What it shows |
|
|
125
131
|
| --- | --- |
|
|
126
|
-
| [HTTP server](examples/http-server/README.md) | The launcher
|
|
132
|
+
| [HTTP server](examples/http-server/README.md) | The launcher serves the engine as a REST service with durability, API-key auth, and Docker packaging, and every setting comes from the environment. |
|
|
127
133
|
| [Browser](examples/browser/README.md) | The app embeds the engine in a browser with IndexedDB persistence and Web Worker search. |
|
|
128
|
-
| [Server app](examples/server-app/README.md) | The app
|
|
134
|
+
| [Server app](examples/server-app/README.md) | The app calls the HTTP server through the client SDK and the React hooks to load corpora as import tasks. In its Ask view, you can ask questions about those corpora. |
|
|
129
135
|
|
|
130
136
|
## Distribution
|
|
131
137
|
|
|
132
|
-
`@delali/narsil/distribution` holds Narsil's multi-node cluster mode: nodes and roles, replication, coordinator adapters for etcd and in-process testing, TCP and gRPC transports with mutual TLS, and distributed query routing. A cluster node
|
|
138
|
+
`@delali/narsil/distribution` holds Narsil's multi-node cluster mode: nodes and roles, replication, coordinator adapters for etcd and in-process testing, TCP and gRPC transports with mutual TLS, and distributed query routing. A cluster node can create, drop, clear, write, update, search, list, count, and suggest across every partition. The [cluster dashboard example](examples/cluster-dashboard) starts three nodes against etcd and shows the state of each partition while you cut the network links. Because the layer is experimental and we may change its APIs without notice, pin an exact version before you depend on it. The [cluster guide](../../docs/cluster.md) covers the API. For the contract that every implementation must uphold, read [`packages/spec/distribution`](../spec/distribution).
|
|
133
139
|
|
|
134
140
|
## Search quality
|
|
135
141
|
|
|
136
|
-
|
|
142
|
+
We measure ranking quality on the [BEIR](https://github.com/beir-cellar/beir) SciFact corpus, which holds 5,183 documents and 300 queries with human relevance judgements. We compare Narsil with Orama and MiniSearch in one process. All three use the same 33 Lucene English stop words and default BM25 parameters, although each one stems English in its own way. Narsil has the top nDCG@10 of the three.
|
|
137
143
|
|
|
138
144
|
**What these metrics mean:**
|
|
139
145
|
|
|
140
|
-
- **nDCG@10** measures whether the most relevant documents appear near the top of the results. A score of 1.0 means perfect ranking,
|
|
146
|
+
- **nDCG@10** measures whether the most relevant documents appear near the top of the results. A score of 1.0 means a perfect ranking, while 0.0 means that no relevant document appears in the top 10.
|
|
141
147
|
- **P@10** is the fraction of the top 10 results that are relevant.
|
|
142
|
-
- **MAP** tracks precision at every rank where a relevant document appears. A higher MAP means relevant documents cluster near the top of the ranking.
|
|
143
|
-
- **MRR** measures how soon the first relevant result appears. A higher
|
|
148
|
+
- **MAP** tracks precision at every rank where a relevant document appears. A higher MAP means that relevant documents cluster near the top of the ranking.
|
|
149
|
+
- **MRR** measures how soon the first relevant result appears. A higher MRR means that the first relevant document appears nearer the top.
|
|
144
150
|
|
|
145
|
-
|
|
151
|
+
Continuous integration executes a separate [SciFact regression test](src/__tests__/relevance/scifact.test.ts) on the same corpus. That test fails the build when ranking quality drops below calibrated thresholds.
|
|
146
152
|
|
|
147
|
-
Reproduce these scores with `pnpm --filter benchmarks bench -- --tiers relevance`.
|
|
153
|
+
Reproduce these scores with `pnpm --filter benchmarks bench -- --tiers relevance`. [BENCHMARKS.md](https://github.com/assetcorp/narsil/blob/main/BENCHMARKS.md) holds the full quality, throughput, and latency tables for all three engines.
|
|
148
154
|
|
|
149
|
-
Narsil also
|
|
155
|
+
Narsil also works as a search server. On the BEIR datasets, we compare the server with 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.
|
|
150
156
|
|
|
151
157
|
## Runtime support
|
|
152
158
|
|
|
@@ -157,7 +163,7 @@ Narsil also runs as a search server. On the BEIR information-retrieval datasets
|
|
|
157
163
|
| Deno | Web Workers | Filesystem | BroadcastChannel |
|
|
158
164
|
| Browser | Web Workers | IndexedDB | BroadcastChannel |
|
|
159
165
|
|
|
160
|
-
The [browser example](examples/browser/README.md) shows an embedded engine with IndexedDB persistence,
|
|
166
|
+
The [browser example](examples/browser/README.md) shows an embedded engine with IndexedDB persistence, while the [server app example](examples/server-app/README.md) shows the same interface over the client SDK and the React hooks.
|
|
161
167
|
|
|
162
168
|
## License
|
|
163
169
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { E as EmbeddingAdapter } from './adapters-79OIa4xm.js';
|
|
2
|
-
import { C as ClusterNamespace } from './config-
|
|
3
|
-
import { h as TaskStore } from './tasks-
|
|
2
|
+
import { C as ClusterNamespace } from './config-CNas6wJH.js';
|
|
3
|
+
import { h as TaskStore } from './tasks-Df73Tjgq.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Context handed to {@link OnRequestHook} for every inbound request, captured
|
|
@@ -181,6 +181,17 @@ interface NarsilServer {
|
|
|
181
181
|
listen(): Promise<void>;
|
|
182
182
|
/** Closes the socket and ends the server. The engine keeps running. */
|
|
183
183
|
close(): Promise<void>;
|
|
184
|
+
/**
|
|
185
|
+
* Closes the server and then shuts the engine down, which is what a process
|
|
186
|
+
* whose work is this server calls on its way out.
|
|
187
|
+
*
|
|
188
|
+
* Shutting the engine down is what ends the request threads, so Node aborts
|
|
189
|
+
* a process that exits while one of those threads still holds a client
|
|
190
|
+
* connection. Call {@link NarsilServer.close} on its own where the engine
|
|
191
|
+
* keeps running after the server closes, as in a process that fronts one
|
|
192
|
+
* engine with two servers.
|
|
193
|
+
*/
|
|
194
|
+
shutdown(): Promise<void>;
|
|
184
195
|
/** The server bound to this port, which is what you read after binding to port 0. */
|
|
185
196
|
readonly listeningPort: number;
|
|
186
197
|
/**
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
import { NATIVE_CRC32_MIN_BYTES } from './chunk-IFBKLBGB.mjs';
|
|
2
|
+
import * as zlib from 'zlib';
|
|
3
|
+
|
|
4
|
+
var candidate = zlib.crc32;
|
|
5
|
+
var nativeCrc32 = typeof candidate === "function" ? candidate : null;
|
|
6
|
+
|
|
1
7
|
// src/serialization/crc32.ts
|
|
2
8
|
var IEEE_POLYNOMIAL = 3988292384;
|
|
9
|
+
var ALL_BITS = 4294967295;
|
|
3
10
|
var cachedTable = null;
|
|
4
11
|
function buildTable() {
|
|
5
12
|
const table = new Uint32Array(256);
|
|
@@ -18,18 +25,16 @@ function getTable() {
|
|
|
18
25
|
}
|
|
19
26
|
return cachedTable;
|
|
20
27
|
}
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
let crc = 4294967295;
|
|
24
|
-
for (let i = 0; i < data.length; i++) {
|
|
25
|
-
crc = crc >>> 8 ^ table[(crc ^ data[i]) & 255];
|
|
26
|
-
}
|
|
27
|
-
return (crc ^ 4294967295) >>> 0;
|
|
28
|
+
function crc322(data) {
|
|
29
|
+
return crc32Final(crc32Update(crc32Init(), data));
|
|
28
30
|
}
|
|
29
31
|
function crc32Init() {
|
|
30
|
-
return
|
|
32
|
+
return ALL_BITS;
|
|
31
33
|
}
|
|
32
34
|
function crc32Update(state, data) {
|
|
35
|
+
if (nativeCrc32 !== null && data.length >= NATIVE_CRC32_MIN_BYTES) {
|
|
36
|
+
return nativeCrc32(data, (state ^ ALL_BITS) >>> 0) ^ ALL_BITS;
|
|
37
|
+
}
|
|
33
38
|
const table = getTable();
|
|
34
39
|
let crc = state;
|
|
35
40
|
for (let i = 0; i < data.length; i++) {
|
|
@@ -38,7 +43,7 @@ function crc32Update(state, data) {
|
|
|
38
43
|
return crc;
|
|
39
44
|
}
|
|
40
45
|
function crc32Final(state) {
|
|
41
|
-
return (state ^
|
|
46
|
+
return (state ^ ALL_BITS) >>> 0;
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
export { crc32, crc32Final, crc32Init, crc32Update };
|
|
49
|
+
export { crc322 as crc32, crc32Final, crc32Init, crc32Update, nativeCrc32 };
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { validateRequiredFields } from './chunk-L225SHNB.mjs';
|
|
2
|
+
import { ErrorCodes, NarsilError } from './chunk-FRQVMFY7.mjs';
|
|
3
|
+
|
|
4
|
+
// src/narsil/internals.ts
|
|
5
|
+
var engineCores = /* @__PURE__ */ new WeakMap();
|
|
6
|
+
function bindEngineCore(engine, core) {
|
|
7
|
+
engineCores.set(engine, core);
|
|
8
|
+
}
|
|
9
|
+
function engineCoreOf(engine) {
|
|
10
|
+
return engineCores.get(engine);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// src/distribution/constants.ts
|
|
14
|
+
var WIRE_BATCH_BUDGET = {
|
|
15
|
+
maxCount: 1e3,
|
|
16
|
+
maxBytes: 8388608,
|
|
17
|
+
overheadBytes: 256
|
|
18
|
+
};
|
|
19
|
+
var MAX_PARTITION_COUNT = 65536;
|
|
20
|
+
var MAX_REPLICATION_FACTOR = 255;
|
|
21
|
+
|
|
22
|
+
// src/engine/embed.ts
|
|
23
|
+
function assertDocumentCarriesMappedVectors(document, embeddingConfig, adapterName) {
|
|
24
|
+
for (const targetField of Object.keys(embeddingConfig.fields)) {
|
|
25
|
+
const existing = resolveFieldValue(document, targetField);
|
|
26
|
+
if (existing === void 0 || existing === null) {
|
|
27
|
+
throw new NarsilError(
|
|
28
|
+
ErrorCodes.EMBEDDING_CONFIG_INVALID,
|
|
29
|
+
adapterName ? `The index embeds "${targetField}" via adapter "${adapterName}", which is not registered on this engine. Register the adapter or supply the vector on the document.` : `The index embeds "${targetField}" automatically but no embedding adapter is bound. Bind an adapter or supply the vector on the document.`,
|
|
30
|
+
adapterName ? { field: targetField, adapter: adapterName } : { field: targetField }
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function validateVector(vector, targetField, expectedDimensions) {
|
|
36
|
+
if (!(vector instanceof Float32Array)) {
|
|
37
|
+
throw new NarsilError(
|
|
38
|
+
ErrorCodes.EMBEDDING_FAILED,
|
|
39
|
+
`Adapter returned ${typeof vector} for field "${targetField}", expected Float32Array`,
|
|
40
|
+
{ field: targetField, returnedType: typeof vector }
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (vector.length !== expectedDimensions) {
|
|
44
|
+
throw new NarsilError(
|
|
45
|
+
ErrorCodes.EMBEDDING_DIMENSION_MISMATCH,
|
|
46
|
+
`Adapter returned ${vector.length}-dimensional vector for field "${targetField}", expected ${expectedDimensions}`,
|
|
47
|
+
{ field: targetField, expected: expectedDimensions, actual: vector.length }
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
for (let i = 0; i < vector.length; i++) {
|
|
51
|
+
if (Number.isNaN(vector[i])) {
|
|
52
|
+
throw new NarsilError(
|
|
53
|
+
ErrorCodes.EMBEDDING_FAILED,
|
|
54
|
+
`Adapter returned vector with NaN at index ${i} for field "${targetField}"`,
|
|
55
|
+
{ field: targetField, index: i }
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return vector;
|
|
60
|
+
}
|
|
61
|
+
function resolveFieldValue(document, path) {
|
|
62
|
+
if (!path.includes(".")) return document[path];
|
|
63
|
+
const segments = path.split(".");
|
|
64
|
+
let current = document;
|
|
65
|
+
for (const segment of segments) {
|
|
66
|
+
if (current === null || current === void 0 || typeof current !== "object") return void 0;
|
|
67
|
+
current = current[segment];
|
|
68
|
+
}
|
|
69
|
+
return current;
|
|
70
|
+
}
|
|
71
|
+
function setFieldValue(document, path, value) {
|
|
72
|
+
if (!path.includes(".")) {
|
|
73
|
+
document[path] = value;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const segments = path.split(".");
|
|
77
|
+
let current = document;
|
|
78
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
79
|
+
let next = current[segments[i]];
|
|
80
|
+
if (next === null || next === void 0 || typeof next !== "object") {
|
|
81
|
+
next = {};
|
|
82
|
+
current[segments[i]] = next;
|
|
83
|
+
}
|
|
84
|
+
current = next;
|
|
85
|
+
}
|
|
86
|
+
current[segments[segments.length - 1]] = value;
|
|
87
|
+
}
|
|
88
|
+
function deleteFieldValue(document, path) {
|
|
89
|
+
if (!path.includes(".")) {
|
|
90
|
+
delete document[path];
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const segments = path.split(".");
|
|
94
|
+
let current = document;
|
|
95
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
96
|
+
const next = current[segments[i]];
|
|
97
|
+
if (next === null || next === void 0 || typeof next !== "object") return;
|
|
98
|
+
current = next;
|
|
99
|
+
}
|
|
100
|
+
delete current[segments[segments.length - 1]];
|
|
101
|
+
}
|
|
102
|
+
function collectSourceText(document, sourceFields, targetField, documentIndex) {
|
|
103
|
+
const sources = Array.isArray(sourceFields) ? sourceFields : [sourceFields];
|
|
104
|
+
const parts = [];
|
|
105
|
+
for (const source of sources) {
|
|
106
|
+
const value = resolveFieldValue(document, source);
|
|
107
|
+
if (value === void 0 || value === null || value === "") continue;
|
|
108
|
+
if (typeof value !== "string") {
|
|
109
|
+
throw new NarsilError(
|
|
110
|
+
ErrorCodes.DOC_VALIDATION_FAILED,
|
|
111
|
+
`Embedding source field "${source}" must be a string, got ${typeof value}`,
|
|
112
|
+
{ field: source, targetField }
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
parts.push(value);
|
|
116
|
+
}
|
|
117
|
+
if (parts.length === 0) {
|
|
118
|
+
const details = { field: targetField };
|
|
119
|
+
if (documentIndex !== void 0) {
|
|
120
|
+
details.documentIndex = documentIndex;
|
|
121
|
+
}
|
|
122
|
+
throw new NarsilError(
|
|
123
|
+
ErrorCodes.EMBEDDING_NO_SOURCE,
|
|
124
|
+
`All source fields for embedding target "${targetField}" are missing or empty`,
|
|
125
|
+
details
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return parts.join("\n");
|
|
129
|
+
}
|
|
130
|
+
function wrapAdapterError(err, targetField) {
|
|
131
|
+
if (err instanceof NarsilError) return err;
|
|
132
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
133
|
+
return new NarsilError(ErrorCodes.EMBEDDING_FAILED, `Embedding failed for field "${targetField}": ${message}`, {
|
|
134
|
+
field: targetField,
|
|
135
|
+
cause: message
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
async function embedDocumentFields(document, embeddingConfig, adapter, signal) {
|
|
139
|
+
const embeddedFields = /* @__PURE__ */ new Set();
|
|
140
|
+
try {
|
|
141
|
+
for (const [targetField, sourceFields] of Object.entries(embeddingConfig.fields)) {
|
|
142
|
+
const existing = resolveFieldValue(document, targetField);
|
|
143
|
+
if (existing !== void 0 && existing !== null) continue;
|
|
144
|
+
const text = collectSourceText(document, sourceFields, targetField);
|
|
145
|
+
let vector;
|
|
146
|
+
try {
|
|
147
|
+
const raw = await adapter.embed(text, "document", signal);
|
|
148
|
+
vector = validateVector(raw, targetField, adapter.dimensions);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
throw wrapAdapterError(err, targetField);
|
|
151
|
+
}
|
|
152
|
+
setFieldValue(document, targetField, vector);
|
|
153
|
+
embeddedFields.add(targetField);
|
|
154
|
+
}
|
|
155
|
+
} catch (err) {
|
|
156
|
+
for (const field of embeddedFields) {
|
|
157
|
+
deleteFieldValue(document, field);
|
|
158
|
+
}
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
return embeddedFields;
|
|
162
|
+
}
|
|
163
|
+
var SOURCE_FAILURE_CODES = /* @__PURE__ */ new Set([
|
|
164
|
+
ErrorCodes.EMBEDDING_NO_SOURCE,
|
|
165
|
+
ErrorCodes.DOC_VALIDATION_FAILED
|
|
166
|
+
]);
|
|
167
|
+
function isDocumentSourceError(err) {
|
|
168
|
+
return err instanceof NarsilError && SOURCE_FAILURE_CODES.has(err.code);
|
|
169
|
+
}
|
|
170
|
+
async function embedBatchDocumentFields(documents, embeddingConfig, adapter, signal) {
|
|
171
|
+
const embedded = /* @__PURE__ */ new Map();
|
|
172
|
+
const failed = /* @__PURE__ */ new Map();
|
|
173
|
+
try {
|
|
174
|
+
for (const [targetField, sourceFields] of Object.entries(embeddingConfig.fields)) {
|
|
175
|
+
const needsEmbedding = [];
|
|
176
|
+
for (let i = 0; i < documents.length; i++) {
|
|
177
|
+
if (failed.has(i)) continue;
|
|
178
|
+
const doc = documents[i];
|
|
179
|
+
const existing = resolveFieldValue(doc, targetField);
|
|
180
|
+
if (existing !== void 0 && existing !== null) continue;
|
|
181
|
+
try {
|
|
182
|
+
const text = collectSourceText(doc, sourceFields, targetField, i);
|
|
183
|
+
needsEmbedding.push({ docIndex: i, text });
|
|
184
|
+
} catch (err) {
|
|
185
|
+
if (isDocumentSourceError(err)) {
|
|
186
|
+
failed.set(i, err);
|
|
187
|
+
} else {
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (needsEmbedding.length === 0) continue;
|
|
193
|
+
const texts = needsEmbedding.map((entry) => entry.text);
|
|
194
|
+
let vectors;
|
|
195
|
+
try {
|
|
196
|
+
if (adapter.embedBatch) {
|
|
197
|
+
vectors = await adapter.embedBatch(texts, "document", signal);
|
|
198
|
+
} else {
|
|
199
|
+
vectors = [];
|
|
200
|
+
const concurrency = 8;
|
|
201
|
+
for (let start = 0; start < texts.length; start += concurrency) {
|
|
202
|
+
const chunk = texts.slice(start, start + concurrency);
|
|
203
|
+
const chunkResults = await Promise.all(chunk.map((t) => adapter.embed(t, "document", signal)));
|
|
204
|
+
vectors.push(...chunkResults);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
throw wrapAdapterError(err, targetField);
|
|
209
|
+
}
|
|
210
|
+
if (vectors.length !== needsEmbedding.length) {
|
|
211
|
+
throw new NarsilError(
|
|
212
|
+
ErrorCodes.EMBEDDING_FAILED,
|
|
213
|
+
`Adapter returned ${vectors.length} vectors for ${needsEmbedding.length} inputs on field "${targetField}"`,
|
|
214
|
+
{ field: targetField, expected: needsEmbedding.length, actual: vectors.length }
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
for (let j = 0; j < needsEmbedding.length; j++) {
|
|
218
|
+
const { docIndex } = needsEmbedding[j];
|
|
219
|
+
const validated = validateVector(vectors[j], targetField, adapter.dimensions);
|
|
220
|
+
setFieldValue(documents[docIndex], targetField, validated);
|
|
221
|
+
let fieldSet = embedded.get(docIndex);
|
|
222
|
+
if (!fieldSet) {
|
|
223
|
+
fieldSet = /* @__PURE__ */ new Set();
|
|
224
|
+
embedded.set(docIndex, fieldSet);
|
|
225
|
+
}
|
|
226
|
+
fieldSet.add(targetField);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
} catch (err) {
|
|
230
|
+
for (const [docIndex, fields] of embedded) {
|
|
231
|
+
for (const field of fields) {
|
|
232
|
+
deleteFieldValue(documents[docIndex], field);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
throw err;
|
|
236
|
+
}
|
|
237
|
+
return { embedded, failed };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// src/engine/mutations/insert-admission.ts
|
|
241
|
+
function providedDocId(document) {
|
|
242
|
+
const id = document.id;
|
|
243
|
+
return typeof id === "string" && id.length > 0 ? id : void 0;
|
|
244
|
+
}
|
|
245
|
+
function asBatchInsertError(err) {
|
|
246
|
+
return err instanceof NarsilError ? err : new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, String(err));
|
|
247
|
+
}
|
|
248
|
+
function admitInsert(ctx, indexName, manager, docId, pendingAdmitted = 0) {
|
|
249
|
+
if (!ctx.isRebalancing(indexName)) {
|
|
250
|
+
manager.assertCapacity(pendingAdmitted);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const bufferedState = ctx.bufferedDocState(indexName, docId);
|
|
254
|
+
const exists = bufferedState !== void 0 ? bufferedState === "present" : manager.has(docId);
|
|
255
|
+
if (exists) {
|
|
256
|
+
throw new NarsilError(ErrorCodes.DOC_ALREADY_EXISTS, `Document "${docId}" already exists`, { docId });
|
|
257
|
+
}
|
|
258
|
+
manager.assertCapacity(
|
|
259
|
+
ctx.pendingRebalanceWrites(indexName) + pendingAdmitted,
|
|
260
|
+
ctx.rebalanceTargetPartitionCount(indexName)
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
function collectRequiredFieldFailures(documents, chunkStart, chunkEnd, required, chunkFailedIndexes, failed) {
|
|
264
|
+
for (let i = chunkStart; i < chunkEnd; i++) {
|
|
265
|
+
try {
|
|
266
|
+
validateRequiredFields(documents[i], required);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
chunkFailedIndexes.add(i);
|
|
269
|
+
failed.push({ docId: providedDocId(documents[i]) ?? "", error: asBatchInsertError(err) });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async function embedChunkDocuments(entry, documents, chunkStart, chunkEnd, signal, chunkFailedIndexes, failed) {
|
|
274
|
+
if (entry.config.embedding === void 0) return;
|
|
275
|
+
if (entry.embeddingAdapter === null) {
|
|
276
|
+
for (let i = chunkStart; i < chunkEnd; i++) {
|
|
277
|
+
if (chunkFailedIndexes.has(i)) continue;
|
|
278
|
+
try {
|
|
279
|
+
assertDocumentCarriesMappedVectors(
|
|
280
|
+
documents[i],
|
|
281
|
+
entry.config.embedding,
|
|
282
|
+
entry.embeddingAdapterName
|
|
283
|
+
);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
chunkFailedIndexes.add(i);
|
|
286
|
+
failed.push({
|
|
287
|
+
docId: providedDocId(documents[i]) ?? "",
|
|
288
|
+
error: err instanceof NarsilError ? err : new NarsilError(ErrorCodes.EMBEDDING_FAILED, String(err))
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const embeddableSlice = [];
|
|
295
|
+
const embeddableOriginalIndexes = [];
|
|
296
|
+
for (let i = chunkStart; i < chunkEnd; i++) {
|
|
297
|
+
if (chunkFailedIndexes.has(i)) continue;
|
|
298
|
+
embeddableSlice.push(documents[i]);
|
|
299
|
+
embeddableOriginalIndexes.push(i);
|
|
300
|
+
}
|
|
301
|
+
if (embeddableSlice.length === 0) return;
|
|
302
|
+
try {
|
|
303
|
+
const embedResult = await embedBatchDocumentFields(
|
|
304
|
+
embeddableSlice,
|
|
305
|
+
entry.config.embedding,
|
|
306
|
+
entry.embeddingAdapter,
|
|
307
|
+
signal
|
|
308
|
+
);
|
|
309
|
+
for (const [sliceIndex, error] of embedResult.failed) {
|
|
310
|
+
const originalIdx = embeddableOriginalIndexes[sliceIndex];
|
|
311
|
+
chunkFailedIndexes.add(originalIdx);
|
|
312
|
+
failed.push({ docId: providedDocId(documents[originalIdx]) ?? "", error });
|
|
313
|
+
}
|
|
314
|
+
} catch (err) {
|
|
315
|
+
const embeddingError = err instanceof NarsilError ? err : new NarsilError(ErrorCodes.EMBEDDING_FAILED, String(err));
|
|
316
|
+
for (const originalIdx of embeddableOriginalIndexes) {
|
|
317
|
+
chunkFailedIndexes.add(originalIdx);
|
|
318
|
+
failed.push({ docId: providedDocId(documents[originalIdx]) ?? "", error: embeddingError });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// src/engine/resolve-vector-text.ts
|
|
324
|
+
async function resolveVectorText(params, embeddingAdapter, signal, embeddingAdapterName) {
|
|
325
|
+
if (!params.vector) return params;
|
|
326
|
+
if (params.vector.text === void 0 && params.vector.value === void 0) return params;
|
|
327
|
+
if (params.vector.value !== void 0 && params.vector.text === void 0) return params;
|
|
328
|
+
if (params.vector.text !== void 0 && params.vector.value !== void 0) {
|
|
329
|
+
throw new NarsilError(ErrorCodes.EMBEDDING_CONFIG_INVALID, "Vector query cannot have both 'text' and 'value'");
|
|
330
|
+
}
|
|
331
|
+
if (!embeddingAdapter) {
|
|
332
|
+
throw new NarsilError(
|
|
333
|
+
ErrorCodes.EMBEDDING_CONFIG_INVALID,
|
|
334
|
+
embeddingAdapterName ? `Vector query with 'text' needs embedding adapter "${embeddingAdapterName}", which is not registered on this engine. Register it with registerEmbeddingAdapter or supply a raw vector.` : "Vector query with 'text' requires an embedding adapter on the index or instance",
|
|
335
|
+
embeddingAdapterName ? { adapter: embeddingAdapterName } : void 0
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
if (typeof params.vector.text !== "string") {
|
|
339
|
+
throw new NarsilError(
|
|
340
|
+
ErrorCodes.DOC_VALIDATION_FAILED,
|
|
341
|
+
`Vector query 'text' must be a string, got ${typeof params.vector.text}`
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
if (params.vector.text.length === 0) {
|
|
345
|
+
throw new NarsilError(ErrorCodes.DOC_VALIDATION_FAILED, "Vector query 'text' must not be empty");
|
|
346
|
+
}
|
|
347
|
+
const raw = await embeddingAdapter.embed(params.vector.text, "query", signal);
|
|
348
|
+
if (!(raw instanceof Float32Array)) {
|
|
349
|
+
throw new NarsilError(
|
|
350
|
+
ErrorCodes.EMBEDDING_FAILED,
|
|
351
|
+
`Adapter returned ${typeof raw} for query embedding, expected Float32Array`
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
if (raw.length !== embeddingAdapter.dimensions) {
|
|
355
|
+
throw new NarsilError(
|
|
356
|
+
ErrorCodes.EMBEDDING_DIMENSION_MISMATCH,
|
|
357
|
+
`Adapter returned ${raw.length}-dimensional vector for query, expected ${embeddingAdapter.dimensions}`,
|
|
358
|
+
{ expected: embeddingAdapter.dimensions, actual: raw.length }
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
const resolved = { ...params.vector, value: Array.from(raw) };
|
|
362
|
+
delete resolved.text;
|
|
363
|
+
return { ...params, vector: resolved };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export { MAX_PARTITION_COUNT, MAX_REPLICATION_FACTOR, WIRE_BATCH_BUDGET, admitInsert, asBatchInsertError, assertDocumentCarriesMappedVectors, bindEngineCore, collectRequiredFieldFailures, embedChunkDocuments, embedDocumentFields, engineCoreOf, providedDocId, resolveVectorText };
|