expo-vector-search 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Emerson Vieira
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # expo-vector-search
2
+
3
+ `expo-vector-search` is a high-performance, on-device **vector search engine** module for Expo and React Native.
4
+
5
+ > [!NOTE]
6
+ > This module is currently **optimized and validated for Android**. The C++ JSI layer is fully functional, but the build configuration and native runtime have been primarily verified on Android devices. iOS support is on the roadmap.
7
+
8
+ ## Performance (Real-world Benchmarks)
9
+ Results obtained on a **Samsung Galaxy S23 FE**:
10
+ - **Search (1000 items, 128 dims)**: **0.08ms** per query.
11
+ - **Batch Ingest (1000 items)**: **74.44ms** total time.
12
+ - **Memory Optimization (10k items, 384 dims)**:
13
+ - F32 Footprint: 36,964.94 KB.
14
+ - Int8 Footprint: 20,580.94 KB.
15
+
16
+ ![Performance Lab Benchmarks on S23 FE](../../assets/images/perf_lab.jpg)
17
+
18
+ ## Key Features
19
+
20
+ - **Blazing Fast Performance**: Powered by the HNSW (Hierarchical Navigable Small World) algorithm, capable of sub-millisecond search latencies on modern mobile hardware.
21
+ - **Direct JSI Integration**: Uses direct memory access between the JavaScript engine and C++ layer, eliminating serialization overhead.
22
+ - **Production-Grade Quantization**: Support for Int8 quantization to reduce memory footprint by up to 4x with minimal impact on search accuracy.
23
+ - **Disk Persistence**: Built-in methods to save and load vector indices to/from the local file system.
24
+ - **Memory Safety**: Strict buffer alignment checks and type validation to ensure native stability.
25
+ - **Explicit Memory Management**: Provision for deterministic resource release via the `delete()` method.
26
+
27
+ ## Installation
28
+
29
+ This module contains custom native code. You must use development builds to use this module.
30
+
31
+ ```bash
32
+ npx expo install expo-vector-search
33
+ ```
34
+
35
+ ## Architecture
36
+
37
+ The module is designed for performance-critical applications where latency and battery efficiency are paramount.
38
+
39
+ - **Engine**: [USearch](https://github.com/unum-cloud/usearch) (unum-cloud).
40
+ - **Bindings**: Custom JSI `HostObject` implementation for low-overhead synchronous execution.
41
+ - **Memory**: Direct data sharing via `ArrayBuffer` and raw pointers, avoiding the JSON serialization bottleneck of the legacy bridge.
42
+ - **Threading**: Native operations run on the JS thread for zero-copy efficiency.
43
+
44
+ ## API Reference
45
+
46
+ ### VectorIndex
47
+
48
+ The primary class for managing a vector collection.
49
+
50
+ #### `constructor(dimensions: number, options?: VectorIndexOptions)`
51
+ Initializes a new vector index.
52
+ - `dimensions`: The dimensionality of the vectors (e.g., 128, 384, 768).
53
+ - `options.quantization`: Scaling mode (`'f32'` or `'i8'`). Use `'i8'` for significant memory savings.
54
+
55
+ #### `add(key: number, vector: Float32Array): void`
56
+ Inserts a vector into the index.
57
+ - `key`: A unique numeric identifier.
58
+ - `vector`: A `Float32Array` containing the embeddings.
59
+
60
+ #### `addBatch(keys: Int32Array, vectors: Float32Array): void`
61
+ High-performance batch insertion. Significantly reduces JSI overhead by processing multiple vectors in a single native call.
62
+ - `keys`: An `Int32Array` of unique identifiers.
63
+ - `vectors`: A single `Float32Array` containing all vectors concatenated (must match `keys.length * dimensions`).
64
+
65
+ #### `search(vector: Float32Array, count: number): SearchResult[]`
66
+ Performs an ANN search.
67
+ - `vector`: The query embedding.
68
+ - `count`: Number of nearest neighbors to retrieve.
69
+ - **Returns**: An array of `SearchResult` objects `{ key: number, distance: number }`.
70
+
71
+ #### `save(path: string): void`
72
+ Serializes the current state of the index to a specified file path.
73
+
74
+ #### `load(path: string): void`
75
+ Deserializes an index from a file path.
76
+
77
+ #### `delete(): void`
78
+ Manually releases native memory resources. The index instance becomes unusable after this call.
79
+
80
+ #### `dimensions: number` (readonly)
81
+ Returns the dimensionality of the index.
82
+
83
+ #### `count: number` (readonly)
84
+ Returns the number of vectors currently indexed.
85
+
86
+ #### `memoryUsage: number` (readonly)
87
+ Returns the estimated memory usage of the native index in bytes.
88
+
89
+ ## Example Usage
90
+
91
+ ```typescript
92
+ import { VectorIndex } from 'expo-vector-search';
93
+
94
+ // Initialize a 384-dimension index with Int8 quantization
95
+ const index = new VectorIndex(384, { quantization: 'i8' });
96
+
97
+ // Add a vector
98
+ const myVector = new Float32Array(384).fill(0.5);
99
+ index.add(1, myVector);
100
+
101
+ // Search
102
+ const query = new Float32Array(384).fill(0.48);
103
+ const results = index.search(query, 5);
104
+
105
+ // High-Performance Batch Insertion
106
+ const manyKeys = new Int32Array([10, 11, 12]);
107
+ const manyVectors = new Float32Array(384 * 3).fill(0.1);
108
+ index.addBatch(manyKeys, manyVectors);
109
+
110
+ console.log(`Found ${results.length} neighbors`);
111
+ results.forEach(res => {
112
+ console.log(`Key: ${res.key}, Distance: ${res.distance}`);
113
+ });
114
+
115
+ // Explicit cleanup when done
116
+ index.delete();
117
+ ```
118
+
119
+ ## Best Practices
120
+
121
+ ### Memory Management
122
+ While the JavaScript garbage collector handles the wrapper object, the native memory associated with large indices can be significant. It is recommended to call `index.delete()` when an index is no longer needed (e.g., in a component's cleanup effect).
123
+
124
+ ### Persistence
125
+ When using `save()` and `load()`, ensure the provided paths are within the application's sandbox (e.g., `expo-file-system` document directory). The module includes path sanitization to prevent directory traversal.
126
+
127
+ ## Security
128
+ This module performs strict validation on input buffers.
129
+ - **Alignment**: `Float32Array` buffers must be 4-byte aligned for safe native access.
130
+ - **Type Safety**: Input vectors are validated against the index's defined dimensions to prevent out-of-bounds memory operations.
131
+
132
+ ## License
133
+ MIT
@@ -0,0 +1,48 @@
1
+ cmake_minimum_required(VERSION 3.10.2)
2
+ project(ExpoVectorSearch)
3
+
4
+ # 1. React Native / Expo Configuration (Default)
5
+ set(CMAKE_VERBOSE_MAKEFILE ON)
6
+ set(CMAKE_CXX_STANDARD 17)
7
+ # Disable external FP16 library requirement (use native or fallback)
8
+ add_compile_definitions(USEARCH_USE_FP16LIB=0)
9
+
10
+ # 2. Baixar e Configurar o USearch
11
+ include(FetchContent)
12
+
13
+ FetchContent_Declare(
14
+ usearch
15
+ GIT_REPOSITORY https://github.com/unum-cloud/usearch.git
16
+ GIT_TAG v2.9.0 # Use a recent stable version
17
+ )
18
+
19
+ # Disponibiliza o usearch para o projeto
20
+ FetchContent_MakeAvailable(usearch)
21
+
22
+ # 3. Definir sua biblioteca compartilhada
23
+ add_library(
24
+ expo-vector-search
25
+ SHARED
26
+ src/main/cpp/ExpoVectorSearchModule.cpp
27
+ )
28
+
29
+ # 4. Include directories (USearch and React Native JSI)
30
+ # Note: React Native defines variables for includes, but if it fails,
31
+ # usearch is header-only, so we need to focus on its include path.
32
+ target_include_directories(
33
+ expo-vector-search
34
+ PUBLIC
35
+ ${usearch_SOURCE_DIR}/include
36
+ )
37
+
38
+ # 5. Linkar bibliotecas (Log do Android e bibliotecas do JS Engine)
39
+ # Important: React Native uses Prefab, so we need to find the ReactAndroid package
40
+ find_package(ReactAndroid REQUIRED CONFIG)
41
+
42
+ target_link_libraries(
43
+ expo-vector-search
44
+ log
45
+ ReactAndroid::jsi
46
+ # Depending on the Expo/RN version, linking might be necessary:
47
+ # ReactAndroid::react_nativemodule_core
48
+ )
@@ -0,0 +1,68 @@
1
+ apply plugin: 'com.android.library'
2
+
3
+ group = 'expo.modules.vectorsearch'
4
+ version = '0.1.0'
5
+
6
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
7
+ apply from: expoModulesCorePlugin
8
+ applyKotlinExpoModulesCorePlugin()
9
+ useCoreDependencies()
10
+ useExpoPublishing()
11
+
12
+ def useManagedAndroidSdkVersions = false
13
+ if (useManagedAndroidSdkVersions) {
14
+ useDefaultAndroidSdkVersions()
15
+ } else {
16
+ buildscript {
17
+ ext.safeExtGet = { prop, fallback ->
18
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
19
+ }
20
+ }
21
+ project.android {
22
+ compileSdkVersion safeExtGet("compileSdkVersion", 36)
23
+ defaultConfig {
24
+ minSdkVersion safeExtGet("minSdkVersion", 24)
25
+ targetSdkVersion safeExtGet("targetSdkVersion", 36)
26
+ }
27
+ }
28
+ }
29
+
30
+ android {
31
+ namespace "expo.modules.vectorsearch"
32
+
33
+ buildFeatures {
34
+ prefab true
35
+ }
36
+
37
+ defaultConfig {
38
+ versionCode 1
39
+ versionName "0.1.0"
40
+
41
+
42
+ externalNativeBuild {
43
+ cmake {
44
+ cppFlags "-std=c++17 -O3 -fexceptions -frtti"
45
+
46
+ arguments "-DANDROID_STL=c++_shared"
47
+ }
48
+ }
49
+ }
50
+
51
+ externalNativeBuild {
52
+ cmake {
53
+ path "CMakeLists.txt"
54
+ }
55
+ }
56
+
57
+ packagingOptions {
58
+ pickFirst '**/libc++_shared.so'
59
+ }
60
+
61
+ lintOptions {
62
+ abortOnError false
63
+ }
64
+ }
65
+
66
+ dependencies {
67
+ implementation "com.facebook.react:react-android"
68
+ }
@@ -0,0 +1,2 @@
1
+ <manifest>
2
+ </manifest>
@@ -0,0 +1,306 @@
1
+ #include <jni.h>
2
+ #include <string>
3
+ #include <vector>
4
+ #include <memory>
5
+ #include <android/log.h>
6
+
7
+ #define TAG "ExpoVectorSearch"
8
+ #define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, TAG, __VA_ARGS__)
9
+ #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
10
+
11
+ // Include the correct USearch header (Dense Index)
12
+ // Polyfill for aligned_alloc on Android API < 28
13
+ #if defined(__ANDROID__) && __ANDROID_API__ < 28
14
+ #include <stdlib.h>
15
+ extern "C" void* aligned_alloc(size_t alignment, size_t size) {
16
+ void* ptr = nullptr;
17
+ if (posix_memalign(&ptr, alignment, size) != 0) {
18
+ return nullptr;
19
+ }
20
+ return ptr;
21
+ }
22
+ #endif
23
+
24
+ #include <usearch/index_dense.hpp>
25
+
26
+ // JSI Headers
27
+ #include <jsi/jsi.h>
28
+
29
+ using namespace facebook;
30
+ using namespace unum::usearch;
31
+
32
+ // Helper function to get raw pointer from a Float32Array
33
+ // Returns {float_pointer, element_count}
34
+ std::pair<const float*, size_t> getRawVector(jsi::Runtime &runtime, const jsi::Value &val) {
35
+ if (!val.isObject()) {
36
+ throw jsi::JSError(runtime, "Invalid argument: Expected a Float32Array.");
37
+ }
38
+ jsi::Object obj = val.asObject(runtime);
39
+
40
+ // Check if it has the 'buffer' property (indicates it's a TypedArray)
41
+ if (!obj.hasProperty(runtime, "buffer")) {
42
+ throw jsi::JSError(runtime, "Invalid argument: Object must have a 'buffer' (Float32Array).");
43
+ }
44
+
45
+ // Access the ArrayBuffer
46
+ auto bufferValue = obj.getProperty(runtime, "buffer");
47
+ if (!bufferValue.isObject() || !bufferValue.asObject(runtime).isArrayBuffer(runtime)) {
48
+ throw jsi::JSError(runtime, "Internal failure: 'buffer' is not a valid ArrayBuffer.");
49
+ }
50
+ auto arrayBuffer = bufferValue.asObject(runtime).getArrayBuffer(runtime);
51
+
52
+ // Google-grade Hardening: Buffer length validation
53
+ if (arrayBuffer.size(runtime) == 0) {
54
+ throw jsi::JSError(runtime, "Invalid argument: Float32Array is empty.");
55
+ }
56
+
57
+ // Read offset and length to support Views (sub-arrays)
58
+ size_t byteOffset = 0;
59
+ if (obj.hasProperty(runtime, "byteOffset")) {
60
+ byteOffset = static_cast<size_t>(obj.getProperty(runtime, "byteOffset").asNumber());
61
+ }
62
+
63
+ size_t byteLength = arrayBuffer.size(runtime); // Default full size
64
+ if (obj.hasProperty(runtime, "byteLength")) {
65
+ byteLength = static_cast<size_t>(obj.getProperty(runtime, "byteLength").asNumber());
66
+ }
67
+
68
+ // ZERO-COPY access to the memory pointer
69
+ uint8_t* rawBytes = arrayBuffer.data(runtime) + byteOffset;
70
+
71
+ // Google-grade Hardening: Memory Alignment Check
72
+ // Float32 needs 4-byte alignment. reinterpret_cast on unaligned memory is undefined behavior.
73
+ if (reinterpret_cast<uintptr_t>(rawBytes) % sizeof(float) != 0) {
74
+ throw jsi::JSError(runtime, "Memory Alignment Error: Float32Array buffer is not 4-byte aligned.");
75
+ }
76
+
77
+ // Cast to float*
78
+ const float* floatPtr = reinterpret_cast<const float*>(rawBytes);
79
+ size_t count = byteLength / sizeof(float);
80
+
81
+ return {floatPtr, count};
82
+ }
83
+
84
+ // Helper to normalize and sanitize paths (strips file:// and prevents traversal)
85
+ std::string normalizePath(jsi::Runtime &runtime, std::string path) {
86
+ // Strips file:// prefix if present
87
+ if (path.compare(0, 7, "file://") == 0) {
88
+ path = path.substr(7);
89
+ }
90
+
91
+ // Security Audit: Basic prevention of path traversal
92
+ if (path.find("..") != std::string::npos) {
93
+ throw jsi::JSError(runtime, "Security violation: Path traversal is not allowed.");
94
+ }
95
+
96
+ return path;
97
+ }
98
+
99
+ // Class that encapsulates a USearch index instance
100
+ class VectorIndex : public facebook::jsi::HostObject {
101
+ public:
102
+ using Index = index_dense_t;
103
+
104
+ VectorIndex(int dimensions, bool quantized) {
105
+ metric_kind_t metric_kind = metric_kind_t::cos_k;
106
+ scalar_kind_t scalar_kind = quantized ? scalar_kind_t::i8_k : scalar_kind_t::f32_k;
107
+ metric_punned_t metric(dimensions, metric_kind, scalar_kind);
108
+
109
+ _index = std::make_unique<Index>(Index::make(metric));
110
+ if (!_index) throw std::runtime_error("Failed to initialize USearch index");
111
+
112
+ if (!_index->reserve(100)) LOGE("Failed to reserve initial capacity");
113
+ }
114
+
115
+ // Expose methods to JavaScript
116
+ jsi::Value get(jsi::Runtime& runtime, const jsi::PropNameID& name) override {
117
+ std::string methodName = name.utf8(runtime);
118
+
119
+ // dimensions
120
+ if (methodName == "dimensions") {
121
+ return jsi::Value((double)_index->dimensions());
122
+ }
123
+
124
+ // count
125
+ if (methodName == "count") {
126
+ if (!_index) return jsi::Value(0);
127
+ return jsi::Value((double)_index->size());
128
+ }
129
+
130
+ // memoryUsage (bytes)
131
+ if (methodName == "memoryUsage") {
132
+ if (!_index) return jsi::Value(0);
133
+ return jsi::Value((double)_index->memory_usage());
134
+ }
135
+
136
+ // delete() - Explicit Memory Release
137
+ if (methodName == "delete") {
138
+ return jsi::Function::createFromHostFunction(runtime, name, 0,
139
+ [this](jsi::Runtime& runtime, const jsi::Value& thisValue, const jsi::Value* arguments, size_t count) -> jsi::Value {
140
+ _index.reset(); // Destroy the unique_ptr and release memory
141
+ return jsi::Value::undefined();
142
+ });
143
+ }
144
+
145
+ // add(key: number, vector: Float32Array)
146
+ if (methodName == "add") {
147
+ return jsi::Function::createFromHostFunction(runtime, name, 2,
148
+ [this](jsi::Runtime& runtime, const jsi::Value& thisValue, const jsi::Value* arguments, size_t count) -> jsi::Value {
149
+ if (count < 2) throw jsi::JSError(runtime, "add expects 2 arguments: key, vector");
150
+ if (!_index) throw jsi::JSError(runtime, "VectorIndex has been deleted.");
151
+
152
+ double keyDouble = arguments[0].asNumber();
153
+ default_key_t key = static_cast<default_key_t>(keyDouble);
154
+
155
+ auto [vecData, vecSize] = getRawVector(runtime, arguments[1]);
156
+
157
+ if (vecSize != _index->dimensions()) {
158
+ throw jsi::JSError(runtime, "Incorrect dimension.");
159
+ }
160
+
161
+ if (_index->size() >= _index->capacity()) _index->reserve(_index->capacity() * 2);
162
+ auto result = _index->add(key, vecData);
163
+ if (!result) throw jsi::JSError(runtime, "Error adding: " + std::string(result.error.what()));
164
+
165
+ return jsi::Value::undefined();
166
+ });
167
+ }
168
+
169
+ // addBatch(keys: Int32Array, vectors: Float32Array)
170
+ if (methodName == "addBatch") {
171
+ return jsi::Function::createFromHostFunction(runtime, name, 2,
172
+ [this](jsi::Runtime& runtime, const jsi::Value& thisValue, const jsi::Value* arguments, size_t count) -> jsi::Value {
173
+ if (count < 2) throw jsi::JSError(runtime, "addBatch expects 2 arguments: keys, vectors");
174
+ if (!_index) throw jsi::JSError(runtime, "VectorIndex has been deleted.");
175
+
176
+ // 1. Get Keys (Int32Array)
177
+ jsi::Object keysArray = arguments[0].asObject(runtime);
178
+ auto keysBufferValue = keysArray.getProperty(runtime, "buffer");
179
+ auto keysBuffer = keysBufferValue.asObject(runtime).getArrayBuffer(runtime);
180
+
181
+ size_t keysByteOffset = keysArray.hasProperty(runtime, "byteOffset") ?
182
+ (size_t)keysArray.getProperty(runtime, "byteOffset").asNumber() : 0;
183
+ size_t keysCount = (keysArray.hasProperty(runtime, "byteLength") ?
184
+ (size_t)keysArray.getProperty(runtime, "byteLength").asNumber() : keysBuffer.size(runtime)) / sizeof(int32_t);
185
+
186
+ const int32_t* keysData = reinterpret_cast<const int32_t*>(keysBuffer.data(runtime) + keysByteOffset);
187
+
188
+ // 2. Get Vectors (Float32Array)
189
+ auto [vecData, vecTotalElements] = getRawVector(runtime, arguments[1]);
190
+ size_t dims = _index->dimensions();
191
+ size_t batchCount = vecTotalElements / dims;
192
+
193
+ // 3. Validation
194
+ if (batchCount != keysCount) {
195
+ throw jsi::JSError(runtime, "Batch mismatch: keys and vectors must have compatible sizes.");
196
+ }
197
+
198
+ // 4. Optimization: Single Reserve
199
+ if (_index->size() + batchCount > _index->capacity()) {
200
+ _index->reserve(_index->size() + batchCount);
201
+ }
202
+
203
+ // 5. Bulk Add
204
+ for (size_t i = 0; i < batchCount; ++i) {
205
+ auto result = _index->add((default_key_t)keysData[i], vecData + (i * dims));
206
+ if (!result) throw jsi::JSError(runtime, "Error adding in batch at index " + std::to_string(i));
207
+ }
208
+
209
+ return jsi::Value::undefined();
210
+ });
211
+ }
212
+
213
+ // search(vector: Float32Array, count: number)
214
+ if (methodName == "search") {
215
+ return jsi::Function::createFromHostFunction(runtime, name, 2,
216
+ [this](jsi::Runtime& runtime, const jsi::Value& thisValue, const jsi::Value* arguments, size_t count) -> jsi::Value {
217
+ if (count < 2) throw jsi::JSError(runtime, "search expects 2 arguments: vector, count");
218
+ if (!_index) throw jsi::JSError(runtime, "VectorIndex has been deleted.");
219
+
220
+ auto [queryData, querySize] = getRawVector(runtime, arguments[0]);
221
+ int resultsCount = static_cast<int>(arguments[1].asNumber());
222
+
223
+ if (querySize != _index->dimensions()) {
224
+ throw jsi::JSError(runtime, "Query vector dimension mismatch.");
225
+ }
226
+
227
+ auto results = _index->search(queryData, resultsCount);
228
+ jsi::Array returnArray(runtime, results.size());
229
+ for (size_t i = 0; i < results.size(); ++i) {
230
+ auto pair = results[i];
231
+ jsi::Object resultObj(runtime);
232
+ resultObj.setProperty(runtime, "key", static_cast<double>(pair.member.key));
233
+ resultObj.setProperty(runtime, "distance", static_cast<double>(pair.distance));
234
+ returnArray.setValueAtIndex(runtime, i, resultObj);
235
+ }
236
+ return returnArray;
237
+ });
238
+ }
239
+
240
+ // save(path: string)
241
+ if (methodName == "save") {
242
+ return jsi::Function::createFromHostFunction(runtime, name, 1,
243
+ [this](jsi::Runtime& runtime, const jsi::Value& thisValue, const jsi::Value* arguments, size_t count) -> jsi::Value {
244
+ if (count < 1 || !arguments[0].isString()) throw jsi::JSError(runtime, "save expects path");
245
+ std::string path = normalizePath(runtime, arguments[0].asString(runtime).utf8(runtime));
246
+
247
+ if (!_index->save(path.c_str())) throw jsi::JSError(runtime, "Critical error saving index to disk: " + path);
248
+ return jsi::Value::undefined();
249
+ });
250
+ }
251
+
252
+ // load(path: string)
253
+ if (methodName == "load") {
254
+ return jsi::Function::createFromHostFunction(runtime, name, 1,
255
+ [this](jsi::Runtime& runtime, const jsi::Value& thisValue, const jsi::Value* arguments, size_t count) -> jsi::Value {
256
+ if (count < 1 || !arguments[0].isString()) throw jsi::JSError(runtime, "load expects path");
257
+ std::string path = normalizePath(runtime, arguments[0].asString(runtime).utf8(runtime));
258
+
259
+ if (!_index->load(path.c_str())) throw jsi::JSError(runtime, "Critical error loading index from disk: " + path);
260
+ return jsi::Value::undefined();
261
+ });
262
+ }
263
+
264
+ return jsi::Value::undefined();
265
+ }
266
+
267
+ private:
268
+ std::unique_ptr<Index> _index;
269
+ };
270
+
271
+ // --------------------------------------------------------------------------
272
+ // Module Installation (Factory)
273
+ // --------------------------------------------------------------------------
274
+
275
+ extern "C" JNIEXPORT void JNICALL
276
+ Java_expo_modules_vectorsearch_ExpoVectorSearchModule_nativeInstall(JNIEnv* env, jobject thiz, jlong jsiPtr) {
277
+ auto runtime = reinterpret_cast<jsi::Runtime*>(jsiPtr);
278
+ if (!runtime) return;
279
+ auto &rt = *runtime;
280
+
281
+ auto global = rt.global();
282
+ auto moduleObj = jsi::Object(rt);
283
+
284
+ // ExpoVectorSearch.createIndex(dimensions: number, options?: { quantization: 'f32' | 'i8' }) -> VectorIndex
285
+ moduleObj.setProperty(rt, "createIndex", jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, "createIndex"), 1,
286
+ [](jsi::Runtime &rt, const jsi::Value &thisValue, const jsi::Value *args, size_t count) -> jsi::Value {
287
+ if (count < 1 || !args[0].isNumber()) {
288
+ throw jsi::JSError(rt, "createIndex expects at least 1 argument: dimensions");
289
+ }
290
+ int dims = static_cast<int>(args[0].asNumber());
291
+
292
+ bool quantized = false;
293
+ if (count > 1 && args[1].isObject()) {
294
+ jsi::Object options = args[1].asObject(rt);
295
+ if (options.hasProperty(rt, "quantization")) {
296
+ std::string q = options.getProperty(rt, "quantization").asString(rt).utf8(rt);
297
+ if (q == "i8") quantized = true;
298
+ }
299
+ }
300
+
301
+ auto indexInstance = std::make_shared<VectorIndex>(dims, quantized);
302
+ return jsi::Object::createFromHostObject(rt, indexInstance);
303
+ }));
304
+
305
+ global.setProperty(rt, "ExpoVectorSearch", moduleObj);
306
+ }