TurboVec (TurboVec v0.2.0)

Copy Markdown View Source

In-process vector search over the turbovec Rust crate (IdMapIndex).

{:ok, results} =
  TurboVec.new!(dim: 1536, bit_width: 4)
  |> TurboVec.add(vectors, ids)
  |> TurboVec.search(query, k: 10, allowlist: tenant_ids)

results is [{id, score}, ...], length <= k, best first. Vectors and queries are a native-endian f32 binary, or an Nx.Tensor of type {:f, 32}.

The index handle is a node-local NIF resource: it cannot cross nodes, survive :erlang.term_to_binary/1, or live in ETS across restarts. Move an index between nodes or lifetimes with write/2 and load/1.

In-memory vector binaries are native-endian f32; the on-disk .tvim format is little-endian. Do not checksum one against the other.

After a NIF panic the recovered index may be half-mutated; a subsequent sync persists that state.

Summary

Types

A native-endian f32 binary, or (with Nx) an Nx.Tensor of type {:f, 32}.

Functions

Adds vectors with stable u64 ids. Returns the handle so it can be piped. Error atomicity: a rejected batch leaves the index exactly as it was — no cleanup needed.

Bits per coordinate (2, 3, or 4). Infallible.

Whether id is in the index. Bare boolean — the one deliberate break from ok/error tuples. An integer outside u64 returns false: it cannot be present. The first id lookup after load/1 builds the id map, O(n).

Number of vectors in the index. Infallible.

Vector dimensionality. Infallible; always committed.

Loads a write/2 snapshot or a sync/2 container. Corruption and foreign-writer conflicts both surface as {:io_error, :invalid_data, msg} — distinguishable only by message (upstream limitation).

Same as load/1, but returns the handle or raises TurboVec.Error.

Creates an index. dim is required (positive multiple of 8, ≤ 16384); bit_width is 2, 3, or 4 (default 4).

Same as new/1, but returns the handle or raises TurboVec.Error.

Removes one id. Returns the handle. {:error, :not_found} if absent.

Top-k search. Returns up to k results — k is clamped to the index (and allowlist) size, so length(results) <= k; an empty index returns {:ok, []}. Scores are length-renormalized inner product, equal to cosine only for L2-normalized vectors.

Durable incremental save. Returns the handle.

Full durable snapshot (atomic replace). Returns the handle.

Types

index()

@type index() :: reference()

vector_input()

@type vector_input() :: binary() | struct()

A native-endian f32 binary, or (with Nx) an Nx.Tensor of type {:f, 32}.

Functions

add(index, vectors, ids)

@spec add(index(), vector_input(), [non_neg_integer()]) :: index() | {:error, term()}

Adds vectors with stable u64 ids. Returns the handle so it can be piped. Error atomicity: a rejected batch leaves the index exactly as it was — no cleanup needed.

bit_width(index)

@spec bit_width(index()) :: 2 | 3 | 4

Bits per coordinate (2, 3, or 4). Infallible.

contains?(index, id)

@spec contains?(index(), integer()) :: boolean()

Whether id is in the index. Bare boolean — the one deliberate break from ok/error tuples. An integer outside u64 returns false: it cannot be present. The first id lookup after load/1 builds the id map, O(n).

count(index)

@spec count(index()) :: non_neg_integer()

Number of vectors in the index. Infallible.

dim(index)

@spec dim(index()) :: pos_integer()

Vector dimensionality. Infallible; always committed.

load(path)

@spec load(Path.t()) :: {:ok, index()} | {:error, term()}

Loads a write/2 snapshot or a sync/2 container. Corruption and foreign-writer conflicts both surface as {:io_error, :invalid_data, msg} — distinguishable only by message (upstream limitation).

load!(path)

@spec load!(Path.t()) :: index()

Same as load/1, but returns the handle or raises TurboVec.Error.

new(opts)

@spec new(keyword()) :: {:ok, index()} | {:error, term()}

Creates an index. dim is required (positive multiple of 8, ≤ 16384); bit_width is 2, 3, or 4 (default 4).

new!(opts)

@spec new!(keyword()) :: index()

Same as new/1, but returns the handle or raises TurboVec.Error.

remove(index, id)

@spec remove(index(), non_neg_integer()) :: index() | {:error, term()}

Removes one id. Returns the handle. {:error, :not_found} if absent.

search(index, query, opts)

@spec search(index(), vector_input(), keyword()) ::
  {:ok, [{non_neg_integer(), float()}]} | {:error, term()}

Top-k search. Returns up to k results — k is clamped to the index (and allowlist) size, so length(results) <= k; an empty index returns {:ok, []}. Scores are length-renormalized inner product, equal to cosine only for L2-normalized vectors.

sync(index, path)

@spec sync(index(), Path.t()) :: index() | {:error, term()}

Durable incremental save. Returns the handle.

First call to a fresh path writes the whole file; the index stays bound to the path. One writer per path — a concurrent writer is detected at the next sync, not locked out. Holds the write lock for the disk IO: searches and mutations queue until it returns.

write(index, path)

@spec write(index(), Path.t()) :: index() | {:error, term()}

Full durable snapshot (atomic replace). Returns the handle.

Do not write/2 to a path another handle is incrementally syncing. write then sync on the same handle rebuilds (the snapshot is unclaimed, not foreign). Two handles incrementally syncing one path is not supported — the lagging handle fails at its next sync. Holding the read lock: mutations queue for the duration, and once one queues, new searches may block behind it (no RwLock fairness is guaranteed). Prefer sync/2 for routine durability; call write/2 in quiet periods.