diff --git a/packages/embed-llamacpp/CMakeLists.txt b/packages/embed-llamacpp/CMakeLists.txt index d93fe44c7c..2a8a238501 100644 --- a/packages/embed-llamacpp/CMakeLists.txt +++ b/packages/embed-llamacpp/CMakeLists.txt @@ -17,6 +17,21 @@ find_package(cmake-vcpkg REQUIRED PATHS node_modules/cmake-vcpkg) set(VCPKG_OVERLAY_TRIPLETS "${CMAKE_CURRENT_SOURCE_DIR}/vcpkg/triplets;${VCPKG_OVERLAY_TRIPLETS}") +# Local overlay-port for fabric (turbovec POC). The overlay at +# `vcpkg/ports/qvac-fabric/` carries the POC fabric build because the public +# `tetherto/qvac-registry-vcpkg` qvac-fabric port has no 8828.x line yet and +# this manifest pins `>=8828.1.0`. By default the overlay fetches from +# `dev-nid/qvac-fabric-llm.cpp` (poc/turbovec-embed branch) at a pinned +# commit + SHA512. Drop this overlay once the registry publishes 8828.x. +# +# Local-iteration override: set `QVAC_FABRIC_LOCAL_PATH=/path/to/fabric` to +# build from a working tree instead. In that mode, run +# `./scripts/sync-fabric-overlay.sh` after each edit to bump the +# portfile-tracked source hash (vcpkg derives ABI from portfile contents, +# not from the source it copies in). `./scripts/rebuild-fabric.sh` chains +# sync + generate + build + install for the inner-loop case. +set(VCPKG_OVERLAY_PORTS "${CMAKE_CURRENT_SOURCE_DIR}/vcpkg/ports;${VCPKG_OVERLAY_PORTS}") + project(embed-llamacpp C CXX) if(CMAKE_SYSTEM_NAME STREQUAL "Linux") @@ -68,6 +83,7 @@ target_sources( ${embed-llamacpp} PRIVATE ${PROJECT_SOURCE_DIR}/addon/src/js-interface/binding.cpp + ${PROJECT_SOURCE_DIR}/addon/src/js-interface/vector-index-binding.cpp ${PROJECT_SOURCE_DIR}/addon/src/model-interface/utils.cpp ${PROJECT_SOURCE_DIR}/addon/src/model-interface/AsyncWeightsLoader.cpp ${PROJECT_SOURCE_DIR}/addon/src/model-interface/BackendSelection.cpp @@ -75,6 +91,7 @@ target_sources( ${PROJECT_SOURCE_DIR}/addon/src/model-interface/LlamaLazyInitializeBackend.cpp ${PROJECT_SOURCE_DIR}/addon/src/model-interface/ModelMetadata.cpp ${PROJECT_SOURCE_DIR}/addon/src/model-interface/BertModel.cpp + ${PROJECT_SOURCE_DIR}/addon/src/model-interface/VectorIndex.cpp ) target_include_directories( ${embed-llamacpp} @@ -82,6 +99,30 @@ target_include_directories( ${QVAC_LIB_INFERENCE_ADDON_CPP_INCLUDE_DIRS} ${PROJECT_SOURCE_DIR}/addon/src ) +# Some fabric branches (HEAD-ish) only export the `llama` target without +# namespacing and without exporting the `llama-common` helper lib at all. +# The `temp-8828` branch exports the full `llama::*` namespace incl. +# `llama::common`. Add a shim only when the expected targets are missing. +if(TARGET llama AND NOT TARGET llama::llama) + add_library(llama::llama ALIAS llama) +endif() +if(NOT TARGET llama::common) + if(NOT TARGET llama-common) + find_library(LLAMA_COMMON_LIB + NAMES llama-common + REQUIRED + HINTS ${LLAMA_LIB_DIR} + NO_CMAKE_FIND_ROOT_PATH) + add_library(llama-common UNKNOWN IMPORTED) + set_target_properties(llama-common + PROPERTIES + IMPORTED_LOCATION "${LLAMA_COMMON_LIB}" + INTERFACE_INCLUDE_DIRECTORIES "${LLAMA_INCLUDE_DIR}" + INTERFACE_LINK_LIBRARIES "llama") + endif() + add_library(llama::common ALIAS llama-common) +endif() + target_link_libraries( ${embed-llamacpp} PRIVATE diff --git a/packages/embed-llamacpp/addon/src/addon/VectorIndexErrors.hpp b/packages/embed-llamacpp/addon/src/addon/VectorIndexErrors.hpp new file mode 100644 index 0000000000..5980ac89fb --- /dev/null +++ b/packages/embed-llamacpp/addon/src/addon/VectorIndexErrors.hpp @@ -0,0 +1,42 @@ +#pragma once +// +// Maps fabric vector-index C error codes to JS-throwable messages. Mirrors +// the layout of `BertErrors.hpp` but for the ANN index path. Kept in its own +// header so the binding can include it without dragging in any BertModel / +// LlamaLazyInitializeBackend symbols (lifecycle isolation requirement of +// the POC). + +#include + +#include + +namespace qvac_lib_infer_llamacpp_embed::vector_index_errors { + +constexpr const char* ADDON_ID = "IdMapIndex"; + +inline std::string toString(int code) { + switch (code) { + case GGML_VEC_INDEX_OK: + return "OK"; + case GGML_VEC_INDEX_E_INVALID_DIM: + return "InvalidDim"; + case GGML_VEC_INDEX_E_INVALID_ARG: + return "InvalidArgument"; + case GGML_VEC_INDEX_E_DUPLICATE: + return "DuplicateId"; + case GGML_VEC_INDEX_E_IO: + return "IOError"; + case GGML_VEC_INDEX_E_BAD_MAGIC: + return "BadMagic"; + case GGML_VEC_INDEX_E_BAD_VERSION: + return "BadVersion"; + case GGML_VEC_INDEX_E_OOM: + return "OutOfMemory"; + case GGML_VEC_INDEX_E_INTERNAL: + return "InternalError"; + default: + return "UnknownError"; + } +} + +} // namespace qvac_lib_infer_llamacpp_embed::vector_index_errors diff --git a/packages/embed-llamacpp/addon/src/js-interface/binding.cpp b/packages/embed-llamacpp/addon/src/js-interface/binding.cpp index d8ebeeb0d0..2c50ff9996 100644 --- a/packages/embed-llamacpp/addon/src/js-interface/binding.cpp +++ b/packages/embed-llamacpp/addon/src/js-interface/binding.cpp @@ -2,6 +2,15 @@ #include "../addon/AddonJs.hpp" +// Forward declaration for the IdMapIndex (vector-index) binding registrar. +// The implementation lives in `vector-index-binding.cpp` and is deliberately +// kept in its own TU so it has no symbol dependency on BertModel / +// LlamaLazyInitializeBackend — required for the POC's lifecycle-isolation +// invariant (constructing IdMapIndex must not boot fabric's LLM backend). +namespace qvac_lib_inference_addon_embed::vector_index { +void registerBindings(js_env_t* env, js_value_t* exports); +} + js_value_t* qvacLibInferLlamacppEmbedExports(js_env_t* env, js_value_t* exports) { @@ -30,6 +39,8 @@ qvacLibInferLlamacppEmbedExports(js_env_t* env, js_value_t* exports) { #undef V // NOLINTEND(cppcoreguidelines-macro-usage) + qvac_lib_inference_addon_embed::vector_index::registerBindings(env, exports); + return exports; } diff --git a/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp b/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp new file mode 100644 index 0000000000..081ebf36c3 --- /dev/null +++ b/packages/embed-llamacpp/addon/src/js-interface/vector-index-binding.cpp @@ -0,0 +1,487 @@ +// vector-index-binding.cpp +// +// N-API surface for the `IdMapIndex` JS class. Registers a small set of free +// functions on the embed-llamacpp addon's exports; the JS wrapper in +// `idMapIndex.js` ties them into a class shape. +// +// Lifecycle isolation: this binding deliberately depends ONLY on the +// VectorIndex C++ wrapper (which in turn depends only on fabric's +// ggml-vector-index C API). It never references BertModel, +// LlamaLazyInitializeBackend, or any other BERT-runtime symbol, so simply +// importing the addon does not boot fabric's LLM backend. The same .bare +// binary carries both class surfaces; the JS side decides which to construct. + +#include + +#include +#include +#include +#include +#include + +#include "../addon/VectorIndexErrors.hpp" +#include "../model-interface/VectorIndex.hpp" + +namespace { + +using qvac_lib_infer_llamacpp_embed::VectorIndex; +namespace verrors = qvac_lib_infer_llamacpp_embed::vector_index_errors; + +// Finalizer: invoked by the JS engine when the external handle is GC'd. +// Tears down the native C handle via VectorIndex's RAII dtor. +void +finalize_vector_index(js_env_t* /*env*/, void* data, void* /*hint*/) { + auto* idx = static_cast(data); + delete idx; +} + +// Wrap an already-constructed VectorIndex into a JS external. Takes +// ownership of `idx`; the JS engine will delete it via finalize on GC. +js_value_t* wrap(js_env_t* env, VectorIndex* idx) { + js_value_t* external = nullptr; + if (js_create_external(env, idx, finalize_vector_index, nullptr, &external) + != 0) { + delete idx; + js_throw_error(env, "InternalError", "failed to create external"); + return nullptr; + } + return external; +} + +// Get a borrowed pointer out of a JS external handle. Throws and returns +// null on failure. +VectorIndex* unwrap(js_env_t* env, js_value_t* handle) { + void* data = nullptr; + if (js_get_value_external(env, handle, &data) != 0 || data == nullptr) { + js_throw_error(env, "InvalidArgument", "expected IdMapIndex handle"); + return nullptr; + } + return static_cast(data); +} + +// Read a JS object property and parse as int32. On failure, throws and +// returns the provided default; caller should check pending exception via +// js_is_exception_pending or by returning nullptr from the function. +bool read_int_prop( + js_env_t* env, js_value_t* obj, const char* name, int32_t* out) { + js_value_t* val = nullptr; + if (js_get_named_property(env, obj, name, &val) != 0) { + return false; + } + return js_get_value_int32(env, val, out) == 0; +} + +void throw_status(js_env_t* env, int code) { + const std::string name = verrors::toString(code); + js_throw_error(env, name.c_str(), name.c_str()); +} + +// --------------------------------------------------------------------------- +// Bindings +// --------------------------------------------------------------------------- + +// idx_create({ dim, bitWidth }) -> external handle +js_value_t* idx_create(js_env_t* env, js_callback_info_t* info) { + size_t argc = 1; + js_value_t* argv[1] = { nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 1) { + js_throw_type_error(env, "InvalidArgument", "expected { dim, bitWidth }"); + return nullptr; + } + int32_t dim = 0; + int32_t bit_width = 32; + if (!read_int_prop(env, argv[0], "dim", &dim)) { + js_throw_type_error(env, "InvalidArgument", "missing or invalid `dim`"); + return nullptr; + } + // bitWidth optional; default to 32 if missing. + (void) read_int_prop(env, argv[0], "bitWidth", &bit_width); + + VectorIndex* idx = nullptr; + try { + idx = new VectorIndex(dim, bit_width); + } catch (const std::invalid_argument& e) { + js_throw_error(env, "InvalidArgument", e.what()); + return nullptr; + } catch (const std::bad_alloc&) { + js_throw_error(env, "OutOfMemory", "allocation failure"); + return nullptr; + } + return wrap(env, idx); +} + +// idx_load(path) -> external handle (throws on file errors). +js_value_t* idx_load(js_env_t* env, js_callback_info_t* info) { + size_t argc = 1; + js_value_t* argv[1] = { nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 1) { + js_throw_type_error(env, "InvalidArgument", "expected path string"); + return nullptr; + } + size_t len = 0; + if (js_get_value_string_utf8(env, argv[0], nullptr, 0, &len) != 0) { + js_throw_type_error(env, "InvalidArgument", "path must be a string"); + return nullptr; + } + std::string path(len, '\0'); + size_t copied = 0; + if (js_get_value_string_utf8( + env, argv[0], + reinterpret_cast(path.data()), len + 1, &copied) != 0) { + js_throw_error(env, "InternalError", "failed to read path string"); + return nullptr; + } + path.resize(copied); + + VectorIndex loaded = VectorIndex::load(path); + if (!loaded.valid()) { + js_throw_error(env, "IOError", "ggml_vec_index_load returned null"); + return nullptr; + } + // Move the wrapper onto the heap so we can hand JS an owning external. + auto* heap = new VectorIndex(std::move(loaded)); + return wrap(env, heap); +} + +// idx_add(handle, Float32Array vectors, BigUint64Array ids) -> undefined +js_value_t* idx_add(js_env_t* env, js_callback_info_t* info) { + size_t argc = 3; + js_value_t* argv[3] = { nullptr, nullptr, nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 3) { + js_throw_type_error(env, "InvalidArgument", + "expected (handle, vectors:Float32Array, ids:BigUint64Array)"); + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + + js_typedarray_type_t vtype{}; + void* vdata = nullptr; + size_t vlen = 0; + if (js_get_typedarray_info( + env, argv[1], &vtype, &vdata, &vlen, nullptr, nullptr) != 0 + || vtype != js_float32array) { + js_throw_type_error(env, "InvalidArgument", + "vectors must be a Float32Array"); + return nullptr; + } + + js_typedarray_type_t itype{}; + void* idata = nullptr; + size_t ilen = 0; + if (js_get_typedarray_info( + env, argv[2], &itype, &idata, &ilen, nullptr, nullptr) != 0 + || itype != js_biguint64array) { + js_throw_type_error(env, "InvalidArgument", + "ids must be a BigUint64Array"); + return nullptr; + } + + const int dim = idx->dim(); + if (dim <= 0) { + js_throw_error(env, "InternalError", "index has invalid dim"); + return nullptr; + } + if (vlen != ilen * static_cast(dim)) { + js_throw_range_error(env, "InvalidArgument", + "vectors.length must equal ids.length * dim"); + return nullptr; + } + if (ilen > static_cast(INT32_MAX)) { + js_throw_range_error(env, "InvalidArgument", "too many vectors in batch"); + return nullptr; + } + + const int rc = idx->add( + static_cast(vdata), + static_cast(ilen), + static_cast(idata)); + if (rc != 0) { + throw_status(env, rc); + return nullptr; + } + js_value_t* u = nullptr; + js_get_undefined(env, &u); + return u; +} + +// idx_search(handle, Float32Array queries, int k) +// -> { scores: Float32Array(m*k), ids: BigUint64Array(m*k), m, k } +js_value_t* idx_search(js_env_t* env, js_callback_info_t* info) { + size_t argc = 3; + js_value_t* argv[3] = { nullptr, nullptr, nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 3) { + js_throw_type_error(env, "InvalidArgument", + "expected (handle, queries:Float32Array, k:number)"); + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + + js_typedarray_type_t qtype{}; + void* qdata = nullptr; + size_t qlen = 0; + if (js_get_typedarray_info( + env, argv[1], &qtype, &qdata, &qlen, nullptr, nullptr) != 0 + || qtype != js_float32array) { + js_throw_type_error(env, "InvalidArgument", + "queries must be a Float32Array"); + return nullptr; + } + + int32_t k = 0; + if (js_get_value_int32(env, argv[2], &k) != 0 || k <= 0) { + js_throw_type_error(env, "InvalidArgument", "k must be a positive int"); + return nullptr; + } + + const int dim = idx->dim(); + if (dim <= 0 || qlen % static_cast(dim) != 0) { + js_throw_range_error(env, "InvalidArgument", + "queries.length must be a multiple of dim"); + return nullptr; + } + const size_t m = qlen / static_cast(dim); + if (m > static_cast(INT32_MAX)) { + js_throw_range_error(env, "InvalidArgument", "too many queries"); + return nullptr; + } + + // Allocate output ArrayBuffers; we'll hand them to JS via typed-array + // views. + const size_t total = m * static_cast(k); + const size_t scores_b = total * sizeof(float); + const size_t ids_b = total * sizeof(uint64_t); + + void* scores_data = nullptr; + js_value_t* scores_ab = nullptr; + if (js_create_arraybuffer(env, scores_b, &scores_data, &scores_ab) != 0) { + js_throw_error(env, "OutOfMemory", "scores arraybuffer"); + return nullptr; + } + void* ids_data = nullptr; + js_value_t* ids_ab = nullptr; + if (js_create_arraybuffer(env, ids_b, &ids_data, &ids_ab) != 0) { + js_throw_error(env, "OutOfMemory", "ids arraybuffer"); + return nullptr; + } + + const int rc = idx->search( + static_cast(qdata), + static_cast(m), + k, + static_cast(scores_data), + static_cast(ids_data)); + if (rc != 0) { + throw_status(env, rc); + return nullptr; + } + + js_value_t* scores_ta = nullptr; + if (js_create_typedarray( + env, js_float32array, total, scores_ab, 0, &scores_ta) != 0) { + js_throw_error(env, "InternalError", "create scores typedarray"); + return nullptr; + } + js_value_t* ids_ta = nullptr; + if (js_create_typedarray( + env, js_biguint64array, total, ids_ab, 0, &ids_ta) != 0) { + js_throw_error(env, "InternalError", "create ids typedarray"); + return nullptr; + } + + js_value_t* result = nullptr; + if (js_create_object(env, &result) != 0) { + js_throw_error(env, "InternalError", "create result object"); + return nullptr; + } + if (js_set_named_property(env, result, "scores", scores_ta) != 0 + || js_set_named_property(env, result, "ids", ids_ta) != 0) { + js_throw_error(env, "InternalError", "set result fields"); + return nullptr; + } + js_value_t* m_val = nullptr; + js_value_t* k_val = nullptr; + js_create_uint32(env, static_cast(m), &m_val); + js_create_int32(env, k, &k_val); + js_set_named_property(env, result, "m", m_val); + js_set_named_property(env, result, "k", k_val); + return result; +} + +// idx_remove(handle, id:bigint) -> boolean +js_value_t* idx_remove(js_env_t* env, js_callback_info_t* info) { + size_t argc = 2; + js_value_t* argv[2] = { nullptr, nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 2) { + js_throw_type_error(env, "InvalidArgument", + "expected (handle, id:bigint)"); + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + + uint64_t id = 0; + bool lossless = false; + if (js_get_value_bigint_uint64(env, argv[1], &id, &lossless) != 0 + || !lossless) { + js_throw_type_error(env, "InvalidArgument", + "id must be an unsigned BigInt fitting in 64 bits"); + return nullptr; + } + + const int rc = idx->remove(id); + if (rc < 0) { + throw_status(env, rc); + return nullptr; + } + js_value_t* result = nullptr; + js_get_boolean(env, rc == 1, &result); + return result; +} + +// idx_contains(handle, id:bigint) -> boolean +js_value_t* idx_contains(js_env_t* env, js_callback_info_t* info) { + size_t argc = 2; + js_value_t* argv[2] = { nullptr, nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 2) { + js_throw_type_error(env, "InvalidArgument", + "expected (handle, id:bigint)"); + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + + uint64_t id = 0; + bool lossless = false; + if (js_get_value_bigint_uint64(env, argv[1], &id, &lossless) != 0 + || !lossless) { + js_throw_type_error(env, "InvalidArgument", + "id must be an unsigned BigInt fitting in 64 bits"); + return nullptr; + } + js_value_t* result = nullptr; + js_get_boolean(env, idx->contains(id), &result); + return result; +} + +// idx_prepare(handle) -> undefined (no-op in POC). +js_value_t* idx_prepare(js_env_t* env, js_callback_info_t* info) { + size_t argc = 1; + js_value_t* argv[1] = { nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + idx->prepare(); + js_value_t* u = nullptr; + js_get_undefined(env, &u); + return u; +} + +// idx_write(handle, path) -> undefined; throws on IO error. +js_value_t* idx_write(js_env_t* env, js_callback_info_t* info) { + size_t argc = 2; + js_value_t* argv[2] = { nullptr, nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + if (argc < 2) { + js_throw_type_error(env, "InvalidArgument", "expected (handle, path)"); + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + + size_t len = 0; + if (js_get_value_string_utf8(env, argv[1], nullptr, 0, &len) != 0) { + js_throw_type_error(env, "InvalidArgument", "path must be a string"); + return nullptr; + } + std::string path(len, '\0'); + size_t copied = 0; + if (js_get_value_string_utf8( + env, argv[1], + reinterpret_cast(path.data()), len + 1, &copied) != 0) { + js_throw_error(env, "InternalError", "failed to read path"); + return nullptr; + } + path.resize(copied); + + const int rc = idx->write(path); + if (rc != 0) { + throw_status(env, rc); + return nullptr; + } + js_value_t* u = nullptr; + js_get_undefined(env, &u); + return u; +} + +// Generic int32 getter for len/dim/bitWidth. +template +js_value_t* idx_int_getter(js_env_t* env, js_callback_info_t* info) { + size_t argc = 1; + js_value_t* argv[1] = { nullptr }; + if (js_get_callback_info(env, info, &argc, argv, nullptr, nullptr) != 0) { + return nullptr; + } + VectorIndex* idx = unwrap(env, argv[0]); + if (idx == nullptr) { return nullptr; } + js_value_t* result = nullptr; + js_create_int32(env, (idx->*Fn)(), &result); + return result; +} + +} // namespace + +namespace qvac_lib_inference_addon_embed::vector_index { + +void registerBindings(js_env_t* env, js_value_t* exports) { +// NOLINTBEGIN(cppcoreguidelines-macro-usage) +#define V(name, fn) \ + do { \ + js_value_t* val = nullptr; \ + if (js_create_function(env, name, -1, fn, nullptr, &val) != 0) { \ + return; \ + } \ + if (js_set_named_property(env, exports, name, val) != 0) { \ + return; \ + } \ + } while (0) + + V("idx_create", idx_create); + V("idx_load", idx_load); + V("idx_add", idx_add); + V("idx_search", idx_search); + V("idx_remove", idx_remove); + V("idx_contains", idx_contains); + V("idx_prepare", idx_prepare); + V("idx_write", idx_write); + V("idx_len", (idx_int_getter<&VectorIndex::len>)); + V("idx_dim", (idx_int_getter<&VectorIndex::dim>)); + V("idx_bit_width", (idx_int_getter<&VectorIndex::bitWidth>)); +#undef V +// NOLINTEND(cppcoreguidelines-macro-usage) +} + +} // namespace qvac_lib_inference_addon_embed::vector_index diff --git a/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.cpp b/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.cpp new file mode 100644 index 0000000000..bfd829b571 --- /dev/null +++ b/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.cpp @@ -0,0 +1,87 @@ +#include "VectorIndex.hpp" + +#include +#include + +namespace qvac_lib_infer_llamacpp_embed { + +VectorIndex::VectorIndex(int dim, int bitWidth) + : handle_(ggml_vec_index_create(dim, bitWidth)) { + if (handle_ == nullptr) { + throw std::invalid_argument( + "ggml_vec_index_create rejected dim/bitWidth"); + } +} + +VectorIndex::VectorIndex(ggml_vec_index_t* handle) noexcept : handle_(handle) {} + +VectorIndex::~VectorIndex() { + if (handle_ != nullptr) { + ggml_vec_index_free(handle_); + handle_ = nullptr; + } +} + +VectorIndex::VectorIndex(VectorIndex&& other) noexcept + : handle_(other.handle_) { + other.handle_ = nullptr; +} + +VectorIndex& VectorIndex::operator=(VectorIndex&& other) noexcept { + if (this != &other) { + if (handle_ != nullptr) { + ggml_vec_index_free(handle_); + } + handle_ = other.handle_; + other.handle_ = nullptr; + } + return *this; +} + +int VectorIndex::add( + const float* vectors, int n, const uint64_t* ids) noexcept { + return ggml_vec_index_add(handle_, vectors, n, ids); +} + +int VectorIndex::remove(uint64_t id) noexcept { + return ggml_vec_index_remove(handle_, id); +} + +bool VectorIndex::contains(uint64_t id) const noexcept { + return ggml_vec_index_contains(handle_, id) != 0; +} + +void VectorIndex::prepare() noexcept { ggml_vec_index_prepare(handle_); } + +int VectorIndex::search( + const float* queries, + int n_q, + int k, + float* outScores, + uint64_t* outIds) const noexcept { + return ggml_vec_index_search( + handle_, queries, n_q, k, outScores, outIds); +} + +int VectorIndex::write(const std::string& path) noexcept { + return ggml_vec_index_write(handle_, path.c_str()); +} + +VectorIndex VectorIndex::load(const std::string& path) noexcept { + ggml_vec_index_t* raw = ggml_vec_index_load(path.c_str()); + return VectorIndex(raw); +} + +int VectorIndex::len() const noexcept { + return ggml_vec_index_len(handle_); +} + +int VectorIndex::dim() const noexcept { + return ggml_vec_index_dim(handle_); +} + +int VectorIndex::bitWidth() const noexcept { + return ggml_vec_index_bit_width(handle_); +} + +} // namespace qvac_lib_infer_llamacpp_embed diff --git a/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.hpp b/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.hpp new file mode 100644 index 0000000000..bd6fbc48fe --- /dev/null +++ b/packages/embed-llamacpp/addon/src/model-interface/VectorIndex.hpp @@ -0,0 +1,73 @@ +#pragma once +// +// RAII C++ wrapper around fabric's `ggml_vec_index_t*` C handle. Provides +// typed methods + status-aware accessors. Lifecycle isolated from +// LlamaLazyInitializeBackend / BertModel by construction: this header +// only depends on the ggml-base vector-index C API. + +#include + +#include +#include + +namespace qvac_lib_infer_llamacpp_embed { + +class VectorIndex { +public: + // Construct a fresh empty index. Throws std::invalid_argument on bad + // dims / bit_width. + VectorIndex(int dim, int bitWidth); + + // Adopt an already-opened native handle (used by static load). + explicit VectorIndex(ggml_vec_index_t* handle) noexcept; + + ~VectorIndex(); + + VectorIndex(const VectorIndex&) = delete; + VectorIndex& operator=(const VectorIndex&) = delete; + VectorIndex(VectorIndex&& other) noexcept; + VectorIndex& operator=(VectorIndex&& other) noexcept; + + // Returns 0 on success, ggml_vec_index_error on failure (e.g. duplicate). + int add(const float* vectors, int n, const uint64_t* ids) noexcept; + + // Returns 1 / 0 (removed / not present), negative on error. + int remove(uint64_t id) noexcept; + + [[nodiscard]] bool contains(uint64_t id) const noexcept; + + void prepare() noexcept; + + // Top-k search. Caller owns out arrays of size n_q * k. + int search( + const float* queries, + int n_q, + int k, + float* outScores, + uint64_t* outIds) const noexcept; + + // Persists to disk. Returns 0 on success. + int write(const std::string& path) noexcept; + + // Reads from disk. On failure returns a wrapper whose `valid()` is false; + // callers must check before using the instance. + static VectorIndex load(const std::string& path) noexcept; + + // Stats. + [[nodiscard]] int len() const noexcept; + [[nodiscard]] int dim() const noexcept; + [[nodiscard]] int bitWidth() const noexcept; + + // True if this instance owns a native handle (i.e. wasn't moved-from / + // wasn't a failed load). + [[nodiscard]] bool valid() const noexcept { return handle_ != nullptr; } + + // Raw handle accessor for the JS binding's finalizer. Caller must not + // free; ownership remains with this object. + [[nodiscard]] ggml_vec_index_t* raw() const noexcept { return handle_; } + +private: + ggml_vec_index_t* handle_; +}; + +} // namespace qvac_lib_infer_llamacpp_embed diff --git a/packages/embed-llamacpp/idMapIndex.js b/packages/embed-llamacpp/idMapIndex.js new file mode 100644 index 0000000000..be9e54a569 --- /dev/null +++ b/packages/embed-llamacpp/idMapIndex.js @@ -0,0 +1,160 @@ +'use strict' + +// IdMapIndex: thin JS wrapper around the IdMapIndex N-API bindings exposed +// by the embed-llamacpp addon (vector-index-binding.cpp). The native side +// keeps the index in RAM as full f32 vectors for the POC; quantization, +// SIMD, and GPU kernels are future work behind the same JS shape. +// +// Lifecycle isolation: constructing IdMapIndex does NOT load any BERT +// model. It only resolves the native binding (loaded once per process) +// and calls idx_create, which is a tiny C-API call into ggml-base. Pair +// this with the assertion in the smoke test (no GGMLBert constructor +// runs while exercising the index). + +const binding = require('./binding') + +const HANDLE = Symbol('IdMapIndex.handle') + +function ensureHandle (self) { + if (self[HANDLE] == null) { + throw new Error('IdMapIndex has been disposed') + } + return self[HANDLE] +} + +/** + * In-memory TurboQuant-style ANN vector index. Indexes vectors of fixed + * dimensionality and stable external uint64 ids; supports top-k cosine / + * dot-product search. + * + * Scope: POC. Internals store full f32 vectors and do scalar dot products; + * quantization, SIMD, and GPU paths come later behind the same JS API. + */ +class IdMapIndex { + /** + * @param {object} opts + * @param {number} opts.dim - vector dimensionality (must be > 0) + * @param {number} [opts.bitWidth=4] - reserved for future quantization + */ + constructor ({ dim, bitWidth = 4 } = {}) { + if (!Number.isInteger(dim) || dim <= 0) { + throw new TypeError('IdMapIndex: dim must be a positive integer') + } + if (!Number.isInteger(bitWidth) || bitWidth <= 0 || bitWidth > 32) { + throw new TypeError('IdMapIndex: bitWidth must be an integer in [1, 32]') + } + this[HANDLE] = binding.idx_create({ dim, bitWidth }) + } + + /** + * Load a persisted index from a .tvim file. Returns a fresh instance. + * @param {string} path + * @returns {Promise} + */ + static async load (path) { + if (typeof path !== 'string' || path.length === 0) { + throw new TypeError('IdMapIndex.load: path must be a non-empty string') + } + const instance = Object.create(IdMapIndex.prototype) + instance[HANDLE] = binding.idx_load(path) + return instance + } + + /** + * Insert `n` vectors with their external ids. Throws on duplicate id or + * dim mismatch; partial mutation is impossible (atomic add). + * @param {Float32Array} vectors - length = n * dim + * @param {BigUint64Array} ids - length = n + */ + addWithIds (vectors, ids) { + if (!(vectors instanceof Float32Array)) { + throw new TypeError('addWithIds: vectors must be a Float32Array') + } + if (!(ids instanceof BigUint64Array)) { + throw new TypeError('addWithIds: ids must be a BigUint64Array') + } + if (ids.length === 0) return + if (vectors.length !== ids.length * this.dim) { + throw new RangeError( + `addWithIds: vectors.length (${vectors.length}) must equal ids.length (${ids.length}) * dim (${this.dim})` + ) + } + binding.idx_add(ensureHandle(this), vectors, ids) + } + + /** + * Top-k search across `m` queries packed contiguously in `queries`. + * Returns `{ scores, ids, m, k }` where scores/ids are typed arrays of + * length m*k (row-major). + * @param {Float32Array} queries - length = m * dim + * @param {number} k + * @returns {{ scores: Float32Array, ids: BigUint64Array, m: number, k: number }} + */ + search (queries, k) { + if (!(queries instanceof Float32Array)) { + throw new TypeError('search: queries must be a Float32Array') + } + if (!Number.isInteger(k) || k <= 0) { + throw new TypeError('search: k must be a positive integer') + } + return binding.idx_search(ensureHandle(this), queries, k) + } + + /** + * Remove an entry by external id. + * @param {bigint} id + * @returns {boolean} true if removed, false if not present + */ + remove (id) { + if (typeof id !== 'bigint') { + throw new TypeError('remove: id must be a bigint') + } + return binding.idx_remove(ensureHandle(this), id) + } + + /** + * @param {bigint} id + * @returns {boolean} + */ + contains (id) { + if (typeof id !== 'bigint') { + throw new TypeError('contains: id must be a bigint') + } + return binding.idx_contains(ensureHandle(this), id) + } + + /** No-op for the POC. Placeholder for future cache warming. */ + prepare () { + binding.idx_prepare(ensureHandle(this)) + } + + /** + * Persist the index to disk in the .tvim v1 format. + * @param {string} path + */ + write (path) { + if (typeof path !== 'string' || path.length === 0) { + throw new TypeError('write: path must be a non-empty string') + } + binding.idx_write(ensureHandle(this), path) + } + + get length () { return binding.idx_len(ensureHandle(this)) } + get dim () { return binding.idx_dim(ensureHandle(this)) } + get bitWidth () { return binding.idx_bit_width(ensureHandle(this)) } + + /** + * Mark the instance as unusable. The native handle is owned by a + * JS-side external whose finalizer runs at GC time; this method only + * drops the local reference so subsequent calls throw (`ensureHandle`). + * No memory is reclaimed synchronously — that requires a finalizer + * pass. Kept as a method for API stability; future versions may add + * a safe early-free path (wrapper object + sentinel) without changing + * the call sites. + */ + async dispose () { + this[HANDLE] = null + } +} + +module.exports = IdMapIndex diff --git a/packages/embed-llamacpp/index.d.ts b/packages/embed-llamacpp/index.d.ts index eac46afd8c..5b70102854 100644 --- a/packages/embed-llamacpp/index.d.ts +++ b/packages/embed-llamacpp/index.d.ts @@ -87,3 +87,57 @@ export class BertInterface implements Addon { /** Returns the first shard (matching `-NNNNN-of-MMMMM.gguf`) or the sole entry for single-file models. */ export function pickPrimaryGgufPath(files: string[]): string + +// --------------------------------------------------------------------------- +// IdMapIndex (turbovec POC) +// --------------------------------------------------------------------------- + +export interface IdMapIndexOptions { + /** Vector dimensionality (must be > 0). */ + dim: number + /** Reserved for future quantization; POC stores full f32 internally. */ + bitWidth?: number +} + +export interface IdMapIndexSearchResult { + /** Row-major scores: m * k. Higher = closer. */ + scores: Float32Array + /** Row-major external ids: m * k. `UINT64_MAX` padding when index is shorter than k. */ + ids: BigUint64Array + /** Number of query rows. */ + m: number + /** Effective k (mirrors the requested k). */ + k: number +} + +export class IdMapIndex { + constructor(opts: IdMapIndexOptions) + + /** Open a persisted .tvim file written by `write()`. */ + static load(path: string): Promise + + /** + * Insert `n` vectors with stable external ids. Throws on duplicate id or + * dim mismatch; mutation is atomic per call. + */ + addWithIds(vectors: Float32Array, ids: BigUint64Array): void + + /** Top-k search across `queries.length / dim` rows. */ + search(queries: Float32Array, k: number): IdMapIndexSearchResult + + /** Returns true if removed, false if not present. */ + remove(id: bigint): boolean + contains(id: bigint): boolean + + /** No-op for the POC. */ + prepare(): void + + /** Persist to disk (.tvim v1). */ + write(path: string): void + + readonly length: number + readonly dim: number + readonly bitWidth: number + + dispose(): Promise +} diff --git a/packages/embed-llamacpp/index.js b/packages/embed-llamacpp/index.js index 73ebf5e65f..0dd425bf60 100644 --- a/packages/embed-llamacpp/index.js +++ b/packages/embed-llamacpp/index.js @@ -217,3 +217,11 @@ class GGMLBert { module.exports = GGMLBert module.exports.pickPrimaryGgufPath = pickPrimaryGgufPath + +// IdMapIndex (turbovec POC) is also re-exported here as a named property +// for discoverability when consumers `require('@qvac/embed-llamacpp')`. +// Consumers that want the ANN index WITHOUT loading this file's GGMLBert / +// addon require chain should import it directly via the package's +// `./idMapIndex` sub-export — that path goes straight to `./binding` and +// never touches `./addon`, satisfying the POC's lifecycle-isolation invariant. +module.exports.IdMapIndex = require('./idMapIndex') diff --git a/packages/embed-llamacpp/package.json b/packages/embed-llamacpp/package.json index e9b7ae73b4..d32794b3cc 100644 --- a/packages/embed-llamacpp/package.json +++ b/packages/embed-llamacpp/package.json @@ -44,6 +44,7 @@ "files": [ "binding.js", "index.js", + "idMapIndex.js", "addon.js", "addonLogging.js", "addonLogging.d.ts", @@ -81,6 +82,10 @@ "types": "./index.d.ts", "default": "./index.js" }, + "./idMapIndex": { + "types": "./index.d.ts", + "default": "./idMapIndex.js" + }, "./addonLogging": { "types": "./addonLogging.d.ts", "default": "./addonLogging.js" diff --git a/packages/embed-llamacpp/scripts/rebuild-fabric.sh b/packages/embed-llamacpp/scripts/rebuild-fabric.sh new file mode 100755 index 0000000000..b7803f76b2 --- /dev/null +++ b/packages/embed-llamacpp/scripts/rebuild-fabric.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Inner-loop rebuild after editing fabric C++ in a LOCAL fabric checkout. +# +# Set QVAC_FABRIC_LOCAL_PATH (or QVAC_FABRIC_SRC_DIR) to point at a fabric +# working tree before running. The script: +# 1. bumps the overlay portfile's fabric-src-hash so vcpkg rebuilds the +# qvac-fabric port from your local source, +# 2. regenerates CMake (which re-runs vcpkg manifest install), +# 3. builds + installs the addon. +# +# If QVAC_FABRIC_LOCAL_PATH is NOT set, the overlay portfile uses its +# pinned github fetch — there's nothing for this script to invalidate, and +# `bare-make build` alone is enough for addon-side edits. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +package_root="$(cd "$here/.." && pwd)" +cd "$package_root" + +if [ -z "${QVAC_FABRIC_LOCAL_PATH:-}${QVAC_FABRIC_SRC_DIR:-}" ]; then + echo "rebuild-fabric: WARNING — neither QVAC_FABRIC_LOCAL_PATH nor" + echo "rebuild-fabric: QVAC_FABRIC_SRC_DIR is set. The overlay is in" + echo "rebuild-fabric: github-fetch mode and will not pick up local edits." + echo "rebuild-fabric: To iterate on local fabric source set, e.g.," + echo "rebuild-fabric: export QVAC_FABRIC_LOCAL_PATH=/path/to/qvac-fabric-llm.cpp" +fi + +bash "$here/sync-fabric-overlay.sh" +bare-make generate +bare-make build +bare-make install +echo "rebuild-fabric: done" diff --git a/packages/embed-llamacpp/scripts/sync-fabric-overlay.sh b/packages/embed-llamacpp/scripts/sync-fabric-overlay.sh new file mode 100755 index 0000000000..4db9369cbd --- /dev/null +++ b/packages/embed-llamacpp/scripts/sync-fabric-overlay.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Bumps the `# fabric-src-hash:` comment in the qvac-fabric overlay portfile +# to a fresh hash of fabric source contents. +# +# This is ONLY needed when iterating on a local fabric checkout via the +# `QVAC_FABRIC_LOCAL_PATH` env-var override (Phase 0 fast-edit loop). In the +# default github-fetch mode the overlay is pinned by commit SHA + SHA512 and +# vcpkg invalidates the cache when those change. +# +# Why this script exists: vcpkg derives its ABI hash from the portfile +# contents, not from the source the portfile copies in. Without bumping a +# portfile-tracked field, local working-tree edits to fabric won't trigger a +# rebuild. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +package_root="$(cd "$here/.." && pwd)" +portfile="$package_root/vcpkg/ports/qvac-fabric/portfile.cmake" + +if [ ! -f "$portfile" ]; then + echo "sync-fabric-overlay: portfile not found at $portfile" >&2 + exit 1 +fi + +# Accept either QVAC_FABRIC_LOCAL_PATH (the overlay's runtime env var) or +# QVAC_FABRIC_SRC_DIR (legacy alias). +if [ -n "${QVAC_FABRIC_LOCAL_PATH:-}" ]; then + fabric_dir="$QVAC_FABRIC_LOCAL_PATH" +elif [ -n "${QVAC_FABRIC_SRC_DIR:-}" ]; then + fabric_dir="$QVAC_FABRIC_SRC_DIR" +else + # Best-effort default: sibling of the qvac-public checkout. + fabric_dir="$(cd "$package_root/../../../qvac-fabric-llm.cpp" 2>/dev/null && pwd || true)" +fi + +if [ -z "$fabric_dir" ] || [ ! -d "$fabric_dir" ]; then + echo "sync-fabric-overlay: fabric checkout not found." >&2 + echo " Tried (in order):" >&2 + echo " QVAC_FABRIC_LOCAL_PATH=${QVAC_FABRIC_LOCAL_PATH:-}" >&2 + echo " QVAC_FABRIC_SRC_DIR=${QVAC_FABRIC_SRC_DIR:-}" >&2 + echo " default sibling: $package_root/../../../qvac-fabric-llm.cpp" >&2 + exit 1 +fi + +if [ ! -f "$fabric_dir/CMakeLists.txt" ]; then + echo "sync-fabric-overlay: $fabric_dir does not look like a fabric checkout" >&2 + exit 1 +fi + +# Hash all tracked source under fabric. `git ls-files` is preferred (fast, +# respects .gitignore); fallback to `find` for non-git trees. +hash=$( + cd "$fabric_dir" && { + git ls-files \ + ':!*.gguf' ':!*.bin' ':!*.png' ':!*.jpg' \ + 2>/dev/null \ + || find . -type f \ + -not -path '*/.git/*' \ + -not -path '*/build*/*' \ + -not -path '*/node_modules/*' \ + -not -path '*/__pycache__/*' + } | LC_ALL=C sort | xargs -I{} shasum "{}" 2>/dev/null | shasum | awk '{print $1}' +) + +if [ -z "$hash" ] || [ "${#hash}" -lt 20 ]; then + echo "sync-fabric-overlay: failed to compute fabric source hash" >&2 + exit 1 +fi + +tmp="$portfile.tmp.$$" +awk -v h="$hash" ' + /^# fabric-src-hash: / { print "# fabric-src-hash: " h; next } + { print } +' "$portfile" > "$tmp" + +mv "$tmp" "$portfile" +echo "sync-fabric-overlay: fabric source dir = $fabric_dir" +echo "sync-fabric-overlay: fabric-src-hash -> $hash" +echo "sync-fabric-overlay: portfile updated -> $portfile" diff --git a/packages/embed-llamacpp/test/integration/id-map-index.test.js b/packages/embed-llamacpp/test/integration/id-map-index.test.js new file mode 100644 index 0000000000..1a380b8cc0 --- /dev/null +++ b/packages/embed-llamacpp/test/integration/id-map-index.test.js @@ -0,0 +1,173 @@ +'use strict' +// +// Phase 2 acceptance: exercises every JS method on `IdMapIndex` end-to-end +// without loading any BERT model. Pair-bound with the C++ test under +// fabric's `tests/test-vector-index.cpp` (which exercises the same C API +// directly): if both pass, the JS↔C++ binding is correct in both +// directions. + +const test = require('brittle') +const fs = require('bare-fs') +const path = require('bare-path') +const os = require('bare-os') + +// Pull IdMapIndex via the package's sub-export path. The package exports +// `./idMapIndex` so consumers can opt into just the ANN-index class +// without dragging the GGMLBert require chain. Loading this module must +// NOT boot any BERT runtime. Resolved via a relative path here because +// the integration tests run inside the package itself (no self-link). +// +// To catch a future regression that wires GGMLBert init back into the +// IdMapIndex path, the first test below inspects `require.cache` and +// asserts the GGMLBert module file was never loaded. Bare keys +// `require.cache` by `file://` URL, so we convert the resolved paths. +const cacheKey = (p) => 'file://' + require.resolve(p) +const KEY_IDMAP = cacheKey('../../idMapIndex') +const KEY_BINDING = cacheKey('../../binding') +const KEY_INDEX = cacheKey('../../index.js') +const KEY_ADDON = cacheKey('../../addon.js') + +const IdMapIndex = require('../../idMapIndex') + +// DIM is chosen large enough that the N seed vectors below can each be a +// distinct unit basis vector — keeps the "self-match top-1" assertion +// well-defined. +const DIM = 16 +const N = 10 + +function unitVec (i) { + const v = new Float32Array(DIM) + v[i] = 1 + return v +} + +let tmpCounter = 0 +function tmpPath (name) { + const pid = os.pid ? os.pid() : 0 + tmpCounter += 1 + return path.join(os.tmpdir(), + `${name}-${pid}-${Date.now()}-${tmpCounter}.tvim`) +} + +test('IdMapIndex sub-export does not boot the BERT runtime', (t) => { + // 1. Construct path: zero-arg + tiny-arg ctors must succeed without + // loading any model or running addon-side BERT init. + const idx = new IdMapIndex({ dim: DIM, bitWidth: 4 }) + t.is(idx.dim, DIM, 'dim getter') + t.is(idx.bitWidth, 4, 'bitWidth getter') + t.is(idx.length, 0, 'starts empty') + idx.dispose() + + // 2. Module-cache invariant: requiring `./idMapIndex` must NOT have + // transitively loaded the GGMLBert entry (`./index.js`) or the + // BertInterface plumbing (`./addon.js`). If a future refactor wires + // BERT init into the IdMapIndex path, one of these files will be + // in the cache after the require above. + t.ok(require.cache[KEY_IDMAP], + 'sub-export module loaded (sanity)') + t.ok(require.cache[KEY_BINDING], + 'native binding loaded (sanity)') + t.absent(require.cache[KEY_INDEX], + 'GGMLBert entry (index.js) NOT loaded by ./idMapIndex') + t.absent(require.cache[KEY_ADDON], + 'BertInterface plumbing (addon.js) NOT loaded by ./idMapIndex') +}) + +test('IdMapIndex: add + search + remove + persistence round-trip', async (t) => { + const idx = new IdMapIndex({ dim: DIM, bitWidth: 4 }) + + const vectors = new Float32Array(N * DIM) + const ids = new BigUint64Array(N) + for (let i = 0; i < N; i++) { + vectors.set(unitVec(i), i * DIM) + // Mix non-trivial ids to flush out BigInt/uint64 round-trip bugs. + ids[i] = BigInt(i) + (1n << 40n) + (1n << 62n) + } + + idx.addWithIds(vectors, ids) + t.is(idx.length, N, 'all 10 vectors inserted') + t.ok(idx.contains(ids[3]), 'contains a known id') + t.absent(idx.contains(999n), 'absent id missing') + + // Top-1 of querying with the i-th unit vector must return that vector's id + // with a score very close to 1.0 (full f32, no quantization noise). + for (let i = 0; i < N; i++) { + const q = unitVec(i) + const out = idx.search(q, 1) + t.is(out.m, 1) + t.is(out.k, 1) + t.is(out.ids[0], ids[i], `query=${i} retrieves itself`) + t.ok(Math.abs(out.scores[0] - 1.0) < 1e-5, `score≈1.0 for self-match`) + } + + // Top-k > length pads sentinels at the tail. + { + const out = idx.search(unitVec(0), N + 4) + t.is(out.ids.length, N + 4) + for (let i = N; i < N + 4; i++) { + t.is(out.ids[i], (1n << 64n) - 1n, `sentinel id at tail slot ${i}`) + } + } + + // Duplicate add must throw atomically (length unchanged). + { + const dupIds = new BigUint64Array([ids[0]]) + const dupVec = unitVec(0) + try { + idx.addWithIds(dupVec, dupIds) + t.fail('duplicate add should have thrown') + } catch (e) { + t.pass('duplicate add threw: ' + (e.message || e.code)) + } + t.is(idx.length, N, 'length unchanged after rejected dup add') + } + + // Remove + search: the removed id should not surface. + { + const removed = idx.remove(ids[2]) + t.ok(removed === true, 'remove returns true on first call') + t.absent(idx.remove(ids[2]), 'remove returns false on second call') + t.absent(idx.contains(ids[2]), 'no longer contained') + t.is(idx.length, N - 1) + const out = idx.search(unitVec(2), 3) + for (let i = 0; i < 3; i++) { + t.absent(out.ids[i] === ids[2], `removed id absent from search result ${i}`) + } + } + + // prepare() is a no-op but must be callable. + idx.prepare() + + // Persist, dispose, reload, re-search. + const file = tmpPath('id-map-index-roundtrip') + try { + idx.write(file) + await idx.dispose() + + const loaded = await IdMapIndex.load(file) + t.is(loaded.dim, DIM, 'dim restored') + t.is(loaded.bitWidth, 4, 'bitWidth restored') + t.is(loaded.length, N - 1, 'length restored (deletion persisted)') + t.ok(loaded.contains(ids[0]), 'kept id still there') + t.absent(loaded.contains(ids[2]), 'deleted id stayed deleted') + + const out = loaded.search(unitVec(0), 1) + t.is(out.ids[0], ids[0], 'self-match after reload') + t.ok(Math.abs(out.scores[0] - 1.0) < 1e-5) + + await loaded.dispose() + } finally { + if (fs.existsSync(file)) fs.unlinkSync(file) + } +}) + +test('IdMapIndex: BigInt id range edge cases', (t) => { + // 2^63 + 7 catches sign-extension bugs at the typed-array boundary. + const idx = new IdMapIndex({ dim: 2 }) + const edge = (1n << 63n) + 7n + idx.addWithIds(new Float32Array([0.5, 0.5]), new BigUint64Array([edge])) + t.ok(idx.contains(edge), 'high-bit id round-trips') + const out = idx.search(new Float32Array([0.5, 0.5]), 1) + t.is(out.ids[0], edge, 'high-bit id surfaces from search') + idx.dispose() +}) diff --git a/packages/embed-llamacpp/vcpkg.json b/packages/embed-llamacpp/vcpkg.json index 86c316bfd4..ae5aa61daf 100644 --- a/packages/embed-llamacpp/vcpkg.json +++ b/packages/embed-llamacpp/vcpkg.json @@ -1,4 +1,7 @@ { + "$schema-comment": "vcpkg requires `name` + a versioning field whenever the manifest uses `version>=` constraints or overlay-ports. Both are present here because the qvac-fabric pin (>=8828.1.0) goes through an overlay (vcpkg/ports/qvac-fabric).", + "name": "embed-llamacpp", + "version-string": "0.19.1", "dependencies": [ { "name": "opencl", diff --git a/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/android-vulkan-version.cmake b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/android-vulkan-version.cmake new file mode 100644 index 0000000000..21fba00feb --- /dev/null +++ b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/android-vulkan-version.cmake @@ -0,0 +1,36 @@ +# Function to detect Vulkan version from NDK vulkan_core.h +function(detect_ndk_vulkan_version) + string(TOLOWER "${CMAKE_HOST_SYSTEM_NAME}" host_system_name_lower) + + # CMAKE_HOST_SYSTEM_PROCESSOR is unavailable here. Use a glob pattern to complete the folder instead. + file(GLOB host_dirs LIST_DIRECTORIES true "$ENV{ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/${host_system_name_lower}-*") + if(host_dirs) + list(GET host_dirs 0 host_dir) + get_filename_component(host_arch "${host_dir}" NAME) + set(vulkan_core_h "$ENV{ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/${host_arch}/sysroot/usr/include/vulkan/vulkan_core.h") + else() + message(FATAL "Could not find NDK host directory for ${host_system_name_lower}") + endif() + + if(NOT vulkan_core_h) + message(FATAL "vulkan_core.h not found, using default version") + endif() + + file(READ "${vulkan_core_h}" header_content) + string(REGEX MATCH "VK_HEADER_VERSION ([0-9]+)" version_match "${header_content}") + if(version_match) + set(header_version_3 "${CMAKE_MATCH_1}") + else() + message(FATAL "Could not extract VK_HEADER_VERSION from vulkan_core.h, using default: ${vulkan_version}") + endif() + + # Extract major.minor version from VK_HEADER_VERSION_COMPLETE for download URL + string(REGEX MATCH "VK_HEADER_VERSION_COMPLETE VK_MAKE_API_VERSION\\(([0-9]+), ([0-9]+), ([0-9]+)" version_match "${header_content}") + if(version_match) + set(major "${CMAKE_MATCH_2}") + set(minor "${CMAKE_MATCH_3}") + set(vulkan_version "${major}.${minor}.${header_version_3}" PARENT_SCOPE) + else() + message(FATAL "Could not extract major.minor version from vulkan_core.h, using default: ${vulkan_version}") + endif() +endfunction() diff --git a/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake new file mode 100644 index 0000000000..baf73cb6d5 --- /dev/null +++ b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/portfile.cmake @@ -0,0 +1,229 @@ +# LOCAL OVERLAY for qvac-fabric (turbovec POC). +# +# Pinned by default to a commit on +# https://github.com/dev-nid/qvac-fabric-llm.cpp.git +# (branch: poc/turbovec-embed) +# which adds the new ggml-vector-index module on top of upstream +# fabric's `v8828.1.1` (which is the highest 8828.x release tag that +# satisfies embed-llamacpp's `qvac-fabric >= 8828.1.0` manifest pin). +# +# Why an overlay at all: the public `tetherto/qvac-registry-vcpkg` port +# for qvac-fabric tops out at v7248.2.2 and has no 8828.x line yet, so +# the manifest's >=8828.1.0 constraint cannot be satisfied by the +# registry. This overlay closes that gap and is the single source of +# fabric source for the POC build. Drop it once the registry publishes +# an 8828.x port. +# +# Local-edit iteration (Phase 0 fast loop) — set QVAC_FABRIC_LOCAL_PATH +# to the path of a fabric working tree. The overlay then copies that +# directory into the vcpkg buildtree instead of fetching from github. +# Because vcpkg's ABI hash is derived from the portfile contents (not +# the source it copies in), use `./scripts/sync-fabric-overlay.sh` to +# bump the `# fabric-src-hash:` comment line on each iteration so vcpkg +# rebuilds the port. + +# fabric-src-hash: 0000000000000000000000000000000000000000 + +set(FABRIC_GH_REPO "dev-nid/qvac-fabric-llm.cpp") +set(FABRIC_GH_REF "dd934a6d9964a718eaca6630c72dc33fe88a8892") # poc/turbovec-embed +set(FABRIC_GH_HEAD_REF "poc/turbovec-embed") +set(FABRIC_GH_SHA512 + "ad696aa0382c207ce5ca3840b9ae0b2fd7ce85b458d0a472275e2fee933bb578539244c716f07ed087b718be0de1057139837d0ec57a8ac6b0eeafabe942fae5") + +if(DEFINED ENV{QVAC_FABRIC_LOCAL_PATH}) + set(FABRIC_LOCAL_PATH "$ENV{QVAC_FABRIC_LOCAL_PATH}") + if(NOT EXISTS "${FABRIC_LOCAL_PATH}/CMakeLists.txt") + message(FATAL_ERROR + "qvac-fabric overlay: QVAC_FABRIC_LOCAL_PATH='${FABRIC_LOCAL_PATH}' " + "is not a fabric checkout (no CMakeLists.txt).") + endif() + message(STATUS + "qvac-fabric overlay: LOCAL mode — copying source from ${FABRIC_LOCAL_PATH}") + set(SOURCE_PATH "${CURRENT_BUILDTREES_DIR}/src/qvac-fabric-local") + file(REMOVE_RECURSE "${SOURCE_PATH}") + file(MAKE_DIRECTORY "${SOURCE_PATH}") + file(COPY "${FABRIC_LOCAL_PATH}/" + DESTINATION "${SOURCE_PATH}" + PATTERN ".git" EXCLUDE + PATTERN "build" EXCLUDE + PATTERN ".cache" EXCLUDE + PATTERN "node_modules" EXCLUDE + PATTERN "__pycache__" EXCLUDE + PATTERN ".venv" EXCLUDE) +else() + message(STATUS + "qvac-fabric overlay: GITHUB mode — fetching ${FABRIC_GH_REPO}@${FABRIC_GH_REF}") + vcpkg_from_github( + OUT_SOURCE_PATH SOURCE_PATH + REPO ${FABRIC_GH_REPO} + REF ${FABRIC_GH_REF} + SHA512 ${FABRIC_GH_SHA512} + HEAD_REF ${FABRIC_GH_HEAD_REF}) +endif() + +vcpkg_check_features( + OUT_FEATURE_OPTIONS FEATURE_OPTIONS + FEATURES + force-profiler FORCE_GGML_VK_PERF_LOGGER + llama BUILD_LLAMA +) + +if (VCPKG_TARGET_IS_ANDROID) + include(${CMAKE_CURRENT_LIST_DIR}/android-vulkan-version.cmake) + detect_ndk_vulkan_version() + message(STATUS "Using Vulkan C++ wrappers from version: ${vulkan_version}") + file(DOWNLOAD + "https://github.com/KhronosGroup/Vulkan-Headers/archive/refs/tags/v${vulkan_version}.tar.gz" + "${SOURCE_PATH}/vulkan-sdk-${vulkan_version}.tar.gz" + TLS_VERIFY ON + ) + file(ARCHIVE_EXTRACT + INPUT "${SOURCE_PATH}/vulkan-sdk-${vulkan_version}.tar.gz" + DESTINATION "${SOURCE_PATH}" + PATTERNS "*.hpp" + ) + file(RENAME + "${SOURCE_PATH}/Vulkan-Headers-${vulkan_version}" + "${SOURCE_PATH}/ggml/src/ggml-vulkan/vulkan_cpp_wrapper" + ) +endif() + +set(PLATFORM_OPTIONS) + +if (VCPKG_TARGET_IS_OSX OR VCPKG_TARGET_IS_IOS) + list(APPEND PLATFORM_OPTIONS -DGGML_METAL=ON) + if (VCPKG_TARGET_IS_IOS) + list(APPEND PLATFORM_OPTIONS -DGGML_BLAS=OFF -DGGML_ACCELERATE=OFF) + endif() +else() + list(APPEND PLATFORM_OPTIONS -DGGML_VULKAN=ON) +endif() + +if(VCPKG_TARGET_IS_ANDROID) + set(DL_BACKENDS ON) + list(APPEND PLATFORM_OPTIONS + -DGGML_BACKEND_DL=ON + -DGGML_CPU_ALL_VARIANTS=ON + -DGGML_CPU_REPACK=ON) +else() + set(DL_BACKENDS OFF) +endif() + +if (VCPKG_TARGET_IS_ANDROID) + list(APPEND PLATFORM_OPTIONS + -DGGML_VULKAN_DISABLE_COOPMAT=ON + -DGGML_VULKAN_DISABLE_COOPMAT2=ON + -DGGML_OPENCL=ON) +endif() + +vcpkg_cmake_configure( + SOURCE_PATH "${SOURCE_PATH}" + DISABLE_PARALLEL_CONFIGURE + OPTIONS + -DGGML_NATIVE=OFF + -DGGML_CCACHE=OFF + -DGGML_OPENMP=OFF + -DGGML_LLAMAFILE=OFF + -DLLAMA_MTMD=ON + -DLLAMA_CURL=OFF + -DLLAMA_BUILD_TESTS=OFF + -DLLAMA_BUILD_TOOLS=OFF + -DLLAMA_BUILD_EXAMPLES=OFF + -DLLAMA_BUILD_SERVER=OFF + -DLLAMA_ALL_WARNINGS=OFF + ${PLATFORM_OPTIONS} + ${FEATURE_OPTIONS} +) + +vcpkg_cmake_install() +# Different fabric branches install CMake configs at either +# `lib/cmake/` (HEAD-ish) or the upstream-standard `share/` +# (temp-8828 / v7248.x). Detect which is present per package so the +# overlay works against both layouts without portfile edits. +if(EXISTS "${CURRENT_PACKAGES_DIR}/lib/cmake/ggml") + vcpkg_cmake_config_fixup( + PACKAGE_NAME ggml + CONFIG_PATH "lib/cmake/ggml" + DO_NOT_DELETE_PARENT_CONFIG_PATH) +else() + vcpkg_cmake_config_fixup(PACKAGE_NAME ggml) +endif() + +if(BUILD_LLAMA) + if(EXISTS "${CURRENT_PACKAGES_DIR}/lib/cmake/llama") + vcpkg_cmake_config_fixup(PACKAGE_NAME llama CONFIG_PATH "lib/cmake/llama") + else() + vcpkg_cmake_config_fixup(PACKAGE_NAME llama) + endif() +endif() + +vcpkg_copy_pdbs() +vcpkg_fixup_pkgconfig() + +if(BUILD_LLAMA) + file(MAKE_DIRECTORY "${CURRENT_PACKAGES_DIR}/tools/${PORT}") + if(EXISTS "${CURRENT_PACKAGES_DIR}/bin/convert_hf_to_gguf.py") + file(RENAME + "${CURRENT_PACKAGES_DIR}/bin/convert_hf_to_gguf.py" + "${CURRENT_PACKAGES_DIR}/tools/${PORT}/convert-hf-to-gguf.py") + endif() + if(EXISTS "${SOURCE_PATH}/gguf-py") + file(INSTALL "${SOURCE_PATH}/gguf-py" + DESTINATION "${CURRENT_PACKAGES_DIR}/tools/${PORT}") + endif() + # Vulkan-only artifact; absent on macOS (Metal backend) and iOS. + if(EXISTS "${CURRENT_PACKAGES_DIR}/bin/vulkan_profiling_analyzer.py") + file(RENAME + "${CURRENT_PACKAGES_DIR}/bin/vulkan_profiling_analyzer.py" + "${CURRENT_PACKAGES_DIR}/tools/${PORT}/vulkan_profiling_analyzer.py") + endif() +endif() + +if (NOT VCPKG_BUILD_TYPE) + if(EXISTS "${CURRENT_PACKAGES_DIR}/debug/bin/convert_hf_to_gguf.py") + file(REMOVE "${CURRENT_PACKAGES_DIR}/debug/bin/convert_hf_to_gguf.py") + endif() +endif() + +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/include") +file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/share") + +# Local fabric HEAD's llama-config.cmake `set_and_check`s LLAMA_BIN_DIR +# even for static builds, so we keep an (empty) bin/ around. Upstream's +# tagged release didn't, hence the original removal block. Preserve the +# dirs so `find_package(llama)` succeeds. +file(MAKE_DIRECTORY "${CURRENT_PACKAGES_DIR}/bin") +file(MAKE_DIRECTORY "${CURRENT_PACKAGES_DIR}/debug/bin") + +# Fabric branches differ in where (and whether) they install the `common/` +# headers from the llama helper library. The embed-llamacpp addon code +# references them as both `` and ``. To make +# both forms resolve to the same physical file (otherwise duplicate +# struct definitions break the build), normalize the layout: +# - Prefer `include/llama/common/`. The addon's `-isystem +# include/llama` lets `` fall through to that path. +# - Remove a redundant `include/common/` if both exist (the two trees +# would otherwise be distinct files for the preprocessor and re-define +# every struct on second inclusion). +# - Synthesize `include/llama/common/` from source if neither is present. +if(BUILD_LLAMA AND EXISTS "${SOURCE_PATH}/common") + set(_qvac_common_have_llama + "${CURRENT_PACKAGES_DIR}/include/llama/common/common.h") + set(_qvac_common_have_root + "${CURRENT_PACKAGES_DIR}/include/common/common.h") + if(EXISTS "${_qvac_common_have_llama}") + if(EXISTS "${_qvac_common_have_root}") + file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/include/common") + endif() + elseif(NOT EXISTS "${_qvac_common_have_root}") + file(MAKE_DIRECTORY "${CURRENT_PACKAGES_DIR}/include/llama/common") + file(COPY "${SOURCE_PATH}/common/" + DESTINATION "${CURRENT_PACKAGES_DIR}/include/llama/common" + FILES_MATCHING + PATTERN "*.h" + PATTERN "*.hpp" + PATTERN "*.inc") + endif() +endif() + +vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/LICENSE") diff --git a/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/vcpkg.json b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/vcpkg.json new file mode 100644 index 0000000000..4753692f1f --- /dev/null +++ b/packages/embed-llamacpp/vcpkg/ports/qvac-fabric/vcpkg.json @@ -0,0 +1,33 @@ +{ + "name": "qvac-fabric", + "version": "8828.1.0", + "port-version": 9999, + "description": "LLM inference in C/C++ (LOCAL OVERLAY built from sibling qvac-fabric-llm.cpp source tree)", + "homepage": "https://github.com/tetherto/qvac-fabric-llm.cpp", + "license": "MIT", + "dependencies": [ + { + "name": "opencl", + "platform": "android" + }, + { + "name": "vcpkg-cmake", + "host": true + }, + { + "name": "vcpkg-cmake-config", + "host": true + } + ], + "default-features": [ + "llama" + ], + "features": { + "force-profiler": { + "description": "Force vk performance logging in ggml" + }, + "llama": { + "description": "Build llama components." + } + } +} diff --git a/packages/rag/examples/turbovec-perf.js b/packages/rag/examples/turbovec-perf.js new file mode 100644 index 0000000000..69c5b737eb --- /dev/null +++ b/packages/rag/examples/turbovec-perf.js @@ -0,0 +1,246 @@ +'use strict' +// +// turbovec-perf: synthetic-data performance probe for the POC. +// +// What this measures: +// - Ingest throughput (vectors/sec) via TurboVecHybridAdapter.saveEmbeddings +// - Persist (write) latency to a .tvim file +// - Open + load latency +// - Query p50 / p95 / p99 / mean over `--queries` random L2-normalized +// query vectors, top-k = 10 +// - Peak RSS sampled after each phase +// +// What this DOES NOT measure (intentionally): +// - Recall. No ground truth without a real corpus; the bench harness +// under benchmarks/turbovec-eval/ (not on this machine) is where the +// real recall@10 vs gold lives. The numbers from this script tell +// you "does the architecture handle N x D" and the order-of-magnitude +// cost of the POC's naive scalar full-f32 path, nothing more. +// +// POC scope reminder: the index is full f32 + scalar dot product. These +// numbers are the BASELINE the optimization phase (quantization, SIMD, +// Vulkan/Metal) measures improvement against. Per prompt.md they're +// "expected to be slow". +// +// Usage: +// bare examples/turbovec-perf.js # default: 3633 vecs, 1024 dim, 100 queries +// bare examples/turbovec-perf.js --n=10000 # bump corpus size +// bare examples/turbovec-perf.js --dim=384 # smaller dim +// bare examples/turbovec-perf.js --queries=500 # more query samples + +const fs = require('bare-fs') +const path = require('bare-path') +const os = require('bare-os') +const Corestore = require('corestore') + +const TurboVecHybridAdapter = + require('../src/adapters/database/TurboVecHybridAdapter') + +// ----- args --------------------------------------------------------------- + +function parseArgs (argv) { + const out = { n: 3633, dim: 1024, queries: 100, k: 10, seed: 0xdeadbeef } + for (const a of argv.slice(2)) { + const m = /^--(\w+)=(.+)$/.exec(a) + if (m) out[m[1]] = /^\d+$/.test(m[2]) ? Number(m[2]) : m[2] + } + return out +} + +const args = parseArgs(Bare.argv) +const { n: N, dim: DIM, queries: NQ, k: K, seed: SEED } = args +const MODEL_ID = 'synthetic-1024' + +// ----- helpers ------------------------------------------------------------ + +// xorshift32 for reproducible synthetic vectors. Not statistically +// rigorous — fine for "shape and timing" but don't infer recall from this. +function makePrng (seed) { + let s = seed >>> 0 + return () => { + s ^= s << 13 + s ^= s >>> 17 + s ^= s << 5 + return ((s >>> 0) / 0xffffffff) + } +} + +function l2normalize (v) { + let acc = 0 + for (let i = 0; i < v.length; i++) acc += v[i] * v[i] + const n = Math.sqrt(acc) || 1 + for (let i = 0; i < v.length; i++) v[i] /= n +} + +function buildVecs (n, dim, rng) { + const buf = new Float32Array(n * dim) + for (let i = 0; i < n; i++) { + const slice = buf.subarray(i * dim, (i + 1) * dim) + for (let j = 0; j < dim; j++) slice[j] = rng() * 2 - 1 + l2normalize(slice) + } + return buf +} + +function fmtMs (ms) { return ms.toFixed(2) + ' ms' } +function fmtMB (b) { return (b / 1024 / 1024).toFixed(1) + ' MB' } + +function rssMB () { return os.memoryUsage().rss / 1024 / 1024 } +function snap (label, t0, extra = '') { + const dt = Date.now() - t0 + console.log(` ${label.padEnd(28)} ${fmtMs(dt).padStart(12)} rss=${rssMB().toFixed(1).padStart(6)} MB${extra ? ' ' + extra : ''}`) + return dt +} + +function percentile (sorted, p) { + if (sorted.length === 0) return NaN + const idx = Math.min(sorted.length - 1, + Math.floor(sorted.length * (p / 100))) + return sorted[idx] +} + +// ----- main --------------------------------------------------------------- + +async function main () { + console.log('=== turbovec-perf (POC, naive scalar full-f32) ===') + console.log(`N=${N} vectors, dim=${DIM}, queries=${NQ}, k=${K}, seed=0x${SEED.toString(16)}`) + console.log(`baseline rss=${rssMB().toFixed(1)} MB`) + console.log() + + const tmpRoot = path.join(os.tmpdir(), + `turbovec-perf-${(os.pid && os.pid()) || 0}-${Date.now()}`) + fs.mkdirSync(tmpRoot, { recursive: true }) + const indexPath = path.join(tmpRoot, 'perf.tvim') + const storeDir = path.join(tmpRoot, 'corestore') + + // ----- generate vectors ----- + let t0 = Date.now() + const rng = makePrng(SEED) + const vectors = buildVecs(N, DIM, rng) + snap('generate vectors', t0, `bytes=${fmtMB(vectors.byteLength)}`) + const queryVecs = buildVecs(NQ, DIM, rng) + + // Build doc objects (adapter expects per-doc embedding arrays). + t0 = Date.now() + const docs = new Array(N) + for (let i = 0; i < N; i++) { + docs[i] = { + id: `v${i}`, + content: '', + contentHash: '', + embeddingModelId: MODEL_ID, + embedding: Array.from(vectors.subarray(i * DIM, (i + 1) * DIM)) + } + } + snap('build doc objects', t0) + + // ----- adapter open ----- + const store = new Corestore(storeDir) + const adapter = new TurboVecHybridAdapter({ + store, + dim: DIM, + bitWidth: 4, + indexPath, + embeddingModelId: MODEL_ID + }) + t0 = Date.now() + await adapter.ready() + snap('adapter.ready (empty)', t0) + + // ----- ingest ----- + t0 = Date.now() + await adapter.saveEmbeddings(docs) + const ingestMs = snap('saveEmbeddings (N total)', t0, + `throughput=${(N / ((Date.now() - t0) / 1000) || N).toFixed(0)} vecs/s`) + void ingestMs + + // ----- explicit re-persist (saveEmbeddings already writes the index; + // this is just to measure the standalone write cost) ----- + t0 = Date.now() + await adapter.idx.write(indexPath) + snap('idx.write (.tvim re-flush)', t0, + `file=${fmtMB(fs.statSync(indexPath).size)}`) + + // ----- queries (top-k=K) ----- + // Two passes: + // (1) Per-query timing with Date.now (~4ms resolution on Bare) — gives + // distribution shape and tail behavior. + // (2) Bulk batch timing of NQ queries — gives a stable mean below the + // single-query resolution floor. + const warmup = Math.min(5, NQ) + for (let i = 0; i < warmup; i++) { + const q = new Float32Array(queryVecs.subarray(i * DIM, (i + 1) * DIM)) + await adapter.search(null, q, { topK: K }) + } + + const latencies = [] + for (let i = 0; i < NQ; i++) { + const q = new Float32Array(queryVecs.subarray((i % NQ) * DIM, (i % NQ) * DIM + DIM)) + const s = Date.now() + await adapter.search(null, q, { topK: K }) + latencies.push(Date.now() - s) + } + latencies.sort((a, b) => a - b) + const p50 = percentile(latencies, 50) + const p95 = percentile(latencies, 95) + const p99 = percentile(latencies, 99) + + // Bulk timing: same NQ queries, but measure the total wall-clock and + // divide. Below the Date.now resolution this gives sub-ms accuracy. + const bulkRepeat = Math.max(1, Math.ceil(200 / NQ)) + const bulkTotal = NQ * bulkRepeat + const bulkStart = Date.now() + for (let rep = 0; rep < bulkRepeat; rep++) { + for (let i = 0; i < NQ; i++) { + const q = new Float32Array(queryVecs.subarray(i * DIM, (i + 1) * DIM)) + await adapter.search(null, q, { topK: K }) + } + } + const bulkMs = Date.now() - bulkStart + const bulkPer = bulkMs / bulkTotal + + console.log() + console.log(` query latency over ${NQ} samples (top-k=${K}):`) + console.log(` p50 = ${fmtMs(p50)} (Date.now resolution ~4ms; use bulk-mean below for sub-ms)`) + console.log(` p95 = ${fmtMs(p95)}`) + console.log(` p99 = ${fmtMs(p99)}`) + console.log(` min = ${fmtMs(latencies[0])}`) + console.log(` max = ${fmtMs(latencies[latencies.length - 1])}`) + console.log(` bulk: ${bulkTotal} queries in ${bulkMs} ms => mean ${bulkPer.toFixed(3)} ms/query`) + console.log() + + // ----- close + reopen ----- + await adapter.close() + await store.close() + + t0 = Date.now() + const store2 = new Corestore(storeDir) + const adapter2 = new TurboVecHybridAdapter({ + store: store2, + dim: DIM, + bitWidth: 4, + indexPath, + embeddingModelId: MODEL_ID + }) + await adapter2.ready() + snap('adapter.ready (reopen)', t0, + `length=${adapter2.idx.length}`) + + // One post-reopen query to confirm the perf characteristic is steady. + t0 = Date.now() + await adapter2.search(null, + new Float32Array(queryVecs.subarray(0, DIM)), { topK: K }) + snap('single query post-reopen', t0) + + await adapter2.close() + await store2.close() + fs.rmSync(tmpRoot, { recursive: true, force: true }) + + console.log() + console.log('--- done. POC scope: naive scalar full-f32; quantization + SIMD/GPU are the optimization phase ---') +} + +main().catch((err) => { + console.error(err) + Bare.exit(1) +}) diff --git a/packages/rag/examples/turbovec-poc.js b/packages/rag/examples/turbovec-poc.js new file mode 100644 index 0000000000..539fa1446e --- /dev/null +++ b/packages/rag/examples/turbovec-poc.js @@ -0,0 +1,197 @@ +'use strict' +// +// Phase 4 end-to-end smoke for the turbovec POC. +// +// Validates the wiring from `@qvac/rag.TurboVecHybridAdapter` -> +// `@qvac/embed-llamacpp.IdMapIndex` -> fabric's `ggml_vec_index_*` C API, +// on a corpus of 200 pre-encoded f32 vectors. +// +// Sequence: +// 1. Lifecycle isolation: no GGMLBert constructor must run. +// 2. Ingest 200 vectors. +// 3. Top-k self-match search. +// 4. Delete the top result and re-search. +// 5. Persist + reopen + re-search (deletion must survive). +// 6. Cleanup. +// +// Data source: if the bench harness's pre-encoded NFCorpus dump is on +// disk (`benchmarks/turbovec-eval/encoded/nfcorpus__thenlper_gte-large.docs.f32`, +// raw little-endian f32, 3633 vectors x 1024 dims), the script uses the +// first 200 vectors from it. Otherwise it falls back to a deterministic +// PRNG-generated 200x1024 synthetic corpus so the POC can run anywhere. + +const fs = require('bare-fs') +const path = require('bare-path') +const os = require('bare-os') +const Corestore = require('corestore') + +const TurboVecHybridAdapter = + require('../src/adapters/database/TurboVecHybridAdapter') + +const N = 200 +const DIM = 1024 +const MODEL_ID = 'thenlper/gte-large' + +const NFCORPUS_PATH = path.join( + __dirname, '..', 'benchmarks', 'turbovec-eval', + 'encoded', 'nfcorpus__thenlper_gte-large.docs.f32') + +// xorshift32 — deterministic so re-runs are reproducible for debugging. +function makePrng (seed) { + let s = seed >>> 0 + return () => { + s ^= s << 13 + s ^= s >>> 17 + s ^= s << 5 + return ((s >>> 0) / 0xffffffff) + } +} + +function l2normalize (v) { + let acc = 0 + for (let i = 0; i < v.length; i++) acc += v[i] * v[i] + const n = Math.sqrt(acc) || 1 + for (let i = 0; i < v.length; i++) v[i] /= n + return v +} + +function loadVectors () { + if (fs.existsSync(NFCORPUS_PATH)) { + console.log(`[load] using real NFCorpus dump: ${NFCORPUS_PATH}`) + const buf = fs.readFileSync(NFCORPUS_PATH) + const wanted = N * DIM * 4 + if (buf.byteLength < wanted) { + throw new Error( + `NFCorpus dump too small: ${buf.byteLength} < ${wanted}`) + } + return new Float32Array(buf.buffer, buf.byteOffset, N * DIM) + } + console.log('[load] NFCorpus dump not found; synthesizing 200x1024') + const rng = makePrng(0xdeadbeef) + const out = new Float32Array(N * DIM) + for (let i = 0; i < N; i++) { + const v = new Float32Array(DIM) + for (let j = 0; j < DIM; j++) v[j] = rng() * 2 - 1 + l2normalize(v) + out.set(v, i * DIM) + } + return out +} + +function assert (cond, msg) { + if (!cond) { + console.error(`ASSERT FAIL: ${msg}`) + process?.exit?.(1) || global.exit?.(1) + } + console.log(` ok ${msg}`) +} + +async function main () { + console.log('--- turbovec POC end-to-end smoke ---') + + // --------------------------------------------------------------------- + // Step 1: lifecycle isolation + // --------------------------------------------------------------------- + // Authoritative lifecycle-isolation coverage lives in the embed-llamacpp + // integration test (`test/integration/id-map-index.test.js`), which + // asserts via `require.cache` that importing the IdMapIndex sub-export + // never loads `index.js` (GGMLBert entry) or `addon.js`. This script + // exercises the same code path via the adapter; that test gates the + // architectural invariant. + + // --------------------------------------------------------------------- + // Step 2: ingest + // --------------------------------------------------------------------- + const tmpRoot = path.join(os.tmpdir(), + `turbovec-poc-${(os.pid && os.pid()) || 0}-${Date.now()}`) + fs.mkdirSync(tmpRoot, { recursive: true }) + const indexPath = path.join(tmpRoot, 'turbovec-poc.tvim') + const storeDir = path.join(tmpRoot, 'corestore') + + const vectors = loadVectors() + const docs = [] + for (let i = 0; i < N; i++) { + docs.push({ + id: `nfcorpus-${i}`, + content: `doc ${i}`, + contentHash: `h-${i}`, + embeddingModelId: MODEL_ID, + embedding: Array.from(vectors.subarray(i * DIM, (i + 1) * DIM)) + }) + } + + const store = new Corestore(storeDir) + const adapter = new TurboVecHybridAdapter({ + store, + dim: DIM, + bitWidth: 4, + indexPath, + embeddingModelId: MODEL_ID + }) + await adapter.ready() + + const saved = await adapter.saveEmbeddings(docs) + assert(saved.length === N, `all ${N} docs saved`) + assert(saved.every((r) => r.status === 'fulfilled'), + 'every save reported fulfilled') + + // --------------------------------------------------------------------- + // Step 3: search + // --------------------------------------------------------------------- + const q0 = new Float32Array(vectors.subarray(0, DIM)) + let hits = await adapter.search(null, q0, { topK: 5 }) + console.log('[search] top-5 for query=vec0:') + for (const h of hits) { + console.log(` ${h.id.padEnd(15)} score=${h.score.toFixed(6)}`) + } + assert(hits[0].id === 'nfcorpus-0', 'top-1 is the query itself') + assert(Math.abs(hits[0].score - 1.0) < 1e-3, 'self-similarity ≈ 1.0') + + // --------------------------------------------------------------------- + // Step 4: delete + // --------------------------------------------------------------------- + await adapter.deleteEmbeddings(['nfcorpus-0']) + hits = await adapter.search(null, q0, { topK: 5 }) + const top1After = hits[0] + console.log(`[delete] top-1 after deleting nfcorpus-0: ${top1After.id} (score=${top1After.score.toFixed(6)})`) + assert(top1After.id !== 'nfcorpus-0', + 'deleted id no longer surfaces') + assert(adapter.idx.length === N - 1, 'index length shrank by 1') + + // --------------------------------------------------------------------- + // Step 5: persistence round-trip + // --------------------------------------------------------------------- + await adapter.close() + await store.close() + assert(fs.existsSync(indexPath), '.tvim file present on disk') + + const store2 = new Corestore(storeDir) + const adapter2 = new TurboVecHybridAdapter({ + store: store2, + dim: DIM, + bitWidth: 4, + indexPath, + embeddingModelId: MODEL_ID + }) + await adapter2.ready() + assert(adapter2.idx.length === N - 1, + 'reopened index has the post-delete count') + const reHits = await adapter2.search(null, q0, { topK: 1 }) + assert(reHits[0].id === top1After.id, + `reopened top-1 matches pre-close (${reHits[0].id} === ${top1After.id})`) + console.log(`[reopen] top-1 after reload: ${reHits[0].id} (score=${reHits[0].score.toFixed(6)})`) + await adapter2.close() + await store2.close() + + // --------------------------------------------------------------------- + // Step 6: cleanup + // --------------------------------------------------------------------- + fs.rmSync(tmpRoot, { recursive: true, force: true }) + console.log('--- turbovec POC: OK ---') +} + +main().catch((err) => { + console.error(err) + process?.exit?.(1) || global.exit?.(1) + throw err +}) diff --git a/packages/rag/examples/turbovec-vs-hyperdb.js b/packages/rag/examples/turbovec-vs-hyperdb.js new file mode 100644 index 0000000000..9a49f69a14 --- /dev/null +++ b/packages/rag/examples/turbovec-vs-hyperdb.js @@ -0,0 +1,331 @@ +'use strict' +// +// turbovec-vs-hyperdb: side-by-side perf on identical synthetic vectors. +// +// Three adapters run end-to-end against the SAME generated vectors so +// numbers are directly comparable on this host: +// +// 1. TurboVecHybridAdapter (POC, naive scalar full-f32) +// 2. HyperDBAdapter — production defaults (NUM_CENTROIDS=16, BUCKET_SIZE=50) +// → at N > 800 this silently evicts via FIFO; recall craters but +// ingest/query is "fast". Included to show what shipping users see. +// 3. HyperDBAdapter — retain-full (BUCKET_SIZE bumped so cap >= N) +// → matches the REPORT.md "tuned to retain everything" config that +// hits recall ~0.986 but at multi-second p50 on real corpora. +// +// Recall is NOT measured here — synthetic random vectors have no semantic +// ground truth. The architectural numbers (ingest / query latency / RAM / +// file size) are the comparison; recall numbers live in REPORT.md. +// +// Usage: +// bare examples/turbovec-vs-hyperdb.js # N=3633, dim=1024, 50 queries +// bare examples/turbovec-vs-hyperdb.js --n=10000 # bigger N +// bare examples/turbovec-vs-hyperdb.js --skip-hyperdb-full --n=50000 +// # skip the slowest run +// bare examples/turbovec-vs-hyperdb.js --only=turbo # just one adapter + +const fs = require('bare-fs') +const path = require('bare-path') +const os = require('bare-os') +const Corestore = require('corestore') + +const TurboVecHybridAdapter = + require('../src/adapters/database/TurboVecHybridAdapter') +const HyperDBAdapter = + require('../src/adapters/database/HyperDBAdapter') + +// ----- args --------------------------------------------------------------- + +function parseArgs (argv) { + const out = { + n: 3633, dim: 1024, queries: 50, k: 10, seed: 0xdeadbeef, + 'skip-hyperdb-default': false, + 'skip-hyperdb-full': false, + only: null + } + for (const a of argv.slice(2)) { + if (a.startsWith('--no-') || a.startsWith('--skip-')) { + out[a.replace(/^--/, '')] = true + continue + } + const m = /^--(\S+?)=(.+)$/.exec(a) + if (m) out[m[1]] = /^\d+$/.test(m[2]) ? Number(m[2]) : m[2] + } + return out +} + +const args = parseArgs(Bare.argv) +const { n: N, dim: DIM, queries: NQ, k: K, seed: SEED } = args +const MODEL_ID = 'synthetic-bench' + +// ----- shared utils ------------------------------------------------------- + +function makePrng (seed) { + let s = seed >>> 0 + return () => { + s ^= s << 13 + s ^= s >>> 17 + s ^= s << 5 + return ((s >>> 0) / 0xffffffff) + } +} + +function l2normalize (v) { + let acc = 0 + for (let i = 0; i < v.length; i++) acc += v[i] * v[i] + const n = Math.sqrt(acc) || 1 + for (let i = 0; i < v.length; i++) v[i] /= n +} + +function buildVecs (n, dim, rng) { + const buf = new Float32Array(n * dim) + for (let i = 0; i < n; i++) { + const slice = buf.subarray(i * dim, (i + 1) * dim) + for (let j = 0; j < dim; j++) slice[j] = rng() * 2 - 1 + l2normalize(slice) + } + return buf +} + +function buildDocs (vectors, n, dim) { + const docs = new Array(n) + for (let i = 0; i < n; i++) { + docs[i] = { + id: `v${i}`, + // Unique content + contentHash per doc. HyperDBAdapter dedupes by + // contentHash internally; empty/duplicated values would collapse + // the whole batch to a single survivor. + content: `doc ${i}`, + contentHash: `h-${i}`, + embeddingModelId: MODEL_ID, + embedding: Array.from(vectors.subarray(i * dim, (i + 1) * dim)) + } + } + return docs +} + +function rssMB () { return os.memoryUsage().rss / 1024 / 1024 } +function percentile (sorted, p) { + if (sorted.length === 0) return NaN + const idx = Math.min(sorted.length - 1, + Math.floor(sorted.length * (p / 100))) + return sorted[idx] +} + +// ----- runner ------------------------------------------------------------- + +// Generic adapter perf harness. Caller passes a setup() returning an +// open BaseDBAdapter + a teardown() that closes the underlying store. +async function runAdapter (label, vectors, queryVecs, setup) { + console.log(`\n--- ${label} ---`) + const before = rssMB() + + const t0open = Date.now() + const { adapter, teardown } = await setup() + const tOpen = Date.now() - t0open + console.log(` open ${tOpen.toString().padStart(7)} ms rss=${rssMB().toFixed(1)} MB`) + + const docs = buildDocs(vectors, N, DIM) + const t0ing = Date.now() + const saveResults = await adapter.saveEmbeddings(docs) + const tIngest = Date.now() - t0ing + const fulfilled = Array.isArray(saveResults) + ? saveResults.filter((r) => r && r.status === 'fulfilled').length + : -1 + const dupes = Array.isArray(saveResults) + ? saveResults.filter((r) => r && (r.status === 'duplicate' || r.duplicate)).length + : 0 + const ingestRSS = rssMB() + console.log(` ingest (N=${N}) ${tIngest.toString().padStart(7)} ms rss=${ingestRSS.toFixed(1)} MB throughput=${Math.round(N / (tIngest / 1000))} vecs/s`) + console.log(` saveEmbeddings return: fulfilled=${fulfilled} duplicates=${dupes} total=${Array.isArray(saveResults) ? saveResults.length : 'non-array'}`) + + // Sanity: count docs actually persisted. For HyperDBAdapter with a + // defaults config, this catches silent FIFO evictions; for both + // adapters, it catches "async-buffered, not actually flushed" cases. + let actuallyPersisted = '?' + try { + if (adapter.idx && typeof adapter.idx.length === 'number') { + actuallyPersisted = adapter.idx.length + } else if (adapter.db && adapter.documentsTable) { + const snap = adapter.db.snapshot() + const rows = await snap.find(adapter.documentsTable).toArray() + actuallyPersisted = rows.length + } + } catch (_) {} + console.log(` -> docs actually queryable: ${actuallyPersisted} / ${N}`) + + // Warmup. + const warmup = Math.min(5, NQ) + for (let i = 0; i < warmup; i++) { + const q = Array.from(queryVecs.subarray(i * DIM, (i + 1) * DIM)) + await adapter.search('', q, { topK: K }) + } + + // Per-query timings (Date.now resolution noise) — used for tail stats. + const latencies = [] + for (let i = 0; i < NQ; i++) { + const q = Array.from(queryVecs.subarray(i * DIM, (i + 1) * DIM)) + const s = Date.now() + await adapter.search('', q, { topK: K }) + latencies.push(Date.now() - s) + } + latencies.sort((a, b) => a - b) + const p50 = percentile(latencies, 50) + const p95 = percentile(latencies, 95) + const p99 = percentile(latencies, 99) + + // Bulk for sub-ms mean (HyperDB at retain-full will be many ms per query + // so bulk mostly matters for fast adapters). + const bulkReps = Math.max(1, Math.ceil(200 / NQ)) + const bulkTotal = NQ * bulkReps + const t0bulk = Date.now() + for (let r = 0; r < bulkReps; r++) { + for (let i = 0; i < NQ; i++) { + const q = Array.from(queryVecs.subarray(i * DIM, (i + 1) * DIM)) + await adapter.search('', q, { topK: K }) + } + } + const tBulk = Date.now() - t0bulk + const meanQ = tBulk / bulkTotal + + console.log(` query mean (bulk x${bulkTotal}) ${meanQ.toFixed(3).padStart(7)} ms`) + console.log(` query p50 / p95 / p99 ${p50.toString().padStart(3)} / ${p95.toString().padStart(3)} / ${p99.toString().padStart(3)} ms`) + console.log(` rss delta over baseline ${(ingestRSS - before).toFixed(1)} MB`) + + await teardown() + + return { label, tOpen, tIngest, ingestRSS, meanQ, p50, p95, p99 } +} + +// ----- adapter factories -------------------------------------------------- + +function freshTmpRoot (tag) { + const dir = path.join(os.tmpdir(), + `bench-${tag}-${(os.pid && os.pid()) || 0}-${Date.now()}`) + fs.mkdirSync(dir, { recursive: true }) + return dir +} + +function turbovecFactory () { + const tmpRoot = freshTmpRoot('turbo') + const indexPath = path.join(tmpRoot, 'idx.tvim') + const storeDir = path.join(tmpRoot, 'corestore') + const store = new Corestore(storeDir) + const adapter = new TurboVecHybridAdapter({ + store, dim: DIM, bitWidth: 4, indexPath, embeddingModelId: MODEL_ID + }) + return { + setup: async () => { + await adapter.ready() + return { + adapter, + teardown: async () => { + await adapter.close() + await store.close() + fs.rmSync(tmpRoot, { recursive: true, force: true }) + } + } + } + } +} + +function hyperdbFactory (config) { + const tmpRoot = freshTmpRoot(config.tag) + const storeDir = path.join(tmpRoot, 'corestore') + const store = new Corestore(storeDir) + const adapter = new HyperDBAdapter({ + store, + dbName: 'bench', + NUM_CENTROIDS: config.NUM_CENTROIDS, + BUCKET_SIZE: config.BUCKET_SIZE + }) + return { + setup: async () => { + await adapter.ready() + return { + adapter, + teardown: async () => { + await adapter.close() + await store.close() + fs.rmSync(tmpRoot, { recursive: true, force: true }) + } + } + } + } +} + +// ----- main --------------------------------------------------------------- + +async function main () { + console.log('=== turbovec-vs-hyperdb (synthetic corpus) ===') + console.log(`N=${N} vectors, dim=${DIM}, queries=${NQ}, k=${K}, seed=0x${SEED.toString(16)}`) + console.log(`baseline rss=${rssMB().toFixed(1)} MB`) + + const rng = makePrng(SEED) + const vectors = buildVecs(N, DIM, rng) + const queries = buildVecs(NQ, DIM, rng) + + const results = [] + + if (args.only == null || args.only === 'turbo') { + results.push(await runAdapter( + 'TurboVecHybridAdapter (POC, scalar f32)', + vectors, queries, + turbovecFactory().setup)) + } + + if ((args.only == null || args.only === 'hyperdb-default') && + !args['skip-hyperdb-default']) { + results.push(await runAdapter( + `HyperDBAdapter (defaults: 16c x 50) -- cap=800, evicts ${Math.max(0, N - 800)} docs`, + vectors, queries, + hyperdbFactory({ + tag: 'hyperdb-default', NUM_CENTROIDS: 16, BUCKET_SIZE: 50 + }).setup)) + } + + if ((args.only == null || args.only === 'hyperdb-full') && + !args['skip-hyperdb-full']) { + const bucketSize = Math.ceil(N / 16) + 10 + results.push(await runAdapter( + `HyperDBAdapter (retain-full: 16c x ${bucketSize})`, + vectors, queries, + hyperdbFactory({ + tag: 'hyperdb-full', NUM_CENTROIDS: 16, BUCKET_SIZE: bucketSize + }).setup)) + } + + // ----- summary table ----- + console.log('\n=== summary ===') + console.log( + 'adapter'.padEnd(56) + + 'open(ms)'.padStart(10) + + 'ingest(ms)'.padStart(12) + + 'vecs/s'.padStart(10) + + 'rss(MB)'.padStart(10) + + 'q-mean(ms)'.padStart(12) + + 'p50'.padStart(6) + + 'p95'.padStart(6) + + 'p99'.padStart(6)) + console.log('-'.repeat(56 + 10 + 12 + 10 + 10 + 12 + 6 + 6 + 6)) + for (const r of results) { + console.log( + r.label.slice(0, 56).padEnd(56) + + r.tOpen.toString().padStart(10) + + r.tIngest.toString().padStart(12) + + Math.round(N / (r.tIngest / 1000)).toString().padStart(10) + + r.ingestRSS.toFixed(0).toString().padStart(10) + + r.meanQ.toFixed(3).padStart(12) + + r.p50.toString().padStart(6) + + r.p95.toString().padStart(6) + + r.p99.toString().padStart(6)) + } + console.log() + console.log('note: recall NOT measured; synthetic random vectors have no ground truth.') + console.log(' see REPORT.md for recall numbers from real corpora.') +} + +main().catch((err) => { + console.error(err) + Bare.exit(1) +}) diff --git a/packages/rag/index.js b/packages/rag/index.js index 024607e9f4..06fbdf31a9 100644 --- a/packages/rag/index.js +++ b/packages/rag/index.js @@ -10,6 +10,13 @@ const RAG = require('./src/RAG') // Database Adapters const BaseDBAdapter = require('./src/adapters/database/BaseDBAdapter') const HyperDBAdapter = require('./src/adapters/database/HyperDBAdapter') +// TurboVecHybridAdapter is intentionally lazy-loaded below (it pulls in +// `@qvac/embed-llamacpp`'s native addon, so unconditional require would +// regress `HyperDBAdapter`-only consumers by forcing a native-binary +// dependency on `require('@qvac/rag')`). Consumers that want it should +// either access it via the lazy getter exposed on this module's exports, +// or import the dedicated sub-export `@qvac/rag/turbovec` to opt in +// explicitly. // Chunker Adapters const BaseChunkAdapter = require('./src/adapters/chunker/BaseChunkAdapter') @@ -36,3 +43,19 @@ module.exports = { ERR_CODES, ...embeddingSchemas } + +// Lazy getter: first access triggers the require, which loads +// `@qvac/embed-llamacpp` and its native binary. Consumers that never +// touch this property never pay the cost. +let _TurboVecHybridAdapter +Object.defineProperty(module.exports, 'TurboVecHybridAdapter', { + enumerable: true, + configurable: false, + get () { + if (_TurboVecHybridAdapter === undefined) { + _TurboVecHybridAdapter = + require('./src/adapters/database/TurboVecHybridAdapter') + } + return _TurboVecHybridAdapter + } +}) diff --git a/packages/rag/package.json b/packages/rag/package.json index 0ef6605e6c..78f4ccf1aa 100644 --- a/packages/rag/package.json +++ b/packages/rag/package.json @@ -40,6 +40,9 @@ "./errors.js": { "types": "./src/errors.d.ts", "default": "./src/errors.js" + }, + "./turbovec": { + "default": "./src/adapters/database/TurboVecHybridAdapter.js" } }, "imports": { diff --git a/packages/rag/src/adapters/database/TurboVecHybridAdapter.js b/packages/rag/src/adapters/database/TurboVecHybridAdapter.js new file mode 100644 index 0000000000..21eb9544d1 --- /dev/null +++ b/packages/rag/src/adapters/database/TurboVecHybridAdapter.js @@ -0,0 +1,397 @@ +'use strict' +// +// TurboVecHybridAdapter +// ===================== +// +// Hybrid database adapter for the turbovec POC. Uses HyperDB for document +// rows + replication (same `@rag/documents` and `@rag/config` schema as +// HyperDBAdapter so corestores are interchangeable for read paths) and +// the fabric-native `IdMapIndex` (full f32 + scalar dot product in the +// POC) for vector indexing and ANN search. +// +// Out of POC scope: quantization, SIMD/GPU kernels, filtered search, +// reindex. The adapter wires the architecture so those can land later by +// replacing internals — see prompt.md. +// +// Lifecycle isolation: this file requires `IdMapIndex` from the dedicated +// sub-export `@qvac/embed-llamacpp/idMapIndex`, which is structured so it +// does NOT trigger fabric LLM/BERT backend initialization. Loading this +// adapter must not load any model. + +const BaseDBAdapter = require('./BaseDBAdapter') +const { QvacErrorRAG, ERR_CODES } = require('../../errors') +const QvacLogger = require('@qvac/logging') +const HyperDB = require('hyperdb') +const dbSpec = require('./hyperspec/hyperdb/index.js') +const stringIdToU64 = require('../../utils/stringIdToU64') + +const IdMapIndex = require('@qvac/embed-llamacpp/idMapIndex') + +// u64 sentinel returned by IdMapIndex.search() when the index holds fewer +// than k entries. Matches fabric's `UINT64_MAX`. +const U64_SENTINEL = (1n << 64n) - 1n + +// Reverse-map (bigint u64 -> string id) is built in-memory on adapter +// `open()` by scanning `@rag/documents` and pulling each row's +// `metadata.__turbovec_u64Id` field. Decision rationale (see prompt.md +// Phase 3): we can't extend the dbSpec without regenerating it; stashing +// the u64 hash inside the `metadata` JSON column is the smallest-blast +// alternative. Memory cost: 16 bytes per doc (vs. e.g. a sidecar table). +// +// `__turbovec_u64Id` is stored as a base-10 string so it survives JSON +// (`bigint` does not). Decoded back to BigInt on read. +const U64_META_KEY = '__turbovec_u64Id' + +class TurboVecHybridAdapter extends BaseDBAdapter { + /** + * @param {Object} config + * @param {Corestore} config.store - Corestore for HyperDB (documents) + * @param {number} config.dim - vector dimensionality + * @param {number} [config.bitWidth=4] - reserved (POC stores full f32) + * @param {string} config.indexPath - filesystem path for the .tvim file + * @param {string} config.embeddingModelId - propagated to documents rows + * @param {string} [config.dbName='turbovec-store'] - hypercore name + * @param {string} [config.documentsTable='@rag/documents'] + * @param {string} [config.configTable='@rag/config'] + * @param {QvacLogger} [config.logger] + */ + constructor (config = {}) { + super(config) + if (!config.store) { + throw new QvacErrorRAG({ + code: ERR_CODES.INVALID_PARAMS, + adds: 'TurboVecHybridAdapter: `store` is required' + }) + } + if (!Number.isInteger(config.dim) || config.dim <= 0) { + throw new QvacErrorRAG({ + code: ERR_CODES.INVALID_PARAMS, + adds: 'TurboVecHybridAdapter: `dim` must be a positive integer' + }) + } + if (typeof config.indexPath !== 'string' || config.indexPath.length === 0) { + throw new QvacErrorRAG({ + code: ERR_CODES.INVALID_PARAMS, + adds: 'TurboVecHybridAdapter: `indexPath` is required' + }) + } + if (typeof config.embeddingModelId !== 'string' || + config.embeddingModelId.length === 0) { + throw new QvacErrorRAG({ + code: ERR_CODES.INVALID_PARAMS, + adds: 'TurboVecHybridAdapter: `embeddingModelId` is required' + }) + } + this.store = config.store + this.dim = config.dim + this.bitWidth = config.bitWidth ?? 4 + this.indexPath = config.indexPath + this.embeddingModelId = config.embeddingModelId + this.dbName = config.dbName || 'turbovec-store' + this.documentsTable = config.documentsTable || '@rag/documents' + this.configTable = config.configTable || '@rag/config' + this.logger = config.logger || new QvacLogger() + + this.hypercore = null + this.db = null + this.idx = null + // bigint -> string id reverse map, populated on _open and maintained + // through saveEmbeddings / deleteEmbeddings. + this._u64ToStringId = new Map() + } + + async _open () { + this.logger.info('TurboVecHybridAdapter: opening') + try { + await this.store.ready() + this.hypercore = this.store.get({ name: this.dbName }) + this.db = HyperDB.bee(this.hypercore, dbSpec, { autoUpdate: true }) + await this.db.ready() + + // Load or create the IdMapIndex. The .tvim file may not exist on + // first open; in that case start fresh and let `write()` create it. + const fs = this._fs() + if (this.indexPath && fs.existsSync(this.indexPath)) { + this.logger.debug( + `TurboVecHybridAdapter: loading index from ${this.indexPath}`) + this.idx = await IdMapIndex.load(this.indexPath) + if (this.idx.dim !== this.dim) { + throw new QvacErrorRAG({ + code: ERR_CODES.INVALID_PARAMS, + adds: `TurboVecHybridAdapter: on-disk dim (${this.idx.dim}) ` + + `does not match constructor dim (${this.dim})` + }) + } + } else { + this.logger.debug('TurboVecHybridAdapter: creating fresh index') + this.idx = new IdMapIndex({ dim: this.dim, bitWidth: this.bitWidth }) + } + + await this._rebuildReverseMap() + this.isInitialized = true + this.logger.info( + `TurboVecHybridAdapter: open (n=${this.idx.length} vectors)`) + } catch (err) { + // Best-effort cleanup so a half-opened adapter doesn't leave + // dangling native handles / open hypercores. Subsequent calls + // (incl. `close()`) become safe no-ops because every reference + // is nulled. + await this._teardown() + throw err + } + } + + /** + * Reset every owned resource to a fresh-construction state. Idempotent + * — safe to call from both `_close` and the `_open` failure path. + * @private + */ + async _teardown () { + if (this.idx) { + try { await this.idx.dispose() } catch (_) {} + this.idx = null + } + if (this.db) { + try { await this.db.close() } catch (_) {} + this.db = null + } + this.hypercore = null + this._u64ToStringId.clear() + this.isInitialized = false + } + + async _close () { + this.logger.info('TurboVecHybridAdapter: closing') + await this._teardown() + } + + /** + * Rebuilds the bigint -> string id reverse map by scanning the + * documents table. Skips rows that lack the u64 metadata field (they + * came from a different adapter or an older write). + * @private + */ + async _rebuildReverseMap () { + this._u64ToStringId.clear() + const snapshot = this.db.snapshot() + try { + const rows = await snapshot.find(this.documentsTable).toArray() + for (const row of rows) { + const raw = row?.metadata?.[U64_META_KEY] + if (typeof raw === 'string' && raw.length > 0) { + try { + this._u64ToStringId.set(BigInt(raw), row.id) + } catch (_) { + // Malformed entry — skip; the IdMapIndex will surface its own + // absence if a search returns it. + } + } + } + } finally { + // hyperdb snapshots don't currently expose a close hook; GC handles it. + } + } + + async saveEmbeddings (embeddedDocs, opts = {}) { + if (!Array.isArray(embeddedDocs) || embeddedDocs.length === 0) { + return [] + } + + // Per-batch model-id sanity. The adapter is bound to one model + // (its constructor `embeddingModelId`). If any doc declares a + // different model, fail loudly rather than silently picking one — + // mixed-model corpora are not a supported configuration for the + // POC and silently choosing would produce hard-to-debug recall + // problems downstream. + for (const doc of embeddedDocs) { + if (doc.embeddingModelId && + doc.embeddingModelId !== this.embeddingModelId) { + throw new QvacErrorRAG({ + code: ERR_CODES.INVALID_PARAMS, + adds: `TurboVecHybridAdapter: doc ${doc.id} declares ` + + `embeddingModelId "${doc.embeddingModelId}" but the adapter ` + + `is bound to "${this.embeddingModelId}"` + }) + } + } + + const now = new Date() + const n = embeddedDocs.length + const vectors = new Float32Array(n * this.dim) + const u64Ids = new BigUint64Array(n) + const docMeta = new Array(n) + + for (let i = 0; i < n; i++) { + const doc = embeddedDocs[i] + if (!doc.embedding || doc.embedding.length !== this.dim) { + throw new QvacErrorRAG({ + code: ERR_CODES.INVALID_PARAMS, + adds: `TurboVecHybridAdapter: doc[${i}] (${doc.id}) embedding length ` + + `${doc.embedding?.length} != adapter dim ${this.dim}` + }) + } + const u64 = stringIdToU64(doc.id) + // Loud collision check: another doc in this adapter already maps + // a different string id to the same u64. + const prev = this._u64ToStringId.get(u64) + if (prev !== undefined && prev !== doc.id) { + throw new QvacErrorRAG({ + code: ERR_CODES.INVALID_PARAMS, + adds: `TurboVecHybridAdapter: u64 hash collision between ` + + `"${doc.id}" and "${prev}"` + }) + } + for (let j = 0; j < this.dim; j++) { + vectors[i * this.dim + j] = doc.embedding[j] + } + u64Ids[i] = u64 + docMeta[i] = { id: doc.id, doc, u64 } + } + + // Insert into the native index first. If addWithIds throws (duplicate + // u64 across batches, dim mismatch, OOM), HyperDB stays untouched — + // the operation is atomic from the caller's perspective. + this.idx.addWithIds(vectors, u64Ids) + + // Persist documents to HyperDB and update the reverse map. + const tx = await this.db.exclusiveTransaction() + try { + for (const { id, doc, u64 } of docMeta) { + const metadata = { ...(doc.metadata || {}) } + metadata[U64_META_KEY] = u64.toString(10) + // Stash embeddingModelId in metadata so downstream consumers + // (incl. HyperDBAdapter readers) can identify the model. The + // adapter-level value is the source of truth — per-doc overrides + // were rejected upfront in the validation loop above. + metadata.embeddingModelId = this.embeddingModelId + await tx.insert(this.documentsTable, { + id, + content: doc.content ?? '', + contentHash: doc.contentHash ?? '', + metadata, + createdAt: now, + updatedAt: now + }) + this._u64ToStringId.set(u64, id) + } + await tx.flush() + } catch (err) { + // Roll back the native index inserts to keep HyperDB and IdMapIndex + // in sync; the doc rows in this tx were never flushed. + try { await tx.rollback() } catch (_) {} + for (const { u64 } of docMeta) { + try { this.idx.remove(u64) } catch (_) {} + this._u64ToStringId.delete(u64) + } + throw err + } + + // Persist the index after a successful batch. POC: full rewrite each + // call — simple, correct, slow. Two durability gaps acknowledged + // here, both deferred to the optimization phase and intentionally + // owned by fabric rather than this adapter: + // (a) `ggml_vec_index_write` is not atomic — a crash mid-write + // leaves a truncated .tvim that fails on load. Fix is a + // write-to-tmp + rename inside the C impl so all consumers + // benefit. + // (b) HyperDB tx.flush() above and idx.write() here are not + // atomic relative to each other. A crash between them leaves + // HyperDB ahead of the .tvim by one batch; on re-open the + // reverse map will list the new docs but the index won't have + // their vectors. Fix is a journaled / two-phase write or a + // delta log on the .tvim side. + this.idx.write(this.indexPath) + + return docMeta.map(({ id }) => ({ id, status: 'fulfilled' })) + } + + async search (query, queryVector, params = {}) { + const topK = params.topK || 5 + const signal = params.signal + if (signal?.aborted) { + throw new QvacErrorRAG({ code: ERR_CODES.OPERATION_CANCELLED }) + } + if (!queryVector || queryVector.length !== this.dim) { + throw new QvacErrorRAG({ + code: ERR_CODES.INVALID_PARAMS, + adds: `TurboVecHybridAdapter.search: queryVector length ` + + `${queryVector?.length} != dim ${this.dim}` + }) + } + const f32 = queryVector instanceof Float32Array + ? queryVector + : Float32Array.from(queryVector) + + const { scores, ids } = this.idx.search(f32, topK) + + if (signal?.aborted) { + throw new QvacErrorRAG({ code: ERR_CODES.OPERATION_CANCELLED }) + } + + // Resolve doc rows in parallel. Each top-k slot is independent so + // serializing them was wasted round-trip latency on HyperDB reads. + const snapshot = this.db.snapshot() + const slots = new Array(topK) + for (let i = 0; i < topK; i++) { + const u64 = ids[i] + if (u64 === U64_SENTINEL) { slots[i] = null; continue } + const stringId = this._u64ToStringId.get(u64) + if (!stringId) { slots[i] = null; continue } + slots[i] = snapshot.get(this.documentsTable, { id: stringId }) + .then((row) => row + ? { id: stringId, content: row.content, score: scores[i], metadata: row.metadata } + : null) + } + const resolved = await Promise.all(slots) + return resolved.filter((r) => r !== null) + } + + async deleteEmbeddings (ids) { + if (!Array.isArray(ids) || ids.length === 0) { + throw new QvacErrorRAG({ code: ERR_CODES.INVALID_PARAMS }) + } + const tx = await this.db.exclusiveTransaction() + let removedAny = false + try { + for (const id of ids) { + const u64 = stringIdToU64(id) + const removed = this.idx.remove(u64) + if (removed) removedAny = true + this._u64ToStringId.delete(u64) + await tx.delete(this.documentsTable, { id }) + } + await tx.flush() + } catch (err) { + try { await tx.rollback() } catch (_) {} + throw err + } + if (removedAny) { + this.idx.write(this.indexPath) + } + return true + } + + async reindex () { + return { + reindexed: false, + details: { reason: 'data-oblivious; no rebuild needed' } + } + } + + // Lazy fs accessor — both Bare and Node ship a file API. Using + // `bare-fs` directly works under Bare; on Node, `fs` is the equivalent. + // The adapter only needs `existsSync` so the resolution surface stays + // tiny. Sync because `require` is sync; no need for an async wrapper. + _fs () { + if (this._fsCache) return this._fsCache + try { + this._fsCache = require('bare-fs') + } catch (_) { + // eslint-disable-next-line node/no-missing-require + this._fsCache = require('fs') + } + return this._fsCache + } +} + +module.exports = TurboVecHybridAdapter diff --git a/packages/rag/src/utils/stringIdToU64.js b/packages/rag/src/utils/stringIdToU64.js new file mode 100644 index 0000000000..6c977ea6fa --- /dev/null +++ b/packages/rag/src/utils/stringIdToU64.js @@ -0,0 +1,33 @@ +'use strict' +// +// Maps a string document id to an unsigned 64-bit integer for use as an +// external id in the fabric vector index (IdMapIndex). The mapping is the +// first 8 bytes of a SHA-256 of the UTF-8 string, decoded little-endian. +// +// Collision math: for a uniformly distributed 64-bit hash, the birthday +// probability of any collision over N documents is roughly N^2 / 2^65. +// At N = 100,000 the probability is ~5.4e-10. We treat collisions as a +// loud failure path: `IdMapIndex.addWithIds` throws on duplicate u64 ids, +// surfacing the collision rather than silently overwriting. + +const qvacCrypto = require('#crypto') + +/** + * Deterministic, well-distributed string -> uint64 mapping. + * @param {string} s - non-empty document id + * @returns {bigint} unsigned 64-bit value + */ +function stringIdToU64 (s) { + if (typeof s !== 'string' || s.length === 0) { + throw new TypeError('stringIdToU64: id must be a non-empty string') + } + const digest = qvacCrypto.createHash('sha256').update(s).digest() + // First 8 bytes, little-endian. + let acc = 0n + for (let i = 7; i >= 0; i--) { + acc = (acc << 8n) | BigInt(digest[i]) + } + return acc +} + +module.exports = stringIdToU64 diff --git a/packages/rag/test/unit/turbovec-hybrid-adapter.test.js b/packages/rag/test/unit/turbovec-hybrid-adapter.test.js new file mode 100644 index 0000000000..0c1a2248e5 --- /dev/null +++ b/packages/rag/test/unit/turbovec-hybrid-adapter.test.js @@ -0,0 +1,140 @@ +'use strict' +// +// Phase 3 acceptance test (turbovec POC). +// Exercises TurboVecHybridAdapter against a temporary Corestore: +// construct -> save 5 -> search -> delete one -> search -> close -> reopen -> search. + +const test = require('brittle') +const tmp = require('test-tmp') +const path = require('bare-path') +const fs = require('bare-fs') +const Corestore = require('corestore') + +const TurboVecHybridAdapter = + require('../../src/adapters/database/TurboVecHybridAdapter') +const stringIdToU64 = + require('../../src/utils/stringIdToU64') + +const DIM = 8 +const MODEL_ID = 'gte-small-test' + +// Build N seed unit vectors in dim DIM (one per axis up to DIM). +function seeds (n) { + const out = [] + for (let i = 0; i < n; i++) { + const v = new Float32Array(DIM) + v[i % DIM] = 1 + out.push(Array.from(v)) + } + return out +} + +function makeDocs (n) { + const v = seeds(n) + return Array.from({ length: n }, (_, i) => ({ + id: `doc-${i}`, + content: `document number ${i}`, + contentHash: `hash-${i}`, + embeddingModelId: MODEL_ID, + embedding: v[i], + metadata: { axis: i } + })) +} + +test('stringIdToU64: deterministic and well-distributed', (t) => { + const a1 = stringIdToU64('doc-1') + const a2 = stringIdToU64('doc-1') + t.is(a1, a2, 'deterministic') + t.unlike(a1, stringIdToU64('doc-2'), 'different strings differ') + + let threw = false + try { stringIdToU64('') } catch (_) { threw = true } + t.ok(threw, 'rejects empty') + + threw = false + try { stringIdToU64(42) } catch (_) { threw = true } + t.ok(threw, 'rejects non-string') +}) + +test('TurboVecHybridAdapter: save -> search -> delete -> reopen -> search', async (t) => { + const tmpDir = await tmp() + const indexPath = path.join(tmpDir, 'test.tvim') + + const store1 = new Corestore(tmpDir) + const adapter1 = new TurboVecHybridAdapter({ + store: store1, + dim: DIM, + bitWidth: 4, + indexPath, + embeddingModelId: MODEL_ID + }) + await adapter1.ready() + t.is(adapter1.idx.length, 0, 'starts empty') + + const docs = makeDocs(5) + const results = await adapter1.saveEmbeddings(docs) + t.is(results.length, 5, 'all 5 docs reported as fulfilled') + for (const r of results) { + t.is(r.status, 'fulfilled') + } + t.is(adapter1.idx.length, 5, 'index has all 5 vectors') + + // Search: query with vector axis-0 must retrieve doc-0 as top result. + { + const hits = await adapter1.search(null, new Float32Array(seeds(1)[0]), + { topK: 3 }) + t.is(hits.length, 3, 'returned 3 hits') + t.is(hits[0].id, 'doc-0', 'top-1 is doc-0 (self)') + t.ok(Math.abs(hits[0].score - 1.0) < 1e-5, 'score≈1.0') + t.is(hits[0].content, 'document number 0', 'content surfaced') + t.is(hits[0].metadata.axis, 0, 'metadata round-trips') + } + + // Delete: removing doc-0 means a subsequent search for axis-0 should + // surface another doc (not doc-0). + await adapter1.deleteEmbeddings(['doc-0']) + t.is(adapter1.idx.length, 4, 'index size shrank by 1') + { + const hits = await adapter1.search(null, new Float32Array(seeds(1)[0]), + { topK: 3 }) + t.absent(hits.some((h) => h.id === 'doc-0'), + 'doc-0 absent from results after delete') + } + + await adapter1.close() + // The HyperDB.bee() inside the adapter shares the hypercore with the + // store; close the corestore too so the rocksdb FD lock can be reacquired + // by the reopen-step Corestore below. + await store1.close() + t.ok(fs.existsSync(indexPath), + '.tvim file persisted (written by saveEmbeddings / deleteEmbeddings)') + + // Reopen: persistence survived close+reopen. + const store2 = new Corestore(tmpDir) + const adapter2 = new TurboVecHybridAdapter({ + store: store2, + dim: DIM, + bitWidth: 4, + indexPath, + embeddingModelId: MODEL_ID + }) + await adapter2.ready() + t.is(adapter2.idx.length, 4, 'index size restored from .tvim') + // Self-match for doc-1 (axis-1) must still be doc-1. + { + const hits = await adapter2.search(null, new Float32Array(seeds(2)[1]), + { topK: 1 }) + t.is(hits.length, 1) + t.is(hits[0].id, 'doc-1', 'self-match survives reopen') + } + // Reverse map was rebuilt: deleted doc-0 stays absent. + t.absent(adapter2._u64ToStringId.has(stringIdToU64('doc-0')), + 'reverse-map omits deleted doc') + + // reindex() is a no-op. + const reindexResult = await adapter2.reindex() + t.is(reindexResult.reindexed, false) + + await adapter2.close() + await store2.close() +})