MVP Factory
ai startup development

iOS semantic image search: Vision, Core ML & HNSW on-device

KW
Krystian Wiewiór · · 5 min read

Meta description: Build a real-time semantic image search pipeline on iPhone using Vision, Core ML embeddings, and an on-device HNSW index — sub-50ms latency on modern iPhones.

Tags: ios swift mobile architecture cleanarchitecture


TL;DR

You can wire VNFeaturePrintObservation through a custom Core ML embedding model into an on-device HNSW index and deliver sub-50ms semantic image search on modern iPhones — if you respect the Neural Engine’s scheduling constraints, choose float16 over full quantization for recall quality, and keep your HNSW efSearch parameter sized to your latency ceiling.


The end-to-end pipeline

Three stages, and they have to be treated as a single scheduling unit:

  1. Feature extraction — Vision’s VNGenerateImageFeaturePrintRequest produces a VNFeaturePrintObservation, a dense float vector representing perceptual image content.
  2. Embedding projection — A lightweight Core ML model (a two-layer MLP or linear projection head) maps that feature print into your semantic embedding space.
  3. ANN retrieval — An on-device HNSW index answers nearest-neighbor queries against your indexed corpus.

Stages 1 and 2 must be fused onto the Neural Engine in a single dispatch. Separating them forces a CPU-to-ANE context switch that costs 8–12ms per query — a budget you cannot afford at 60fps scroll.

let request = VNGenerateImageFeaturePrintRequest()
request.usesCPUOnly = false // Let the ANE schedule this

let handler = VNImageRequestHandler(cgImage: image, options: [:])
try handler.perform([request])

guard let observation = request.results?.first as? VNFeaturePrintObservation else { return }

// Pass feature vector directly into Core ML — no intermediate copy
let mlArray = try MLMultiArray(observation.data)
let embedding = try embeddingModel.prediction(input: .init(features: mlArray))

Neural Engine scheduling: what most teams get wrong

The ANE processes graphs, not individual layers. If your Core ML model has a pre-processing op that falls back to CPU — common with dynamic shape inputs — the entire graph degrades to CPU execution. Profile with Instruments’ Core ML and Neural Engine instruments before assuming ANE utilization.

In production, pad all inputs to a fixed shape at compile time. The 0.3% accuracy penalty is irrelevant; the 11ms latency recovery is not.

Watch for these failure modes:

  • Dynamic shape ops silently pull the entire Core ML graph off the ANE. Always verify with Instruments.
  • Padding to a fixed input shape has to happen at model compile time, not at inference time.
  • A default efSearch of 64 during active UIScrollView deceleration will miss your frame deadline. Drop it dynamically; restore it when the gesture ends.

Float16 vs quantized embeddings

Benchmarked on iPhone 14 Pro (A16 Bionic), 512-dimensional embeddings, 10K-image corpus sourced from the Oxford Buildings dataset, Recall@10 measured over 1,000 random queries.

ConfigurationModel SizeIndex Size (10K imgs)Recall@10Median Query Latency
float32 embeddings18 MB40 MB94.2%61ms
float16 embeddings9 MB20 MB93.8%34ms
INT8 quantized5 MB10 MB88.1%28ms
INT4 quantized3 MB6 MB79.4%26ms

Float16 is the right default for production: half the memory footprint, 0.4% recall cost. INT8 drops recall by 6 points — that’s perceptible in search quality, not a rounding error. Only reach for INT4 if your corpus exceeds 100K images and you’re genuinely fighting RAM pressure.


HNSW index sizing and the latency budget

Your UICollectionView has a hard deadline: a query result must land within one frame budget of the user stopping their swipe gesture — roughly 33ms at 30fps; in practice, target ~50ms end-to-end to account for rendering overhead.

HNSW’s key parameters against that budget:

  • M (graph connectivity): 16 is sufficient for corpora under 50K images. Pushing to 32 improves recall by ~2% but doubles index construction time.
  • efSearch (query-time beam width): Start at 64. Each +16 increment costs ~3ms. Tune this dynamically — drop efSearch to 32 during active scrolling, restore to 64 when the user pauses.
  • efConstruction: Run this offline at index build time. 200 is a reasonable ceiling for on-device storage constraints.

Build the index on a background actor at app launch. A 10K-image corpus builds in under 400ms on an A15 Bionic.


Index persistence: surviving app restarts

A rebuilt HNSW index on every cold start is a non-starter for corpora beyond a few thousand images. Serialize the index to the app’s Caches directory at build time and deserialize on launch.

import HNSWSwift // or your chosen HNSW wrapper

actor EmbeddingIndex {
    private var index: HNSWIndex<Float>?
    private let indexURL: URL = {
        FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
            .appendingPathComponent("semantic_index.bin")
    }()

    func loadOrBuild(embeddings: [(id: String, vector: [Float])]) throws {
        if FileManager.default.fileExists(atPath: indexURL.path) {
            index = try HNSWIndex<Float>(from: indexURL)
            return
        }
        index = HNSWIndex<Float>(dimensions: 512, metric: .cosine, M: 16, efConstruction: 200)
        for item in embeddings {
            try index?.add(vector: item.vector, label: item.id)
        }
        try index?.save(to: indexURL)
    }

    func query(_ vector: [Float], k: Int = 10, efSearch: Int = 64) throws -> [String] {
        guard let index else { throw IndexError.notLoaded }
        return try index.search(vector: vector, k: k, efSearch: efSearch)
    }
}

Two production notes: invalidate the cached binary whenever your embedding model version changes — a version mismatch between model and index produces silent garbage results. Store the model version alongside the index file and check it on load.


What actually matters

  1. Fuse Vision and Core ML into a single ANE dispatch. Profile with Instruments to confirm ANE utilization before shipping. A CPU fallback will silently destroy your latency budget — there’s no warning, the numbers just quietly get worse.
  2. Default to float16 embeddings. The recall/size tradeoff beats every other configuration for corpora under 100K images. Only consider INT8 if you’re already facing RAM eviction pressure.
  3. Tune efSearch dynamically and persist your index. Dynamic tuning recovers ~15ms during scroll without touching your model. Persisting the index keeps cold-start time from scaling linearly with corpus size.

Share: Twitter LinkedIn