雲端 Mac 上的 iOS 向量搜尋驗收:餘弦與穩定 Top-K
同一個查詢,昨天命中文件甲,今天卻把文件乙排在前面。先別急著重新訓練模型:候選項目的輸入順序、零向量與浮點溢位,都可能讓 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 必須保留這個結束狀態,不能讓後續成功的命令將它覆蓋。若需要先編譯再執行,請使用一般的偵錯編譯,不要加入會移除前置條件檢查的最佳化選項。
參考實作會逐一正規化向量,並對結果完整排序,適合小樣本驗收,不是大規模檢索的效能方案。若正式環境會快取正規化向量,測試還要額外確認快取是否與原始向量版本一致;改用批次運算或索引後,也應將輸出與這份參考結果比對。
留存可重現的執行證據
每次執行都應保存提交識別碼、工具鏈版本、樣本摘要、維度、門檻與預期候選順序。樣本只使用人工向量,或經授權且已去識別化的資料,不要將使用者的原始文字寫入公開日誌。
區分演算法迴歸與模型漂移
固定向量測試通過,但真實搜尋結果改變時,下一步才是檢查模型版本、斷詞方式與向量前處理。若固定向量測試也失敗,應優先檢查數值運算流程、排序與候選項目身分,不要靠重新產生向量來掩蓋錯誤。
最後,要明確界定驗收範圍:精確搜尋要求結果一致;近似索引還需要另外訂定召回率標準;不同硬體上的浮點數末位不一定逐位元相同。先確保輸入有效、邊界明確、同分排序穩定,再擴大資料規模,迴歸報告才能指向真正可修復的問題。
常見問題
零向量的餘弦相似度應該回傳零嗎?
不應預設回傳零。零向量無法正規化,建議視為輸入錯誤並拒絕,避免與合法的正交向量混淆。
同分結果可以保留輸入順序嗎?
只有輸入順序本身屬於明確契約時才可以。本文採用唯一識別碼遞增排序,確保候選資料的順序改變後,結果仍可重現。