iOS-Vektorsuche auf dem Cloud-Mac reproduzierbar testen
Dieselbe Suchanfrage liefert gestern Dokument A als ersten Treffer, heute steht plötzlich Dokument B vorn. Bevor Sie das Modell neu trainieren, prüfen Sie die Suche selbst: Schon die Eingabereihenfolge der Kandidaten, Nullvektoren oder Gleitkommaüberläufe können bei der semantischen Suche unter iOS den Eindruck einer Regression erwecken. Führen Sie Tests mit festen Vektoren auf einem Cloud-Mac aus und prüfen Sie zuerst die Retrieval-Schicht, danach die Modellausgaben. So lässt sich die Fehlerquelle deutlich eingrenzen.
Erst den Retrieval-Vertrag festlegen, dann Modellausgaben anbinden
Hier prüfen wir ausschließlich die exakte Kosinussuche im Arbeitsspeicher, keine approximativen Indizes. Als Eingabe dienen bereits erzeugte Vektoren; die Ausgabe enthält Dokumentkennungen und Scores. Die Modellinferenz bleibt in einer separaten Testsuite, damit ein einzelner Fehler nicht gleichzeitig zwei Implementierungsschichten infrage stellt.
Dafür gelten fünf Regeln: Die Vektoren haben dieselbe Dimension und sind nicht leer; alle Elemente sind endlich; Nullvektoren werden abgewiesen; die Ergebnisse werden nach absteigendem Score sortiert, bei Gleichstand nach aufsteigender eindeutiger Kennung; der Schwellenwert ist einschließlich. Bei k gleich null ist das Ergebnis leer, negative Werte führen zu einem Fehler, und bei einem Wert oberhalb der Kandidatenzahl werden alle zulässigen Treffer zurückgegeben.
Ein Nullvektor ist nicht einfach „für die Anfrage irrelevant“. Er hat keine definierbare Richtung und darf deshalb nicht wie ein orthogonaler Vektor mit einem Kosinus-Score von null behandelt werden.
Das Beispiel begrenzt die Eingabedimension auf 16384. Das ist ein lokales Ressourcenbudget, keine Modellspezifikation. In einem realen Projekt sollte die Grenze an das verwendete Modell angepasst und enger gefasst werden. Beim Einlesen von Dateien ist zusätzlich die Byteanzahl zu begrenzen.
Numerische Überläufe durch skalierte Normalisierung vermeiden
Wer die Summe der Quadrate direkt berechnet, kann selbst bei ausschließlich endlichen Elementen ein unendliches Zwischenergebnis erhalten. Dividiert man zuerst durch den größten Absolutwert und berechnet erst danach die Länge, lässt sich der Überlauf beim Quadrieren großer Zahlen vermeiden. Addieren Sie nicht willkürlich eine Konstante zum Nenner: Das verändert das Ergebnis der Richtungsnormalisierung bei Vektoren mit kleiner Länge.
Speichern Sie den folgenden vollständigen Code als vector-contract.swift. Er verwendet nur Module aus der Toolchain und lässt sich auf einem Cloud-Mac über die Kommandozeile ausführen.
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
)
})
Die Implementierung validiert bewusst alle Kandidaten, bevor sie die Ergebnisliste kürzt. Ungültige Daten lösen deshalb auch dann einen Fehler aus, wenn k null ist. Soll die produktive Schnittstelle in diesem Fall sofort zurückkehren, muss dieses Verhalten ausdrücklich als eigener Vertrag festgehalten werden. Test und Produktion dürfen den Vertrag nicht unterschiedlich auslegen.
Grenzfälle der Rangfolge mit geometrischen Beispielen prüfen
Für die Basistests sind keine echten Texte nötig. Vektoren in gleicher Richtung haben den Score eins, entgegengesetzte den Score minus eins und orthogonale den Score null. Eine Skalierung mit einem positiven Faktor darf die Richtung nicht ändern. Die beiden Kandidaten „a“ und „b“ im Code zeigen in dieselbe Richtung wie die Anfrage. Auch nach Umkehrung der Eingabereihenfolge müssen sie nach Kennung sortiert bleiben. So werden Implementierungen sichtbar, deren Ergebnis von der Durchlaufreihenfolge abhängt.
Ergänzen Sie die Tests um folgende Fälle:
- Setzen Sie
kauf null, eins, die Kandidatenzahl und einen noch größeren Wert. - Verwenden Sie die Schwellenwerte minus eins, null und eins und prüfen Sie, ob Treffer genau auf der Grenze eingeschlossen werden.
- Leere Arrays, unendliche Werte, doppelte Kennungen und falsche Dimensionen müssen zu einem Fehler führen.
- Prüfen Sie mit festen Beispielen die Symmetrie der Ähnlichkeit und die Invarianz bei Skalierung mit einem positiven Faktor.
Für numerische Assertions sind Toleranzen sinnvoll. Im Sortiervergleich sollten Sie jedoch nicht festlegen, dass zwei Werte als gleich gelten, sobald ihre Differenz unter einer Toleranz liegt. Näherungsweise Gleichheit ist nicht transitiv und kann die Anforderungen an eine gültige Sortierung verletzen. Wenn die Anwendung nach Score-Stufen sortieren soll, definieren Sie zuerst die Quantisierung und vergleichen Sie anschließend ganzzahlige Stufen.
Die Referenzimplementierung in die Abnahmetests auf dem Cloud-Mac einbinden
Führen Sie den folgenden Befehl in einer Umgebung mit installierten Kommandozeilen-Entwicklertools aus:
xcrun swift vector-contract.swift
Bei Erfolg endet das Skript regulär. Schlägt eine Assertion fehl oder wird ein Fehler nicht behandelt, liefert es einen Fehlerstatus zurück. Die CI muss diesen Exit-Status erhalten; ein späterer erfolgreicher Befehl darf ihn nicht überschreiben. Wenn Sie den Code kompilieren und anschließend ausführen möchten, verwenden Sie eine gewöhnliche Debug-Kompilierung ohne Optimierungsoptionen, die Vorbedingungsprüfungen entfernen.
Die Referenzimplementierung normalisiert jeden Vektor einzeln und sortiert die vollständige Trefferliste. Sie eignet sich zur Abnahme kleiner Testsätze, nicht als Performance-Lösung für große Suchbestände. Wenn die Produktion normalisierte Vektoren zwischenspeichert, müssen die Tests zusätzlich prüfen, ob der Cache zur Version der ursprünglichen Vektoren passt. Nach einer Umstellung auf Stapelberechnung oder einen Index sollten die Ausgaben mit diesen Referenzergebnissen verglichen werden.
Reproduzierbare Ausführungsnachweise festhalten
Speichern Sie bei jedem Lauf die Commit-Kennung, die Toolchain-Version, eine Zusammenfassung der Testdaten, die Dimension, den Schwellenwert und die erwartete Kandidatenreihenfolge. Verwenden Sie ausschließlich künstlich erstellte Vektoren oder zur Nutzung freigegebene, bereinigte Daten. Unverarbeitete Nutzertexte gehören nicht in öffentliche Protokolle.
Algorithmische Regressionen von Modelldrift trennen
Bestehen die Tests mit festen Vektoren, während sich die reale Suche verändert, prüfen Sie als Nächstes die Modellversion, die Tokenisierung und die Vorverarbeitung der Vektoren. Schlagen bereits die Tests mit festen Vektoren fehl, haben numerische Berechnung, Sortierung und Kandidatenidentität Vorrang. Verdecken Sie solche Fehler nicht durch neu erzeugte Vektoren.
Zum Schluss muss der Umfang der Abnahme klar sein: Eine exakte Suche muss konsistente Ergebnisse liefern; für approximative Indizes sind zusätzliche Recall-Kriterien nötig. Die letzten Gleitkommastellen müssen auf unterschiedlicher Hardware nicht bitgenau übereinstimmen. Sorgen Sie zuerst für gültige Eingaben, eindeutig definierte Grenzen und eine stabile Reihenfolge bei gleichen Scores. Erst danach sollte der Datenbestand wachsen. So zeigen Regressionsberichte auf Probleme, die sich gezielt beheben lassen.
Häufig gestellte Fragen
Soll ein Nullvektor die Kosinusähnlichkeit null erhalten?
Nein. Ein Nullvektor lässt sich nicht normalisieren und sollte als ungültige Eingabe abgelehnt werden. Null ist dagegen ein gültiger Wert für orthogonale Vektoren.
Wie bleiben Treffer mit gleichem Wert stabil sortiert?
Verwenden Sie eine eindeutige Kennung als zweites Sortierkriterium. So bleibt die Reihenfolge unabhängig von der Anlieferung der Kandidaten.
Build-Maschine ans Rechenzentrum auslagern
Mieten Sie einen exklusiven physischen Mac mini und betreiben Sie Xcode-Builds und macOS-CI auf einer rund um die Uhr verfügbaren Maschine. Wählen Sie die Ausstattung passend zu Ihren Aufgaben und den Standort passend zu Ihrem Team.
Cloud-Mac auswählen