lucivy 0.2.1 → 2.0.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 (4) hide show
  1. package/README.md +111 -30
  2. package/index.d.ts +202 -19
  3. package/lucivy.node +0 -0
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -1,7 +1,17 @@
1
- # lucivy
1
+ # lucivy v2
2
2
 
3
3
  Fast BM25 full-text search for Node.js — with substring matching, fuzzy search, regex, and highlights. Powered by Rust via napi-rs.
4
4
 
5
+ [**Try the live playground**](https://l-defraiteur.github.io/lucivy/) — runs entirely in your browser via WASM.
6
+
7
+ ### What's new in v2
8
+
9
+ - **SFX-only engine** — all queries route through the Suffix FST, no legacy code paths
10
+ - **Distributed search** — `exportStats` / `mergeStats` / `searchWithGlobalStats`
11
+ - **Incremental sync** — LUCIDS sharded delta export/apply
12
+ - **Correct BM25 cross-shard** — identical scores whether 1 shard or 4
13
+ - **5 bindings** — Python, Node.js, C++, WASM, Rust
14
+
5
15
  ## Install
6
16
 
7
17
  ```bash
@@ -13,16 +23,16 @@ npm install lucivy
13
23
  ```javascript
14
24
  const { Index } = require('lucivy');
15
25
 
16
- const index = Index.create('./my_index', [
17
- { name: 'title', type: 'text' },
18
- { name: 'body', type: 'text' },
26
+ const index = Index.create('/tmp/my_index', [
27
+ { name: 'title', type: 'text', stored: true },
28
+ { name: 'body', type: 'text', stored: true },
19
29
  ]);
20
30
 
21
31
  index.add(1, { title: 'Rust Programming', body: 'Systems programming with memory safety' });
22
32
  index.add(2, { title: 'Python Guide', body: 'Data science and web development' });
23
33
  index.commit();
24
34
 
25
- let results = index.search('programming', { highlights: true });
35
+ const results = index.search('programming', { highlights: true });
26
36
  for (const r of results) {
27
37
  console.log(r.docId, r.score, r.highlights);
28
38
  }
@@ -33,24 +43,31 @@ for (const r of results) {
33
43
  ### Create / open
34
44
 
35
45
  ```javascript
36
- const index = Index.create('./my_index', [
37
- { name: 'title', type: 'text' },
38
- { name: 'body', type: 'text' },
39
- { name: 'tag', type: 'keyword' },
40
- { name: 'year', type: 'i64', indexed: true, fast: true },
46
+ const index = Index.create('/tmp/my_index', [
47
+ { name: 'title', type: 'text', stored: true },
48
+ { name: 'body', type: 'text', stored: true },
49
+ { name: 'score', type: 'f64', fast: true },
41
50
  ]);
42
51
 
43
- const index2 = Index.open('./my_index');
52
+ // Sharded (4 shards)
53
+ const sharded = Index.create('/tmp/sharded', [...], 4);
54
+
55
+ // Open existing
56
+ const index2 = Index.open('/tmp/my_index');
44
57
  ```
45
58
 
59
+ Field types: `"text"` (full-text, tokenized), `"u64"`, `"i64"`, `"f64"`, `"bool"`, `"date"`.
60
+
46
61
  ### Add / update / delete
47
62
 
48
63
  ```javascript
49
- index.add(1, { title: 'Hello', body: 'World' });
64
+ index.add(1, { title: 'Hello', body: 'World', score: 3.14 });
65
+
50
66
  index.addMany([
51
67
  { docId: 2, title: 'Foo', body: 'Bar' },
52
68
  { docId: 3, title: 'Baz', body: 'Qux' },
53
69
  ]);
70
+
54
71
  index.update(1, { title: 'Updated', body: 'Content' });
55
72
  index.delete(2);
56
73
  index.commit();
@@ -62,7 +79,7 @@ index.commit();
62
79
  // String query — each word searched across all text fields (contains_split)
63
80
  let results = index.search('rust async programming');
64
81
 
65
- // Options
82
+ // Options: limit, highlights, allowedIds, fields
66
83
  results = index.search('rust', { limit: 20, highlights: true, allowedIds: [1, 3] });
67
84
 
68
85
  // Retrieve stored field values with results
@@ -74,26 +91,33 @@ for (const r of results) {
74
91
 
75
92
  #### contains — substring, fuzzy, regex (cross-token)
76
93
 
77
- Searches **stored text**, not individual tokens. Handles multi-word phrases, substrings, typos, and regex across token boundaries.
94
+ All substring queries are cross-token: they match across token boundaries.
78
95
 
79
96
  ```javascript
80
- // Substring — matches "programming", "programmer", etc.
97
+ // Substring — matches "programming", "programmer", "getProgramHandle", etc.
81
98
  index.search({ type: 'contains', field: 'body', value: 'program' });
82
99
 
83
- // Multi-word phrase
84
- index.search({ type: 'contains', field: 'body', value: 'memory safety' });
100
+ // Fuzzy substring (Levenshtein distance)
101
+ index.search({ type: 'contains', field: 'body', value: 'mutx', distance: 1 });
102
+
103
+ // Regex substring — cross-token regex matching
104
+ index.search({ type: 'contains', field: 'body', value: 'lock.*mutex', regex: true });
105
+
106
+ // Prefix / startsWith — match must start at token boundary (SI=0)
107
+ index.search({ type: 'startsWith', field: 'body', value: 'prog' });
85
108
 
86
- // Fuzzy (catches typos, distance=1 by default)
87
- index.search({ type: 'contains', field: 'body', value: 'programing languag', distance: 1 });
109
+ // Exact whole-token match
110
+ index.search({ type: 'term', field: 'body', value: 'lock' });
88
111
 
89
- // Regex on stored text
90
- index.search({ type: 'contains', field: 'body', value: 'program.*language', regex: true });
112
+ // Phrase — adjacent tokens in order
113
+ index.search({ type: 'phrase', field: 'body', value: 'mutex lock' });
91
114
  ```
92
115
 
93
- #### contains_split — one word = one contains query, OR'd together
116
+ #### contains_split — multi-word search
117
+
118
+ Split on whitespace, each word becomes a `contains` query, combined with boolean OR.
94
119
 
95
120
  ```javascript
96
- // "rust safety" → contains("rust") OR contains("safety") on body
97
121
  index.search({ type: 'contains_split', field: 'body', value: 'rust safety' });
98
122
 
99
123
  // With fuzzy distance — each word gets fuzzy tolerance
@@ -117,16 +141,31 @@ index.search({
117
141
  });
118
142
  ```
119
143
 
120
- #### keyword / range — for non-text fields
144
+ #### Filtering
145
+
146
+ Filter on non-text fields (combined with AND):
121
147
 
122
148
  ```javascript
123
- index.search({ type: 'keyword', field: 'tag', value: 'rust' });
124
149
  index.search({
125
- type: 'contains', field: 'body', value: 'programming',
126
- filters: [{ field: 'year', op: 'gte', value: 2023 }],
150
+ type: 'contains', field: 'body', value: 'lock',
151
+ filters: [
152
+ { field: 'category', op: 'eq', value: 'kernel' },
153
+ { field: 'score', op: 'gte', value: 0.5 },
154
+ { field: 'status', op: 'in', value: ['active', 'review'] },
155
+ ]
127
156
  });
128
157
  ```
129
158
 
159
+ Filter ops: `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `in`, `not_in`, `between`, `starts_with`, `contains`.
160
+
161
+ Pre-filter by document ID (fast, bitmap-based):
162
+
163
+ ```javascript
164
+ index.search({ type: 'contains', field: 'body', value: 'lock' }, { allowedIds: [1, 2, 3] });
165
+ ```
166
+
167
+ > **Note:** napi-rs converts snake_case to camelCase — use `allowedIds`, `docId`, `numDocs`, etc. in JavaScript.
168
+
130
169
  ### Snapshots (export / import)
131
170
 
132
171
  ```javascript
@@ -143,12 +182,54 @@ const restored = Index.importSnapshotFrom('./backup.luce', './restored_index');
143
182
  const restored2 = Index.importSnapshot(buf, './restored_index');
144
183
  ```
145
184
 
185
+ ### Delta sync (incremental)
186
+
187
+ Sync only the segments that changed since the client's last version.
188
+
189
+ ```javascript
190
+ // Get current shard versions
191
+ const versions = index.shardVersions;
192
+
193
+ // Export delta (only changed segments)
194
+ const delta = index.exportShardedDelta(clientVersions);
195
+
196
+ // Apply delta on the client side
197
+ clientIndex.applyShardedDelta(delta);
198
+ ```
199
+
200
+ ### Distributed search
201
+
202
+ Run BM25 search across multiple machines with correct IDF.
203
+
204
+ ```javascript
205
+ const { mergeStats } = require('lucivy');
206
+
207
+ const queryJson = '{"type":"contains","field":"body","value":"mutex"}';
208
+
209
+ // 1. Each node exports its local BM25 stats
210
+ const statsA = nodeA.exportStats(queryJson); // JSON string
211
+ const statsB = nodeB.exportStats(queryJson); // JSON string
212
+
213
+ // 2. Coordinator merges stats from all nodes
214
+ const merged = mergeStats([statsA, statsB]);
215
+
216
+ // 3. Each node searches with global stats (correct IDF across all nodes)
217
+ const resultsA = nodeA.searchWithGlobalStats(queryJson, merged, 10);
218
+ const resultsB = nodeB.searchWithGlobalStats(queryJson, merged, 10);
219
+
220
+ // 4. Coordinator merges top-K results by score
221
+ const all = [...resultsA, ...resultsB].sort((a, b) => b.score - a.score).slice(0, 10);
222
+ ```
223
+
146
224
  ### Properties
147
225
 
148
226
  ```javascript
149
- index.numDocs // number of documents
150
- index.path // index directory path
151
- index.schema // array of field definitions
227
+ index.numDocs // number of documents (getter)
228
+ index.numShards // number of shards (getter)
229
+ index.path // index directory path (getter)
230
+ index.schema // array of {name, type} objects (getter)
231
+ index.shardVersions // per-shard version info for delta sync (getter)
232
+ index.close() // flush + release writer lock
152
233
  ```
153
234
 
154
235
  ## License
package/index.d.ts CHANGED
@@ -3,6 +3,11 @@
3
3
 
4
4
  /* auto-generated by NAPI-RS */
5
5
 
6
+ export interface ShardVersion {
7
+ shardId: number
8
+ version: string
9
+ segmentIds: Array<string>
10
+ }
6
11
  export interface SearchResult {
7
12
  docId: number
8
13
  score: number
@@ -23,48 +28,226 @@ export interface SearchOptions {
23
28
  fields?: boolean
24
29
  }
25
30
  export declare class Index {
26
- /** Create a new index at the given path. */
27
- static create(path: string, fields: Array<FieldDef>, stemmer?: string | undefined | null): Index
28
- /** Open an existing index at the given path. */
31
+ /**
32
+ * Create a new index at the given path.
33
+ *
34
+ * @param path - Directory path for the index files.
35
+ * @param fields - Field definitions: `[{name: "body", type: "text", stored: true}]`.
36
+ * Types: `"text"` (full-text), `"u64"`, `"i64"`, `"f64"`, `"bool"`, `"date"`.
37
+ * @param shards - Number of shards (default 1). More shards = faster search on large datasets.
38
+ */
39
+ static create(path: string, fields: Array<FieldDef>, shards?: number | undefined | null): Index
40
+ /**
41
+ * Open an existing index at the given path.
42
+ *
43
+ * Reads the persisted schema and segment metadata from disk.
44
+ * The index must have been previously created with `Index.create()`.
45
+ *
46
+ * @param path - Directory path of the existing index (same path used in `create()`).
47
+ * @returns An `Index` ready for search, add, delete, etc.
48
+ */
29
49
  static open(path: string): Index
30
- /** Add a document. `fields` is an object with field names as keys. */
50
+ /**
51
+ * Add a document.
52
+ *
53
+ * @param docId - Unique document ID (_node_id).
54
+ * @param fields - Object with field names as keys: `{title: "Hello", body: "World", score: 3.14}`
55
+ */
31
56
  add(docId: number, fields: Record<string, any>): void
32
57
  /**
33
58
  * Add multiple documents at once.
34
- * Each element must have a `docId` key and field values.
59
+ *
60
+ * Each element must have a `docId` (or `doc_id`) key plus field values.
61
+ *
62
+ * @param docs - Array of objects: `[{docId: 1, title: "Hello"}, {docId: 2, title: "World"}]`.
35
63
  */
36
64
  addMany(docs: Array<Record<string, any>>): void
37
- /** Delete a document by doc_id. */
65
+ /**
66
+ * Delete a document by its `_node_id`.
67
+ *
68
+ * The deletion is staged in memory. Call `commit()` or run a search
69
+ * (which auto-commits via lazy commit) to make it visible.
70
+ *
71
+ * @param docId - The `_node_id` of the document to delete.
72
+ */
38
73
  delete(docId: number): void
39
- /** Update a document (delete + re-add). */
74
+ /**
75
+ * Update a document (delete old + re-add with new fields).
76
+ *
77
+ * @param docId - The `_node_id` of the document to update.
78
+ * @param fields - New field values: `{title: "Updated", body: "New content"}`.
79
+ */
40
80
  update(docId: number, fields: Record<string, any>): void
41
- /** Commit pending changes (makes added/deleted docs visible to searches). */
81
+ /**
82
+ * Commit pending changes to disk, making them visible to subsequent searches.
83
+ *
84
+ * Lucivy uses lazy commit: if you search without calling `commit()`,
85
+ * uncommitted changes are auto-flushed before the search executes.
86
+ * Call `commit()` explicitly when you need to control the commit point
87
+ * (e.g., after a batch of adds/deletes).
88
+ */
42
89
  commit(): void
43
- /** Rollback pending changes. */
44
- rollback(): void
90
+ /**
91
+ * Flush any pending writes and release the writer lock.
92
+ *
93
+ * After `close()`, the index data remains on disk and can be re-opened
94
+ * with `Index.open()`. No further mutations are allowed on this instance.
95
+ */
96
+ close(): void
45
97
  /**
46
98
  * Search the index.
47
- * `query` can be a string (contains_split on all text fields) or an object (QueryConfig).
99
+ *
100
+ * @param query - String or query object.
101
+ *
102
+ * **String**: `"hello world"` — auto contains_split across all text fields.
103
+ *
104
+ * **Query object** (all substring queries are cross-token):
105
+ * - `{type: "contains", field: "body", value: "lock"}` — substring match
106
+ * - `{type: "contains", field: "body", value: "lock", distance: 1}` — fuzzy substring
107
+ * - `{type: "contains", field: "body", value: "lock.*init", regex: true}` — regex substring
108
+ * - `{type: "startsWith", field: "body", value: "lock"}` — token prefix
109
+ * - `{type: "contains_split", field: "body", value: "struct device"}` — words OR'd
110
+ * - `{type: "term", field: "body", value: "lock"}` — exact whole-token
111
+ * - `{type: "fuzzy", field: "body", value: "schdule", distance: 1}` — alias for contains+distance
112
+ * - `{type: "phrase", field: "body", value: "mutex lock"}` — adjacent tokens
113
+ * - `{type: "regex", field: "body", pattern: "sched[a-z]+"}` — regex on tokens
114
+ * - `{type: "boolean", must: [...], should: [...], must_not: [...]}` — boolean combo
115
+ * - `{type: "disjunction_max", queries: [...], tie_breaker: 0.1}` — best score
116
+ * - `{type: "more_like_this", field: "body", value: "sample text", min_doc_frequency: 1}` — similarity
117
+ *
118
+ * **Filtering:**
119
+ * - `allowed_ids` in options: pre-filter by _node_id (fast, bitmap-based)
120
+ * - `filters` key in query: filter on non-text fields (AND'd with search):
121
+ * ```json
122
+ * {type: "contains", field: "body", value: "lock",
123
+ * filters: [
124
+ * {field: "category", op: "eq", value: "kernel"},
125
+ * {field: "score", op: "gte", value: 0.5},
126
+ * {field: "status", op: "in", value: ["active", "review"]}
127
+ * ]}
128
+ * ```
129
+ * Ops: `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `in`, `not_in`, `between`, `starts_with`, `contains`.
130
+ * Composite: `must`, `should`, `must_not` with nested `clauses`.
131
+ *
132
+ * @param options - `{limit?: number, highlights?: boolean, fields?: boolean, allowed_ids?: number[]}`
48
133
  */
49
134
  search(query: any, options?: SearchOptions | undefined | null): Array<SearchResult>
50
- /** Number of documents in the index. */
135
+ /**
136
+ * Number of documents in the index (getter, access as `index.numDocs`).
137
+ *
138
+ * @returns Total document count across all shards.
139
+ */
51
140
  get numDocs(): number
52
- /** Index path. */
141
+ /**
142
+ * Number of shards (getter, access as `index.numShards`).
143
+ *
144
+ * @returns Shard count (1 for single-shard indexes).
145
+ */
146
+ get numShards(): number
147
+ /**
148
+ * Index directory path (getter, access as `index.path`).
149
+ *
150
+ * @returns The directory path where the index files are stored.
151
+ */
53
152
  get path(): string
54
153
  /**
55
- * Export this index as a LUCE snapshot (Buffer).
56
- * Throws if there are uncommitted changes.
154
+ * Export this index as a LUCE snapshot.
155
+ *
156
+ * Returns the full index content (all shards, schema, segments) as a
157
+ * binary blob. Restore later with `Index.importSnapshot()`.
158
+ *
159
+ * @returns `Buffer` containing the LUCE snapshot bytes.
57
160
  */
58
161
  exportSnapshot(): Buffer
59
- /** Export this index as a LUCE snapshot to a file. */
162
+ /**
163
+ * Export this index as a LUCE snapshot directly to a file.
164
+ *
165
+ * @param path - Destination file path (typically ending in `.luce`).
166
+ */
60
167
  exportSnapshotTo(path: string): void
61
168
  /**
62
169
  * Import an index from a LUCE snapshot (Buffer).
63
- * The snapshot must contain exactly one index.
170
+ *
171
+ * Restores a full index from a binary blob previously created by
172
+ * `exportSnapshot()`. The index files are written to `destPath`.
173
+ *
174
+ * @param data - Raw LUCE snapshot bytes (Buffer).
175
+ * @param destPath - Directory to write the restored index into.
176
+ * Defaults to `"/tmp/lucivy_import"`.
177
+ * @returns A new `Index` instance ready for search.
64
178
  */
65
179
  static importSnapshot(data: Buffer, destPath?: string | undefined | null): Index
66
- /** Import an index from a LUCE snapshot file. */
180
+ /**
181
+ * Import an index from a LUCE snapshot file (.luce).
182
+ *
183
+ * Convenience wrapper that reads the file then calls `importSnapshot()`.
184
+ *
185
+ * @param path - Path to the `.luce` snapshot file.
186
+ * @param destPath - Directory to write the restored index into.
187
+ * Defaults to `"/tmp/lucivy_import"`.
188
+ * @returns A new `Index` instance ready for search.
189
+ */
67
190
  static importSnapshotFrom(path: string, destPath?: string | undefined | null): Index
68
- /** Schema as a list of field definitions. */
191
+ /**
192
+ * Schema as a list of field definitions (getter, access as `index.schema`).
193
+ *
194
+ * @returns `Array<{name: string, type: string}>` for each user-defined field.
195
+ */
69
196
  get schema(): Array<FieldDef>
197
+ /**
198
+ * Per-shard version info for delta sync (getter, access as `index.shardVersions`).
199
+ *
200
+ * Returns the current version and segment IDs for each shard.
201
+ * Pass this to a remote server's `exportShardedDelta()` to
202
+ * receive only the segments that changed since your last sync.
203
+ *
204
+ * @returns `Array<{shardId: number, version: string, segmentIds: string[]}>`.
205
+ */
206
+ get shardVersions(): Array<ShardVersion>
207
+ /**
208
+ * Export a sharded delta (LUCIDS blob) containing only segments that
209
+ * changed since the client's known versions.
210
+ *
211
+ * Used for incremental sync: the client sends its `shardVersions`,
212
+ * the server computes and returns only the diff.
213
+ *
214
+ * @param clientVersions - Array of `{shardId, version, segmentIds}`,
215
+ * typically obtained from the client's `shardVersions` getter.
216
+ * @returns `Buffer` containing the LUCIDS binary delta blob.
217
+ */
218
+ exportShardedDelta(clientVersions: Array<ShardVersion>): Buffer
219
+ /**
220
+ * Apply a sharded delta (LUCIDS blob) to this index.
221
+ *
222
+ * Merges the delta's segments into the local index, bringing it
223
+ * up to date with the server. Only modified shards are touched.
224
+ *
225
+ * @param data - LUCIDS binary blob from `exportShardedDelta()`.
226
+ */
227
+ applyShardedDelta(data: Buffer): void
228
+ /**
229
+ * Export BM25 statistics for a query (for distributed search).
230
+ *
231
+ * In a distributed setup, each node exports its local BM25 stats.
232
+ * A coordinator merges them into global stats and sends them back
233
+ * for scoring with `searchWithGlobalStats()`.
234
+ *
235
+ * @param queryJson - JSON string of QueryConfig (same format as `search()` object).
236
+ * @returns JSON string of `ExportableStats` (document frequencies, doc counts).
237
+ */
238
+ exportStats(queryJson: string): string
239
+ /**
240
+ * Search using externally-provided global BM25 stats (distributed mode).
241
+ *
242
+ * Scores are computed using the merged global stats instead of local-only
243
+ * stats, ensuring consistent ranking across nodes.
244
+ *
245
+ * @param queryJson - JSON string of QueryConfig.
246
+ * @param globalStatsJson - JSON string of merged `ExportableStats`
247
+ * from all nodes (obtained by merging `exportStats()` outputs).
248
+ * @param limit - Maximum number of results (default 10).
249
+ * @param highlights - If true, return highlight byte offsets per field.
250
+ * @returns `Array<SearchResult>` scored with global BM25 statistics.
251
+ */
252
+ searchWithGlobalStats(queryJson: string, globalStatsJson: string, limit?: number | undefined | null, highlights?: boolean | undefined | null): Array<SearchResult>
70
253
  }
package/lucivy.node CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lucivy",
3
- "version": "0.2.1",
3
+ "version": "2.0.0",
4
4
  "description": "Fast BM25 full-text search with substring matching, fuzzy search, and regex — powered by Rust",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -33,7 +33,7 @@
33
33
  "README.md"
34
34
  ],
35
35
  "devDependencies": {
36
- "@napi-rs/cli": "^2.18.0"
36
+ "@napi-rs/cli": "^2.18.4"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "napi build --release",