@harperfast/hnsw 0.1.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/Cargo.lock +229 -0
- package/Cargo.toml +30 -0
- package/DESIGN.md +319 -0
- package/LICENSE +202 -0
- package/README.md +88 -0
- package/build.mjs +46 -0
- package/build.rs +6 -0
- package/index.d.ts +104 -0
- package/index.js +57 -0
- package/package.json +44 -0
- package/prebuilds/darwin-arm64/hnsw-plane.node +0 -0
- package/prebuilds/linux-arm64/hnsw-plane.node +0 -0
- package/prebuilds/linux-x64/hnsw-plane.node +0 -0
- package/prebuilds/win32-x64/hnsw-plane.node +0 -0
- package/src/bin/bench.rs +246 -0
- package/src/distance.rs +138 -0
- package/src/format.rs +556 -0
- package/src/graph.rs +650 -0
- package/src/insert.rs +292 -0
- package/src/lib.rs +15 -0
- package/src/napi.rs +486 -0
- package/src/search.rs +477 -0
- package/src/seqlock.rs +217 -0
package/src/napi.rs
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
//! NAPI surface (feature = "napi"). One boundary crossing per operation; searches run on
|
|
2
|
+
//! the libuv thread pool via AsyncTask so the JS event loop is never blocked (C1).
|
|
3
|
+
//! The surface is deliberately Harper-agnostic — pk↔id mapping, commit-callback glue, and
|
|
4
|
+
//! txnlog-anchored replay live in the host application.
|
|
5
|
+
|
|
6
|
+
use crate::distance::Query;
|
|
7
|
+
use crate::insert::{insert, InsertParams};
|
|
8
|
+
use crate::search::{search_filtered, search_predicated, PredicatePipe, SearchScratch};
|
|
9
|
+
use crate::{Graph, PlaneFile};
|
|
10
|
+
use napi::bindgen_prelude::*;
|
|
11
|
+
use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode};
|
|
12
|
+
use napi::JsFunction;
|
|
13
|
+
use napi_derive::napi;
|
|
14
|
+
use std::sync::{Arc, Mutex};
|
|
15
|
+
|
|
16
|
+
/// Pooled per-query scratch (the visited array is O(nodes); never allocate per query).
|
|
17
|
+
struct ScratchPool(Mutex<Vec<SearchScratch>>);
|
|
18
|
+
|
|
19
|
+
impl ScratchPool {
|
|
20
|
+
fn take(&self) -> SearchScratch {
|
|
21
|
+
self.0.lock().unwrap().pop().unwrap_or_default()
|
|
22
|
+
}
|
|
23
|
+
fn put(&self, s: SearchScratch) {
|
|
24
|
+
let mut pool = self.0.lock().unwrap();
|
|
25
|
+
if pool.len() < 64 {
|
|
26
|
+
pool.push(s);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#[napi(object)]
|
|
32
|
+
pub struct SearchHit {
|
|
33
|
+
pub id: u32,
|
|
34
|
+
pub distance: f64,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
pub struct SearchTask {
|
|
38
|
+
graph: Arc<Graph>,
|
|
39
|
+
pool: Arc<ScratchPool>,
|
|
40
|
+
query: Vec<f32>,
|
|
41
|
+
k: usize,
|
|
42
|
+
ef: usize,
|
|
43
|
+
filter: Option<Vec<u8>>,
|
|
44
|
+
filter_expansion: usize,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#[napi]
|
|
48
|
+
impl Task for SearchTask {
|
|
49
|
+
type Output = Vec<(u32, f32)>;
|
|
50
|
+
type JsValue = Vec<SearchHit>;
|
|
51
|
+
|
|
52
|
+
fn compute(&mut self) -> Result<Self::Output> {
|
|
53
|
+
let mut scratch = self.pool.take();
|
|
54
|
+
let query = Query::new(std::mem::take(&mut self.query));
|
|
55
|
+
let (hits, _stats) = search_filtered(
|
|
56
|
+
&self.graph,
|
|
57
|
+
&query,
|
|
58
|
+
self.k,
|
|
59
|
+
self.ef,
|
|
60
|
+
self.filter.as_deref(),
|
|
61
|
+
self.filter_expansion,
|
|
62
|
+
&mut scratch,
|
|
63
|
+
);
|
|
64
|
+
self.pool.put(scratch);
|
|
65
|
+
Ok(hits)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
|
|
69
|
+
Ok(output.into_iter().map(|(id, d)| SearchHit { id, distance: d as f64 }).collect())
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pub struct PredicateSearchTask {
|
|
74
|
+
graph: Arc<Graph>,
|
|
75
|
+
pool: Arc<ScratchPool>,
|
|
76
|
+
query: Vec<f32>,
|
|
77
|
+
k: usize,
|
|
78
|
+
ef: usize,
|
|
79
|
+
tsfn: Option<ThreadsafeFunction<Vec<u32>, ErrorStrategy::Fatal>>,
|
|
80
|
+
visit_budget: u64,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#[napi]
|
|
84
|
+
impl Task for PredicateSearchTask {
|
|
85
|
+
type Output = Vec<(u32, f32)>;
|
|
86
|
+
type JsValue = Vec<SearchHit>;
|
|
87
|
+
|
|
88
|
+
fn compute(&mut self) -> Result<Self::Output> {
|
|
89
|
+
let tsfn = self.tsfn.take().ok_or_else(|| Error::from_reason("task reused"))?;
|
|
90
|
+
let (tx, rx) = std::sync::mpsc::channel::<(Vec<u32>, Vec<u8>)>();
|
|
91
|
+
let mut pipe = PredicatePipe {
|
|
92
|
+
dispatch: Box::new(move |ids: Vec<u32>| {
|
|
93
|
+
let tx = tx.clone();
|
|
94
|
+
let ids_echo = ids.clone();
|
|
95
|
+
tsfn.call_with_return_value(
|
|
96
|
+
ids,
|
|
97
|
+
ThreadsafeFunctionCallMode::NonBlocking,
|
|
98
|
+
move |ret: Uint8Array| {
|
|
99
|
+
// predicate errors / env teardown surface as a missing send; the
|
|
100
|
+
// drain deadline in search_predicated treats absent verdicts as deny
|
|
101
|
+
let _ = tx.send((ids_echo, ret.to_vec()));
|
|
102
|
+
Ok(())
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
}),
|
|
106
|
+
rx,
|
|
107
|
+
};
|
|
108
|
+
let mut scratch = self.pool.take();
|
|
109
|
+
let query = Query::new(std::mem::take(&mut self.query));
|
|
110
|
+
let (hits, _stats) =
|
|
111
|
+
search_predicated(&self.graph, &query, self.k, self.ef, &mut pipe, self.visit_budget, &mut scratch);
|
|
112
|
+
self.pool.put(scratch);
|
|
113
|
+
Ok(hits)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
|
|
117
|
+
Ok(output.into_iter().map(|(id, d)| SearchHit { id, distance: d as f64 }).collect())
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
pub struct FlushTask {
|
|
122
|
+
graph: Arc<Graph>,
|
|
123
|
+
txn: Option<u64>,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#[napi]
|
|
127
|
+
impl Task for FlushTask {
|
|
128
|
+
type Output = ();
|
|
129
|
+
type JsValue = ();
|
|
130
|
+
|
|
131
|
+
fn compute(&mut self) -> Result<Self::Output> {
|
|
132
|
+
self.graph.file.flush_with_watermark(self.txn).map_err(|e| Error::from_reason(e.to_string()))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
fn resolve(&mut self, _env: Env, _output: Self::Output) -> Result<Self::JsValue> {
|
|
136
|
+
Ok(())
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#[napi]
|
|
141
|
+
pub struct Plane {
|
|
142
|
+
graph: Arc<Graph>,
|
|
143
|
+
pool: Arc<ScratchPool>,
|
|
144
|
+
params: InsertParams,
|
|
145
|
+
// insert scratch, serialized: phase-1 hosts call insert from a single writer at a time
|
|
146
|
+
// per index (Harper's commit path); a Mutex keeps misuse safe rather than fast.
|
|
147
|
+
insert_scratch: Mutex<SearchScratch>,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#[napi]
|
|
151
|
+
impl Plane {
|
|
152
|
+
/// Create a new plane file. `maxNodes` bounds the sparse reservation (pages materialize
|
|
153
|
+
/// on write).
|
|
154
|
+
#[napi(factory)]
|
|
155
|
+
pub fn create(path: String, dims: u32, layer0_cap: u32, max_nodes: f64) -> Result<Plane> {
|
|
156
|
+
let file = PlaneFile::create(std::path::Path::new(&path), dims as usize, layer0_cap as usize, max_nodes as u64)
|
|
157
|
+
.map_err(|e| Error::from_reason(e.to_string()))?;
|
|
158
|
+
Ok(Self::wrap(file))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/// Open an existing plane file (the upper-layer region lives in the same file).
|
|
162
|
+
#[napi(factory)]
|
|
163
|
+
pub fn open(path: String) -> Result<Plane> {
|
|
164
|
+
let file = PlaneFile::open(std::path::Path::new(&path)).map_err(|e| Error::from_reason(e.to_string()))?;
|
|
165
|
+
Ok(Self::wrap(file))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
fn wrap(file: PlaneFile) -> Plane {
|
|
169
|
+
Plane {
|
|
170
|
+
graph: Arc::new(Graph::new(file)),
|
|
171
|
+
pool: Arc::new(ScratchPool(Mutex::new(Vec::new()))),
|
|
172
|
+
params: InsertParams::default(),
|
|
173
|
+
insert_scratch: Mutex::new(SearchScratch::new()),
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// Insert a vector; returns the allocated node id (freelist ids are reused). Throws on
|
|
178
|
+
/// a dimension mismatch or a full plane (maxNodes reached).
|
|
179
|
+
#[napi]
|
|
180
|
+
pub fn insert(&self, vector: Float32Array) -> Result<u32> {
|
|
181
|
+
if vector.len() != self.graph.file.dims {
|
|
182
|
+
return Err(Error::from_reason(format!(
|
|
183
|
+
"vector has {} dims; plane was created with {}",
|
|
184
|
+
vector.len(),
|
|
185
|
+
self.graph.file.dims
|
|
186
|
+
)));
|
|
187
|
+
}
|
|
188
|
+
for (i, v) in vector.iter().enumerate() {
|
|
189
|
+
if !v.is_finite() {
|
|
190
|
+
// a NaN component yields a huge invMag and -inf distances: that node would
|
|
191
|
+
// rank first for roughly half of all queries, permanently
|
|
192
|
+
return Err(Error::from_reason(format!("vector component {i} is not finite")));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
let mut scratch = self.insert_scratch.lock().unwrap();
|
|
196
|
+
insert(&self.graph, &vector, &self.params, &mut scratch).map_err(|e| match e {
|
|
197
|
+
crate::insert::InsertError::Full => Error::from_reason("plane is full (maxNodes reached)"),
|
|
198
|
+
crate::insert::InsertError::Wedged => {
|
|
199
|
+
Error::from_reason("plane slot lock is wedged (unreclaimable holder); rebuild the index")
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// Delete a node; its id returns to the plane freelist. Standalone-allocation mode only
|
|
205
|
+
/// (pairs with insert()); dual-write hosts use clearNode instead.
|
|
206
|
+
#[napi]
|
|
207
|
+
pub fn remove(&self, id: u32) -> Result<()> {
|
|
208
|
+
self.graph
|
|
209
|
+
.delete_node(id)
|
|
210
|
+
.map_err(|_| Error::from_reason("plane slot lock is wedged (unreclaimable holder); rebuild the index"))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/// Mirror a host-maintained node into the plane (dual-write phase 1): full node state
|
|
214
|
+
/// per call, host-allocated id, int8 vector bin + quantization scale + cached 1/|v|,
|
|
215
|
+
/// layer-0 neighbor ids, and per-upper-level neighbor id arrays (level 1 first). An
|
|
216
|
+
/// existing upper entry is rewritten in place. Idempotent per (id, state).
|
|
217
|
+
#[napi]
|
|
218
|
+
pub fn write_node_raw(
|
|
219
|
+
&self,
|
|
220
|
+
id: u32,
|
|
221
|
+
level: u8,
|
|
222
|
+
vector: Buffer,
|
|
223
|
+
scale: f64,
|
|
224
|
+
inv_mag: f64,
|
|
225
|
+
neighbors: Uint32Array,
|
|
226
|
+
upper: Option<Vec<Uint32Array>>,
|
|
227
|
+
) -> Result<()> {
|
|
228
|
+
if vector.len() != self.graph.file.dims {
|
|
229
|
+
return Err(Error::from_reason(format!(
|
|
230
|
+
"vector is {} bytes; plane dims = {}",
|
|
231
|
+
vector.len(),
|
|
232
|
+
self.graph.file.dims
|
|
233
|
+
)));
|
|
234
|
+
}
|
|
235
|
+
// ensure_high_water + slot_ptr have no bounds check, so a host id past the fixed
|
|
236
|
+
// reservation would address past the slot region (mmap overrun) — reject it here.
|
|
237
|
+
if id as u64 >= self.graph.file.max_nodes {
|
|
238
|
+
return Err(Error::from_reason(format!(
|
|
239
|
+
"node id {} exceeds the plane's maxNodes reservation ({})",
|
|
240
|
+
id, self.graph.file.max_nodes
|
|
241
|
+
)));
|
|
242
|
+
}
|
|
243
|
+
if (id as u64) >= self.graph.file.max_nodes {
|
|
244
|
+
return Err(Error::from_reason(format!("id {} exceeds plane capacity {}", id, self.graph.file.max_nodes)));
|
|
245
|
+
}
|
|
246
|
+
if !(scale as f32).is_finite() || !(inv_mag as f32).is_finite() {
|
|
247
|
+
return Err(Error::from_reason("scale/invMag must be finite"));
|
|
248
|
+
}
|
|
249
|
+
let vec_i8 = unsafe { std::slice::from_raw_parts(vector.as_ptr() as *const i8, vector.len()) };
|
|
250
|
+
let upper_levels: Vec<Vec<u32>> =
|
|
251
|
+
upper.map(|ls| ls.iter().map(|l| l.to_vec()).collect()).unwrap_or_default();
|
|
252
|
+
// reject out-of-range neighbor ids rather than letting them poison traversal
|
|
253
|
+
// (SearchScratch::visit would size its array from them; distance_to skips them, but
|
|
254
|
+
// a u32::MAX id costs a huge allocation before it is skipped)
|
|
255
|
+
let max = self.graph.file.max_nodes;
|
|
256
|
+
for &n in neighbors.iter() {
|
|
257
|
+
if (n as u64) >= max {
|
|
258
|
+
return Err(Error::from_reason(format!("neighbor id {n} exceeds plane capacity {max}")));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
for level in &upper_levels {
|
|
262
|
+
for &n in level {
|
|
263
|
+
if (n as u64) >= max {
|
|
264
|
+
return Err(Error::from_reason(format!("upper neighbor id {n} exceeds plane capacity {max}")));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
self.graph
|
|
269
|
+
.write_node_raw(id, level, vec_i8, scale as f32, inv_mag as f32, &neighbors.to_vec(), &upper_levels)
|
|
270
|
+
.map_err(|_| Error::from_reason("plane slot lock is wedged (unreclaimable holder); rebuild the index"))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/// Builder-scan variant of writeNodeRaw: writes ONLY when the slot has never been
|
|
274
|
+
/// touched (valid or deleted). A backfill scan mirroring a snapshot must not overwrite
|
|
275
|
+
/// a node a concurrent live mirror already wrote with newer state — the check and the
|
|
276
|
+
/// write happen under the slot's seqlock, so the race is closed across workers too.
|
|
277
|
+
/// Returns true when the scan's state was written.
|
|
278
|
+
#[napi]
|
|
279
|
+
#[allow(clippy::too_many_arguments)]
|
|
280
|
+
pub fn write_node_raw_if_absent(
|
|
281
|
+
&self,
|
|
282
|
+
id: u32,
|
|
283
|
+
level: u8,
|
|
284
|
+
vector: Buffer,
|
|
285
|
+
scale: f64,
|
|
286
|
+
inv_mag: f64,
|
|
287
|
+
neighbors: Uint32Array,
|
|
288
|
+
upper: Option<Vec<Uint32Array>>,
|
|
289
|
+
) -> Result<bool> {
|
|
290
|
+
if vector.len() != self.graph.file.dims {
|
|
291
|
+
return Err(Error::from_reason(format!(
|
|
292
|
+
"vector is {} bytes; plane dims = {}",
|
|
293
|
+
vector.len(),
|
|
294
|
+
self.graph.file.dims
|
|
295
|
+
)));
|
|
296
|
+
}
|
|
297
|
+
if (id as u64) >= self.graph.file.max_nodes {
|
|
298
|
+
return Err(Error::from_reason(format!("id {} exceeds plane capacity {}", id, self.graph.file.max_nodes)));
|
|
299
|
+
}
|
|
300
|
+
if !(scale as f32).is_finite() || !(inv_mag as f32).is_finite() {
|
|
301
|
+
return Err(Error::from_reason("scale/invMag must be finite"));
|
|
302
|
+
}
|
|
303
|
+
let max = self.graph.file.max_nodes;
|
|
304
|
+
for &n in neighbors.iter() {
|
|
305
|
+
if (n as u64) >= max {
|
|
306
|
+
return Err(Error::from_reason(format!("neighbor id {n} exceeds plane capacity {max}")));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
let upper_levels: Vec<Vec<u32>> =
|
|
310
|
+
upper.map(|ls| ls.iter().map(|l| l.to_vec()).collect()).unwrap_or_default();
|
|
311
|
+
for level_ids in &upper_levels {
|
|
312
|
+
for &n in level_ids {
|
|
313
|
+
if (n as u64) >= max {
|
|
314
|
+
return Err(Error::from_reason(format!("upper neighbor id {n} exceeds plane capacity {max}")));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
let vec_i8 = unsafe { std::slice::from_raw_parts(vector.as_ptr() as *const i8, vector.len()) };
|
|
319
|
+
let mut l0 = neighbors.to_vec();
|
|
320
|
+
l0.truncate(self.graph.file.layer0_cap);
|
|
321
|
+
// the untouched check and the write share one seqlock acquisition inside the crate:
|
|
322
|
+
// a live mirror's newer write can never be overwritten by this scan's older snapshot
|
|
323
|
+
self.graph
|
|
324
|
+
.write_node_if_untouched(id, level, vec_i8, scale as f32, inv_mag as f32, &l0, &upper_levels)
|
|
325
|
+
.map_err(|_| Error::from_reason("plane slot lock is wedged (unreclaimable holder); rebuild the index"))
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/// Advisory: whether the file recorded a durability barrier (flush) as its last state
|
|
329
|
+
/// when this handle opened it. Crash recovery does not depend on it — torn per-slot
|
|
330
|
+
/// locks are taken over lazily at the affected slot.
|
|
331
|
+
#[napi]
|
|
332
|
+
pub fn opened_clean(&self) -> bool {
|
|
333
|
+
self.graph.file.opened_clean
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/// Async durability barrier on the libuv pool: same ordering contract as flush(), off
|
|
337
|
+
/// the event loop — a whole-map msync over a large mapping stalls its calling thread.
|
|
338
|
+
#[napi(ts_return_type = "Promise<void>")]
|
|
339
|
+
pub fn flush_async(&self, watermark: Option<f64>) -> AsyncTask<FlushTask> {
|
|
340
|
+
let txn = watermark.map(|w| w as u64);
|
|
341
|
+
AsyncTask::new(FlushTask { graph: self.graph.clone(), txn })
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/// Mark a node deleted without touching the plane freelist (dual-write mode: the host
|
|
345
|
+
/// owns id allocation).
|
|
346
|
+
#[napi]
|
|
347
|
+
pub fn clear_node(&self, id: u32) -> Result<()> {
|
|
348
|
+
self.graph
|
|
349
|
+
.clear_node(id)
|
|
350
|
+
.map_err(|_| Error::from_reason("plane slot lock is wedged (unreclaimable holder); rebuild the index"))
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/// Set the graph entry point (dual-write mode mirrors the host's entry-point updates).
|
|
354
|
+
#[napi]
|
|
355
|
+
pub fn set_entry_point(&self, id: u32, level: u32) {
|
|
356
|
+
// clamp: a garbage level would make every search iterate that many empty levels
|
|
357
|
+
self.graph.file.set_entry_point(id, level.min(crate::format::MAX_UPPER_LEVELS as u32));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#[napi]
|
|
361
|
+
pub fn get_entry_point(&self) -> Vec<f64> {
|
|
362
|
+
let (id, level) = self.graph.file.entry_point();
|
|
363
|
+
vec![id as f64, level as f64]
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/// Query dimensionality must match the plane: the distance kernel streams
|
|
367
|
+
/// `query.len()` bytes from each slot's vector, so an oversized query would read past
|
|
368
|
+
/// it into adjacent slot bytes (or off the mapping entirely).
|
|
369
|
+
fn check_query_dims(&self, len: usize) -> Result<()> {
|
|
370
|
+
if len != self.graph.file.dims {
|
|
371
|
+
return Err(Error::from_reason(format!(
|
|
372
|
+
"query vector has {} dimensions; plane dims = {}",
|
|
373
|
+
len, self.graph.file.dims
|
|
374
|
+
)));
|
|
375
|
+
}
|
|
376
|
+
Ok(())
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
#[napi(getter)]
|
|
380
|
+
pub fn dims(&self) -> u32 {
|
|
381
|
+
self.graph.file.dims as u32
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#[napi(getter)]
|
|
385
|
+
pub fn layer0_cap(&self) -> u32 {
|
|
386
|
+
self.graph.file.layer0_cap as u32
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/// Async k-NN search on the libuv thread pool. `filter` is an optional allow-bitset
|
|
390
|
+
/// over node ids (bit i of byte i>>3); filtered searches are visit-bounded by
|
|
391
|
+
/// ef * filterExpansion (default 24).
|
|
392
|
+
#[napi(ts_return_type = "Promise<Array<SearchHit>>")]
|
|
393
|
+
pub fn search(
|
|
394
|
+
&self,
|
|
395
|
+
vector: Float32Array,
|
|
396
|
+
k: u32,
|
|
397
|
+
ef: u32,
|
|
398
|
+
filter: Option<Uint8Array>,
|
|
399
|
+
filter_expansion: Option<u32>,
|
|
400
|
+
) -> Result<AsyncTask<SearchTask>> {
|
|
401
|
+
self.check_query_dims(vector.len())?;
|
|
402
|
+
Ok(AsyncTask::new(SearchTask {
|
|
403
|
+
graph: self.graph.clone(),
|
|
404
|
+
pool: self.pool.clone(),
|
|
405
|
+
query: vector.to_vec(),
|
|
406
|
+
k: k as usize,
|
|
407
|
+
ef: ef as usize,
|
|
408
|
+
filter: filter.map(|f| f.to_vec()),
|
|
409
|
+
filter_expansion: filter_expansion.unwrap_or(24) as usize,
|
|
410
|
+
}))
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/// Async k-NN search with a JS predicate: `predicate(ids: number[]) => Uint8Array`
|
|
414
|
+
/// (one 0/1 byte per id, evaluated synchronously). Batches of candidate ids stream to
|
|
415
|
+
/// the predicate over a ThreadsafeFunction while traversal keeps expanding — the search
|
|
416
|
+
/// thread never blocks on the JS event loop until the beam itself is done, so a busy
|
|
417
|
+
/// loop costs speculative overshoot (bounded by the visit budget), not latency.
|
|
418
|
+
/// `visitBudget` caps layer-0 visits absolutely (a host budget may sit below ef, which a
|
|
419
|
+
/// multiplier cannot express); when absent the budget is ef * filterExpansion.
|
|
420
|
+
/// Must not be awaited synchronously from code the predicate itself blocks.
|
|
421
|
+
#[napi(ts_return_type = "Promise<Array<SearchHit>>")]
|
|
422
|
+
pub fn search_with_predicate(
|
|
423
|
+
&self,
|
|
424
|
+
vector: Float32Array,
|
|
425
|
+
k: u32,
|
|
426
|
+
ef: u32,
|
|
427
|
+
#[napi(ts_arg_type = "(ids: Array<number>) => Uint8Array")] predicate: JsFunction,
|
|
428
|
+
filter_expansion: Option<u32>,
|
|
429
|
+
visit_budget: Option<f64>,
|
|
430
|
+
) -> Result<AsyncTask<PredicateSearchTask>> {
|
|
431
|
+
self.check_query_dims(vector.len())?;
|
|
432
|
+
let tsfn: ThreadsafeFunction<Vec<u32>, ErrorStrategy::Fatal> = predicate
|
|
433
|
+
.create_threadsafe_function(0, |ctx: napi::threadsafe_function::ThreadSafeCallContext<Vec<u32>>| {
|
|
434
|
+
let ids: Vec<f64> = ctx.value.iter().map(|&v| v as f64).collect();
|
|
435
|
+
Ok(vec![ids])
|
|
436
|
+
})?;
|
|
437
|
+
let ef = ef as usize;
|
|
438
|
+
Ok(AsyncTask::new(PredicateSearchTask {
|
|
439
|
+
graph: self.graph.clone(),
|
|
440
|
+
pool: self.pool.clone(),
|
|
441
|
+
query: vector.to_vec(),
|
|
442
|
+
k: k as usize,
|
|
443
|
+
ef,
|
|
444
|
+
tsfn: Some(tsfn),
|
|
445
|
+
visit_budget: visit_budget
|
|
446
|
+
.map(|b| b.max(1.0) as u64)
|
|
447
|
+
.unwrap_or((ef * filter_expansion.unwrap_or(24) as usize) as u64),
|
|
448
|
+
}))
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/// Synchronous search (benchmarks/tests; blocks the calling thread).
|
|
452
|
+
#[napi]
|
|
453
|
+
pub fn search_sync(&self, vector: Float32Array, k: u32, ef: u32) -> Result<Vec<SearchHit>> {
|
|
454
|
+
self.check_query_dims(vector.len())?;
|
|
455
|
+
let mut scratch = self.pool.take();
|
|
456
|
+
let query = Query::new(vector.to_vec());
|
|
457
|
+
let (hits, _) = search_filtered(&self.graph, &query, k as usize, ef as usize, None, 24, &mut scratch);
|
|
458
|
+
self.pool.put(scratch);
|
|
459
|
+
Ok(hits.into_iter().map(|(id, d)| SearchHit { id, distance: d as f64 }).collect())
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/// Lifetime id high-water (allocated ids, including freed ones awaiting reuse).
|
|
463
|
+
#[napi]
|
|
464
|
+
pub fn id_high_water(&self) -> f64 {
|
|
465
|
+
self.graph.file.id_high_water() as f64
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
#[napi]
|
|
469
|
+
pub fn get_watermark(&self) -> f64 {
|
|
470
|
+
self.graph.file.watermark() as f64
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
#[napi]
|
|
474
|
+
pub fn set_watermark(&self, txn: f64) {
|
|
475
|
+
self.graph.file.set_watermark(txn as u64);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/// Durability barrier: flush all data, then advance the watermark (defaults to the
|
|
479
|
+
/// current one) and the clean-shutdown flag, then flush the header alone — so a crash
|
|
480
|
+
/// between the flushes can only leave an OLD watermark over durable data (replay
|
|
481
|
+
/// re-covers a suffix), never a new watermark over missing data.
|
|
482
|
+
#[napi]
|
|
483
|
+
pub fn flush(&self, watermark: Option<f64>) -> Result<()> {
|
|
484
|
+
self.graph.file.flush_with_watermark(watermark.map(|w| w as u64)).map_err(|e| Error::from_reason(e.to_string()))
|
|
485
|
+
}
|
|
486
|
+
}
|