@delali/narsil 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,9 +23,9 @@ Requires Node.js >= 22.
23
23
  ## Quick start
24
24
 
25
25
  ```ts
26
- import { Narsil } from '@delali/narsil'
26
+ import { createNarsil } from '@delali/narsil'
27
27
 
28
- const narsil = new Narsil()
28
+ const narsil = await createNarsil()
29
29
 
30
30
  await narsil.createIndex('products', {
31
31
  schema: {
@@ -59,6 +59,72 @@ const results = await narsil.query('products', {
59
59
  })
60
60
  ```
61
61
 
62
+ ## Configuration
63
+
64
+ `createNarsil` accepts an optional `NarsilConfig` object. All fields are optional.
65
+
66
+ ```ts
67
+ import { createNarsil } from '@delali/narsil'
68
+
69
+ const narsil = await createNarsil({
70
+ persistence: myAdapter,
71
+ workers: { enabled: true, count: 4 },
72
+ flush: { interval: 5000, mutationThreshold: 100 },
73
+ })
74
+ ```
75
+
76
+ ### NarsilConfig
77
+
78
+ | Field | Type | Description |
79
+ | --- | --- | --- |
80
+ | `persistence` | `PersistenceAdapter` | Storage backend for durable indexes (filesystem, IndexedDB, or custom) |
81
+ | `invalidation` | `InvalidationAdapter` | Cross-instance cache coordination adapter for multi-process or multi-tab setups |
82
+ | `plugins` | `NarsilPlugin[]` | Lifecycle hooks for insert, remove, update, search, and index events |
83
+ | `idGenerator` | `() => string` | Custom function for generating document IDs (defaults to UUID v7) |
84
+ | `workers` | `WorkerConfig` | Worker thread configuration for parallel search |
85
+ | `flush` | `FlushConfig` | Controls when dirty partitions persist to storage |
86
+ | `eagerLoad` | `boolean` | When `true`, loads all persisted data into memory at creation time |
87
+ | `embedding` | `EmbeddingAdapter` | Default adapter for auto-embedding text fields into vectors (see [Embedding adapters](#embedding-adapters)) |
88
+
89
+ ### WorkerConfig
90
+
91
+ | Field | Type | Default | Description |
92
+ | --- | --- | --- | --- |
93
+ | `enabled` | `boolean` | `false` | Enables worker thread pool for search |
94
+ | `count` | `number` | CPU count | Number of worker threads to spawn |
95
+ | `promotionThreshold` | `number` | - | Document count per index that triggers auto-promotion to workers |
96
+ | `totalPromotionThreshold` | `number` | - | Total document count across all indexes that triggers auto-promotion |
97
+
98
+ ### FlushConfig
99
+
100
+ | Field | Type | Default | Description |
101
+ | --- | --- | --- | --- |
102
+ | `interval` | `number` | - | Milliseconds between persistence flushes |
103
+ | `mutationThreshold` | `number` | - | Number of mutations that triggers a flush |
104
+
105
+ ### Tokenizer cache
106
+
107
+ The stemmer normalization cache auto-sizes based on the runtime environment. On Node.js it reads container memory limits (via `process.constrainedMemory()`), in browsers it checks `navigator.deviceMemory`, and falls back to a sensible default elsewhere.
108
+
109
+ This cache is process-global, shared across all Narsil instances in the same process. You can override the size by calling `configureNormalizationCache` once at startup, before creating any instances:
110
+
111
+ ```ts
112
+ import { configureNormalizationCache, createNarsil } from '@delali/narsil'
113
+
114
+ configureNormalizationCache(500_000)
115
+
116
+ const narsil = await createNarsil()
117
+ ```
118
+
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`.
120
+
121
+ Four utility functions are available for cache management:
122
+
123
+ - `configureNormalizationCache(maxSize)` - set the maximum cache size
124
+ - `clearNormalizationCache()` - drop all cached entries (reclaim memory after one-off indexing)
125
+ - `resetNormalizationCache()` - clear the cache and reset the size to the auto-detected default
126
+ - `getNormalizationCacheSize()` - return the current number of cached entries (useful for monitoring)
127
+
62
128
  ## Features
63
129
 
64
130
  ### Full-text search
@@ -146,6 +212,68 @@ const results = await narsil.query('products', {
146
212
  // hit.highlights.title.snippet => '<mark>Mechanical</mark> Keyboard'
147
213
  ```
148
214
 
215
+ ### Embedding adapters
216
+
217
+ Auto-embed text fields into vectors on insert and query. Pass an `EmbeddingAdapter` at the instance level (shared default) or per-index. When a document is inserted without a pre-computed vector, Narsil concatenates the configured source text fields and calls the adapter to generate the embedding. On query, pass `text` instead of `value` to auto-embed the search phrase.
218
+
219
+ ```ts
220
+ import { createNarsil } from '@delali/narsil'
221
+ import { createOpenAIEmbedding } from '@delali/narsil/embeddings/openai'
222
+
223
+ const narsil = await createNarsil({
224
+ embedding: createOpenAIEmbedding({
225
+ apiKey: process.env.OPENAI_API_KEY,
226
+ model: 'text-embedding-3-small',
227
+ dimensions: 1536,
228
+ }),
229
+ })
230
+
231
+ await narsil.createIndex('articles', {
232
+ schema: {
233
+ title: 'string',
234
+ body: 'string',
235
+ embedding: 'vector[1536]',
236
+ },
237
+ embedding: {
238
+ fields: {
239
+ embedding: ['title', 'body'],
240
+ },
241
+ },
242
+ })
243
+
244
+ await narsil.insert('articles', {
245
+ title: 'Distributed search engines',
246
+ body: 'Partitioning data across shards improves throughput...',
247
+ })
248
+
249
+ const results = await narsil.query('articles', {
250
+ mode: 'vector',
251
+ vector: { field: 'embedding', text: 'how do search engines scale?' },
252
+ })
253
+ ```
254
+
255
+ Two adapters ship with the project:
256
+
257
+ | Adapter | Package | Dependencies |
258
+ | --- | --- | --- |
259
+ | OpenAI | `@delali/narsil/embeddings/openai` | None (uses `fetch`) |
260
+ | Transformers.js | `@delali/narsil-embeddings-transformers` | `@huggingface/transformers` (peer) |
261
+
262
+ The OpenAI adapter includes retry logic with exponential backoff and jitter, batch chunking (up to 2,048 inputs per request), and configurable timeouts. The Transformers.js adapter runs models locally with lazy pipeline initialization, supports WebGPU/WASM/CPU backends, and handles asymmetric models through document/query prefixes.
263
+
264
+ Build a custom adapter by implementing the `EmbeddingAdapter` interface:
265
+
266
+ ```ts
267
+ interface EmbeddingAdapter {
268
+ embed(input: string, purpose: 'document' | 'query', signal?: AbortSignal): Promise<Float32Array>
269
+ embedBatch?(inputs: string[], purpose: 'document' | 'query', signal?: AbortSignal): Promise<Float32Array[]>
270
+ readonly dimensions: number
271
+ shutdown?(): Promise<void>
272
+ }
273
+ ```
274
+
275
+ If `embedBatch` is not provided, Narsil falls back to parallel `embed()` calls with a concurrency limit of 8.
276
+
149
277
  ### Term suggestions
150
278
 
151
279
  Get autocomplete suggestions from the index's term dictionary. The API tokenizes the input, extracts the last word, and matches it against stored terms across all partitions. Results are ranked by document frequency.
@@ -1,4 +1,4 @@
1
- import { P as PersistenceAdapter } from '../adapters-XODT2F3h.js';
1
+ import { P as PersistenceAdapter } from '../adapters-BbRkoiu9.js';
2
2
 
3
3
  interface FilesystemPersistenceConfig {
4
4
  directory: string;
@@ -1,4 +1,4 @@
1
- import { NarsilError, ErrorCodes } from '../chunk-7GKPWGCN.mjs';
1
+ import { NarsilError, ErrorCodes } from '../chunk-R2C2EQGO.mjs';
2
2
 
3
3
  // src/persistence/filesystem.ts
4
4
  var cachedFs = null;
@@ -1,4 +1,4 @@
1
- import { P as PersistenceAdapter } from '../adapters-XODT2F3h.js';
1
+ import { P as PersistenceAdapter } from '../adapters-BbRkoiu9.js';
2
2
 
3
3
  interface IndexedDBPersistenceConfig {
4
4
  dbName?: string;
@@ -1,4 +1,4 @@
1
- import { NarsilError, ErrorCodes } from '../chunk-7GKPWGCN.mjs';
1
+ import { NarsilError, ErrorCodes } from '../chunk-R2C2EQGO.mjs';
2
2
 
3
3
  // src/persistence/indexeddb.ts
4
4
  var DEFAULT_DB_NAME = "narsil";
@@ -1,4 +1,4 @@
1
- import { P as PersistenceAdapter } from '../adapters-XODT2F3h.js';
1
+ import { P as PersistenceAdapter } from '../adapters-BbRkoiu9.js';
2
2
 
3
3
  declare function createMemoryPersistence(): PersistenceAdapter;
4
4
 
@@ -26,5 +26,11 @@ interface PartitionStatistics {
26
26
  docFrequencies: Record<string, number>;
27
27
  totalFieldLengths: Record<string, number>;
28
28
  }
29
+ interface EmbeddingAdapter {
30
+ embed(input: string, purpose: 'document' | 'query', signal?: AbortSignal): Promise<Float32Array>;
31
+ embedBatch?(inputs: string[], purpose: 'document' | 'query', signal?: AbortSignal): Promise<Float32Array[]>;
32
+ readonly dimensions: number;
33
+ shutdown?(): Promise<void>;
34
+ }
29
35
 
30
- export type { InvalidationAdapter as I, PersistenceAdapter as P, InvalidationEvent as a, PartitionStatistics as b };
36
+ export type { EmbeddingAdapter as E, InvalidationAdapter as I, PersistenceAdapter as P, InvalidationEvent as a, PartitionStatistics as b };
@@ -53,7 +53,6 @@ var RE_STEP4_SUFFIXES = /^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|
53
53
  var RE_STEP4_STION = /^(.+?)(s|t)(ion)$/;
54
54
  var RE_TRAILING_E = /^(.+?)e$/;
55
55
  var RE_TRAILING_LL = /ll$/;
56
- var RE_LAST_CHAR = /.$/;
57
56
  function hasMeasureGt0(stem) {
58
57
  return RE_MEASURE_GT_0.test(stem);
59
58
  }
@@ -61,14 +60,14 @@ function hasMeasureGt1(stem) {
61
60
  return RE_MEASURE_GT_1.test(stem);
62
61
  }
63
62
  function stripPlurals(word) {
64
- if (RE_PLURAL_SS_IES.test(word)) return word.replace(RE_PLURAL_SS_IES, "$1$2");
65
- if (RE_PLURAL_S.test(word)) return word.replace(RE_PLURAL_S, "$1$2");
63
+ if (RE_PLURAL_SS_IES.test(word)) return word.slice(0, -2);
64
+ if (RE_PLURAL_S.test(word)) return word.slice(0, -1);
66
65
  return word;
67
66
  }
68
67
  function stripVerbSuffixes(word) {
69
68
  let match = RE_SUFFIX_EED.exec(word);
70
69
  if (match) {
71
- return hasMeasureGt0(match[1]) ? word.replace(RE_LAST_CHAR, "") : word;
70
+ return hasMeasureGt0(match[1]) ? word.slice(0, -1) : word;
72
71
  }
73
72
  match = RE_SUFFIX_ED_ING.exec(word);
74
73
  if (match) {
@@ -76,7 +75,7 @@ function stripVerbSuffixes(word) {
76
75
  if (!RE_HAS_VOWEL.test(base)) return word;
77
76
  word = base;
78
77
  if (RE_SUFFIX_AT_BL_IZ.test(word)) return `${word}e`;
79
- if (RE_DOUBLE_CONSONANT.test(word)) return word.replace(RE_LAST_CHAR, "");
78
+ if (RE_DOUBLE_CONSONANT.test(word)) return word.slice(0, -1);
80
79
  if (RE_CONSONANT_CVC.test(word)) return `${word}e`;
81
80
  }
82
81
  return word;
@@ -122,7 +121,7 @@ function tidyTrailingE(word) {
122
121
  }
123
122
  function collapseDoubleLl(word) {
124
123
  if (RE_TRAILING_LL.test(word) && hasMeasureGt1(word)) {
125
- return word.replace(RE_LAST_CHAR, "");
124
+ return word.slice(0, -1);
126
125
  }
127
126
  return word;
128
127
  }