Contents

christopherkarani/metalanns


Performance

Single-query warm p50 on Apple M3 Max, dim 384, cosine, in-process. Graph indexes (hnswlib, USearch, FAISS HNSW) were tuned until recall@10 ≥ 0.99. MetalANNS default is exact.

| Backend | 10k | 100k | Recall@10 | |---|---:|---:|---| | MetalANNS exact | 88 µs | 831 µs | 1.000 / 0.999 | | FAISS IndexFlatIP | 227 µs | 1 521 µs | 1.000 | | NumPy scan | 161 µs | 2 330 µs | 1.000 | | sqlite-vec | 794 µs | 7 906 µs | 1.000 | | FAISS HNSW | 207 µs | 4 024 µs | ~0.99 | | hnswlib | 639 µs | 3 096 µs | ~0.99 | | USearch | 545 µs | 7 693 µs | ~0.99 |

<p align="center"> <img src="docs/benchmarks/competitive-latency.png" alt="MetalANNS vs FAISS, hnswlib, USearch, sqlite-vec single-query latency" width="900"> </p>

0.999 at 100k is fp32 tie-order versus a scalar reference, not missed neighbors. Graphs only look faster here if you leave ef low and accept recall like 0.13. At millions of vectors they win; that is a different problem.

Opt-in .fast (IVF, nprobe chosen so recall@10 stays honest):

| n | Exact p50 | .fast p50 | recall@10 | |---:|---:|---:|---:| | 1k | 0.029 ms | 0.018 ms (nprobe 8) | 1.000 | | 10k | 0.100 ms | 0.016 ms (nprobe 4) | 0.976 | | 50k | 0.49 ms | 0.034 ms (nprobe 4) | 0.978 | | 100k | 0.90 ms | 0.102 ms (nprobe 8) | 0.995 |

Do not claim 10× at 1k. Exact is already 29 µs. Reproduce commands: BENCHMARKS.md.


API

Type-state index: Unbuilt → Ready → ReadOnly. The compiler blocks search on an unbuilt index and mutations on a read-only one.

import MetalANNS

let config = IndexConfiguration(degree: 32, metric: .cosine)
// Faster, not exact:
// IndexConfiguration(degree: 32, metric: .cosine, searchMode: .fast)
let index = VectorIndex<String, VectorIndexState.Unbuilt>(configuration: config)

let readyIndex = try await index.build(
    vectors: myEmbeddings, // [[Float]]
    ids: myDocumentIDs     // [String]
)

let results = try await readyIndex.search(query: queryVector, topK: 10) {
    QueryFilter.equals(Field<String>("category"), "research")
    QueryFilter.greaterThan(Field<Float>("relevance"), 0.85)
}

try await readyIndex.save(to: fileURL)
let loaded = try await VectorIndex<String, VectorIndexState.Ready>
    .loadReadOnly(from: fileURL, mode: .mmap)

How search works

Default search is a fused exact scan (CPU NEON / int8 prefilter / one GPU dispatch), not a per-hop graph walk. A CAGRA-style fixed-degree graph is still built for construction, edits, and the large-n fallback. HNSW is sequential to construct; this graph is GPU-parallel. That is a construction story, not why 10k queries are 88 µs.

.fast probes nprobe inverted lists and exact-scans those rows on the CPU. No Metal round trip. Default nprobe is 4; raise it at 1k and 100k if you want recall@10 ≥ 0.99.


Install

dependencies: [
    .package(url: "https://github.com/christopherkarani/MetalANNS.git", from: "0.3.0")
]

Platforms: macOS 14+, iOS 17+, visionOS 1+.

License

MIT. See LICENSE.

Package Metadata

Repository: christopherkarani/metalanns

Default branch: main

README: README.md