@delali/narsil 0.1.14 → 0.2.0

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.
Files changed (251) hide show
  1. package/NOTICE +54 -0
  2. package/README.md +38 -972
  3. package/dist/adapters/filesystem.mjs +1 -1
  4. package/dist/adapters/indexeddb.mjs +1 -1
  5. package/dist/{chunk-WISGW24C.mjs → chunk-AUMKFYTG.mjs} +1 -1
  6. package/dist/{chunk-HRABJLPB.mjs → chunk-BKAI2WXI.mjs} +1 -0
  7. package/dist/chunk-GHJWC2E5.mjs +9 -0
  8. package/dist/chunk-KH3YGMVT.mjs +15 -0
  9. package/dist/{chunk-ALB35PRQ.mjs → chunk-M2EDG55Y.mjs} +8 -0
  10. package/dist/{chunk-HMO6OMZ3.mjs → chunk-S6IAXOBA.mjs} +1073 -506
  11. package/dist/chunk-TGP5M5R7.mjs +880 -0
  12. package/dist/chunk-U2BKYEC7.mjs +254 -0
  13. package/dist/{chunk-MEW7HD75.mjs → chunk-XAKNSWIK.mjs} +2543 -1154
  14. package/dist/{config-CojEPOgS.d.ts → config-P27AtSn4.d.ts} +28 -7
  15. package/dist/direct-executor-2FV3MEDD.mjs +5 -0
  16. package/dist/distribution/coordinator/etcd.d.ts +2 -2
  17. package/dist/distribution/coordinator/etcd.mjs +2 -2
  18. package/dist/distribution/coordinator/in-memory.d.ts +2 -2
  19. package/dist/distribution.d.ts +10 -4
  20. package/dist/distribution.mjs +1875 -1772
  21. package/dist/embeddings/openai.mjs +1 -1
  22. package/dist/index.browser.mjs +4566 -1783
  23. package/dist/index.d.ts +14 -5
  24. package/dist/index.mjs +6 -5
  25. package/dist/{language-ONQ3iVDf.d.ts → language-CmJBHf1j.d.ts} +4 -1
  26. package/dist/languages/albanian.d.ts +5 -0
  27. package/dist/languages/albanian.mjs +103 -0
  28. package/dist/languages/amharic.d.ts +5 -0
  29. package/dist/languages/amharic.mjs +240 -0
  30. package/dist/languages/arabic.d.ts +1 -1
  31. package/dist/languages/arabic.mjs +939 -154
  32. package/dist/languages/armenian.d.ts +1 -1
  33. package/dist/languages/armenian.mjs +275 -243
  34. package/dist/languages/azerbaijani.d.ts +5 -0
  35. package/dist/languages/azerbaijani.mjs +152 -0
  36. package/dist/languages/bambara.d.ts +5 -0
  37. package/dist/languages/bambara.mjs +60 -0
  38. package/dist/languages/basque.d.ts +5 -0
  39. package/dist/languages/basque.mjs +726 -0
  40. package/dist/languages/belarusian.d.ts +5 -0
  41. package/dist/languages/belarusian.mjs +100 -0
  42. package/dist/languages/bengali.d.ts +5 -0
  43. package/dist/languages/bengali.mjs +128 -0
  44. package/dist/languages/bosnian.d.ts +5 -0
  45. package/dist/languages/bosnian.mjs +197 -0
  46. package/dist/languages/breton.d.ts +5 -0
  47. package/dist/languages/breton.mjs +96 -0
  48. package/dist/languages/bulgarian.d.ts +1 -1
  49. package/dist/languages/bulgarian.mjs +11 -2
  50. package/dist/languages/burmese.d.ts +5 -0
  51. package/dist/languages/burmese.mjs +57 -0
  52. package/dist/languages/catalan.d.ts +5 -0
  53. package/dist/languages/catalan.mjs +987 -0
  54. package/dist/languages/chinese.d.ts +1 -1
  55. package/dist/languages/chinese.mjs +4 -3
  56. package/dist/languages/croatian.d.ts +5 -0
  57. package/dist/languages/croatian.mjs +191 -0
  58. package/dist/languages/czech.d.ts +5 -0
  59. package/dist/languages/czech.mjs +628 -0
  60. package/dist/languages/dagbani.d.ts +1 -1
  61. package/dist/languages/dagbani.mjs +11 -2
  62. package/dist/languages/danish.d.ts +1 -1
  63. package/dist/languages/danish.mjs +215 -110
  64. package/dist/languages/dutch.d.ts +1 -1
  65. package/dist/languages/dutch.mjs +1248 -163
  66. package/dist/languages/english.d.ts +1 -1
  67. package/dist/languages/english.mjs +2 -1
  68. package/dist/languages/esperanto.d.ts +5 -0
  69. package/dist/languages/esperanto.mjs +689 -0
  70. package/dist/languages/estonian.d.ts +5 -0
  71. package/dist/languages/estonian.mjs +2160 -0
  72. package/dist/languages/ewe.d.ts +1 -1
  73. package/dist/languages/ewe.mjs +11 -2
  74. package/dist/languages/faroese.d.ts +5 -0
  75. package/dist/languages/faroese.mjs +93 -0
  76. package/dist/languages/fijian.d.ts +5 -0
  77. package/dist/languages/fijian.mjs +55 -0
  78. package/dist/languages/finnish.d.ts +1 -1
  79. package/dist/languages/finnish.mjs +528 -263
  80. package/dist/languages/french.d.ts +1 -1
  81. package/dist/languages/french.mjs +950 -437
  82. package/dist/languages/ga.d.ts +1 -1
  83. package/dist/languages/ga.mjs +2 -1
  84. package/dist/languages/galician.d.ts +5 -0
  85. package/dist/languages/galician.mjs +172 -0
  86. package/dist/languages/georgian.d.ts +5 -0
  87. package/dist/languages/georgian.mjs +91 -0
  88. package/dist/languages/german.d.ts +1 -1
  89. package/dist/languages/german.mjs +432 -162
  90. package/dist/languages/greek.d.ts +1 -1
  91. package/dist/languages/greek.mjs +1970 -281
  92. package/dist/languages/guarani.d.ts +5 -0
  93. package/dist/languages/guarani.mjs +132 -0
  94. package/dist/languages/gujarati.d.ts +5 -0
  95. package/dist/languages/gujarati.mjs +234 -0
  96. package/dist/languages/haitian-creole.d.ts +5 -0
  97. package/dist/languages/haitian-creole.mjs +106 -0
  98. package/dist/languages/hausa.d.ts +1 -1
  99. package/dist/languages/hausa.mjs +2 -1
  100. package/dist/languages/hawaiian.d.ts +5 -0
  101. package/dist/languages/hawaiian.mjs +69 -0
  102. package/dist/languages/hebrew.d.ts +5 -0
  103. package/dist/languages/hebrew.mjs +213 -0
  104. package/dist/languages/hindi.d.ts +1 -1
  105. package/dist/languages/hindi.mjs +175 -97
  106. package/dist/languages/hungarian.d.ts +1 -1
  107. package/dist/languages/hungarian.mjs +412 -326
  108. package/dist/languages/icelandic.d.ts +5 -0
  109. package/dist/languages/icelandic.mjs +161 -0
  110. package/dist/languages/igbo.d.ts +1 -1
  111. package/dist/languages/igbo.mjs +2 -1
  112. package/dist/languages/indonesian.d.ts +1 -1
  113. package/dist/languages/indonesian.mjs +329 -137
  114. package/dist/languages/irish.d.ts +1 -1
  115. package/dist/languages/irish.mjs +223 -139
  116. package/dist/languages/italian.d.ts +1 -1
  117. package/dist/languages/italian.mjs +703 -406
  118. package/dist/languages/japanese.d.ts +1 -1
  119. package/dist/languages/japanese.mjs +5 -108
  120. package/dist/languages/kannada.d.ts +5 -0
  121. package/dist/languages/kannada.mjs +94 -0
  122. package/dist/languages/kazakh.d.ts +5 -0
  123. package/dist/languages/kazakh.mjs +90 -0
  124. package/dist/languages/khmer.d.ts +5 -0
  125. package/dist/languages/khmer.mjs +181 -0
  126. package/dist/languages/kinyarwanda.d.ts +5 -0
  127. package/dist/languages/kinyarwanda.mjs +99 -0
  128. package/dist/languages/kirundi.d.ts +5 -0
  129. package/dist/languages/kirundi.mjs +92 -0
  130. package/dist/languages/korean.d.ts +5 -0
  131. package/dist/languages/korean.mjs +252 -0
  132. package/dist/languages/kurmanji.d.ts +5 -0
  133. package/dist/languages/kurmanji.mjs +50 -0
  134. package/dist/languages/kyrgyz.d.ts +5 -0
  135. package/dist/languages/kyrgyz.mjs +108 -0
  136. package/dist/languages/lao.d.ts +5 -0
  137. package/dist/languages/lao.mjs +63 -0
  138. package/dist/languages/latin.d.ts +5 -0
  139. package/dist/languages/latin.mjs +69 -0
  140. package/dist/languages/latvian.d.ts +5 -0
  141. package/dist/languages/latvian.mjs +173 -0
  142. package/dist/languages/lingala.d.ts +5 -0
  143. package/dist/languages/lingala.mjs +61 -0
  144. package/dist/languages/lithuanian.d.ts +5 -0
  145. package/dist/languages/lithuanian.mjs +571 -0
  146. package/dist/languages/luxembourgish.d.ts +5 -0
  147. package/dist/languages/luxembourgish.mjs +214 -0
  148. package/dist/languages/macedonian.d.ts +5 -0
  149. package/dist/languages/macedonian.mjs +808 -0
  150. package/dist/languages/malagasy.d.ts +5 -0
  151. package/dist/languages/malagasy.mjs +67 -0
  152. package/dist/languages/malay.d.ts +5 -0
  153. package/dist/languages/malay.mjs +482 -0
  154. package/dist/languages/malayalam.d.ts +5 -0
  155. package/dist/languages/malayalam.mjs +122 -0
  156. package/dist/languages/maltese.d.ts +5 -0
  157. package/dist/languages/maltese.mjs +139 -0
  158. package/dist/languages/maori.d.ts +5 -0
  159. package/dist/languages/maori.mjs +79 -0
  160. package/dist/languages/marathi.d.ts +5 -0
  161. package/dist/languages/marathi.mjs +111 -0
  162. package/dist/languages/nepali.d.ts +1 -1
  163. package/dist/languages/nepali.mjs +209 -99
  164. package/dist/languages/norwegian.d.ts +1 -1
  165. package/dist/languages/norwegian.mjs +218 -85
  166. package/dist/languages/oromo.d.ts +5 -0
  167. package/dist/languages/oromo.mjs +78 -0
  168. package/dist/languages/persian.d.ts +5 -0
  169. package/dist/languages/persian.mjs +710 -0
  170. package/dist/languages/polish.d.ts +5 -0
  171. package/dist/languages/polish.mjs +614 -0
  172. package/dist/languages/portuguese.d.ts +1 -1
  173. package/dist/languages/portuguese.mjs +613 -380
  174. package/dist/languages/punjabi.d.ts +5 -0
  175. package/dist/languages/punjabi.mjs +197 -0
  176. package/dist/languages/romanian.d.ts +1 -1
  177. package/dist/languages/romanian.mjs +561 -382
  178. package/dist/languages/russian.d.ts +1 -1
  179. package/dist/languages/russian.mjs +373 -244
  180. package/dist/languages/samoan.d.ts +5 -0
  181. package/dist/languages/samoan.mjs +63 -0
  182. package/dist/languages/sanskrit.d.ts +1 -1
  183. package/dist/languages/sanskrit.mjs +2 -2
  184. package/dist/languages/scottish-gaelic.d.ts +5 -0
  185. package/dist/languages/scottish-gaelic.mjs +370 -0
  186. package/dist/languages/serbian.d.ts +1 -1
  187. package/dist/languages/serbian.mjs +3635 -155
  188. package/dist/languages/shona.d.ts +5 -0
  189. package/dist/languages/shona.mjs +91 -0
  190. package/dist/languages/sinhala.d.ts +5 -0
  191. package/dist/languages/sinhala.mjs +182 -0
  192. package/dist/languages/slovak.d.ts +5 -0
  193. package/dist/languages/slovak.mjs +430 -0
  194. package/dist/languages/slovenian.d.ts +1 -1
  195. package/dist/languages/slovenian.mjs +11 -2
  196. package/dist/languages/sorani.d.ts +5 -0
  197. package/dist/languages/sorani.mjs +156 -0
  198. package/dist/languages/spanish.d.ts +1 -1
  199. package/dist/languages/spanish.mjs +676 -374
  200. package/dist/languages/swahili.d.ts +1 -1
  201. package/dist/languages/swahili.mjs +2 -1
  202. package/dist/languages/swedish.d.ts +1 -1
  203. package/dist/languages/swedish.mjs +237 -95
  204. package/dist/languages/tagalog.d.ts +5 -0
  205. package/dist/languages/tagalog.mjs +159 -0
  206. package/dist/languages/tamil.d.ts +1 -1
  207. package/dist/languages/tamil.mjs +836 -64
  208. package/dist/languages/tatar.d.ts +5 -0
  209. package/dist/languages/tatar.mjs +1017 -0
  210. package/dist/languages/telugu.d.ts +5 -0
  211. package/dist/languages/telugu.mjs +62 -0
  212. package/dist/languages/thai.d.ts +5 -0
  213. package/dist/languages/thai.mjs +65 -0
  214. package/dist/languages/tibetan.d.ts +5 -0
  215. package/dist/languages/tibetan.mjs +119 -0
  216. package/dist/languages/tigrinya.d.ts +5 -0
  217. package/dist/languages/tigrinya.mjs +194 -0
  218. package/dist/languages/tongan.d.ts +5 -0
  219. package/dist/languages/tongan.mjs +56 -0
  220. package/dist/languages/turkish.d.ts +1 -1
  221. package/dist/languages/turkish.mjs +1246 -393
  222. package/dist/languages/twi.d.ts +1 -1
  223. package/dist/languages/twi.mjs +13 -2
  224. package/dist/languages/ukrainian.d.ts +1 -1
  225. package/dist/languages/ukrainian.mjs +125 -47
  226. package/dist/languages/urdu.d.ts +5 -0
  227. package/dist/languages/urdu.mjs +528 -0
  228. package/dist/languages/vietnamese.d.ts +5 -0
  229. package/dist/languages/vietnamese.mjs +277 -0
  230. package/dist/languages/welsh.d.ts +5 -0
  231. package/dist/languages/welsh.mjs +446 -0
  232. package/dist/languages/wolof.d.ts +5 -0
  233. package/dist/languages/wolof.mjs +79 -0
  234. package/dist/languages/xhosa.d.ts +5 -0
  235. package/dist/languages/xhosa.mjs +95 -0
  236. package/dist/languages/yoruba.d.ts +1 -1
  237. package/dist/languages/yoruba.mjs +1 -0
  238. package/dist/languages/zulu.d.ts +1 -1
  239. package/dist/languages/zulu.mjs +2 -1
  240. package/dist/narsil-CMZRPDlQ.d.ts +159 -0
  241. package/dist/persistence/durability/checkpoint-worker.d.ts +13 -0
  242. package/dist/persistence/durability/checkpoint-worker.mjs +6 -5
  243. package/dist/{schema-DCgkO1j4.d.ts → schema-BnvLvjRp.d.ts} +5 -3
  244. package/dist/server.d.ts +12 -3
  245. package/dist/server.mjs +183 -64
  246. package/dist/{types-DHX6zGeD.d.ts → types-BRTcBgAp.d.ts} +1 -1
  247. package/dist/workers/entry.mjs +2 -2
  248. package/package.json +10 -3
  249. package/dist/chunk-TW6BXTOL.mjs +0 -338
  250. package/dist/direct-executor-E52FFGDE.mjs +0 -4
  251. package/dist/narsil-D-84Nvm4.d.ts +0 -72
package/README.md CHANGED
@@ -14,7 +14,7 @@ Narsil is a distributed search engine with full-text, vector, hybrid, and geosea
14
14
 
15
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. On BEIR SciFact it ranks level with Elasticsearch and OpenSearch at 0.678 nDCG@10 and answers 1,020 keyword queries per second, about a quarter more than either ([benchmarks](https://github.com/assetcorp/narsil/blob/main/BENCHMARKS.md)). This TypeScript package is the reference implementation of the cross-language `.nrsl` format, and a second-language port in Go or Rust is the headline item on the [roadmap](https://github.com/assetcorp/narsil/blob/main/ROADMAP.md).
16
16
 
17
- Try it in your browser at [narsil.sondelali.com/demo](https://narsil.sondelali.com/demo).
17
+ 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
18
 
19
19
  > *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.
20
20
 
@@ -22,30 +22,13 @@ Try it in your browser at [narsil.sondelali.com/demo](https://narsil.sondelali.c
22
22
 
23
23
  - [Install](#install)
24
24
  - [Quick start](#quick-start)
25
- - [Configuration](#configuration)
26
- - [Indexes](#indexes)
27
- - [Documents](#documents)
28
- - [Batch operations](#batch-operations)
29
- - [Search](#search)
30
- - [Vector search](#vector-search)
31
- - [Hybrid search](#hybrid-search)
32
- - [Geosearch](#geosearch)
33
- - [Embedding adapters](#embedding-adapters)
34
- - [Persistence](#persistence)
35
- - [Durability](#durability)
36
- - [Snapshots and restore](#snapshots-and-restore)
37
- - [Partitions and rebalancing](#partitions-and-rebalancing)
38
- - [Workers](#workers)
39
- - [Multi-instance invalidation](#multi-instance-invalidation)
40
- - [Plugins](#plugins)
41
- - [Events](#events)
42
- - [Errors](#errors)
43
- - [Memory reporting](#memory-reporting)
44
- - [Language support](#language-support)
45
- - [HTTP server](#http-server)
25
+ - [Features](#features)
26
+ - [Documentation](#documentation)
27
+ - [Examples](#examples)
46
28
  - [Distribution](#distribution)
47
29
  - [Search quality](#search-quality)
48
30
  - [Runtime support](#runtime-support)
31
+ - [License](#license)
49
32
 
50
33
  ## Install
51
34
 
@@ -98,965 +81,48 @@ const results = await narsil.query('products', {
98
81
 
99
82
  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.
100
83
 
101
- ## Configuration
84
+ ## Features
102
85
 
103
- `createNarsil` accepts an optional `NarsilConfig` object. All fields are optional.
86
+ **Search.** [Full-text search](https://github.com/assetcorp/narsil/blob/main/docs/full-text-search.md) 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. Queries compose with [filters, facets, sorting, grouping, cursor pagination, and pinned results](https://github.com/assetcorp/narsil/blob/main/docs/filters-facets-and-pagination.md).
104
87
 
105
- ```ts
106
- import { createNarsil } from '@delali/narsil'
107
- import { createFilesystemPersistence } from '@delali/narsil/adapters/filesystem'
108
-
109
- const narsil = await createNarsil({
110
- persistence: createFilesystemPersistence({ directory: './narsil-data' }),
111
- workers: { enabled: true, count: 4 },
112
- flush: { interval: 5000, mutationThreshold: 100 },
113
- })
114
- ```
115
-
116
- ### NarsilConfig
117
-
118
- | Field | Type | Description |
119
- | --- | --- | --- |
120
- | `persistence` | `PersistenceAdapter` | Sets the storage backend for persisted partitions. See [Persistence](#persistence). |
121
- | `invalidation` | `InvalidationAdapter` | Coordinates cache eviction across processes or tabs. See [Multi-instance invalidation](#multi-instance-invalidation). |
122
- | `plugins` | `NarsilPlugin[]` | Registers lifecycle hooks for document and search operations. See [Plugins](#plugins). |
123
- | `idGenerator` | `() => string` | Replaces the default UUID v7 generator for document ids. |
124
- | `workers` | `WorkerConfig` | Controls the worker thread pool for parallel search. See [Workers](#workers). |
125
- | `flush` | `FlushConfig` | Controls when dirty partitions persist to storage. |
126
- | `embedding` | `EmbeddingAdapter` | Sets the default adapter for auto-embedding text into vectors. See [Embedding adapters](#embedding-adapters). |
127
- | `embeddingAdapters` | `Record<string, EmbeddingAdapter>` | Registers named adapters that index configs reference by name. Names persist in index metadata, so durability recovery can rebind them. |
128
- | `durability` | `DurabilityConfig` | Enables write-ahead logging and snapshots. See [Durability](#durability). |
129
-
130
- ### WorkerConfig
131
-
132
- | Field | Type | Default | Description |
133
- | --- | --- | --- | --- |
134
- | `enabled` | `boolean` | `false` | Turns on the worker thread pool for search. |
135
- | `count` | `number` | CPU cores minus one, clamped between 2 and 8 | Sets the number of worker threads to spawn. |
136
- | `promotionThreshold` | `number` | `10000` | Sets the per-index document count that triggers promotion to workers. |
137
- | `totalPromotionThreshold` | `number` | `50000` | Sets the document count across all indexes that triggers promotion. |
138
-
139
- ### FlushConfig
140
-
141
- | Field | Type | Description |
142
- | --- | --- | --- |
143
- | `interval` | `number` | Sets the milliseconds between persistence flushes. |
144
- | `mutationThreshold` | `number` | Sets the mutation count that triggers a flush. |
145
-
146
- ### Tokenizer cache
147
-
148
- 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.
149
-
150
- 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:
151
-
152
- ```ts
153
- import { configureNormalizationCache, createNarsil } from '@delali/narsil'
154
-
155
- configureNormalizationCache(500_000)
156
-
157
- const narsil = await createNarsil()
158
- ```
159
-
160
- 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`.
161
-
162
- Four functions manage the cache:
163
-
164
- - `configureNormalizationCache(maxSize)` sets the maximum cache size.
165
- - `clearNormalizationCache()` drops all cached entries, which reclaims memory after one-off indexing.
166
- - `resetNormalizationCache()` clears the cache and resets the size to the auto-detected default.
167
- - `getNormalizationCacheSize()` returns the current number of cached entries for monitoring.
168
-
169
- ## Indexes
170
-
171
- `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.
172
-
173
- ```ts
174
- await narsil.createIndex('articles', {
175
- schema: {
176
- title: 'string',
177
- body: 'string',
178
- author: {
179
- name: 'string',
180
- verified: 'boolean',
181
- },
182
- publishedYear: 'number',
183
- },
184
- language: 'english',
185
- required: ['title'],
186
- })
187
- ```
188
-
189
- ### IndexConfig
190
-
191
- | Field | Type | Description |
192
- | --- | --- | --- |
193
- | `schema` | `SchemaDefinition` | Declares the fields and their types. This field is required. |
194
- | `language` | `string` | Selects the language module for tokenization and stemming. The default is `english`. |
195
- | `partitions` | `PartitionConfig` | Sets `maxDocsPerPartition` and `maxPartitions`. See [Partitions and rebalancing](#partitions-and-rebalancing). |
196
- | `defaultScoring` | `'local' \| 'dfs' \| 'broadcast'` | Sets the scoring mode used when a query does not pass one. See [Scoring modes](#scoring-modes). |
197
- | `bm25` | `BM25Params` | Overrides the BM25 `k1` and `b` parameters. |
198
- | `stopWords` | `Set<string> \| (defaults: Set<string>) => Set<string>` | Replaces or transforms the language module's stop word set. |
199
- | `tokenizer` | `CustomTokenizer` | Replaces the built-in tokenizer with your own `tokenize(text)` implementation. |
200
- | `trackPositions` | `boolean` | Stores token positions for highlighting. The default is `true`. |
201
- | `surfaceForms` | `boolean` | Records the original spellings of stemmed words for suggestions and prefix completions. The default is `false`. See [Suggestions](#suggestions). |
202
- | `vectorPromotion` | `VectorIndexConfig` | Tunes the HNSW promotion threshold, graph parameters, and quantization. See [Vector search](#vector-search). |
203
- | `strict` | `boolean` | Rejects documents that carry fields missing from the schema. |
204
- | `embedding` | `EmbeddingFieldConfig` | Maps text fields to vector fields for auto-embedding. See [Embedding adapters](#embedding-adapters). |
205
- | `required` | `string[]` | Lists fields a document must carry; inserts without them fail with `DOC_MISSING_REQUIRED_FIELD`. |
206
-
207
- ### Index management
208
-
209
- ```ts
210
- const indexes = narsil.listIndexes()
211
- // [{ name: 'articles', documentCount: 1204, partitionCount: 1, language: 'english' }]
212
-
213
- const stats = narsil.getStats('articles')
214
- // { documentCount, partitionCount, estimatedMemoryBytes, language, schema }
215
-
216
- await narsil.clear('articles')
217
-
218
- await narsil.dropIndex('articles')
219
- ```
220
-
221
- `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.
222
-
223
- ## Documents
224
-
225
- ### Insert
226
-
227
- `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.
228
-
229
- ```ts
230
- const generatedId = await narsil.insert('products', { title: 'Trackball Mouse' })
231
-
232
- const explicitId = await narsil.insert('products', { title: 'Split Keyboard' }, 'kb-042')
233
-
234
- await narsil.insert('products', { id: 'kb-043', title: 'Tenkeyless Keyboard' })
235
- ```
236
-
237
- 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.
238
-
239
- ### Read
240
-
241
- ```ts
242
- const doc = await narsil.get('products', 'kb-042')
243
- // the document, or undefined when the id is unknown
244
-
245
- const docs = await narsil.getMultiple('products', ['kb-042', 'kb-043'])
246
- // Map<string, AnyDocument> holding only the ids that exist
247
-
248
- const exists = await narsil.has('products', 'kb-042')
249
-
250
- const count = await narsil.countDocuments('products')
251
- ```
252
-
253
- ### Update and remove
254
-
255
- `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.
256
-
257
- ```ts
258
- await narsil.update('products', 'kb-042', { title: 'Split Ergonomic Keyboard' })
259
-
260
- await narsil.remove('products', 'kb-042')
261
- ```
262
-
263
- Both methods throw `DOC_NOT_FOUND` for an unknown id.
264
-
265
- ## Batch operations
266
-
267
- `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.
268
-
269
- ```ts
270
- const result = await narsil.insertBatch('products', [
271
- { id: 'p1', title: 'USB-C Hub', price: 49 },
272
- { id: 'p2', title: 'Laptop Stand', price: 89 },
273
- { id: 'p3', title: 'Broken Doc', price: 'not-a-number' },
274
- ])
275
-
276
- // result.succeeded => ['p1', 'p2']
277
- // result.failed => [{ docId: 'p3', error: NarsilError(DOC_VALIDATION_FAILED) }]
278
-
279
- await narsil.updateBatch('products', [
280
- { docId: 'p1', document: { title: 'USB-C Hub, 8 ports', price: 59 } },
281
- ])
282
-
283
- await narsil.removeBatch('products', ['p1', 'p2'])
284
- ```
285
-
286
- 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.
287
-
288
- ## Search
289
-
290
- `query(indexName, params)` runs every search. The `mode` parameter selects `'fulltext'` (the default), `'vector'`, or `'hybrid'`.
291
-
292
- ### Full-text search
293
-
294
- Full-text search scores with BM25. `fields` restricts the search to specific fields, and `boost` multiplies per-field scores.
295
-
296
- ```ts
297
- const results = await narsil.query('products', {
298
- term: 'wireless keyboard',
299
- fields: ['title', 'description'],
300
- boost: { title: 2.0 },
301
- limit: 10,
302
- offset: 0,
303
- })
304
- ```
305
-
306
- `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.
307
-
308
- ### Fuzzy matching
309
-
310
- `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.
311
-
312
- ```ts
313
- const results = await narsil.query('products', {
314
- term: 'keybaord',
315
- tolerance: 2,
316
- prefixLength: 3,
317
- })
318
- ```
319
-
320
- ### Search as you type
321
-
322
- `prefix: true` treats the last word of the query as an unfinished word, so `secur` matches documents containing `security`. Earlier words must match fully, and `tolerance` keeps applying to them while the unfinished word is completed instead of typo-corrected. Completions score against a shared document frequency and rank below full-word matches, so a document containing the exact typed word comes first. The option is off by default; turn it on for queries fired on every keystroke.
323
-
324
- Completions match against the term dictionary, which stores stemmed tokens, so a typed word that runs past the end of a stem stops matching: `security` is indexed as `secur`, and the query `securi` finds nothing. Create the index with `surfaceForms: true` to match completions against the original spellings instead; the same setting gives [suggestions](#suggestions) their display words.
325
-
326
- ```ts
327
- const results = await narsil.query('products', {
328
- term: 'mechanical keyb',
329
- prefix: true,
330
- })
331
- ```
332
-
333
- ### Score and coverage thresholds
334
-
335
- `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.
336
-
337
- ```ts
338
- const results = await narsil.query('products', {
339
- term: 'mechanical gaming keyboard',
340
- termMatch: 2,
341
- minScore: 1.5,
342
- })
343
- ```
344
-
345
- ### Filters
346
-
347
- 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`.
348
-
349
- ```ts
350
- const results = await narsil.query('products', {
351
- term: 'wireless',
352
- filters: {
353
- or: [
354
- { fields: { category: { eq: 'electronics' } } },
355
- { fields: { category: { eq: 'accessories' } } },
356
- ],
357
- fields: {
358
- price: { between: [10, 100] },
359
- tags: { containsAll: ['bluetooth'] },
360
- },
361
- },
362
- })
363
- ```
364
-
365
- 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.
366
-
367
- ### Facets
368
-
369
- 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`.
370
-
371
- ```ts
372
- const results = await narsil.query('products', {
373
- term: 'laptop',
374
- facets: {
375
- category: { limit: 10, sort: 'desc' },
376
- price: { ranges: [{ from: 0, to: 500 }, { from: 500, to: 1000 }, { from: 1000, to: 5000 }] },
377
- },
378
- })
379
-
380
- // results.facets => { category: { values: { electronics: 42, computers: 28 }, count: 70 }, ... }
381
- ```
382
-
383
- ### Sort
384
-
385
- `sort` orders hits by field values instead of score. Multiple entries apply in order, so the second field breaks ties in the first.
386
-
387
- ```ts
388
- const results = await narsil.query('products', {
389
- term: 'keyboard',
390
- sort: { price: 'asc', title: 'asc' },
391
- })
392
- ```
393
-
394
- ### Grouping
395
-
396
- `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.
397
-
398
- ```ts
399
- const results = await narsil.query('products', {
400
- term: 'keyboard',
401
- group: {
402
- fields: ['category'],
403
- maxPerGroup: 3,
404
- },
405
- })
406
-
407
- // results.groups => [{ values: { category: 'electronics' }, hits: [...] }, ...]
408
-
409
- const withTotals = await narsil.query('products', {
410
- term: 'keyboard',
411
- group: {
412
- fields: ['category'],
413
- reduce: {
414
- initialValue: () => 0,
415
- reducer: (total, doc) => (total as number) + ((doc.price as number) ?? 0),
416
- },
417
- },
418
- })
419
- ```
420
-
421
- ### Highlighting
422
-
423
- `highlight` returns snippets with tags marking where query terms appear. Highlighting needs `trackPositions` left at its default of `true`.
424
-
425
- ```ts
426
- const results = await narsil.query('products', {
427
- term: 'mechanical',
428
- highlight: {
429
- fields: ['title', 'description'],
430
- preTag: '<mark>',
431
- postTag: '</mark>',
432
- maxSnippetLength: 160,
433
- },
434
- })
435
-
436
- // hit.highlights?.title.snippet => '<mark>Mechanical</mark> Keyboard'
437
- ```
438
-
439
- ### Pagination
440
-
441
- 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.
442
-
443
- ```ts
444
- const firstPage = await narsil.query('products', { term: 'keyboard', limit: 20 })
445
-
446
- if (firstPage.cursor) {
447
- const secondPage = await narsil.query('products', {
448
- term: 'keyboard',
449
- limit: 20,
450
- searchAfter: firstPage.cursor,
451
- })
452
- }
453
- ```
454
-
455
- A cursor is only valid for the same query parameters it came from. A malformed cursor fails with `SEARCH_INVALID_CURSOR`.
456
-
457
- ### Pinning
458
-
459
- `pinned` places specific documents at fixed positions in the ranked results, which serves sponsored or editorial placements. Positions are zero-based.
460
-
461
- ```ts
462
- const results = await narsil.query('products', {
463
- term: 'keyboard',
464
- pinned: [{ docId: 'kb-editorial-pick', position: 0 }],
465
- })
466
- ```
467
-
468
- ### Scoring modes
469
-
470
- Three scoring modes handle the statistics-skew problem that appears when an index spans partitions or instances:
471
-
472
- - `'local'` scores each partition with its own statistics. It is the fastest mode and the default.
473
- - `'dfs'` runs a two-phase query that first collects global term statistics, then scores with unified IDF values.
474
- - `'broadcast'` has instances share statistics through the invalidation adapter, so scoring uses pre-computed global values.
475
-
476
- Set a per-index default with `defaultScoring` in the index config, or set `scoring` per query:
477
-
478
- ```ts
479
- const results = await narsil.query('products', {
480
- term: 'keyboard',
481
- scoring: 'dfs',
482
- })
483
- ```
484
-
485
- ### Preflight
486
-
487
- `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.
488
-
489
- ```ts
490
- const { count, elapsed } = await narsil.preflight('products', { term: 'keyboard' })
491
- ```
492
-
493
- ### Suggestions
494
-
495
- `suggest(indexName, params)` returns autocomplete candidates. It tokenizes the input, takes the last word as the prefix, and ranks completions by the number of documents they match. By default the candidates are the stemmed tokens the index stores, so a catalogue containing "mechanical" suggests the stem `mechan`. Create the index with `surfaceForms: true` to suggest the words as they appear in your documents; the engine then records the original spelling of every word the stemmer changed and suggests `mechanical` instead.
496
-
497
- ```ts
498
- await narsil.createIndex('products', {
499
- schema: { title: 'string', description: 'string' },
500
- surfaceForms: true,
501
- })
502
-
503
- const suggestions = await narsil.suggest('products', { prefix: 'mech', limit: 5 })
504
- // suggestions.terms => [{ term: 'mechanical', documentFrequency: 12 }, ...]
505
- ```
506
-
507
- ## Vector search
508
-
509
- 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`.
510
-
511
- ```ts
512
- await narsil.createIndex('docs', {
513
- schema: {
514
- title: 'string',
515
- embedding: 'vector[768]',
516
- },
517
- vectorPromotion: {
518
- threshold: 2048,
519
- hnswConfig: { m: 16, efConstruction: 200, metric: 'cosine' },
520
- quantization: 'sq8',
521
- },
522
- })
523
-
524
- await narsil.insert('docs', {
525
- title: 'Distributed consensus',
526
- embedding: myPrecomputedVector,
527
- })
528
-
529
- const results = await narsil.query('docs', {
530
- mode: 'vector',
531
- vector: {
532
- field: 'embedding',
533
- value: myQueryVector,
534
- metric: 'cosine',
535
- similarity: 0.35,
536
- efSearch: 100,
537
- },
538
- limit: 10,
539
- })
540
- ```
541
-
542
- 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`.
543
-
544
- 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.
545
-
546
- ### Vector maintenance
547
-
548
- 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:
549
-
550
- ```ts
551
- const status = narsil.vectorMaintenanceStatus('docs')
552
- // [{ fieldName, tombstoneRatio, graphCount, bufferSize, building, estimatedCompactMs, estimatedOptimizeMs }]
88
+ **Vector and hybrid retrieval.** [Vector search](https://github.com/assetcorp/narsil/blob/main/docs/vector-search.md) serves cosine, dot-product, and Euclidean queries, starts on an exact scan, and promotes a field to an HNSW graph as it grows, with scalar quantization on by default. [Hybrid search](https://github.com/assetcorp/narsil/blob/main/docs/hybrid-search.md) fuses BM25 and vector rankings through reciprocal rank fusion or linear blending, and [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.
553
89
 
554
- await narsil.compactVectors('docs', 'embedding')
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, and they compose with every other query feature.
555
91
 
556
- await narsil.optimizeVectors('docs', 'embedding')
557
- ```
558
-
559
- `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.
560
-
561
- ## Hybrid search
562
-
563
- Hybrid mode runs the full-text and vector searches in one query and fuses the two rankings.
564
-
565
- ```ts
566
- const results = await narsil.query('docs', {
567
- mode: 'hybrid',
568
- term: 'how do search engines scale',
569
- vector: { field: 'embedding', text: 'how do search engines scale' },
570
- hybrid: { strategy: 'rrf', k: 60 },
571
- limit: 10,
572
- })
573
- ```
574
-
575
- Two fusion strategies are available:
576
-
577
- - `'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.
578
- - `'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].
579
-
580
- ```ts
581
- const weighted = await narsil.query('docs', {
582
- mode: 'hybrid',
583
- term: 'partition rebalancing',
584
- vector: { field: 'embedding', text: 'partition rebalancing' },
585
- hybrid: { strategy: 'linear', alpha: 0.7 },
586
- })
587
- ```
588
-
589
- ## Geosearch
590
-
591
- 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.
592
-
593
- ```ts
594
- await narsil.createIndex('stores', {
595
- schema: {
596
- name: 'string',
597
- location: 'geopoint',
598
- },
599
- })
600
-
601
- await narsil.insert('stores', {
602
- name: 'Osu Night Market',
603
- location: { lat: 5.5571, lon: -0.1824 },
604
- })
605
-
606
- const nearby = await narsil.query('stores', {
607
- filters: {
608
- fields: {
609
- location: {
610
- radius: { lat: 5.556, lon: -0.1969, distance: 5, unit: 'km' },
611
- },
612
- },
613
- },
614
- })
615
-
616
- const inArea = await narsil.query('stores', {
617
- filters: {
618
- fields: {
619
- location: {
620
- polygon: {
621
- points: [
622
- { lat: 5.52, lon: -0.25 },
623
- { lat: 5.52, lon: -0.15 },
624
- { lat: 5.62, lon: -0.15 },
625
- { lat: 5.62, lon: -0.25 },
626
- ],
627
- },
628
- },
629
- },
630
- },
631
- })
632
- ```
633
-
634
- 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.
635
-
636
- ## Embedding adapters
637
-
638
- 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.
639
-
640
- ```ts
641
- import { createNarsil } from '@delali/narsil'
642
- import { createOpenAIEmbedding } from '@delali/narsil/embeddings/openai'
643
-
644
- const narsil = await createNarsil({
645
- embedding: createOpenAIEmbedding({
646
- baseUrl: 'https://api.openai.com/v1',
647
- apiKey: process.env.OPENAI_API_KEY ?? '',
648
- model: 'text-embedding-3-small',
649
- dimensions: 1536,
650
- }),
651
- })
652
-
653
- await narsil.createIndex('articles', {
654
- schema: {
655
- title: 'string',
656
- body: 'string',
657
- embedding: 'vector[1536]',
658
- },
659
- embedding: {
660
- fields: {
661
- embedding: ['title', 'body'],
662
- },
663
- },
664
- })
665
-
666
- await narsil.insert('articles', {
667
- title: 'Distributed search engines',
668
- body: 'Partitioning data across shards improves throughput...',
669
- })
92
+ **Storage.** [Persistence adapters](https://github.com/assetcorp/narsil/blob/main/docs/persistence-and-durability.md) plug in filesystem, IndexedDB, memory, or custom backends. Durability adds a write-ahead log with periodic checkpoints and automatic recovery, and snapshots capture a whole index as one portable byte array.
670
93
 
671
- const results = await narsil.query('articles', {
672
- mode: 'vector',
673
- vector: { field: 'embedding', text: 'how do search engines scale?' },
674
- })
675
- ```
676
-
677
- ### Named adapters
678
-
679
- 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:
680
-
681
- ```ts
682
- import { createNarsil } from '@delali/narsil'
683
- import { createOpenAIEmbedding } from '@delali/narsil/embeddings/openai'
684
-
685
- const engine = await createNarsil({
686
- embeddingAdapters: {
687
- 'openai-small': createOpenAIEmbedding({
688
- baseUrl: 'https://api.openai.com/v1',
689
- apiKey: process.env.OPENAI_API_KEY ?? '',
690
- model: 'text-embedding-3-small',
691
- dimensions: 1536,
692
- }),
693
- },
694
- durability: { directory: './narsil-data' },
695
- })
696
-
697
- await engine.createIndex('articles', {
698
- schema: { title: 'string', embedding: 'vector[1536]' },
699
- embedding: {
700
- adapter: 'openai-small',
701
- fields: { embedding: ['title'] },
702
- },
703
- })
704
-
705
- engine.registerEmbeddingAdapter('openai-small', myReplacementAdapter)
706
- ```
707
-
708
- `registerEmbeddingAdapter` rebinds every index referencing that name, which lets you rotate credentials or swap models without recreating indexes.
709
-
710
- ### Shipped adapters
711
-
712
- | Adapter | Package | Dependencies |
713
- | --- | --- | --- |
714
- | OpenAI | `@delali/narsil/embeddings/openai` | The adapter has no dependencies and uses `fetch`. |
715
- | Transformers.js | `@delali/narsil-embeddings-transformers` | The adapter needs `@huggingface/transformers` as a peer dependency. |
716
-
717
- 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.
718
-
719
- ### Custom adapters
720
-
721
- Build an adapter by satisfying the `EmbeddingAdapter` interface:
722
-
723
- ```ts
724
- interface EmbeddingAdapter {
725
- embed(input: string, purpose: 'document' | 'query', signal?: AbortSignal): Promise<Float32Array>
726
- embedBatch?(inputs: string[], purpose: 'document' | 'query', signal?: AbortSignal): Promise<Float32Array[]>
727
- readonly dimensions: number
728
- shutdown?(): Promise<void>
729
- }
730
- ```
731
-
732
- When `embedBatch` is missing, Narsil falls back to parallel `embed()` calls with a concurrency limit of 8.
733
-
734
- ## Persistence
735
-
736
- Persistence stores serialized partitions through a pluggable adapter. Three adapters ship with the package:
737
-
738
- | Adapter | Import | Environment |
739
- | --- | --- | --- |
740
- | Memory | `@delali/narsil/adapters/memory` | The adapter works everywhere and is meant for tests. |
741
- | Filesystem | `@delali/narsil/adapters/filesystem` | The adapter runs on Node.js, Bun, and Deno. |
742
- | IndexedDB | `@delali/narsil/adapters/indexeddb` | The adapter runs in browsers. |
94
+ **Scale.** [Partitioned indexes](https://github.com/assetcorp/narsil/blob/main/docs/partitions-and-workers.md) route documents by deterministic hash and reshape online through `rebalance()`, with writes buffering in a write-ahead queue during the reshape. Worker promotion moves search off the main thread once document counts cross a threshold.
743
95
 
744
- ```ts
745
- import { createNarsil } from '@delali/narsil'
746
- import { createFilesystemPersistence } from '@delali/narsil/adapters/filesystem'
747
-
748
- const narsil = await createNarsil({
749
- persistence: createFilesystemPersistence({ directory: './narsil-data' }),
750
- flush: { interval: 5000, mutationThreshold: 100 },
751
- })
752
- ```
753
-
754
- 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.
755
-
756
- 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.
757
-
758
- A custom backend satisfies the `PersistenceAdapter` interface: `save(key, data)`, `load(key)`, `delete(key)`, and `list(prefix)`, all returning promises.
759
-
760
- ## Durability
761
-
762
- 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:
763
-
764
- ```ts
765
- const narsil = await createNarsil({
766
- durability: {
767
- directory: './narsil-data',
768
- mode: 'sync',
769
- },
770
- })
771
- ```
772
-
773
- `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:
774
-
775
- ```ts
776
- await narsil.checkpoint('products')
777
- ```
778
-
779
- ### DurabilityConfig
780
-
781
- | Field | Type | Default | Description |
782
- | --- | --- | --- | --- |
783
- | `directory` | `string` | none | Sets the root directory for the log and checkpoints. |
784
- | `mode` | `'sync' \| 'async'` | `'sync'` | Selects the acknowledgement contract described below. |
785
- | `flushIntervalMs` | `number` | `1000` | Sets how often the async mode flushes the log to disk. |
786
- | `segmentMaxBytes` | `number` | `67108864` | Caps a log segment at this size (64 MiB) before rolling to a new one. |
787
- | `checkpointIntervalMs` | `number` | `300000` | Sets the time between automatic checkpoints (5 minutes). |
788
- | `checkpointMutationThreshold` | `number` | `100000` | Sets the mutation count that triggers a checkpoint early. |
789
- | `compactionThreshold` | `number` | `12` | Sets the checkpoint segment count that triggers compaction. |
790
-
791
- 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).
792
-
793
- ## Snapshots and restore
794
-
795
- `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.
796
-
797
- ```ts
798
- import { readFile, writeFile } from 'node:fs/promises'
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. [Events, plugins, and memory reporting](https://github.com/assetcorp/narsil/blob/main/docs/observability.md) cover observability, and [language modules](https://github.com/assetcorp/narsil/blob/main/docs/language-support.md) cover 107 languages as separate entry points, 20 of them African.
799
97
 
800
- const bytes = await narsil.snapshot('products')
801
- await writeFile('./products.nrsl', bytes)
98
+ ## Documentation
802
99
 
803
- const saved = await readFile('./products.nrsl')
804
- await narsil.restore('products', new Uint8Array(saved))
805
- ```
806
-
807
- 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`.
808
-
809
- ## Partitions and rebalancing
810
-
811
- 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`.
812
-
813
- ```ts
814
- await narsil.createIndex('logs', {
815
- schema: { message: 'string' },
816
- partitions: { maxPartitions: 4, maxDocsPerPartition: 250_000 },
817
- })
818
-
819
- await narsil.rebalance('logs', 8)
820
-
821
- await narsil.updatePartitionConfig('logs', { maxDocsPerPartition: 500_000 })
822
- ```
823
-
824
- `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`.
825
-
826
- Two measured costs are worth knowing before raising partition counts:
827
-
828
- **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.
829
-
830
- **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.
831
-
832
- ## Workers
833
-
834
- 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.
835
-
836
- ```ts
837
- const narsil = await createNarsil({
838
- workers: {
839
- enabled: true,
840
- count: 4,
841
- promotionThreshold: 10_000,
842
- totalPromotionThreshold: 50_000,
843
- },
844
- })
845
- ```
846
-
847
- Promotion emits the `workerPromote` event, and a crashed worker emits `workerCrash`; see [Events](#events). Worker heap usage appears in `getMemoryStats()`.
848
-
849
- ## Multi-instance invalidation
850
-
851
- 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:
852
-
853
- | Adapter | Import | Use case |
854
- | --- | --- | --- |
855
- | Filesystem | `@delali/narsil/invalidation/filesystem` | The adapter coordinates processes on one machine through marker files. |
856
- | BroadcastChannel | `@delali/narsil/invalidation/broadcast-channel` | The adapter coordinates browser tabs through a BroadcastChannel. |
857
-
858
- ```ts
859
- import { createNarsil } from '@delali/narsil'
860
- import { createFilesystemPersistence } from '@delali/narsil/adapters/filesystem'
861
- import { createFilesystemInvalidation } from '@delali/narsil/invalidation/filesystem'
862
-
863
- const narsil = await createNarsil({
864
- persistence: createFilesystemPersistence({ directory: './narsil-data' }),
865
- invalidation: createFilesystemInvalidation({ directory: './narsil-data', pollInterval: 1000 }),
866
- })
867
- ```
868
-
869
- 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()`.
870
-
871
- ## Plugins
872
-
873
- Plugins hook into the document and search lifecycle. A plugin is an object with a `name` and any of the optional hooks:
874
-
875
- | Hook | Fires |
100
+ | Guide | What it covers |
876
101
  | --- | --- |
877
- | `beforeInsert` / `afterInsert` | The hooks fire around every document insert, including each document of a batch. |
878
- | `beforeUpdate` / `afterUpdate` | The hooks fire around every document update. |
879
- | `beforeRemove` / `afterRemove` | The hooks fire around every document removal. |
880
- | `beforeSearch` / `afterSearch` | The hooks fire around every query; `afterSearch` receives the results. |
881
- | `onIndexCreate` / `onIndexDrop` | The hooks fire when an index is created or dropped. |
882
-
883
- 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.
884
-
885
- ```ts
886
- import { createNarsil, type NarsilPlugin } from '@delali/narsil'
887
-
888
- const auditLog: NarsilPlugin = {
889
- name: 'audit-log',
890
- async afterInsert(ctx) {
891
- console.log(`indexed ${ctx.docId} into ${ctx.indexName}`)
892
- },
893
- async afterSearch(ctx) {
894
- console.log(`query on ${ctx.indexName} returned ${ctx.results?.hits.length ?? 0} hits`)
895
- },
896
- }
897
-
898
- const narsil = await createNarsil({ plugins: [auditLog] })
899
- ```
900
-
901
- 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.
902
-
903
- ## Events
904
-
905
- `on(event, handler)` subscribes to engine events and `off(event, handler)` unsubscribes. The payloads are typed through `NarsilEventMap`.
906
-
907
- | Event | Payload | Meaning |
908
- | --- | --- | --- |
909
- | `persistenceError` | `{ indexName, partitionId, error, retriesExhausted }` | A partition flush failed; `retriesExhausted` reports whether the engine gave up. |
910
- | `durabilityError` | `{ error }` | A write-ahead log or checkpoint operation failed. |
911
- | `workerCrash` | `{ workerId, indexNames, error }` | A worker died; the engine reassigns its indexes. |
912
- | `workerPromote` | `{ workerCount, reason }` | The engine moved search onto the worker pool. |
913
- | `partitionRebalance` | `{ indexName, oldCount, newCount }` | A partition reshape completed. |
914
-
915
- ```ts
916
- narsil.on('persistenceError', payload => {
917
- console.error(`flush failed for ${payload.indexName}:`, payload.error)
918
- })
919
- ```
920
-
921
- Subscribe to `persistenceError` and `durabilityError` in any deployment that persists data; they are the engine's only channel for reporting background write failures.
922
-
923
- ## Errors
924
-
925
- 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`.
926
-
927
- ```ts
928
- import { ErrorCodes, NarsilError } from '@delali/narsil'
929
-
930
- try {
931
- await narsil.insert('products', { title: 42 })
932
- } catch (err) {
933
- if (err instanceof NarsilError && err.code === ErrorCodes.DOC_VALIDATION_FAILED) {
934
- console.error(err.message, err.details)
935
- }
936
- }
937
- ```
938
-
939
- The codes you handle most often:
940
-
941
- | Code | Thrown when |
942
- | --- | --- |
943
- | `INDEX_NOT_FOUND` / `INDEX_ALREADY_EXISTS` | An operation names an unknown index, or `createIndex` reuses a name. |
944
- | `DOC_NOT_FOUND` / `DOC_ALREADY_EXISTS` | A read, update, or removal names an unknown id, or an insert reuses one. |
945
- | `DOC_VALIDATION_FAILED` / `DOC_MISSING_REQUIRED_FIELD` | A document does not match the schema or omits a required field. |
946
- | `SEARCH_INVALID_FIELD` / `SEARCH_INVALID_FILTER` / `SEARCH_INVALID_CURSOR` | A query names an unknown field, passes a malformed filter, or replays a bad cursor. |
947
- | `VECTOR_DIMENSION_MISMATCH` | A vector's length differs from the field's declared dimension. |
948
- | `EMBEDDING_FAILED` / `EMBEDDING_CONFIG_INVALID` | An adapter call failed, or the embedding configuration is contradictory. |
949
- | `PARTITION_CAPACITY_EXCEEDED` / `PARTITION_REBALANCING_BACKPRESSURE` | An insert passes the capacity cap, or a config change collides with a running reshape. |
950
- | `LANGUAGE_NOT_SUPPORTED` | An index config names a language module that was never imported. |
951
- | `CONFIG_INVALID` | A configuration value is out of range or contradictory. |
952
-
953
- ## Memory reporting
954
-
955
- 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.
956
-
957
- ```ts
958
- const indexStats = narsil.getStats('products')
959
-
960
- console.log(indexStats.estimatedMemoryBytes)
961
- ```
962
-
963
- 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.
964
-
965
- `getPartitionStats(indexName)` returns the same estimate for each partition. The partition values sum to `getStats(indexName).estimatedMemoryBytes`.
966
-
967
- ```ts
968
- const partitions = narsil.getPartitionStats('products')
969
-
970
- for (const partition of partitions) {
971
- console.log(partition.partitionId, partition.estimatedMemoryBytes)
972
- }
973
- ```
974
-
975
- `getMemoryStats()` returns a runtime snapshot and worker reports. It is async because workers report their heap usage through the worker message channel.
976
-
977
- ```ts
978
- const memory = await narsil.getMemoryStats()
979
-
980
- console.log(memory.estimatedIndexBytes)
981
- console.log(memory.process?.heapUsed)
982
- console.log(memory.workers)
983
- ```
984
-
985
- `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.
986
-
987
- ## Language support
988
-
989
- Narsil ships with language modules for tokenization, stemming (Snowball algorithm), and stop word removal.
990
-
991
- ### Full support (tokenizer + stemmer + stop words)
992
-
993
- Arabic, Armenian, Bulgarian, Danish, Dutch, English, Finnish, French, German, Greek, Hindi, Hungarian, Indonesian, Irish, Italian, Nepali, Norwegian, Portuguese, Romanian, Russian, Sanskrit, Serbian, Slovenian, Spanish, Swahili, Swedish, Tamil, Turkish, Ukrainian
994
-
995
- ### CJK support (character n-gram tokenizer + stop words)
996
-
997
- Chinese (Mandarin), Japanese
998
-
999
- ### African language support
1000
-
1001
- Full stemmer support ships for Swahili. Tokenization and stop word support is available for Dagbani, Ewe, Ga, Hausa, Igbo, Twi (Akan), Yoruba, and Zulu.
1002
-
1003
- Each language module is a separate entry point, so you only bundle the languages your application needs:
1004
-
1005
- ```ts
1006
- import '@delali/narsil/languages/french'
1007
- import '@delali/narsil/languages/swahili'
1008
- import '@delali/narsil/languages/twi'
1009
- ```
1010
-
1011
- 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.
1012
-
1013
- ## HTTP server
1014
-
1015
- `@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:
1016
-
1017
- ```bash
1018
- pnpm add -E uWebSockets.js@github:uNetworking/uWebSockets.js#v20.58.0
1019
- ```
1020
-
1021
- ```ts
1022
- import { createNarsil } from '@delali/narsil'
1023
- import { createServer } from '@delali/narsil/server'
1024
-
1025
- const engine = await createNarsil({ durability: { directory: './narsil-data' } })
1026
-
1027
- const server = createServer(engine, {
1028
- host: '127.0.0.1',
1029
- port: 7700,
1030
- })
1031
-
1032
- await server.listen()
1033
- ```
1034
-
1035
- `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.
1036
-
1037
- The full surface:
1038
-
1039
- | Method and path | Purpose |
102
+ | [Configuration](../../docs/configuration.md) | Every `createNarsil` option, worker tuning, analysis rebuilds, and the tokenizer cache |
103
+ | [Indexes and documents](../../docs/indexes-and-documents.md) | Schemas, index management, inserts, reads, updates, removals, and batch operations |
104
+ | [Full-text search](../../docs/full-text-search.md) | Term queries, fuzzy matching, prefix completion, thresholds, highlighting, scoring modes, and suggestions |
105
+ | [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 |
107
+ | [Hybrid search](../../docs/hybrid-search.md) | Reciprocal rank fusion and linear blending of text and vector rankings |
108
+ | [Geosearch](../../docs/geosearch.md) | Radius and polygon filters, and the two distance formulas |
109
+ | [Embedding adapters](../../docs/embedding-adapters.md) | Automatic embedding on insert and query, named adapters, the bundled ones, and custom ones |
110
+ | [Persistence and durability](../../docs/persistence-and-durability.md) | Storage backends, the write-ahead log, checkpoints, recovery, and snapshots |
111
+ | [Partitions and workers](../../docs/partitions-and-workers.md) | Partition routing, online rebalancing, worker promotion, and multi-instance invalidation |
112
+ | [Language support](../../docs/language-support.md) | The 107 language modules, analysis revisions and rebuilds, and named tokenizers and stop words |
113
+ | [HTTP server](../../docs/http-server.md) | Wrapping an engine in a REST API, and every route it serves |
114
+ | [Observability](../../docs/observability.md) | Plugin hooks, engine events, and memory reporting |
115
+ | [Errors](../../docs/errors.md) | Every error code and what throws it |
116
+
117
+ The [specification](../spec/) defines the `.nrsl` format, the analysis pipeline, and the replication invariants that every implementation follows.
118
+
119
+ ## Examples
120
+
121
+ | Example | What it shows |
1040
122
  | --- | --- |
1041
- | `GET /livez`, `GET /readyz`, `GET /health` | The probes report liveness and readiness without authentication. |
1042
- | `GET /version` | The endpoint reports the build identity stamped at startup. |
1043
- | `GET /stats/memory` | The endpoint returns `getMemoryStats()`. |
1044
- | `POST /indexes`, `GET /indexes`, `DELETE /indexes/{name}` | The endpoints create, list, and drop indexes. |
1045
- | `GET /indexes/{name}/stats`, `GET /indexes/{name}/partitions`, `GET /indexes/{name}/count` | The endpoints report index, partition, and document-count statistics. |
1046
- | `POST /indexes/{name}/_clear` | The endpoint removes every document but keeps the index. |
1047
- | `POST /indexes/{name}/documents` | The endpoint inserts one document. |
1048
- | `GET`, `PUT`, `PATCH`, `DELETE /indexes/{name}/documents/{id}` | The endpoints read, upsert, update, and remove one document. |
1049
- | `GET /indexes/{name}/documents/{id}/_exists` | The endpoint reports whether the id exists. |
1050
- | `POST /indexes/{name}/documents/_batch` | The endpoint runs a batch insert, update, or delete with partial results. |
1051
- | `POST /indexes/{name}/documents/_multi-get` | The endpoint fetches many documents by id. |
1052
- | `POST /indexes/{name}/documents/_import` | The endpoint streams an NDJSON corpus in bounded batches. |
1053
- | `POST /indexes/{name}/search`, `POST /indexes/{name}/search/preflight`, `POST /indexes/{name}/suggest` | The endpoints run queries, match counts, and autocomplete. |
1054
- | `POST /indexes/{name}/_checkpoint`, `GET /indexes/{name}/snapshot`, `POST /indexes/{name}/restore` | The endpoints force a checkpoint, download a snapshot, and restore one. |
1055
- | `GET /indexes/{name}/vector-maintenance`, `POST /indexes/{name}/vectors/_compact`, `POST /indexes/{name}/vectors/_optimize` | The endpoints report and run vector maintenance. |
1056
- | `POST /indexes/{name}/_rebalance`, `POST /indexes/{name}/partition-config` | The endpoints reshape partitions and adjust partition caps. |
1057
- | `GET /tasks`, `GET /tasks/{id}` | The endpoints report long-running task status. |
1058
-
1059
- 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.
123
+ | [HTTP server](examples/http-server/README.md) | The launcher runs the engine as a REST service with durability, API-key auth, and Docker packaging, and its README documents the full API surface. |
124
+ | [Browser](examples/browser/README.md) | The app embeds the engine in a browser with IndexedDB persistence and Web Worker search. |
125
+ | [Server app](examples/server-app/README.md) | The app pairs a search UI with the HTTP server, including dataset loading and an embedding-backed Ask view. |
1060
126
 
1061
127
  ## Distribution
1062
128
 
@@ -1071,7 +137,7 @@ Ranking quality is measured against the [BEIR](https://github.com/beir-cellar/be
1071
137
  - **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.
1072
138
  - **P@10** is the fraction of the top 10 results that are relevant.
1073
139
  - **MAP** tracks precision at every rank where a relevant document appears. A higher MAP means relevant documents cluster near the top of the ranking.
1074
- - **MRR** measures how soon the first relevant result appears. A higher value means the first relevant document sits closer to the top.
140
+ - **MRR** measures how soon the first relevant result appears. A higher value puts the first relevant document nearer the top.
1075
141
 
1076
142
  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.
1077
143