AI-поний текстовий пошук фотографій в мобільних додатках
"Покажи фото з собакою на пляжі" — користувач описує текстом, додаток знаходить релевантні фото. Це CLIP (Contrastive Language-Image Pretraining від OpenAI): модель навчена сопоставляти зображення і текстові описи у спільному векторному просторі. Косинусна подібність між вектором тексту і вектором зображення — це "релевантність".
Архітектура: embeddings + vector search
Pipeline складається з двох незалежних етапів:
Індексація (відбувається один раз для всієї галереї, потім інкрементально):
- Для кожного фото → 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 мс інференцу зображення на 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 мс. Для галерей до 20k приймаємо.
Індексація у фоні
Первинна індексація галереї 10k фото при 4 мс/фото = 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 мс) + ANN search (~15 мс) = ~20 мс. Результати миттєві з точки зору користувача.
Багатомовний пошук
CLIP навчена переважно на англійській мові. Для російського запиту "собака на пляжі" — якість гірша, ніж для "dog on beach". Рішення: переведіть запит через простий словник частих слів або Google Translate API перед embeddings. На практиці достатньо перекласти 100–200 частих запитів без API.
Часові рамки
Базовий CLIP-пошук з flat index для галерей до 10k — 1–1,5 тижні. Масштабуюча реалізація з ANN-індексом, інкрементальним оновленням, багатомовністю та візуальним пошуком за референс-фото — 3–4 тижні. Вартість розраховується індивідуально.







