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 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 deferred index rebuilds for fast imports
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 batched WAL writes |
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(encode(records), options.namespace ?? null, options.batchSize ?? 10_000),
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(encode(records), options.namespace ?? null, options.batchSize ?? 10_000),
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
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vectlite-node"
3
- version = "0.9.3"
3
+ version = "0.11.0"
4
4
  edition = "2024"
5
5
  license = "MIT"
6
6
  description = "Node.js bindings for vectlite."
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, Metadata,
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
- database
424
- .bulk_ingest(records, batch_size as usize)
425
- .map(|count| count as u32)
426
- .map_err(to_napi_error)
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
- database
894
- .bulk_ingest(records, self.batch_size)
895
- .map(|count| count as u32)
896
- .map_err(to_napi_error)
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
  }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vectlite-core"
3
- version = "0.9.3"
3
+ version = "0.11.0"
4
4
  edition = "2024"
5
5
  license = "MIT"
6
6
  description = "Core storage engine for vectlite."