По одному и тому же запросу вчера первым находился документ А, а сегодня выше оказался документ Б. Не спешите переобучать модель: порядок входных кандидатов, нулевые векторы и переполнение при вычислениях с плавающей точкой могут создать видимость регрессии в семантическом поиске iOS. Запустите проверку с фиксированными векторами на облачном Mac: сначала проверьте слой поиска, а затем — выходные данные модели. Так круг возможных причин заметно сузится.
Сначала зафиксируйте контракт поиска, затем подключайте выходные данные модели
В этой проверке рассматривается только точный поиск по косинусному сходству в памяти, без приближённых индексов. На вход подаются уже готовые векторы, на выходе — идентификаторы документов и оценки сходства. Инференс модели проверяется отдельным набором тестов, чтобы один сбой не затрагивал сразу два слоя реализации.
Контракт задаёт пять правил: векторы непустые и одинаковой размерности; все элементы конечны; нулевые векторы отклоняются; результаты упорядочиваются по убыванию оценки, а при равенстве — по возрастанию уникального идентификатора; порог включается в допустимый диапазон. При k, равном нулю, возвращается пустой результат, при отрицательном значении — ошибка, а при значении больше числа кандидатов — все элементы, прошедшие отбор.
Нулевой вектор не означает «не связан с запросом». У него нет определённого направления, поэтому его нельзя приравнивать к ортогональному вектору с косинусным сходством, равным нулю.
В примере размерность входного вектора ограничена значением 16384. Это ограничение локального бюджета ресурсов, а не характеристика модели. В реальном проекте его следует ужесточить с учётом используемой модели, а при чтении файлов также ограничивать количество байтов.
Масштабируйте перед нормализацией, чтобы избежать переполнения
При прямом вычислении суммы квадратов промежуточный результат может стать бесконечным, даже если каждый элемент конечен. Если сначала разделить значения на максимальный модуль элемента, а затем вычислить длину, можно избежать переполнения при возведении больших чисел в квадрат. Не добавляйте произвольную константу в знаменатель: это изменяет результат нормализации векторов малой длины.
Сохраните приведённый ниже код целиком в файл vector-contract.swift. Он использует только модули, входящие в состав инструментария, и запускается из командной строки на облачном Mac.
import Foundation
enum VectorError: Error {
case invalid
}
func unit(_ values: [Double]) throws -> [Double] {
guard !values.isEmpty,
values.count <= 16384,
values.allSatisfy({ $0.isFinite }),
let scale = values.map({ abs($0) }).max(),
scale > 0 else {
throw VectorError.invalid
}
let scaled = values.map { $0 / scale }
let length = sqrt(scaled.reduce(0) { $0 + $1 * $1 })
return scaled.map { $0 / length }
}
struct Candidate {
let id: String
let vector: [Double]
}
struct Hit {
let id: String
let score: Double
}
func search(
query: [Double],
candidates: [Candidate],
k: Int,
threshold: Double
) throws -> [Hit] {
guard k >= 0, threshold.isFinite,
(-1.0...1.0).contains(threshold),
Set(candidates.map(\.id)).count == candidates.count,
candidates.allSatisfy({ !$0.id.isEmpty }) else {
throw VectorError.invalid
}
let q = try unit(query)
var hits: [Hit] = []
for candidate in candidates {
guard candidate.vector.count == q.count else {
throw VectorError.invalid
}
let v = try unit(candidate.vector)
let raw = zip(q, v).reduce(0.0) {
$0 + $1.0 * $1.1
}
guard raw.isFinite, abs(raw) <= 1 + 1e-12 else {
throw VectorError.invalid
}
let score = min(1, max(-1, raw))
if score >= threshold {
hits.append(Hit(id: candidate.id, score: score))
}
}
hits.sort {
if $0.score == $1.score { return $0.id < $1.id }
return $0.score > $1.score
}
return Array(hits.prefix(k))
}
let candidates = [
Candidate(id: "b", vector: [2, 0]),
Candidate(id: "a", vector: [1, 0]),
Candidate(id: "c", vector: [0, 1]),
Candidate(id: "d", vector: [-1, 0])
]
let hits = try search(
query: [1, 0], candidates: candidates,
k: 3, threshold: 0
)
precondition(hits.map(\.id) == ["a", "b", "c"])
precondition(hits.map(\.score) == [1, 1, 0])
let reversed = try search(
query: [1, 0], candidates: Array(candidates.reversed()),
k: 3, threshold: 0
)
precondition(reversed.map(\.id) == hits.map(\.id))
let huge = try unit([
Double.greatestFiniteMagnitude,
Double.greatestFiniteMagnitude
])
precondition(abs(huge.reduce(0) { $0 + $1 * $1 } - 1) < 1e-12)
func rejects(_ body: () throws -> Void) -> Bool {
do {
try body()
return false
} catch {
return true
}
}
precondition(rejects { _ = try unit([0, 0]) })
precondition(rejects { _ = try unit([Double.nan, 1]) })
precondition(rejects {
_ = try search(
query: [1], candidates: candidates,
k: 1, threshold: 0
)
})
Здесь намеренно проверяются все кандидаты, и лишь затем результат обрезается до нужного размера. Поэтому некорректные данные вызывают ошибку даже при нулевом k. Если рабочий API должен сразу возвращать результат без дальнейшей обработки, это необходимо явно закрепить в отдельном контракте: тесты и рабочая реализация не должны трактовать поведение по-разному.
Проверяйте границы сортировки на геометрических примерах
Для базовых примеров реальные тексты не нужны. У сонаправленных векторов оценка равна единице, у противоположно направленных — минус единице, у ортогональных — нулю; умножение на положительное число не должно менять направление. Оба кандидата a и b в коде сонаправлены с запросом. После разворота входного списка они по-прежнему сортируются по идентификатору — такая проверка выявляет зависимость от порядка обхода.
Дополните набор следующими проверками:
- Задайте
k, равное нулю, единице, числу кандидатов и значению больше этого числа. - Используйте пороги минус один, ноль и один, проверяя включение самого порогового значения.
- Пустые массивы, бесконечные значения, повторяющиеся идентификаторы и неверная размерность должны приводить к ошибке.
- На фиксированных примерах проверьте симметричность сходства и его инвариантность при умножении векторов на положительное число.
В численных проверках допустима погрешность, но в компараторе сортировки нельзя считать оценки равными, если разность между ними меньше допуска. Приближённое равенство не обладает транзитивностью и может нарушить требования к сортировке. Если прикладная логика требует сортировки по диапазонам оценок, сначала явно определите правила квантования, а затем сравнивайте целочисленные уровни.
Подключите эталонную реализацию к проверкам на облачном Mac
Выполните команду в среде с установленными инструментами разработки командной строки:
xcrun swift vector-contract.swift
При успешном выполнении скрипт завершается штатно, а при нарушении проверяемого условия или необработанной ошибке возвращает статус неуспеха. CI должен сохранять этот статус завершения, а не подменять его успешным статусом последующей команды. Если требуется сначала скомпилировать программу, используйте обычную отладочную сборку без параметров оптимизации, удаляющих проверки предусловий.
Эталонная реализация нормализует векторы по одному и полностью сортирует результаты. Она подходит для проверок на небольших выборках, но не служит высокопроизводительным решением для масштабного поиска. Если рабочая система кеширует нормализованные векторы, дополнительно проверяйте соответствие версий кеша и исходных векторов. После перехода на пакетные вычисления или индекс сопоставляйте результаты с этой эталонной реализацией.
Фиксируйте данные для воспроизводимости запусков
Для каждого запуска сохраняйте идентификатор коммита, версию инструментария, сводку по тестовым данным, размерность, порог и ожидаемый порядок кандидатов. Используйте только искусственные векторы или обезличенные данные, на использование которых получено разрешение. Исходные пользовательские тексты не должны попадать в публичные журналы.
Отделяйте регрессии алгоритма от дрейфа модели
Если проверки на фиксированных векторах проходят, а результаты реального поиска изменились, следующим шагом проверяйте версию модели, токенизацию и предварительную обработку векторов. Если же не проходят и проверки на фиксированных векторах, в первую очередь ищите проблему в численных вычислениях, сортировке и идентификаторах кандидатов. Не маскируйте ошибку повторной генерацией векторов.
Наконец, явно определите границы проверки: точный поиск требует совпадения результатов; для приближённого индекса дополнительно нужны отдельные критерии полноты поиска; младшие разряды чисел с плавающей точкой на разном оборудовании не обязательно совпадают побитово. Сначала обеспечьте корректность входных данных, однозначность граничных условий и стабильный порядок при равных оценках, а уже затем увеличивайте объём данных. Только тогда отчёты о регрессиях будут указывать на проблемы, которые можно исправить.
Часто задаваемые вопросы
Нужно ли возвращать ноль для нулевого вектора?
Нет. Нулевой вектор нельзя нормализовать, поэтому его следует отклонить. Нулевая оценка уже обозначает корректный случай ортогональных ненулевых векторов.
Как упорядочить результаты с одинаковой оценкой?
Используйте уникальный идентификатор как второй ключ сортировки. Тогда результат не будет зависеть от порядка поступления кандидатов.
Передайте сборки на удалённый Mac
Арендуйте выделенный физический Mac mini и запускайте сборки Xcode и CI для macOS на компьютере, который работает круглый год. Выберите конфигурацию под задачу, а узел — с учётом расположения команды.
Выбрать облачный Mac