Tester la recherche vectorielle iOS sur un Mac cloud
La même requête plaçait hier le document A en tête ; aujourd’hui, c’est le document B qui arrive premier. Avant de réentraîner le modèle, vérifiez la recherche elle-même : l’ordre des candidats en entrée, les vecteurs nuls ou les dépassements de capacité en virgule flottante peuvent donner l’illusion d’une régression de la recherche sémantique sur iOS. Exécutez des tests avec des vecteurs fixes sur un Mac cloud, validez d’abord la couche de recherche, puis examinez les sorties du modèle. Vous réduirez ainsi nettement le périmètre à investiguer.
Définir le contrat de recherche avant d’intégrer les sorties du modèle
Nous validons ici uniquement la recherche exacte par similarité cosinus en mémoire, sans index approximatif. Les entrées sont des vecteurs déjà générés ; les sorties contiennent les identifiants des documents et leurs scores. L’inférence du modèle reste dans une suite de tests distincte, afin qu’un seul échec ne remette pas simultanément en cause deux couches d’implémentation.
Le contrat comporte cinq règles : les vecteurs ont la même dimension et ne sont pas vides ; tous leurs éléments sont finis ; les vecteurs nuls sont rejetés ; les résultats sont triés par score décroissant, puis par identifiant unique croissant en cas d’égalité ; le seuil est inclusif. Si k vaut zéro, le résultat est vide. Une valeur négative provoque une erreur. Si k dépasse le nombre de candidats, tous les résultats admissibles sont renvoyés.
Un vecteur nul n’est pas simplement « sans rapport avec la requête ». Sa direction n’est pas définie : il ne faut pas le confondre avec un vecteur orthogonal, dont le score cosinus vaut zéro.
L’exemple limite la dimension des entrées à 16384. Il s’agit d’un budget de ressources local, pas d’une caractéristique du modèle. Dans un projet réel, resserrez cette limite en fonction du modèle utilisé et limitez également le nombre d’octets lors de la lecture des fichiers.
Éviter les dépassements numériques par une normalisation avec mise à l’échelle
Le calcul direct de la somme des carrés peut produire un résultat intermédiaire infini, même si chaque élément est fini. En divisant d’abord les éléments par la plus grande valeur absolue, puis en calculant la norme, on évite le dépassement lors de la mise au carré des grandes valeurs. N’ajoutez pas arbitrairement une constante au dénominateur : cela modifie le résultat de la normalisation directionnelle pour les vecteurs de faible norme.
Enregistrez le code complet ci-dessous dans vector-contract.swift. Il ne dépend que des modules fournis avec la chaîne d’outils et s’exécute en ligne de commande sur un Mac cloud.
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
)
})
Cette implémentation valide volontairement tous les candidats avant de tronquer les résultats. Des données invalides provoquent donc une erreur même si k vaut zéro. Si l’interface de production doit au contraire renvoyer immédiatement un résultat vide dans ce cas, ce comportement doit faire l’objet d’un contrat explicite. Les tests et la production ne doivent pas en avoir deux interprétations différentes.
Vérifier les cas limites du classement avec des exemples géométriques
Les tests de base n’ont pas besoin de textes réels. Deux vecteurs de même direction ont un score de un ; de directions opposées, de moins un ; orthogonaux, de zéro. Une multiplication par un facteur positif ne doit pas modifier la direction. Dans le code, les candidats « a » et « b » ont la même direction que la requête. Même après inversion de l’ordre des entrées, ils doivent rester classés par identifiant, ce qui permet de repérer les implémentations dépendantes de l’ordre de parcours.
Complétez les tests avec les vérifications suivantes :
- Donnez à
kles valeurs zéro, un, le nombre de candidats, puis une valeur supérieure. - Testez les seuils moins un, zéro et un, en vérifiant que l’égalité avec le seuil est bien incluse.
- Les tableaux vides, les valeurs infinies, les identifiants en double et les dimensions incorrectes doivent provoquer un échec.
- Vérifiez, à l’aide d’exemples fixes, la symétrie de la similarité et son invariance par multiplication par un facteur positif.
Les assertions numériques peuvent utiliser une tolérance. En revanche, le comparateur de tri ne doit pas considérer deux scores comme égaux au seul motif que leur différence est inférieure à cette tolérance. L’égalité approchée n’est pas transitive et peut enfreindre les propriétés requises pour le tri. Si l’application doit classer les scores par paliers, définissez d’abord les règles de quantification, puis comparez les paliers entiers.
Intégrer l’implémentation de référence à la validation sur un Mac cloud
Exécutez la commande suivante dans un environnement où les outils de développement en ligne de commande sont installés :
xcrun swift vector-contract.swift
Le script se termine normalement en cas de succès et renvoie un statut d’échec si une assertion échoue ou si une erreur n’est pas interceptée. La CI doit conserver ce code de sortie : une commande ultérieure réussie ne doit pas le masquer. Si vous souhaitez compiler puis exécuter le programme, utilisez une compilation de débogage standard, sans option d’optimisation supprimant les vérifications de préconditions.
L’implémentation de référence normalise les vecteurs un par un et trie l’ensemble des résultats. Elle convient à la validation de petits jeux de tests, pas à une recherche à grande échelle exigeant de bonnes performances. Si la production met en cache les vecteurs normalisés, les tests doivent aussi vérifier que le cache correspond à la version des vecteurs d’origine. Après un passage au calcul par lots ou à un index, comparez les sorties aux résultats de cette référence.
Conserver des éléments de preuve reproductibles
À chaque exécution, conservez l’identifiant du commit, la version de la chaîne d’outils, un résumé des données de test, la dimension, le seuil et l’ordre attendu des candidats. N’utilisez que des vecteurs construits manuellement ou des données expurgées dont l’utilisation est autorisée. Ne versez pas les textes bruts des utilisateurs dans des journaux publics.
Distinguer les régressions algorithmiques de la dérive du modèle
Si les tests sur vecteurs fixes passent alors que la recherche réelle change, examinez ensuite la version du modèle, la tokenisation et le prétraitement des vecteurs. Si les tests sur vecteurs fixes échouent eux aussi, vérifiez en priorité les calculs numériques, le tri et l’identité des candidats. Ne masquez pas ces erreurs en régénérant les vecteurs.
Enfin, définissez clairement le périmètre de validation : la recherche exacte exige des résultats cohérents ; les index approximatifs nécessitent en plus leurs propres critères de rappel ; les derniers bits des nombres flottants ne sont pas nécessairement identiques d’un matériel à l’autre. Commencez par garantir des entrées valides, des limites explicites et un ordre stable en cas d’égalité, puis augmentez le volume des données. Les rapports de régression pourront ainsi désigner des problèmes sur lesquels il est possible d’agir.
Questions fréquentes
Faut-il attribuer un score nul à un vecteur nul ?
Non. Un vecteur nul ne peut pas être normalisé. Rejetez cette entrée pour ne pas la confondre avec deux vecteurs orthogonaux, dont le score est réellement nul.
Comment départager deux résultats de même score ?
Ajoutez un identifiant unique comme second critère de tri. Le classement ne dépend alors plus de l’ordre d’arrivée des candidats.
Confiez votre machine de build à notre centre de données
Louez un Mac mini physique dédié et exécutez vos builds Xcode et votre CI macOS sur une machine disponible toute l’année. Choisissez la configuration selon vos besoins et le nœud selon l’emplacement de votre équipe.
Choisir un Mac cloud