vectlite 0.9.3 → 0.11.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.
- package/README.md +42 -3
- package/index.d.ts +75 -0
- package/index.js +90 -2
- package/native/Cargo.toml +1 -1
- package/native/src/lib.rs +209 -11
- package/native/vectlite-core/Cargo.toml +1 -1
- package/native/vectlite-core/src/lib.rs +1179 -43
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/vectlite.node +0 -0
- package/prebuilds/darwin-x64/vectlite.node +0 -0
- package/prebuilds/linux-x64-gnu/vectlite.node +0 -0
- package/prebuilds/win32-x64-msvc/vectlite.node +0 -0
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ db.close()
|
|
|
68
68
|
### Data Management
|
|
69
69
|
|
|
70
70
|
- **Physical collections** -- `vectlite.openStore()` manages a directory of independent databases
|
|
71
|
-
- **Bulk ingestion** -- `bulkIngest()` with
|
|
71
|
+
- **Bulk ingestion** -- `bulkIngest()` with Rayon-parallel HNSW build, coalesced WAL fsync, and tunable `m` / `efConstruction` / `efSearch` / tombstone rebuild threshold
|
|
72
72
|
- **Listing & filtered counts** -- `list()` and `count({ namespace, filter })` without a vector query
|
|
73
73
|
- **Delete by filter** -- `deleteByFilter()` for bulk deletion by metadata filter
|
|
74
74
|
- **Partial metadata updates** -- `updateMetadata()` merges a patch without re-writing the vector or rebuilding indexes
|
|
@@ -345,6 +345,37 @@ await db.compactAsync()
|
|
|
345
345
|
const count = await db.bulkIngestAsync(records, { batchSize: 5000 })
|
|
346
346
|
```
|
|
347
347
|
|
|
348
|
+
### Tuning the HNSW index
|
|
349
|
+
|
|
350
|
+
`bulkIngest()` and `bulkIngestAsync()` accept optional HNSW parameters that
|
|
351
|
+
control the recall/latency trade-off and trigger Rayon-backed parallel graph
|
|
352
|
+
construction once the dataset crosses `parallelInsertThreshold` (default 256):
|
|
353
|
+
|
|
354
|
+
```js
|
|
355
|
+
// Higher recall, slightly slower build/search
|
|
356
|
+
db.bulkIngest(records, {
|
|
357
|
+
batchSize: 5000,
|
|
358
|
+
m: 32, // max bidirectional links per node (default 16)
|
|
359
|
+
efConstruction: 400, // build-time search width (default 200)
|
|
360
|
+
efSearch: 200, // query-time search width (default: auto)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
// Faster build/search, lower recall
|
|
364
|
+
db.bulkIngest(records, { m: 8, efConstruction: 100, efSearch: 40 })
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
The same parameters can be changed at any time without re-ingesting:
|
|
368
|
+
|
|
369
|
+
```js
|
|
370
|
+
db.setIndexConfig({ m: 32, efConstruction: 400 }) // rebuilds the ANN graph
|
|
371
|
+
db.setEfSearch(200) // query-time only, no rebuild
|
|
372
|
+
console.log(db.indexConfig())
|
|
373
|
+
// { m: 32, ef_construction: 400, ef_search: 200, parallel_insert_threshold: 256 }
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Use higher `m` / `efConstruction` / `efSearch` to push Recall@10 toward `1.0`;
|
|
377
|
+
use lower values when latency or memory matter more than recall.
|
|
378
|
+
|
|
348
379
|
### OpenTelemetry Integration
|
|
349
380
|
|
|
350
381
|
vectlite ships with optional OpenTelemetry tracing. When enabled, every search
|
|
@@ -398,7 +429,15 @@ before re-throwing.
|
|
|
398
429
|
| `db.insert(id, vector, metadata, options)` | Insert a record (throws on duplicate id) |
|
|
399
430
|
| `db.upsertMany(records, { namespace })` | Upsert a batch of records |
|
|
400
431
|
| `db.insertMany(records, { namespace })` | Insert a batch |
|
|
401
|
-
| `db.bulkIngest(records, { namespace, batchSize })` | Fastest bulk import with
|
|
432
|
+
| `db.bulkIngest(records, { namespace, batchSize, m, efConstruction, efSearch, parallelInsertThreshold, tombstoneRebuildPct })` | Fastest bulk import with coalesced WAL fsync and Rayon-parallel HNSW build |
|
|
433
|
+
| `db.setIndexConfig({ m, efConstruction, efSearch, parallelInsertThreshold, tombstoneRebuildPct })` | Update HNSW parameters; rebuilds the ANN graph if `m`/`efConstruction` changed |
|
|
434
|
+
| `db.setEfSearch(efSearch)` | Adjust query-time HNSW search width without rebuilding |
|
|
435
|
+
| `db.indexConfig()` | Return the current HNSW configuration |
|
|
436
|
+
| `db.setWalSyncMode(mode, n)` | Configure WAL fsync cadence: `'per_op'`, `'every_n'`, or `'on_flush'` |
|
|
437
|
+
| `db.walSyncMode()` | Return the current WAL sync mode |
|
|
438
|
+
| `db.tombstoneStats()` | Return live and tombstoned HNSW node counts |
|
|
439
|
+
| `db.prepareForScan()` | Materialise the contiguous vector arena |
|
|
440
|
+
| `db.vectorArenaLen()` | Return the vector arena size or `null` |
|
|
402
441
|
| `db.delete(id, { namespace })` | Delete a single record |
|
|
403
442
|
| `db.deleteMany(ids, { namespace })` | Delete multiple records by id |
|
|
404
443
|
| `db.deleteByFilter(filter, { namespace })` | Delete all records matching a filter |
|
|
@@ -462,7 +501,7 @@ before re-throwing.
|
|
|
462
501
|
| `db.searchWithStatsAsync(query, options)` | Non-blocking search with stats (returns Promise) |
|
|
463
502
|
| `db.flushAsync()` | Non-blocking flush/compact (returns Promise) |
|
|
464
503
|
| `db.compactAsync()` | Non-blocking compact (returns Promise) |
|
|
465
|
-
| `db.bulkIngestAsync(records, options)` | Non-blocking bulk import (returns Promise) |
|
|
504
|
+
| `db.bulkIngestAsync(records, options)` | Non-blocking bulk import (returns Promise); accepts the same HNSW tuning options as `bulkIngest` |
|
|
466
505
|
|
|
467
506
|
## Filter Operators
|
|
468
507
|
|
package/index.d.ts
CHANGED
|
@@ -110,6 +110,50 @@ export interface ListCursorResult {
|
|
|
110
110
|
export interface BulkIngestOptions {
|
|
111
111
|
namespace?: string | null
|
|
112
112
|
batchSize?: number
|
|
113
|
+
/** Max bidirectional links per HNSW node (default 16). */
|
|
114
|
+
m?: number | null
|
|
115
|
+
/** Build-time search width (default 200). Higher = better recall, slower build. */
|
|
116
|
+
efConstruction?: number | null
|
|
117
|
+
/** Query-time search width. `null` = auto (derived from k). */
|
|
118
|
+
efSearch?: number | null
|
|
119
|
+
/** Minimum dataset size to engage Rayon-parallel HNSW insertion (default 256). */
|
|
120
|
+
parallelInsertThreshold?: number | null
|
|
121
|
+
/**
|
|
122
|
+
* Percentage (0..=100) of dead nodes at which `compact()` triggers an
|
|
123
|
+
* HNSW rebuild. Default 30. Set to 100 to disable automatic rebuild.
|
|
124
|
+
*/
|
|
125
|
+
tombstoneRebuildPct?: number | null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface IndexConfig {
|
|
129
|
+
m: number
|
|
130
|
+
ef_construction: number
|
|
131
|
+
ef_search: number | null
|
|
132
|
+
parallel_insert_threshold: number
|
|
133
|
+
tombstone_rebuild_pct: number
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface SetIndexConfigOptions {
|
|
137
|
+
m?: number | null
|
|
138
|
+
efConstruction?: number | null
|
|
139
|
+
efSearch?: number | null
|
|
140
|
+
parallelInsertThreshold?: number | null
|
|
141
|
+
tombstoneRebuildPct?: number | null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** WAL fsync mode. See `Database.setWalSyncMode`. */
|
|
145
|
+
export type WalSyncMode = 'per_op' | 'every_n' | 'on_flush'
|
|
146
|
+
|
|
147
|
+
export type WalSyncModeInfo =
|
|
148
|
+
| { mode: 'per_op' }
|
|
149
|
+
| { mode: 'every_n'; n: number }
|
|
150
|
+
| { mode: 'on_flush' }
|
|
151
|
+
|
|
152
|
+
export interface TombstoneStats {
|
|
153
|
+
/** Live (non-tombstoned) records across all HNSW graphs. */
|
|
154
|
+
live: number
|
|
155
|
+
/** Dead (tombstoned) records still in the graphs, awaiting compact(). */
|
|
156
|
+
dead: number
|
|
113
157
|
}
|
|
114
158
|
|
|
115
159
|
export interface SearchOptions {
|
|
@@ -212,6 +256,37 @@ export class Database {
|
|
|
212
256
|
insertMany(records: Record[], options?: { namespace?: string | null }): number
|
|
213
257
|
upsertMany(records: Record[], options?: { namespace?: string | null }): number
|
|
214
258
|
bulkIngest(records: Record[], options?: BulkIngestOptions): number
|
|
259
|
+
/** Get the current HNSW configuration. */
|
|
260
|
+
indexConfig(): IndexConfig
|
|
261
|
+
/** Adjust query-time `ef_search` only (no rebuild). `null` reverts to auto. */
|
|
262
|
+
setEfSearch(efSearch: number | null): void
|
|
263
|
+
/** Update HNSW parameters; rebuilds the ANN graph if `m`/`efConstruction` changed. */
|
|
264
|
+
setIndexConfig(config: SetIndexConfigOptions): void
|
|
265
|
+
/**
|
|
266
|
+
* Configure WAL durability.
|
|
267
|
+
*
|
|
268
|
+
* - `"per_op"` (default): fsync after every insert. Strongest durability.
|
|
269
|
+
* - `"every_n"` : fsync once every `n` inserts (pass `n` as 2nd arg).
|
|
270
|
+
* - `"on_flush"` : only fsync at `flush()` / `compact()` / `close()`.
|
|
271
|
+
*
|
|
272
|
+
* On macOS APFS, `"on_flush"` typically multiplies ingestion throughput
|
|
273
|
+
* by 5–10× at the cost of losing un-flushed writes on a crash.
|
|
274
|
+
*/
|
|
275
|
+
setWalSyncMode(mode: WalSyncMode, n?: number | null): void
|
|
276
|
+
/** Return the current WAL sync mode. */
|
|
277
|
+
walSyncMode(): WalSyncModeInfo
|
|
278
|
+
/** Total live and tombstoned record counts across every HNSW graph. */
|
|
279
|
+
tombstoneStats(): TombstoneStats
|
|
280
|
+
/**
|
|
281
|
+
* Materialise the contiguous-vector arena up front for cache- and
|
|
282
|
+
* SIMD-friendly scans. Normally built lazily on first use.
|
|
283
|
+
*/
|
|
284
|
+
prepareForScan(): void
|
|
285
|
+
/**
|
|
286
|
+
* Number of vectors in the contiguous arena, or `null` if it hasn't
|
|
287
|
+
* been materialised yet in this session.
|
|
288
|
+
*/
|
|
289
|
+
vectorArenaLen(): number | null
|
|
215
290
|
get(id: string, options?: { namespace?: string | null }): Record | null
|
|
216
291
|
delete(id: string, options?: { namespace?: string | null }): boolean
|
|
217
292
|
deleteMany(ids: string[], options?: { namespace?: string | null }): number
|
package/index.js
CHANGED
|
@@ -415,10 +415,89 @@ class Database {
|
|
|
415
415
|
|
|
416
416
|
bulkIngest(records, options = {}) {
|
|
417
417
|
return wrapError(() =>
|
|
418
|
-
this._native.bulkIngest(
|
|
418
|
+
this._native.bulkIngest(
|
|
419
|
+
encode(records),
|
|
420
|
+
options.namespace ?? null,
|
|
421
|
+
options.batchSize ?? 10_000,
|
|
422
|
+
options.m ?? null,
|
|
423
|
+
options.efConstruction ?? null,
|
|
424
|
+
options.efSearch ?? null,
|
|
425
|
+
options.parallelInsertThreshold ?? null,
|
|
426
|
+
options.tombstoneRebuildPct ?? null,
|
|
427
|
+
),
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
indexConfig() {
|
|
432
|
+
return wrapError(() => decode(this._native.indexConfig()))
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
setEfSearch(efSearch) {
|
|
436
|
+
return wrapError(() => this._native.setEfSearch(efSearch ?? null))
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
setIndexConfig(config = {}) {
|
|
440
|
+
return wrapError(() =>
|
|
441
|
+
this._native.setIndexConfig(
|
|
442
|
+
config.m ?? null,
|
|
443
|
+
config.efConstruction ?? null,
|
|
444
|
+
config.efSearch ?? null,
|
|
445
|
+
config.parallelInsertThreshold ?? null,
|
|
446
|
+
config.tombstoneRebuildPct ?? null,
|
|
447
|
+
),
|
|
419
448
|
)
|
|
420
449
|
}
|
|
421
450
|
|
|
451
|
+
/**
|
|
452
|
+
* Configure WAL durability. Valid modes are:
|
|
453
|
+
* - "per_op" : fsync after every insert (default, strongest durability)
|
|
454
|
+
* - "every_n" : fsync once every `n` inserts — pass `n` as second arg
|
|
455
|
+
* - "on_flush": fsync only at flush() / compact() / close()
|
|
456
|
+
* On macOS APFS, "on_flush" typically multiplies ingestion throughput by
|
|
457
|
+
* 5–10× at the cost of losing un-flushed writes on a crash.
|
|
458
|
+
*/
|
|
459
|
+
setWalSyncMode(mode, n = null) {
|
|
460
|
+
return wrapError(() => this._native.setWalSyncMode(mode, n))
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Return the current WAL sync mode. Shape:
|
|
465
|
+
* { mode: "per_op" } | { mode: "every_n", n: number } | { mode: "on_flush" }
|
|
466
|
+
*/
|
|
467
|
+
walSyncMode() {
|
|
468
|
+
return wrapError(() => decode(this._native.walSyncMode()))
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Return `{ live, dead }` summed across every HNSW graph (global +
|
|
473
|
+
* namespaced). Use to monitor when a compact() will rebuild the graph
|
|
474
|
+
* for recall.
|
|
475
|
+
*/
|
|
476
|
+
tombstoneStats() {
|
|
477
|
+
return wrapError(() => {
|
|
478
|
+
const [live, dead] = this._native.tombstoneStats()
|
|
479
|
+
return { live, dead }
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Materialise the contiguous-vector arena up front. The arena mirrors
|
|
485
|
+
* every record's default dense vector into a single flat Float32 buffer
|
|
486
|
+
* for cache- and SIMD-friendly brute-force / rescoring scans. Built
|
|
487
|
+
* lazily on first use otherwise.
|
|
488
|
+
*/
|
|
489
|
+
prepareForScan() {
|
|
490
|
+
return wrapError(() => this._native.prepareForScan())
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Number of vectors in the contiguous arena, or `null` if it has not
|
|
495
|
+
* been materialised yet in this session.
|
|
496
|
+
*/
|
|
497
|
+
vectorArenaLen() {
|
|
498
|
+
return wrapError(() => this._native.vectorArenaLen())
|
|
499
|
+
}
|
|
500
|
+
|
|
422
501
|
get(id, options = {}) {
|
|
423
502
|
return wrapError(() => decode(this._native.get(id, options.namespace ?? null)))
|
|
424
503
|
}
|
|
@@ -585,7 +664,16 @@ class Database {
|
|
|
585
664
|
|
|
586
665
|
bulkIngestAsync(records, options = {}) {
|
|
587
666
|
return wrapAsync(
|
|
588
|
-
this._native.bulkIngestAsync(
|
|
667
|
+
this._native.bulkIngestAsync(
|
|
668
|
+
encode(records),
|
|
669
|
+
options.namespace ?? null,
|
|
670
|
+
options.batchSize ?? 10_000,
|
|
671
|
+
options.m ?? null,
|
|
672
|
+
options.efConstruction ?? null,
|
|
673
|
+
options.efSearch ?? null,
|
|
674
|
+
options.parallelInsertThreshold ?? null,
|
|
675
|
+
options.tombstoneRebuildPct ?? null,
|
|
676
|
+
),
|
|
589
677
|
)
|
|
590
678
|
}
|
|
591
679
|
}
|
package/native/Cargo.toml
CHANGED
package/native/src/lib.rs
CHANGED
|
@@ -12,10 +12,10 @@ use vectlite::quantization::{
|
|
|
12
12
|
default_product_num_sub_vectors,
|
|
13
13
|
};
|
|
14
14
|
use vectlite::{
|
|
15
|
-
Database as CoreDatabase, DistanceMetric, FusionStrategy, HybridSearchOptions,
|
|
16
|
-
MetadataFilter, MetadataValue, MultiVectorSearchOptions, MultiVectors, NamedVectors,
|
|
15
|
+
Database as CoreDatabase, DistanceMetric, FusionStrategy, HybridSearchOptions, IndexConfig,
|
|
16
|
+
Metadata, MetadataFilter, MetadataValue, MultiVectorSearchOptions, MultiVectors, NamedVectors,
|
|
17
17
|
PayloadIndexType, Record, SearchOutcome, SearchResult, SparseVector, Store as CoreStore,
|
|
18
|
-
WriteOperation,
|
|
18
|
+
WalSyncMode, WriteOperation,
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
#[napi(js_name = "NativeDatabase")]
|
|
@@ -417,13 +417,140 @@ impl NativeDatabase {
|
|
|
417
417
|
records_json: String,
|
|
418
418
|
namespace: Option<String>,
|
|
419
419
|
batch_size: u32,
|
|
420
|
+
m: Option<u32>,
|
|
421
|
+
ef_construction: Option<u32>,
|
|
422
|
+
ef_search: Option<u32>,
|
|
423
|
+
parallel_insert_threshold: Option<u32>,
|
|
424
|
+
tombstone_rebuild_pct: Option<u32>,
|
|
420
425
|
) -> Result<u32> {
|
|
421
426
|
let records = parse_record_batch_json(&records_json, namespace.as_deref())?;
|
|
422
427
|
let mut database = self.write_open()?;
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
428
|
+
let tuning = merge_index_config(
|
|
429
|
+
m,
|
|
430
|
+
ef_construction,
|
|
431
|
+
ef_search,
|
|
432
|
+
parallel_insert_threshold,
|
|
433
|
+
tombstone_rebuild_pct,
|
|
434
|
+
);
|
|
435
|
+
let count = if let Some(cfg) = tuning {
|
|
436
|
+
let merged = apply_index_overrides(database.index_config(), cfg);
|
|
437
|
+
database.bulk_ingest_with_config(records, batch_size as usize, Some(merged))
|
|
438
|
+
} else {
|
|
439
|
+
database.bulk_ingest(records, batch_size as usize)
|
|
440
|
+
};
|
|
441
|
+
count.map(|n| n as u32).map_err(to_napi_error)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
#[napi(js_name = "indexConfig")]
|
|
445
|
+
pub fn index_config(&self) -> Result<String> {
|
|
446
|
+
let cfg = self.read()?.index_config();
|
|
447
|
+
let value = json!({
|
|
448
|
+
"m": cfg.m as u32,
|
|
449
|
+
"ef_construction": cfg.ef_construction as u32,
|
|
450
|
+
"ef_search": cfg.ef_search.map(|v| v as u32),
|
|
451
|
+
"parallel_insert_threshold": cfg.parallel_insert_threshold as u32,
|
|
452
|
+
"tombstone_rebuild_pct": cfg.tombstone_rebuild_pct as u32,
|
|
453
|
+
});
|
|
454
|
+
stringify_value(value)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
#[napi(js_name = "setEfSearch")]
|
|
458
|
+
pub fn set_ef_search(&self, ef_search: Option<u32>) -> Result<()> {
|
|
459
|
+
let ef = ef_search.map(|v| v as usize);
|
|
460
|
+
self.write_open()?.set_ef_search(ef).map_err(to_napi_error)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
#[napi(js_name = "setIndexConfig")]
|
|
464
|
+
pub fn set_index_config(
|
|
465
|
+
&self,
|
|
466
|
+
m: Option<u32>,
|
|
467
|
+
ef_construction: Option<u32>,
|
|
468
|
+
ef_search: Option<u32>,
|
|
469
|
+
parallel_insert_threshold: Option<u32>,
|
|
470
|
+
tombstone_rebuild_pct: Option<u32>,
|
|
471
|
+
) -> Result<()> {
|
|
472
|
+
let mut database = self.write_open()?;
|
|
473
|
+
let overrides = merge_index_config(
|
|
474
|
+
m,
|
|
475
|
+
ef_construction,
|
|
476
|
+
ef_search,
|
|
477
|
+
parallel_insert_threshold,
|
|
478
|
+
tombstone_rebuild_pct,
|
|
479
|
+
)
|
|
480
|
+
.ok_or_else(|| err("setIndexConfig requires at least one field"))?;
|
|
481
|
+
let merged = apply_index_overrides(database.index_config(), overrides);
|
|
482
|
+
database.set_index_config(merged).map_err(to_napi_error)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/// Configure WAL durability. `mode` is one of: `"per_op"` (the default,
|
|
486
|
+
/// fsync after every insert), `"every_n"` (fsync once every `n` inserts
|
|
487
|
+
/// — provide `n`), `"on_flush"` (only fsync at flush / compact / close).
|
|
488
|
+
///
|
|
489
|
+
/// Relaxing this knob is the single biggest ingestion throughput lever
|
|
490
|
+
/// on macOS APFS: `on_flush` typically multiplies throughput by 5–10×
|
|
491
|
+
/// at the cost of losing un-flushed writes on a crash.
|
|
492
|
+
#[napi(js_name = "setWalSyncMode")]
|
|
493
|
+
pub fn set_wal_sync_mode(&self, mode: String, n: Option<u32>) -> Result<()> {
|
|
494
|
+
let parsed = match mode.to_ascii_lowercase().as_str() {
|
|
495
|
+
"per_op" | "perop" => WalSyncMode::PerOp,
|
|
496
|
+
"every_n" | "everyn" => {
|
|
497
|
+
let n = n.ok_or_else(|| {
|
|
498
|
+
err("setWalSyncMode(\"every_n\", ...) requires the second `n` argument")
|
|
499
|
+
})?;
|
|
500
|
+
WalSyncMode::EveryN(n as usize)
|
|
501
|
+
}
|
|
502
|
+
"on_flush" | "onflush" => WalSyncMode::OnFlush,
|
|
503
|
+
other => {
|
|
504
|
+
return Err(err(format!(
|
|
505
|
+
"unknown WAL sync mode '{other}' (expected 'per_op', 'every_n', or 'on_flush')"
|
|
506
|
+
)));
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
let mut database = self.write_open()?;
|
|
510
|
+
database.set_wal_sync_mode(parsed).map_err(to_napi_error)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/// Return the current WAL sync mode as a JSON string: either
|
|
514
|
+
/// `{"mode":"per_op"}`, `{"mode":"every_n","n":64}`, or
|
|
515
|
+
/// `{"mode":"on_flush"}`.
|
|
516
|
+
#[napi(js_name = "walSyncMode")]
|
|
517
|
+
pub fn wal_sync_mode(&self) -> Result<String> {
|
|
518
|
+
let database = self.read()?;
|
|
519
|
+
let value = match database.wal_sync_mode() {
|
|
520
|
+
WalSyncMode::PerOp => json!({"mode": "per_op"}),
|
|
521
|
+
WalSyncMode::EveryN(n) => json!({"mode": "every_n", "n": n as u32}),
|
|
522
|
+
WalSyncMode::OnFlush => json!({"mode": "on_flush"}),
|
|
523
|
+
};
|
|
524
|
+
stringify_value(value)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/// Return `[live, dead]` summed across every HNSW graph (global +
|
|
528
|
+
/// namespaced). Useful for monitoring when to `compact()`.
|
|
529
|
+
#[napi(js_name = "tombstoneStats")]
|
|
530
|
+
pub fn tombstone_stats(&self) -> Result<Vec<u32>> {
|
|
531
|
+
let database = self.read()?;
|
|
532
|
+
let (live, dead) = database.tombstone_stats();
|
|
533
|
+
Ok(vec![live as u32, dead as u32])
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/// Materialise the contiguous-vector arena. Mirrors every record's
|
|
537
|
+
/// default dense vector into a single flat `Float32Array`-shaped
|
|
538
|
+
/// buffer for cache- and SIMD-friendly brute-force / rescoring scans.
|
|
539
|
+
/// Normally built lazily; call this before a heavy scan to pay the
|
|
540
|
+
/// build cost up front. Cheap when already fresh.
|
|
541
|
+
#[napi(js_name = "prepareForScan")]
|
|
542
|
+
pub fn prepare_for_scan(&self) -> Result<()> {
|
|
543
|
+
let mut database = self.write_open()?;
|
|
544
|
+
database.prepare_for_scan();
|
|
545
|
+
Ok(())
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/// Number of vectors in the contiguous arena, or `null` if it has
|
|
549
|
+
/// not been materialised yet in this session.
|
|
550
|
+
#[napi(js_name = "vectorArenaLen")]
|
|
551
|
+
pub fn vector_arena_len(&self) -> Result<Option<u32>> {
|
|
552
|
+
let database = self.read()?;
|
|
553
|
+
Ok(database.vector_arena_len().map(|n| n as u32))
|
|
427
554
|
}
|
|
428
555
|
|
|
429
556
|
#[napi]
|
|
@@ -878,6 +1005,7 @@ pub struct BulkIngestTask {
|
|
|
878
1005
|
db: Arc<RwLock<CoreDatabase>>,
|
|
879
1006
|
records: Vec<Record>,
|
|
880
1007
|
batch_size: usize,
|
|
1008
|
+
tuning: Option<IndexConfigPatch>,
|
|
881
1009
|
}
|
|
882
1010
|
|
|
883
1011
|
impl napi::Task for BulkIngestTask {
|
|
@@ -890,10 +1018,13 @@ impl napi::Task for BulkIngestTask {
|
|
|
890
1018
|
.db
|
|
891
1019
|
.write()
|
|
892
1020
|
.map_err(|e| err(format!("lock poisoned: {e}")))?;
|
|
893
|
-
|
|
894
|
-
.
|
|
895
|
-
.
|
|
896
|
-
|
|
1021
|
+
let res = if let Some(cfg) = self.tuning.clone() {
|
|
1022
|
+
let merged = apply_index_overrides(database.index_config(), cfg);
|
|
1023
|
+
database.bulk_ingest_with_config(records, self.batch_size, Some(merged))
|
|
1024
|
+
} else {
|
|
1025
|
+
database.bulk_ingest(records, self.batch_size)
|
|
1026
|
+
};
|
|
1027
|
+
res.map(|count| count as u32).map_err(to_napi_error)
|
|
897
1028
|
}
|
|
898
1029
|
|
|
899
1030
|
fn resolve(&mut self, _env: napi::Env, output: Self::Output) -> Result<Self::JsValue> {
|
|
@@ -959,12 +1090,25 @@ impl NativeDatabase {
|
|
|
959
1090
|
records_json: String,
|
|
960
1091
|
namespace: Option<String>,
|
|
961
1092
|
batch_size: u32,
|
|
1093
|
+
m: Option<u32>,
|
|
1094
|
+
ef_construction: Option<u32>,
|
|
1095
|
+
ef_search: Option<u32>,
|
|
1096
|
+
parallel_insert_threshold: Option<u32>,
|
|
1097
|
+
tombstone_rebuild_pct: Option<u32>,
|
|
962
1098
|
) -> Result<AsyncTask<BulkIngestTask>> {
|
|
963
1099
|
let records = parse_record_batch_json(&records_json, namespace.as_deref())?;
|
|
1100
|
+
let tuning = merge_index_config(
|
|
1101
|
+
m,
|
|
1102
|
+
ef_construction,
|
|
1103
|
+
ef_search,
|
|
1104
|
+
parallel_insert_threshold,
|
|
1105
|
+
tombstone_rebuild_pct,
|
|
1106
|
+
);
|
|
964
1107
|
Ok(AsyncTask::new(BulkIngestTask {
|
|
965
1108
|
db: self.inner.clone(),
|
|
966
1109
|
records,
|
|
967
1110
|
batch_size: batch_size as usize,
|
|
1111
|
+
tuning,
|
|
968
1112
|
}))
|
|
969
1113
|
}
|
|
970
1114
|
}
|
|
@@ -1962,6 +2106,60 @@ fn value_to_usize(value: &Value, label: &str) -> Result<usize> {
|
|
|
1962
2106
|
.ok_or_else(|| err(format!("{label} must be an unsigned integer")))
|
|
1963
2107
|
}
|
|
1964
2108
|
|
|
2109
|
+
#[derive(Clone, Copy)]
|
|
2110
|
+
struct IndexConfigPatch {
|
|
2111
|
+
m: Option<usize>,
|
|
2112
|
+
ef_construction: Option<usize>,
|
|
2113
|
+
ef_search: Option<usize>,
|
|
2114
|
+
parallel_insert_threshold: Option<usize>,
|
|
2115
|
+
tombstone_rebuild_pct: Option<u8>,
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
/// Pack the five optional HNSW tuning fields into a patch. Returns `None`
|
|
2119
|
+
/// when every field is `None`; explicit zeroes are preserved so core
|
|
2120
|
+
/// validation can reject invalid build/search widths instead of treating
|
|
2121
|
+
/// them as "not provided".
|
|
2122
|
+
fn merge_index_config(
|
|
2123
|
+
m: Option<u32>,
|
|
2124
|
+
ef_construction: Option<u32>,
|
|
2125
|
+
ef_search: Option<u32>,
|
|
2126
|
+
parallel_insert_threshold: Option<u32>,
|
|
2127
|
+
tombstone_rebuild_pct: Option<u32>,
|
|
2128
|
+
) -> Option<IndexConfigPatch> {
|
|
2129
|
+
if m.is_none()
|
|
2130
|
+
&& ef_construction.is_none()
|
|
2131
|
+
&& ef_search.is_none()
|
|
2132
|
+
&& parallel_insert_threshold.is_none()
|
|
2133
|
+
&& tombstone_rebuild_pct.is_none()
|
|
2134
|
+
{
|
|
2135
|
+
return None;
|
|
2136
|
+
}
|
|
2137
|
+
Some(IndexConfigPatch {
|
|
2138
|
+
m: m.map(|v| v as usize),
|
|
2139
|
+
ef_construction: ef_construction.map(|v| v as usize),
|
|
2140
|
+
ef_search: ef_search.map(|v| v as usize),
|
|
2141
|
+
parallel_insert_threshold: parallel_insert_threshold.map(|v| v as usize),
|
|
2142
|
+
tombstone_rebuild_pct: tombstone_rebuild_pct.map(|v| if v > 100 { 101 } else { v as u8 }),
|
|
2143
|
+
})
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
/// Merge a tuning patch into the current `IndexConfig`. Omitted fields inherit
|
|
2147
|
+
/// from `current`; `ef_search = None` in the patch means "no change" because
|
|
2148
|
+
/// callers use `setEfSearch(null)` to reset query-time tuning to auto.
|
|
2149
|
+
fn apply_index_overrides(current: IndexConfig, patch: IndexConfigPatch) -> IndexConfig {
|
|
2150
|
+
IndexConfig {
|
|
2151
|
+
m: patch.m.unwrap_or(current.m),
|
|
2152
|
+
ef_construction: patch.ef_construction.unwrap_or(current.ef_construction),
|
|
2153
|
+
ef_search: patch.ef_search.or(current.ef_search),
|
|
2154
|
+
parallel_insert_threshold: patch
|
|
2155
|
+
.parallel_insert_threshold
|
|
2156
|
+
.unwrap_or(current.parallel_insert_threshold),
|
|
2157
|
+
tombstone_rebuild_pct: patch
|
|
2158
|
+
.tombstone_rebuild_pct
|
|
2159
|
+
.unwrap_or(current.tombstone_rebuild_pct),
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
|
|
1965
2163
|
fn err(message: impl Into<String>) -> NapiError {
|
|
1966
2164
|
NapiError::from_reason(message.into())
|
|
1967
2165
|
}
|