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
- Vector similarity search (L2, inner product)
- Index factory support (
"Flat","IVF4,Flat","HNSW32", etc.) - Add, search, train, clone, reset, reconstruct
- Binary fast path — pass raw f32 binaries straight through, no list encoding
- File I/O (save/load indexes)
- K-means clustering
- GPU support (CUDA)
Prerequisites
- Elixir 1.18+ on Erlang/OTP 27+ with NIF support (the CI-tested floor; CI also tests Elixir 1.19/OTP 28 and Elixir 1.20/OTP 29)
- A C11 compiler for the NIF and a C++20 compiler for FAISS (GCC 10+, Clang 12+, or AppleClang 14+)
- Either a system FAISS installation or CMake 3.24+ to build from source (the default; the build checks the version before configuring)
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
- BLAS comes from the built-in Accelerate framework — nothing to install
- libomp is auto-detected with
brew --prefix libomp; setLIBOMP_PREFIXto point the build at a different libomp keg
Linux setup (Debian/Ubuntu)
sudo apt-get update
sudo apt-get install -y build-essential cmake libopenblas-dev
libopenblas-devprovides the BLAS/LAPACK libraries FAISS links against- OpenMP (
libgomp) ships with gcc — nothing extra to install - This is the exact recipe this project's CI uses (ubuntu-24.04). Ubuntu
22.04 ships CMake 3.22, which is too old: install a newer one with
pip install cmakeor from Kitware's apt repository (https://apt.kitware.com)
Linux setup (Fedora/RHEL)
sudo dnf install -y gcc gcc-c++ make cmake openblas-devel
GPU (optional)
- CUDA toolkit 12.x — FAISS 1.12.0+ dropped CUDA 11 (see the
GPU Support section). The build checks
nvcc --versionand stops with an error below 12 - Build with
USE_CUDA=true mix compile
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
libfaissbut notlibfaiss_c(the C API wrapper). If you get linker errors aboutlibfaiss_c, you may need to build from source — just omitFAISS_PREFIXand 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:
-
Linux:
libopenblas0andlibgomp1(plus a matching libc)# in the runtime stage of your Dockerfile RUN apt-get update && apt-get install -y libopenblas0 libgomp1 && rm -rf /var/lib/apt/lists/* -
macOS:
brew install libomp(Accelerate is part of the OS)
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_flagsaccepts0,2,4, or6; seeFaissEx.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)
Batch search
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
- Under 100K vectors: Use
"Flat". It's brute force but fast enough and gives exact results. - 100K to 1M vectors: Use
"IVF{nlist},Flat"wherenlist = 4*sqrt(n). Train on at least40*nlistvectors. - 1M+ vectors: Use
"IVF{nlist},PQ{m}"or"HNSW32". PQ compresses vectors to save memory. - Cosine similarity: Normalize your vectors to unit length first, then use
:inner_productmetric.
# 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:
- Concurrent reads (search, reconstruct, save, clone, property getters) run in parallel with each other
- Mutations (add, train, reset) take the write lock and serialize against all other operations on the same index — racing an
addagainst asearchis safe and cannot crash the VM - Heavy operations (add, search, train) run on BEAM dirty CPU schedulers so they don't block normal Erlang processes
- A call that finds its index locked (say, a
searchwhiletrainholds the write lock) waits on a dirty IO scheduler instead, so waiters on one busy index never tie up the dirty-CPU pool that other indexes need - File I/O (to_file, from_file) runs on dirty IO schedulers
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:
- install
libopenblas-openmp-devrather than the default pthreads OpenBLAS, so BLAS threads come from the same OpenMP pool instead of a second nested one; - consider
KMP_BLOCKTIME=0(LLVM libomp) orOMP_WAIT_POLICY=passive(GNU libgomp) so idle OpenMP threads stop spinning between calls.
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++)
- Single C file: All NIF code lives in
c_src/faiss_ex_nif.c - NIF resources: FAISS index pointers are wrapped in NIF resource types with destructors, so BEAM GC handles cleanup
- No processes: FaissEx uses plain functions and data — no GenServers or supervision trees
- No dependencies: Vectors flow in/out as plain lists (with binary conversion handled internally) or as raw f32 binaries via the Binary API
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 to be installed. The build adds -DFAISS_ENABLE_GPU=ON to cmake and compiles GPU-specific NIF functions.
Note: FAISS 1.12.0+ (including the pinned v1.15.1) requires CUDA 12.x — 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
- GPU resources (
FaissStandardGpuResources) must outlive GPU indexes. FaissEx handles this automatically — the%FaissEx.Index{}struct holds both the GPU resources ref and the index ref, so BEAM GC keeps both alive. - You can create indexes on CPU, add vectors, then move to GPU for fast search.
- File I/O requires CPU indexes — move back with
gpu_to_cpu/1before saving. - Concurrent reads (search) on the same GPU index are safe. Concurrent writes (add) are not.
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
addscenarios are dominated by list→binary encoding and BEAM garbage collection of the benchmark corpus, not by FAISS — which is whyadd 1000andadd 10000land 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
License
Apache License 2.0.