Реализация AI-поиска по фотографиям по текстовому описанию в мобильном приложении
«Покажи фото с собакой на пляже» — пользователь описывает текстом, приложение находит релевантные фото. Это CLIP (Contrastive Language-Image Pretraining от OpenAI): модель обучена сопоставлять изображения и текстовые описания в общем векторном пространстве. Косинусное сходство между вектором текста и вектором изображения — это «релевантность».
Архитектура: embeddings + vector search
Пайплайн состоит из двух независимых этапов:
Индексация (происходит один раз для всей галереи, потом инкрементально):
- Для каждого фото → CLIP Image Embedding (512-мерный вектор)
- Сохраняем в локальную векторную БД
Поиск (происходит при каждом запросе пользователя):
- Запрос пользователя → CLIP Text Embedding (тот же 512-мерный вектор)
- ANN-поиск ближайших векторов в базе
- Возвращаем фото по убыванию косинусного сходства
CLIP on-device через CoreML
Apple не включила CLIP в стандартный Vision framework, но Apple ML Research выпустила ml-mobileclip — дистиллированная версия специально для мобильных устройств. MobileCLIP-S0: 18 MB, 3–5 ms на инференс изображения на iPhone 14.
import CoreML
class MobileCLIPEmbedder {
private let imageEncoder: MobileCLIPImageEncoder
private let textEncoder: MobileCLIPTextEncoder
func embedImage(_ cgImage: CGImage) throws -> [Float] {
let resized = resize(cgImage, to: CGSize(width: 256, height: 256))
let input = MobileCLIPImageInput(image: MLMultiArray(from: resized))
let output = try imageEncoder.prediction(input: input)
return l2Normalize(output.embedding.toFloatArray())
}
func embedText(_ query: String) throws -> [Float] {
let tokens = tokenize(query) // BPE tokenizer
let input = MobileCLIPTextInput(tokens: MLMultiArray(from: tokens))
let output = try textEncoder.prediction(input: input)
return l2Normalize(output.embedding.toFloatArray())
}
}
Tokenizer для CLIP — BPE (Byte Pair Encoding). Swift-реализация есть в репозитории apple/ml-mobileclip.
На Android: ONNX Runtime с MobileCLIP — менее удобно, но работает. OrtEnvironment + OrtSession, батчинг по 8 изображений.
Векторная БД на устройстве
Для поиска среди 50 000 векторов нужен ANN-индекс. Варианты:
SQLite с расширением sqlite-vss — добавляет виртуальные таблицы для векторного поиска. Компактный, работает в embedded режиме:
CREATE VIRTUAL TABLE photo_embeddings USING vss0(embedding(512));
INSERT INTO photo_embeddings(rowid, embedding) VALUES (42, json('[0.1, -0.3, ...]'));
SELECT rowid, distance FROM photo_embeddings WHERE vss_search(embedding, json('[0.2, -0.1, ...]')) LIMIT 20;
Простой FAISS (C++) через JNI/Swift bridging — быстрее на больших объёмах, но сложнее в интеграции.
Простой flat L2/cosine через Accelerate — для галерей до 10k фото вполне достаточно без специализированного индекса:
func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float {
var dotProduct: Float = 0
vDSP_dotpr(a, 1, b, 1, &dotProduct, vDSP_Length(a.count))
return dotProduct // После L2-нормализации = косинусное сходство
}
Перебор 10000 512-мерных векторов на iPhone 14 через vDSP_dotpr — ~15 ms. Для галерей до 20k приемлемо.
Индексация в фоне
Первичная индексация галереи в 10k фото при 4 ms/фото = 40 секунд. Запускаем через BGProcessingTask:
// Прогресс сохраняем — чтобы при следующем запуске продолжить с места остановки
class GalleryIndexer {
private var lastIndexedDate: Date {
get { UserDefaults.standard.object(forKey: "lastIndexedDate") as? Date ?? .distantPast }
set { UserDefaults.standard.set(newValue, forKey: "lastIndexedDate") }
}
func indexNewPhotos() async {
let fetchOptions = PHFetchOptions()
fetchOptions.predicate = NSPredicate(format: "creationDate > %@", lastIndexedDate as CVarArg)
let newPhotos = PHAsset.fetchAssets(with: .image, options: fetchOptions)
newPhotos.enumerateObjects { [weak self] asset, _, _ in
guard let self else { return }
if let embedding = self.computeEmbedding(for: asset) {
self.vectorDB.insert(assetId: asset.localIdentifier, embedding: embedding)
}
}
lastIndexedDate = Date()
}
}
Поиск: обработка запроса
func search(query: String, topK: Int = 30) async throws -> [PHAsset] {
let textEmbedding = try mobileCLIP.embedText(query)
let results = vectorDB.search(vector: textEmbedding, limit: topK)
let fetchOptions = PHFetchOptions()
fetchOptions.predicate = NSPredicate(
format: "localIdentifier IN %@",
results.map { $0.assetId }
)
let assets = PHAsset.fetchAssets(with: fetchOptions)
// Сортируем по релевантности (по порядку из vectorDB)
let idToScore = Dictionary(uniqueKeysWithValues: results.map { ($0.assetId, $0.score) })
return assets.objects(at: IndexSet(0..<assets.count))
.sorted { idToScore[$0.localIdentifier, default: 0] > idToScore[$1.localIdentifier, default: 0] }
}
Задержка поиска — text embedding (~5 ms) + ANN search (~15 ms) = ~20 ms. Результаты мгновенные с точки зрения пользователя.
Мультиязычный поиск
CLIP обучен преимущественно на английском. Для русского запроса «собака на пляже» — качество хуже, чем для «dog on beach». Решение: перевод запроса через простой словарь частых слов или Google Translate API перед embeddings. На практике достаточно перевести 100–200 частых запросов без API.
Сроки
Базовый CLIP-поиск с flat index для галерей до 10k — 1–1.5 недели. Масштабируемая реализация с ANN-индексом, инкрементальным обновлением, мультиязычностью и визуальным поиском по референс-фото — 3–4 недели. Стоимость рассчитывается индивидуально.







