Vettore

Vettore is a small vector toolkit for Elixir that keeps your data in ETS and uses Rust only where it helps: SIMD/GPU distance kernels, normalization, HNSW search, and MUVERA-style encodings.

Earlier versions leaned toward a Rust-owned in-memory database. That was fast, but it made the library feel less like an Elixir tool and more like an external engine with Elixir bindings. Vettore now chooses ETS as the canonical store on purpose:

The important idea is simple:

That choice is not the absolute fastest possible architecture. A fully Rust-owned vector database can beat ETS for large exact scans, but Vettore optimizes for a different kind of usefulness: simple integration with ordinary Elixir systems, with Rust kept as acceleration rather than ownership.

What You Get

Installation

def deps do
  [
    {:vettore, "~> 0.3.6"}
  ]
end

Quick Start

Create a collection, insert a few records, and search:

{:ok, collection} =
  Vettore.new(
    name: :documents,
    dimensions: 3,
    index: :flat,
    metric: :cosine,
    normalize: :l2
  )

:ok =
  Vettore.put_many(collection, [
    %{id: "east", vector: [1.0, 0.0, 0.0], metadata: %{kind: :axis}},
    %{id: "north", vector: [0.0, 1.0, 0.0]},
    %{id: "west", vector: [-1.0, 0.0, 0.0]}
  ])

{:ok, results} =
  Vettore.search(collection, [1.0, 0.0, 0.0], limit: 2)

Dimensions are configured per collection and are not limited to 384 or 768. Lengths such as 787, 1024, 1536, and 3072 work with Flat, HNSW, and GPU search; SIMD handles lengths that are not multiples of eight. Every stored vector and query must match the collection's dimensions. Available memory and GPU buffer limits still apply. The GPU top-k limit of 64 concerns returned results, not vector dimensions.

Results are %Vettore.Result{} structs:

%Vettore.Result{
  id: "east",
  value: "east",
  score: 1.0,
  distance: 0.0,
  metric: :cosine,
  metadata: %{kind: :axis}
}

Public API

New code can stay under the top-level Vettore module:

Vettore.new(opts)
Vettore.put(collection, embedding)
Vettore.put_many(collection, embeddings)
Vettore.get(collection, id)
Vettore.delete(collection, id)
Vettore.all(collection)
Vettore.search(collection, query, opts)
Vettore.funnel_search(collection, query, opts)
Vettore.quantized_search(collection, query, opts)
Vettore.multi_vector_search(collection, query_vectors, opts)
Vettore.hybrid_search(collection, query, opts)
Vettore.snapshot(collection, path)
Vettore.load_snapshot(path, opts)
Vettore.close(collection)

Vettore.new/1 creates a collection. Vettore.new/0 still creates the older compatibility database.

Score mode

Collections created by Vettore.new/1 use score: :similarity by default. This keeps the preferred collection API aligned with the compatibility API, but changes the Result.score scale used by earlier releases. Pass score: :raw when creating or loading a collection to preserve the previous behavior. Existing snapshots persist their score mode and keep their original scale.

Similarity scores are rounded display values: for L2, 1 / (1 + distance) can round distinct distances such as 1e-30 and 3e-30 to the same 1.0. Ranking still uses the raw metric. Read Result.distance or use score: :raw when those small differences matter to the caller.

Lifecycle ownership

A collection belongs to the process that calls Vettore.new/1. Its ETS table and native index are reclaimed automatically when that process exits, even if Vettore.close/1 was not called. Create long-lived collections from a long-lived owner such as a dedicated GenServer. Do not create a collection in a short-lived Task or request process and then hand it to another process: it will close when its creator terminates.

Call Vettore.close/1 when the owner no longer needs the collection to release resources immediately. The compatibility database returned by Vettore.new/0 uses the same creator-process ownership rule.

Choosing A Search Path

Start with the simplest thing that matches your job.

Use this When
search/3 with index: :flat Small data, tests, correctness baselines, exact results
search/3 with index: :hnsw Fast approximate search over larger collections
funnel_search/3 Matryoshka embeddings where early dimensions are meaningful
quantized_search/3 Cheap sign-bit candidate search before exact reranking
multi_vector_search/3 ColBERT-style late interaction over token/page vectors
hybrid_search/3 Combine candidate generators, then rerank once

The standalone helpers are nice while exploring. For production-style retrieval, hybrid_search/3 is usually the most ergonomic surface.

Flat search keeps ids and vectors in a Rust resource and scores the whole exact scan in one native call. ETS remains the canonical store for values, metadata, snapshots, and usability.

{:ok, collection} =
  Vettore.new(
    name: :exact_vectors,
    dimensions: 384,
    index: :flat,
    index_options: [
      gpu: :auto,
      gpu_min_size: 1_000_000,
      gpu_fallback: :cpu
    ],
    metric: :cosine,
    normalize: :l2
  )

{:ok, results} =
  Vettore.search(collection, query_vector, limit: 10)

On CPU, Flat stores all rows in one contiguous native matrix and scans each row with portable f32/f64 SIMD kernels, including stable cosine accumulation. With GPU enabled, its first eligible search creates a stable, device-resident matrix snapshot. Warm searches upload only the query, score query-by-all-rows in one batched dispatch, reduce top-k on the device in two stages, and read back only the final ids and scores. Snapshot sorting, device upload, dispatch, and readback run without holding the index read lock; only the immutable host copy blocks writers. Inserts, deletes, bulk loads, and snapshot restores invalidate that GPU snapshot; the next eligible query rebuilds it once. Deterministic matrix-build failures are remembered until the next mutation, so an unchanged unsupported row cannot force a full copy on every fallback query. Prefer put_many/2 when ingesting a large collection so the resident copy is built after the batch rather than between individual writes.

GPU Flat reduction is optimized for result limits up to 64. Larger limits use the contiguous SIMD path when gpu_fallback: :cpu; strict GPU mode returns {:error, :gpu_limit_too_large}. The exact scan remains exhaustive on either device, although normal f32 reduction-order differences can produce small score rounding differences. When candidates are mathematically tied or separated only by that rounding noise, CPU and GPU may return different ids inside the tied boundary group; scores and all unambiguous ranks remain equivalent.

HNSW keeps a native graph beside the ETS store. ETS remains canonical; the graph is an acceleration structure.

{:ok, collection} =
  Vettore.new(
    name: :ann_vectors,
    dimensions: 768,
    index: :hnsw,
    index_options: [
      m: 16,
      m0: 32,
      ef_construction: 100,
      ef_search: 64,
      max_level: 12
    ],
    metric: :cosine,
    normalize: :l2
  )

:ok = Vettore.put(collection, %{id: "doc-1", vector: embedding})

{:ok, results} =
  Vettore.search(collection, query_vector, limit: 10)

Supported HNSW metrics:

HNSW results are hydrated from ETS, so they contain the same value, metadata, score, and distance fields as exact flat results.

HNSW graph traversal remains CPU/SIMD because it is irregular and branch-heavy. When an adaptive or hybrid search performs a dense exact rerank after candidate generation, that candidate matrix can use the batched GPU scorer under the same global compute policy.

These helpers first find a candidate set, then rerank with full stored vectors. They are useful when you want to make the first pass cheaper without changing the canonical store.

When :candidates is omitted, Vettore uses ten times :limit up to a maximum of 1,000,000 candidates. Adaptive searches with limit > 1_000_000 are rejected; pass an explicit, smaller result limit rather than relying on an unbounded implicit candidate allocation.

Matryoshka Funnel

Funnel search scores progressively larger vector prefixes. It works best with models trained for Matryoshka or nested embeddings.

{:ok, results} =
  Vettore.funnel_search(collection, query_vector,
    stages: [128, 256, 384],
    candidates: 200,
    limit: 10
  )

Binary Quantized Candidates

Quantized search uses stored sign bits for a cheap Hamming-distance first pass, then reranks with the collection metric.

{:ok, results} =
  Vettore.quantized_search(collection, query_vector,
    candidates: 200,
    limit: 10
  )

Vettore generates binary_vector at insert time:

{:ok, embedding} = Vettore.get(collection, "doc-1")
embedding.binary_vector
# [7]

Sign bits are packed into unsigned 64-bit words; they are not stored as one integer per vector dimension.

hybrid_search/3 lets you combine candidate generators, union their ids, fetch the canonical records from ETS, and rerank once.

{:ok, results} =
  Vettore.hybrid_search(collection, query_vector,
    generators: [
      funnel: [stages: [128, 384], candidates: 200],
      quantized: [candidates: 200]
    ],
    rerank: :exact,
    limit: 10
  )

For HNSW collections, add :hnsw as a generator:

{:ok, results} =
  Vettore.hybrid_search(collection, query_vector,
    generators: [
      hnsw: [candidates: 100],
      quantized: [candidates: 200]
    ],
    rerank: :exact,
    limit: 10
  )

The same pipeline can rerank with late interaction:

{:ok, results} =
  Vettore.hybrid_search(collection, query_vector,
    generators: [quantized: [candidates: 200]],
    rerank: {:multi_vector, query_vectors},
    limit: 10
  )

That is the general pattern:

  1. Generate cheap candidates.
  2. Merge them by id.
  3. Rerank with the expensive scorer you actually care about.

Multi-vector search is for ColBERT-style retrieval: each record can hold many vectors, usually token vectors or page-patch vectors. A query also has many vectors. For each query vector, Vettore finds the best matching document vector and sums those best scores.

:ok =
  Vettore.put(collection, %Vettore.Embedding{
    id: "page-1",
    vectors: [
      [1.0, 0.0],
      [0.0, 1.0]
    ],
    metadata: %{source: "manual"}
  })

{:ok, results} =
  Vettore.multi_vector_search(
    collection,
    [[1.0, 0.0], [0.0, 1.0]],
    metric: :inner_product,
    limit: 10
  )

The lower-level scoring helper is available too:

Vettore.MultiVector.colbert_score(
  [[1.0, 0.0], [0.0, 1.0]],
  [[1.0, 0.0], [1.0, 1.0]],
  metric: :inner_product
)
# {:ok, 2.0}

Vettore.MultiVector.chamfer/3 is the same MaxSim-style operation under a more general name.

MUVERA-Style Encodings

MUVERA reduces multi-vector retrieval to fixed-dimensional vectors. The intended flow is:

  1. Encode query multi-vectors into a fixed-dimensional query vector.
  2. Encode document multi-vectors into fixed-dimensional document vectors.
  3. Search those vectors with inner product.
  4. Rerank candidates with exact MaxSim/Chamfer.
vectors = [
  [1.0, 0.0],
  [0.0, 1.0]
]

config = [
  num_repetitions: 1,
  num_simhash_projections: 4,
  seed: 42,
  projection_dimension: 2
]

{:ok, query_fde} = Vettore.Encoding.Muvera.encode_query(vectors, config)
{:ok, doc_fde} = Vettore.Encoding.Muvera.encode_document(vectors, config)

Config options:

Records And Storage

Records are %Vettore.Embedding{} structs or maps with equivalent keys.

%Vettore.Embedding{
  id: "doc-1",
  value: "optional external value",
  vector: [0.1, 0.2, 0.3],
  vectors: [[0.1, 0.2, 0.3], [0.0, 0.5, 0.5]],
  binary_vector: [7],
  metadata: %{source: "local"}
}

Useful details:

Each collection table is owned by a supervised Vettore worker, so it remains alive if the process that created the collection exits. Tables are :protected: all caller processes can read them directly and concurrently, with ETS read_concurrency enabled. Reads do not pass through the owner process. Perform writes through Vettore.put/2, Vettore.put_many/2, and Vettore.delete/2 so ETS and the native index stay in sync. Release resources deterministically when they are no longer needed:

:ok = Vettore.close(collection)

Closing is idempotent, releases both ETS and mirrored native index memory, and makes later operations return {:error, :closed}.

ETS collections can be snapshotted:

:ok = Vettore.snapshot(collection, "priv/snapshots/docs.ets")

{:ok, loaded} =
  Vettore.load_snapshot("priv/snapshots/docs.ets")

Snapshots store the ETS table: records, metadata, normalized vectors, binary vectors, multi-vectors, and collection config. Native indexes are rebuilt from ETS when loaded. Snapshot writes use a same-directory temporary file followed by a rename and include ETS integrity metadata. Loads validate the table type, schema, and every stored record before rebuilding the index; legacy public tables are restored as protected tables.

Snapshot integrity metadata detects accidental corruption; it is not an authentication mechanism. Load snapshots only from trusted sources.

You can load the same data with a different index:

{:ok, loaded} =
  Vettore.load_snapshot("priv/snapshots/docs.ets", index: :hnsw)

Supported load overrides are :name, :index, :index_options, :score, and :store. Structural fields such as dimensions, metric, normalization, and compression cannot be changed because stored vectors were already prepared with those settings. Name, index, index options, and score overrides are written back to the collection config, so they persist through later snapshots. The :store option selects the loader for that call and must be supplied again for a custom snapshot format.

ETS compression is available when you want to trade CPU for memory:

{:ok, collection} =
  Vettore.new(
    name: :compressed_documents,
    dimensions: 384,
    metric: :cosine,
    normalize: :l2,
    compressed: true
  )

Metrics And Scoring

Collection metrics:

Aliases accepted by Vettore.new/1:

with Vettore.Distance you can use directly all distance functions:

Vettore.Distance.l2([0.0, 0.0], [3.0, 4.0])
# {:ok, 5.0}

Vettore.Distance.cosine([1.0, 0.0], [0.0, 1.0])
# {:ok, 0.0}

Vettore.Distance.inner_product([1.0, 2.0], [3.0, 4.0])
# {:ok, 11.0}

Vector Formats And Nx Interchange

Vettore.Vector is the format boundary for dense vectors. It accepts numeric lists and little-endian f32 binaries directly. Conversion, normalization, metrics, and mean pooling return tagged results and validate that every coordinate is finite and representable as f32.

{:ok, stored} = Vettore.Vector.to_f32_binary([3.0, 4.0])
{:ok, 2} = Vettore.Vector.dimensions(stored)

{:ok, normalized} =
  Vettore.Vector.normalize(stored, :l2, as: :list)

{:ok, similarity} =
  Vettore.Vector.cosine(stored, [6.0, 8.0])

For boundaries that should carry their format explicitly, new/2 builds a %Vettore.Vector{data, dimensions, representation, shape} wrapper:

{:ok, vector} = Vettore.Vector.new([1, 2, 3], as: :f32_binary)
vector.representation
# :f32_binary

{:ok, matrix_vector} =
  Vettore.Vector.new([1, 2, 3, 4], as: :f32_binary, shape: {2, 2})

Vettore.Vector.shape(matrix_vector)
# {:ok, {2, 2}}

Equally sized vectors can be stacked, validated, and sliced without losing their row/column shape:

{:ok, matrix} =
  Vettore.Vector.stack([[1.0, 2.0], [3.0, 4.0]])

Vettore.Vector.validate_matrix_f32(matrix, 2)
# {:ok, {2, 2}}

Vettore.Vector.take_rows_f32(matrix, 2, [1, 0], as: :list)
# {:ok, [[3.0, 4.0], [1.0, 2.0]]}

Model tables can be pooled without decoding the full matrix into Elixir floats. The matrix is row-major little-endian f32 and repeated row ids are counted repeatedly, as expected for token sequences:

{:ok, embedding_binary} =
  Vettore.Vector.mean_pool_f32(model_matrix, dimensions, token_ids)

{:ok, embedding_list} =
  Vettore.Vector.mean_pool_f32(model_matrix, dimensions, token_ids, as: :list)

Vettore has no Nx dependency. Vettore.Interop.Nx detects Nx at runtime only when the host application already provides it. This keeps normal vector work independent while still allowing explicit interchange:

# In the host application, when Nx interop is wanted:
# {:nx, "~> 0.11"}

{:ok, tensor} = Vettore.Vector.to_nx(matrix, shape: {2, 2})
{:ok, binary} = Vettore.Vector.from_nx(tensor)

# A backend is an opaque host value; Vettore does not depend on its module.
{:ok, cuda_tensor} =
  Vettore.Interop.Nx.transfer(tensor, {EXLA.Backend, client: :cuda})

Without Nx in the host application, tensor conversions return {:error, :nx_not_available}; every list and f32-binary operation remains available.

Native CPU And GPU Execution

Rust SIMD on CPU is the default and requires no configuration. Vettore can also execute exact Flat searches, dense reranks, Vettore.Distance metrics/normalization, and Vettore.Vector metrics/normalization/mean-pooling through native wgpu compute shaders. This is independent of Nx and works through the platform graphics-compute API exposed by wgpu, such as Vulkan, Metal, or DirectX 12.

Inspect the runtime before enabling it:

Vettore.gpu_detected?()
# true or false

Vettore.gpu_info()
# {:ok, %{name: "...", backend: "vulkan", device_type: "integrated_gpu"}}

Enable GPU execution globally:

config :vettore,
  gpu: :auto,
  gpu_min_size: 1_000_000,
  gpu_fallback: :cpu

The modes are explicit:

Every supported call can override the application configuration:

Vettore.Distance.cosine(left, right,
  gpu: true,
  gpu_fallback: :error
)

Vettore.Vector.mean_pool_f32(model_matrix, dimensions, token_ids,
  as: :list,
  gpu: :auto,
  gpu_min_size: 8_192
)

The GPU runtime, device, and shader pipelines are initialized lazily and reused. Initialization tries compatible adapters in a deterministic order, preferring hardware and native compute backends over GL. If a device or pipeline cannot be initialized, the next adapter is tried. The initial setup is serialized; warm queries execute concurrently. Device-loss callbacks invalidate the runtime so the next query can initialize a replacement and rebuild its resident buffers. Calls may submit concurrently. Failed initialization is cached for ten seconds before another adapter probe, while device-loss errors invalidate a live runtime immediately. Readback waits default to ten seconds and can be set from 100 to 120,000 milliseconds with VETTORE_GPU_TIMEOUT_MS before the first GPU readback. Flat also pools query, score, top-k, parameter, and staging buffers per resident snapshot, with a bounded idle pool. Matrix rows are normalized once for stable f32 reductions, while per-row scale metadata and explicit validity flags preserve every supported metric and discard only genuinely overflowing rows. Inputs with a device-unsafe numeric range return to SIMD under the normal fallback policy rather than silently losing small coordinates.

Resident InnerProduct, NegativeInnerProduct, and L2Squared conservatively reject nonzero row/query scales below sqrt(f32::MIN_POSITIVE) (about 1.08e-19). Device-side subnormal scores can otherwise become zero and corrupt top-k order. These inputs use CPU recovery under gpu_fallback: :cpu; strict GPU mode returns {:error, :gpu_failed} (the native reason is "gpu numeric range unsupported"). Single-pair GPU metrics rescale on the host and are tested separately for representable subnormal outputs.

An in-flight native Flat search owns an immutable snapshot and may finish using the generation that preceded a concurrent write. That snapshot is never republished as the current cache; a subsequent search rebuilds it. Result hydration still reads the current ETS records and omits deleted ids. If a native writer panics while holding the index lock, later operations return "flat lock poisoned" or "hnsw lock poisoned": recreate the index from canonical records instead of reusing potentially inconsistent internal state.

Recovery is explicit; Vettore does not replace collection handles automatically. Pause new operations and drain in-flight calls through your collection-owning process, then snapshot the still-live ETS store and load a new collection:

path = "priv/snapshots/index-recovery.ets"
:ok = Vettore.snapshot(failed_collection, path)
{:ok, replacement} = Vettore.load_snapshot(path)
# Publish `replacement` to every caller before resuming operations.
# After all callers have stopped using the old handle:
close_result = Vettore.close(failed_collection)

Snapshotting reads canonical ETS records without acquiring the poisoned native lock; loading rebuilds a fresh native index and a separate ETS store, preserving vectors, values, metadata, and collection settings. Keep the old collection if snapshotting or loading fails. On the poisoned handle, close_result is {:error, "flat lock poisoned"} or {:error, "hnsw lock poisoned"}, but its ETS store is still closed. Release all old handles to let the native resource be reclaimed. Do not close the old collection before taking the snapshot, and reconcile any failed writes against the restored canonical records before retrying them.

Single-pair metric calls still upload both vectors, so SIMD can remain faster for that shape. Flat search is the throughput-oriented GPU path: the matrix upload is amortized across warm queries, top-k is reduced completely in GPU memory, and only the final k row ids and scores cross back to the host. Its per-chunk top-k stage uses a 16-lane reduction instead of one serial thread. Use the dedicated benchmark to measure cold upload and warm-query latency on the target adapter:

VETTORE_BENCH_BATCH=25000 \
VETTORE_BENCH_DIMENSIONS=384 \
mix run bench/gpu_flat_bench.exs

Mean pooling still gathers and validates only selected rows on the host before uploading them, avoiding transfer of the complete model matrix. Selected columns are scaled on the host and accumulated by 256 GPU lanes per output coordinate, which avoids representable means overflowing in intermediate f32 sums. HNSW traversal remains on CPU, while its optional exact rerank can use the batched GPU path.

Choosing CPU, automatic GPU, or forced GPU

Workload Recommended policy Benefit and cost
Large, read-heavy Flat index with repeated queries gpu: :auto, tuned gpu_min_size Warm queries amortize the upload and keep top-k on the device. The first eligible query pays snapshot, upload, and pipeline latency.
Flat index with frequent inserts or deletes CPU, or a higher gpu_min_size Every effective mutation invalidates the resident matrix; the next GPU query must copy and upload it again. Batch ingestion with put_many/2 limits rebuilds.
Single-pair metrics or small batches CPU Transfer, dispatch, and readback overhead normally exceeds the arithmetic saved by the GPU.
Flat searches requesting more than 64 results gpu_fallback: :cpu The current GPU reduction is intentionally bounded at 64; forced strict GPU mode returns {:error, :gpu_limit_too_large}.
HNSW collections CPU traversal, optional automatic GPU rerank The graph walk is CPU-native. Only exact batched reranking can move to the GPU.
Hosts without a reliable adapter gpu: :auto, gpu_fallback: :cpu Availability is preserved, but fallback queries run at CPU speed. Failed initialization is retried after ten seconds; readback uses the configured timeout.
GPU validation, benchmarking, or capacity tests gpu: true, gpu_fallback: :error Proves that work reached the adapter and exposes failures immediately; it deliberately gives up transparent CPU recovery.

A resident Flat index keeps its native CPU matrix and an additional prepared copy in device memory, plus row metadata and bounded scratch buffers. Budget at least rows * dimensions * 4 bytes of GPU memory for the matrix itself. Use :auto as the production default and measure gpu_min_size on the target adapter: the included benchmark reports cold-build and warm-query latency separately. Exact CPU and GPU scans cover the same rows, but f32 reduction order can change ids only within a numerically tied top-k boundary. Overflowing rows are skipped consistently on both devices rather than aborting the whole batch.

Normalization

Supported normalization modes:

Vettore.Distance.normalize([3.0, 4.0], :l2)
# {:ok, [0.6, 0.8]}

Collection defaults:

Inserted vectors and query vectors are prepared with the same collection normalization mode.

Other Helpers

MMR reranking:

initial = [{"a", 0.9}, {"b", 0.8}, {"c", 0.1}]
embeddings = [{"a", [1.0, 0.0]}, {"b", [1.0, 0.0]}, {"c", [0.0, 1.0]}]

Vettore.Distance.mmr_rerank(initial, embeddings, :cosine, 0.5, 2)
# {:ok, [{"a", 0.9}, {"c", 0.1}]}

MMR returns {:error, :metric_overflow} when a pairwise diversity metric exceeds the f32 output range. Its pairwise comparisons determine the reranking objective; an overflowing comparison therefore fails the call rather than returning a partial result. Top-k query scoring separately skips candidates whose query metric overflows.

Sign compression:

Vettore.Distance.compress_f32_vector([1.0, -2.0, 0.0])
# [5]

left = Vettore.Distance.compress_f32_vector([1.0, -2.0, 0.0])
right = Vettore.Distance.compress_f32_vector([-1.0, -2.0, 0.0])
Vettore.Distance.packed_hamming(left, right, 3)
# {:ok, 1.0}

Compatibility API

The old top-level API still exists as a small compatibility layer backed by ETS collections:

db = Vettore.new()

{:ok, "legacy"} =
  Vettore.create_collection(db, "legacy", 2, :cosine)

{:ok, "a"} =
  Vettore.insert(db, "legacy", %Vettore.Embedding{
    value: "a",
    vector: [1.0, 0.0]
  })

{:ok, results} =
  Vettore.similarity_search(db, "legacy", [1.0, 0.0], limit: 1)

New code should prefer the collection-style top-level API: Vettore.new/1, Vettore.put/2, and Vettore.search/3.

Development

The test-only ex_fastembed dependency generates the committed BAAI/bge-small-en-v1.5 fixture under test/fixtures. The normal suite uses those real vectors offline to exercise exact search, HNSW, funnel search, quantized search, multi-vector search, hybrid search, snapshot reloads, and strict resident-GPU parity against the SIMD Flat path when an adapter is available. GPU-backed CI requires that adapter and verifies that the resident matrix is built once and reused across all real-embedding queries.

CI additionally runs fresh model inference, checks it against the committed artifact, and searches the newly produced vectors through strict resident GPU Flat with no CPU fallback. Enable that slower check locally with:

VETTORE_BUILD=1 VETTORE_TEST_EX_FASTEMBED=1 \
  mix test test/ex_fastembed_integration_test.exs

Both committed and freshly inferred embeddings are checked against independent f64 metric references through list and f32-binary inputs. The suite also checks cache invalidation after writes, concurrent warm searches, and Flat/HNSW plus adaptive retrieval on fresh embeddings. Rust tests cover SIMD tail dimensions, tiny distances, adapter fallback, simulated synchronization errors, and device destruction/recovery. Pure GPU preparation and selection policy are included in the Rust coverage gate; the driver boundary is exercised by integration tests.

When switching an existing checkout from precompiled to locally built NIFs, first run MIX_ENV=test VETTORE_BUILD=1 mix compile --force so the tests load the current native source.

Regenerate the fixture after an intentional model or dependency update with:

MIX_ENV=test VETTORE_BUILD=1 \
  mix run test/support/generate_fastembed_fixture.exs

Build the Rust crate locally with Rust 1.91 or newer by setting VETTORE_BUILD=1:

VETTORE_BUILD=1 VETTORE_GPU_ALLOW_SOFTWARE=1 mix test --cover
cargo test --manifest-path native/vettore/Cargo.toml

Run the deterministic latency-and-overlap matrix for every search mode with:

VETTORE_BUILD=1 mix run bench/search_modes_bench.exs

See bench/performance.md for the full search, metric, MUVERA, MaxSim, and ETS benchmark matrix.

Without that variable, Vettore uses the published precompiled NIF for the current package version. See RELEASE.md for the full release verification and checksum workflow.