同一条搜索词,昨天命中文档甲,今天却把文档乙排在前面。先别重新训练模型:候选输入顺序、零向量和浮点溢出,都可能让 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 为零,非法数据仍会报错。若线上接口希望直接短路,必须单独写出契约,不能让测试和生产各有一种解释。
用几何样本验证排序边界
基础样本不需要真实文本。同向分数为一,反向为负一,正交为零;正数倍缩放不应改变方向。代码中的甲乙候选都与查询同向,倒转输入后仍按标识符排序,能揭露依赖遍历顺序的实现。
继续补齐这些检查项:
k分别取零、一、候选数量和更大值。- 阈值取负一、零和一,检查等号包含关系。
- 空数组、无穷值、重复标识符和错误维度必须失败。
- 用固定样本验证相似度对称性,以及正数倍缩放不变性。
数值断言可以使用容差,排序比较器却不要使用“差值小于容差就算相等”。近似相等不具传递性,可能破坏排序要求。若业务需要按分数档位排序,应先明确量化规则,再比较整数档位。
把参考实现接入云端验收
在安装好命令行开发工具的环境执行:
xcrun swift vector-contract.swift
脚本成功时正常退出,断言失败或抛出未处理错误时返回失败状态。CI 必须保留该退出状态,不能用后续成功命令覆盖。需要编译运行时,用普通调试编译,不要加会移除前置条件检查的优化选项。
参考实现逐一归一化并完整排序,适合小样本验收,不是大规模检索的性能方案。生产若缓存归一化向量,测试要额外检查缓存与原始向量版本是否匹配;换成批量计算或索引后,应将输出与这份参考结果对照。
固定可重复的运行证据
每次保存提交标识、工具链版本、样本摘要、维度、阈值和预期候选顺序。样本只使用人工向量或经过授权的脱敏数据,不把用户原始文本带进公开日志。
将算法回归与模型漂移分开
固定向量通过、真实搜索变化,下一步才检查模型版本、分词方式和向量预处理。固定向量也失败,则优先检查数值路径、排序和候选身份,不要靠重新生成向量掩盖错误。
最后明确验收范围:精确搜索要求结果一致;近似索引还需要另设召回率标准;不同硬件上的浮点末位不一定逐位相同。先让输入合法、边界明确、同分稳定,再扩大数据规模,回归报告才能指向可修复的问题。
常见问题
零向量的余弦相似度应该返回零吗?
不应默认返回零。零向量无法归一化,建议作为输入错误拒绝,避免与合法的正交向量混为一谈。
同分结果可以直接沿用输入顺序吗?
只有输入顺序本身属于明确契约时才可以。本文使用唯一标识符升序打破同分,确保候选输入顺序改变后结果仍可重复。