FaissEx

Elixir NIF bindings for FAISS — Facebook's library for efficient similarity search and clustering of dense vectors.

Binds to FAISS via its official C API (libfaiss_c). No external dependencies beyond elixir_make.

Features

Prerequisites

There are no precompiled binaries: the first mix compile builds FAISS and the NIF from source as shared libraries, cached at ~/.cache/faiss_ex/ (~5–15 min, once per FAISS version). Install the platform packages below first.

macOS setup

# Compiler (if you don't have Xcode / command line tools yet)
xcode-select --install

# Build tools + OpenMP
brew install cmake libomp

Linux setup (Debian/Ubuntu)

sudo apt-get update
sudo apt-get install -y build-essential cmake libopenblas-dev

Linux setup (Fedora/RHEL)

sudo dnf install -y gcc gcc-c++ make cmake openblas-devel

GPU (optional)

Installation

Add faiss_ex to your dependencies in mix.exs:

def deps do
  [
    {:faiss_ex, "~> 0.4.0"}
  ]
end

Then fetch and compile:

mix deps.get
mix compile

Option A: Build FAISS from source (default)

By default, the first mix compile clones FAISS from GitHub and builds it from source. This takes ~5-15 minutes but requires no pre-installed FAISS. The result is cached at ~/.cache/faiss_ex/ — subsequent builds are fast.

# Just works — no extra setup needed (besides cmake and a C++ compiler)
mix compile

Option B: Use a system-installed FAISS

If you already have FAISS installed (via a package manager or custom build), point FAISS_PREFIX at it to skip building from source entirely:

# macOS (Homebrew)
brew install faiss
FAISS_PREFIX=$(brew --prefix faiss) mix compile

# Ubuntu/Debian
sudo apt install libfaiss-dev
FAISS_PREFIX=/usr mix compile

# Conda
conda install -c pytorch faiss-cpu
FAISS_PREFIX=$CONDA_PREFIX mix compile

# Custom install location
FAISS_PREFIX=/opt/faiss mix compile

FAISS_PREFIX should point to a directory containing include/ (with c_api/ headers) and lib/ (with libfaiss and libfaiss_c shared libraries).

Note: Most system packages ship libfaiss but not libfaiss_c (the C API wrapper). If you get linker errors about libfaiss_c, you may need to build from source — just omit FAISS_PREFIX and let FaissEx handle it.

Build configuration

Variable Default Description
FAISS_PREFIX (unset) Path to system FAISS install. When set, skips building from source
FAISS_OPT_LEVEL dd SIMD optimization level: dd or generic (see below). Only used when building from source
USE_CUDA false Set to true to enable GPU support (CUDA 12+)
FAISS_GIT_REPO https://github.com/facebookresearch/faiss.git FAISS git repository, https:// only (ignored when FAISS_PREFIX is set)
FAISS_GIT_REV v1.15.1 FAISS version tag or commit (ignored when FAISS_PREFIX is set)
FAISS_GIT_SHA commit of v1.15.1 Commit FAISS_GIT_REV must resolve to; checked after cloning and on every build. Empty skips the check (with a warning)
LIBOMP_PREFIX brew --prefix libomp macOS only: the libomp keg FAISS and the NIF use

SIMD optimization

FAISS uses SIMD instructions for fast distance computation. The default, dd (dynamic dispatch), compiles every SIMD variant into libfaiss and picks the best one for the running CPU at load time: AVX2 or AVX-512 on x86-64, NEON or SVE on aarch64. One build is therefore fast on every CPU of its architecture.

Value Description
dd Runtime SIMD dispatch (default)
generic Portable code only, no SIMD dispatch

FAISS's avx2/avx512/sve levels are rejected: they build separately named libraries, and dd already dispatches to the same code paths.

FAISS_OPT_LEVEL=generic mix compile

Each FAISS build configuration (opt level × CPU/GPU) gets its own cached build directory, ~/.cache/faiss_ex/faiss-<rev>/build-<level>-<cpu|gpu>/, so switching FAISS_OPT_LEVEL or USE_CUDA re-links the NIF against the matching build without clearing the cache.

Runtime dependencies (releases & Docker)

The compiled artifact in priv/ contains the NIF plus shared libfaiss / libfaiss_c libraries, which link against system libraries at runtime. If you build in one environment and run in another (multi-stage Docker builds, mix release to a different host), the runtime image/host also needs:

Build and runtime must use the same OS/architecture — the cached FAISS build is not portable across platforms.

Usage

All functions accept plain lists (single vector) or lists of lists (batch):

# Single vector
FaissEx.Index.add(index, [1.0, 2.0, 3.0])
# Batch of vectors
FaissEx.Index.add(index, [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])

Hot paths also accept raw binaries directly — see Binary API.

Creating an index and searching

# Create a flat L2 index with 128 dimensions
{:ok, index} = FaissEx.Index.new(128, "Flat")

# Add vectors
:ok = FaissEx.Index.add(index, [[0.1, 0.2, ...], [0.3, 0.4, ...]])

# Search for 5 nearest neighbors
{:ok, %{distances: distances, labels: labels}} = FaissEx.Index.search(index, query, 5)
# distances: [[float]] — n rows of k distances
# labels: [[integer]] — n rows of k vector indices

Index types

FAISS uses index factory strings to create different index types:

# Flat (exact search, no training needed)
{:ok, index} = FaissEx.Index.new(128, "Flat")

# IVF with flat quantizer (needs training)
{:ok, index} = FaissEx.Index.new(128, "IVF256,Flat")

# HNSW graph-based index
{:ok, index} = FaissEx.Index.new(128, "HNSW32")

# Inner product metric instead of L2
{:ok, index} = FaissEx.Index.new(128, "Flat", metric: :inner_product)

Training (IVF, PQ indexes)

Some index types require training before adding vectors:

{:ok, index} = FaissEx.Index.new(128, "IVF256,Flat")

# Train on representative data (list of lists)
training_data = for _ <- 1..10_000, do: for(_ <- 1..128, do: :rand.uniform())
:ok = FaissEx.Index.train(index, training_data)

# Now add and search
:ok = FaissEx.Index.add(index, training_data)
{:ok, results} = FaissEx.Index.search(index, query, 10)

Adding vectors with IDs

{:ok, index} = FaissEx.Index.new(128, "IDMap,Flat")

vectors = for _ <- 1..100, do: for(_ <- 1..128, do: :rand.uniform())
ids = Enum.to_list(1000..1099)
:ok = FaissEx.Index.add_with_ids(index, vectors, ids)

Saving and loading

:ok = FaissEx.Index.to_file(index, "/tmp/my_index.faiss")
{:ok, loaded} = FaissEx.Index.from_file("/tmp/my_index.faiss")

Only load index files you trust. FAISS's file reader does limited validation of the binary input. An index file can describe an on-disk IVF index whose inverted lists live at a path stored inside the file; loading it memory-maps that path (read-write by default), so a crafted file can read and modify any file the BEAM process can open — and malformed lengths can still crash the VM. When on-disk IVF indexes are involved, pass io_flags: 6 (read-only, and inverted lists resolved next to the index file instead of at the stored path); it narrows the risk but does not make an untrusted file safe:

{:ok, loaded} = FaissEx.Index.from_file(path, io_flags: 6)

:io_flags accepts 0, 2, 4, or 6; see FaissEx.Index.from_file/2.

Cloning and resetting

{:ok, copy} = FaissEx.Index.clone(index)
:ok = FaissEx.Index.reset(index)  # clear all vectors

Index properties

{:ok, dim} = FaissEx.Index.dim(index)
{:ok, count} = FaissEx.Index.ntotal(index)
{:ok, trained?} = FaissEx.Index.trained?(index)

Reconstructing vectors

{:ok, vectors} = FaissEx.Index.reconstruct(index, [0, 5, 10])
# vectors: [[float]] — 3 rows of dim floats

Computing residuals

Compute the difference between vectors and their quantized reconstructions:

{:ok, index} = FaissEx.Index.new(4, "Flat")
vectors = [[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]]
:ok = FaissEx.Index.add(index, vectors)

{:ok, residuals} = FaissEx.Index.compute_residuals(index, vectors, [0, 1])
# For a flat index, residuals are zero (exact reconstruction)

Search with multiple query vectors at once for better throughput:

{:ok, index} = FaissEx.Index.new(128, "Flat")
:ok = FaissEx.Index.add(index, corpus_vectors)

# Search 100 queries at once
{:ok, %{distances: distances, labels: labels}} = FaissEx.Index.search(index, batch_of_queries, 10)
# distances: [[float]] — 100 rows of 10 distances
# labels: [[integer]] — 100 rows of 10 vector IDs

Binary API (zero-copy hot path)

Encoding lists to the flat f32 layout FAISS expects dominates the cost of batch operations — adding 10,000×128 vectors as lists spends ~14 ms encoding (plus GC) around a ~0.4 ms FAISS call. When your vectors already arrive as binaries (an HTTP response, a database blob, a file), pass them straight through and skip all of it:

# Embeddings API responses are already packed floats — decode base64, done.
# Layout contract: f32-native, row-major, dim floats per vector.
embeddings_bin = Base.decode64!(response.body["embedding_b64"])

{:ok, index} = FaissEx.Index.new(1536, "Flat")
:ok = FaissEx.Index.add(index, embeddings_bin)   # n inferred from byte_size

add/2, add_with_ids/3, train/2, search/3, compute_residuals/3, and FaissEx.Clustering.train/2 / get_cluster_assignment/2 accept binaries anywhere they accept lists (IDs/keys are s64-native; mixing forms is fine). For zero-decode results, opt in with the _binary variants — the list-returning functions never change shape based on input type:

{:ok, %{n: n, distances: dist_bin, labels: labels_bin}} =
  FaissEx.Index.search_binary(index, queries_bin, 10)

{:ok, vectors_bin} = FaissEx.Index.reconstruct_binary(index, ids_bin)
{:ok, residuals_bin} = FaissEx.Index.compute_residuals_binary(index, xs_bin, ids_bin)
{:ok, centroids_bin} = FaissEx.Clustering.get_centroids_binary(trained)

# Decode when needed
labels = for <<i::signed-native-64 <- labels_bin>>, do: i

Example: Semantic Search with External Embeddings

FaissEx works with embeddings from any source — OpenAI, Cohere, Bumblebee, etc. Just pass them as lists of floats:

# Embeddings from any source (OpenAI, Cohere, Voyage, etc.)
embeddings = [
  [0.023, -0.041, 0.067, ...],  # 1536-dim for text-embedding-3-small
  [0.011, -0.032, 0.089, ...],
  # ...
]

dim = length(hd(embeddings))

# For cosine similarity, normalize vectors to unit length first
normalize = fn vec ->
  norm = :math.sqrt(Enum.reduce(vec, 0.0, fn x, acc -> acc + x * x end))
  Enum.map(vec, &(&1 / norm))
end

normalized = Enum.map(embeddings, normalize)

{:ok, index} = FaissEx.Index.new(dim, "Flat", metric: :inner_product)
:ok = FaissEx.Index.add(index, normalized)

# Query with a new embedding
query = normalize.(query_floats)
{:ok, %{distances: scores, labels: indices}} = FaissEx.Index.search(index, query, 10)
# scores: [[float]] — similarity scores (higher = more similar)
# indices: [[integer]] — vector IDs

Choosing an Index

Index Training Memory Speed Recall Best for
"Flat" No High Slow (brute force) 100% < 100K vectors, exact results
"IVFx,Flat" Yes High Fast ~95% 100K-10M vectors
"HNSWx" No High Very fast ~99% Read-heavy workloads
"IVFx,PQy" Yes Low Fast ~90% 1M+ vectors, memory constrained
"IVFx,SQfp16" Yes Medium Fast ~97% 1M+ vectors, good recall/memory balance
"Flat" + :inner_product No High Slow 100% Cosine similarity (normalize first)

x = number of IVF clusters (typical: 4*sqrt(n)), y = PQ subquantizers (typical: dim/4).

Practical guidelines

# Cosine similarity search — normalize vectors to unit length, then use inner product
{:ok, index} = FaissEx.Index.new(128, "Flat", metric: :inner_product)
:ok = FaissEx.Index.add(index, normalized_vectors)

IDMap wrapper

FAISS indexes assign sequential IDs by default (0, 1, 2...). Wrap with "IDMap," to use your own IDs:

# Custom IDs with any index type
{:ok, index} = FaissEx.Index.new(128, "IDMap,Flat")
{:ok, index} = FaissEx.Index.new(128, "IDMap,HNSW32")

:ok = FaissEx.Index.add_with_ids(index, vectors, [42, 99, 1337])

{:ok, %{labels: labels}} = FaissEx.Index.search(index, query, 5)
# labels now contain your custom IDs (42, 99, 1337...)

K-means Clustering

Basic clustering

# Cluster 128-dimensional vectors into 10 groups
{:ok, clustering} = FaissEx.Clustering.new(128, 10)

data = for _ <- 1..5000, do: for(_ <- 1..128, do: :rand.uniform())
{:ok, trained} = FaissEx.Clustering.train(clustering, data)

{:ok, centroids} = FaissEx.Clustering.get_centroids(trained)
# centroids: [[float]] — 10 rows of 128 floats, center of each cluster

Assigning vectors to clusters

# Which cluster does each vector belong to?
{:ok, %{labels: labels, distances: distances}} =
  FaissEx.Clustering.get_cluster_assignment(trained, data)
# labels: [[integer]] — 5000 rows of 1 cluster ID each
# distances: [[float]] — 5000 rows of 1 distance each

Clustering for preprocessing

Use clustering to build an IVF quantizer or to reduce a dataset:

# Cluster embeddings, then keep only vectors near centroids
{:ok, clustering} = FaissEx.Clustering.new(dim, num_clusters)
{:ok, trained} = FaissEx.Clustering.train(clustering, embeddings)
{:ok, %{labels: labels, distances: dists}} =
  FaissEx.Clustering.get_cluster_assignment(trained, embeddings)

# Filter to vectors within distance threshold
threshold = 10.0
nearby = Enum.zip(embeddings, List.flatten(dists))
  |> Enum.filter(fn {_vec, dist} -> dist < threshold end)
  |> Enum.map(&elem(&1, 0))

Thread Safety

Indexes can be shared freely across processes — the NIF layer guards every index (and clustering object) with a read-write lock:

For write-heavy concurrent workloads, note that mutations are serialized per index — shard across multiple indexes if you need parallel ingestion.

Concurrency tuning

Waiters and +SDio. Callers waiting on a locked index share the dirty-IO pool (10 threads by default) with file I/O. If you expect more concurrent waiters than that — many processes searching an index that is being retrained — raise it: +SDio 64 in vm.args, or ELIXIR_ERL_OPTIONS="+SDio 64".

OpenMP threads. FAISS parallelizes each call with OpenMP, one thread per core by default — even a single-query search on a large index. Many concurrent callers then multiply those teams across the dirty schedulers and oversubscribe the CPU. Set the thread count per workload:

# config/config.exs (or prod.exs) — many concurrent small requests.
# Not runtime.exs: the value is read when the NIF loads, which in a release
# happens before runtime.exs runs.
config :faiss_ex, omp_threads: 1

# or at runtime (e.g. in your Application.start/2); 0 restores the default
FaissEx.set_omp_threads(1)

Measured on an Apple M1 Max (Flat index, 100k × 128, k = 10):

Workload Default threads omp_threads: 1
10 processes × 200 single-query searches 1529 ms 586 ms
One 1000-query batch search 78 ms 123 ms

Keep the default for few large batches (bulk add, train, batch search); use 1 for many concurrent small requests.

On Linux, also:

Architecture

FaissEx uses Erlang NIF (Native Implemented Functions) to call FAISS through its C API:

Elixir (FaissEx.Index) → NIF stubs (FaissEx.NIF) → C (faiss_ex_nif.c) → libfaiss_c → libfaiss (C++)

Troubleshooting

OpenMP not found during build

On macOS, install libomp:

brew install libomp

On Linux, OpenMP ships with gcc; make sure build-essential (or gcc-c++) is installed.

Could NOT find BLAS / LAPACK not found during build (Linux)

FAISS needs a BLAS implementation:

# Debian/Ubuntu
sudo apt-get install -y libopenblas-dev

# Fedora/RHEL
sudo dnf install -y openblas-devel

macOS never needs this — the Accelerate framework provides BLAS.

Library not loaded: libfaiss_c.dylib

The shared libraries weren't installed correctly. Clean and rebuild:

mix clean
mix compile

Build takes too long

The first build compiles FAISS from source. This is cached at ~/.cache/faiss_ex/. To force a clean FAISS rebuild:

rm -rf ~/.cache/faiss_ex/
mix compile

cmake not found

# macOS
brew install cmake

# Ubuntu/Debian
sudo apt install cmake

GPU Support

Building with CUDA

USE_CUDA=true mix compile

This requires the CUDA toolkit (nvcc on PATH; headers under CUDA_HOME, default /usr/local/cuda). The build adds -DFAISS_ENABLE_GPU=ON to cmake and compiles the GPU NIF functions. Set CUDAARCHS (read by CMake) to build only for your GPU's compute capability — much faster than the default list:

USE_CUDA=true CUDAARCHS=121 mix compile   # e.g. NVIDIA GB10 (DGX Spark)

Verified on a DGX Spark (GB10, aarch64, CUDA 13.0): full suite including --include cuda passes, and GPU search results match CPU.

Note: FAISS 1.12.0+ (including the pinned v1.15.1) requires CUDA 12 or newer — CUDA 11 support was dropped upstream. If you are stuck on CUDA 11, build with FAISS_GIT_REV=v1.11.0.

Moving indexes to GPU

# Create index on CPU first
{:ok, index} = FaissEx.Index.new(128, "Flat")
:ok = FaissEx.Index.add(index, vectors)

# Move to GPU (device 0)
{:ok, gpu_index} = FaissEx.Index.cpu_to_gpu(index, 0)

# Search on GPU (faster for large datasets)
{:ok, results} = FaissEx.Index.search(gpu_index, query, 10)

# Move back to CPU (e.g. for saving to file)
{:ok, cpu_index} = FaissEx.Index.gpu_to_cpu(gpu_index)
:ok = FaissEx.Index.to_file(cpu_index, "/tmp/index.faiss")

GPU at index creation time

{:ok, gpu_index} = FaissEx.Index.new(128, "Flat", device: {:cuda, 0})

Checking GPU availability

num_gpus = FaissEx.num_gpus()
# Returns 0 if not compiled with CUDA or no GPUs available

GPU lifecycle notes

Running GPU tests

USE_CUDA=true mix test --include cuda

Benchmarks

Measured on Apple M4 Max (16 cores, 64 GB RAM), Elixir 1.20.0, OTP 29, FAISS v1.14.3. Index: 10,000 vectors, 128 dimensions, Flat L2.

Operation ips avg median 99th %
reconstruct 1 vector 268.99 K 3.72 μs 3.50 μs 8.83 μs
add 1 vector 92.00 K 10.87 μs 10.42 μs 15.83 μs
reconstruct 10 vectors 38.68 K 25.85 μs 23.83 μs 40.67 μs
compute_residuals 1 vector 18.02 K 55.48 μs 52.54 μs 119.36 μs
compute_residuals 10 vectors 9.80 K 102.08 μs 99.71 μs 184.31 μs
search k=10, 1 query 8.98 K 111.33 μs 109.67 μs 140.55 μs
reconstruct 100 vectors 3.78 K 264.60 μs 257.75 μs 416.53 μs
kmeans assign 100 vectors 3.72 K 268.60 μs 258.67 μs 482.14 μs
search k=10, 10 queries 3.07 K 325.63 μs 322.13 μs 385.24 μs
add 10000 vectors (binary) 2.84 K 352.32 μs 407.63 μs 530.05 μs
compute_residuals 100 vectors 1.75 K 570.45 μs 563.38 μs 774.81 μs
search k=10, 100 queries (binary) 0.50 K 1.99 ms 1.95 ms 2.24 ms
search k=10, 100 queries 0.47 K 2.14 ms 2.12 ms 2.43 ms
kmeans k=50, 1000 vectors 0.25 K 3.96 ms 4.31 ms 4.93 ms
kmeans k=10, 1000 vectors 0.25 K 4.06 ms 3.93 ms 4.92 ms
add 1000 vectors 0.090 K 11.03 ms 11.28 ms 21.59 ms
add 10000 vectors 0.069 K 14.41 ms 13.41 ms 36.50 ms

Note: the list-input batch add scenarios are dominated by list→binary encoding and BEAM garbage collection of the benchmark corpus, not by FAISS — which is why add 1000 and add 10000 land so close together. The (binary) rows are the same operations through the Binary API: adding 10,000 vectors drops from ~14.4 ms to ~0.35 ms (41×) and allocates ~0.2 KB instead of ~938 KB on the BEAM side.

Run benchmarks yourself:

mix run bench/faiss_ex_bench.exs

GPU (DGX Spark)

Measured 2026-11-01 on an NVIDIA DGX Spark: GB10 GPU (compute capability 12.1) and 20-core Grace CPU (10× Cortex-X925 + 10× Cortex-A725), unified memory, driver 580.173.02, CUDA 13.0, Elixir 1.20.2 / OTP 29, FAISS v1.15.1 (dd, OpenBLAS 0.3.34 with OpenMP). Data: 500,000 random 128-d vectors, binary API, k = 10; median of 3–21 runs (adds and training run once).

Scenario CPU (ms) GPU (ms) GPU speedup
Flat add 500k 92.3 16.3 5.7×
Flat search, 1 query 6.36 3.33 1.9×
Flat search, 100 queries 203.0 6.13 33×
Flat search, 1,000 queries 785.8 27.3 29×
Flat search, 10,000 queries 4,830 268 18×
IVF1024 train on 100k 580.0 104.4 5.6×
IVF1024 add 500k 353.7 85.0 4.2×
IVF1024 search, 1 query 0.02 0.34 0.06×
IVF1024 search, 1,000 queries 4.35 1.51 2.9×
IVF1024 search, 10,000 queries 33.8 15.9 2.1×

cpu_to_gpu/2 of a 100k × 128 Flat index: 185 ms. One GPU index reserves ~1.7 GB of GPU memory for FAISS's temporary buffers.

20 processes × 100 single-query searches on the 500k Flat index (2,000 searches total):

Configuration Wall time (ms)
CPU, default OpenMP threads 7,513
CPU, FaissEx.set_omp_threads(1) 3,603
GPU (searches serialized, see GPU lifecycle notes) 6,443

Takeaways: the GPU wins by 18–33× on batched brute-force search and ~5× on add/train; single queries gain little (Flat) or lose to launch overhead (IVF, where the CPU answers in 20 µs). Many concurrent single queries are fastest on the CPU with one OpenMP thread per call — batch them if you want the GPU.

USE_CUDA=true CUDAARCHS=121 MIX_ENV=test mix run bench/gpu_bench.exs

License

Apache License 2.0.