雲端 Mac 上的 iOS 向量搜尋驗收:餘弦與穩定 Top-K

雲端 Mac 上的 iOS 向量搜尋驗收:餘弦與穩定 Top-K

雲端 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 為零,無效資料仍會觸發錯誤。若正式環境的介面需要直接提前回傳,就必須另行寫明契約,不能讓測試與正式環境各有一套解讀。

用幾何樣本驗證排序邊界

基礎樣本不需要真實文字。同方向的分數為一,反方向為負一,正交為零;乘上正數不應改變向量方向。程式碼中的甲乙候選項目都與查詢同方向,反轉輸入順序後,仍須依識別碼排序,藉此找出依賴走訪順序的實作。

接著補齊以下檢查項目:

數值斷言可以使用容許誤差,但排序比較器不能採用「差值小於容許誤差就視為相等」的規則。近似相等不具遞移性,可能破壞排序要求。若應用需求是依分數級距排序,應先明確定義量化規則,再比較整數級距。

將參考實作納入雲端驗收

在已安裝命令列開發工具的環境中執行:

xcrun swift vector-contract.swift

指令稿成功時正常結束,斷言失敗或拋出未處理的錯誤時,則回傳失敗狀態。CI 必須保留這個結束狀態,不能讓後續成功的命令將它覆蓋。若需要先編譯再執行,請使用一般的偵錯編譯,不要加入會移除前置條件檢查的最佳化選項。

參考實作會逐一正規化向量,並對結果完整排序,適合小樣本驗收,不是大規模檢索的效能方案。若正式環境會快取正規化向量,測試還要額外確認快取是否與原始向量版本一致;改用批次運算或索引後,也應將輸出與這份參考結果比對。

留存可重現的執行證據

每次執行都應保存提交識別碼、工具鏈版本、樣本摘要、維度、門檻與預期候選順序。樣本只使用人工向量,或經授權且已去識別化的資料,不要將使用者的原始文字寫入公開日誌。

區分演算法迴歸與模型漂移

固定向量測試通過,但真實搜尋結果改變時,下一步才是檢查模型版本、斷詞方式與向量前處理。若固定向量測試也失敗,應優先檢查數值運算流程、排序與候選項目身分,不要靠重新產生向量來掩蓋錯誤。

最後,要明確界定驗收範圍:精確搜尋要求結果一致;近似索引還需要另外訂定召回率標準;不同硬體上的浮點數末位不一定逐位元相同。先確保輸入有效、邊界明確、同分排序穩定,再擴大資料規模,迴歸報告才能指向真正可修復的問題。

常見問題

零向量的餘弦相似度應該回傳零嗎?

不應預設回傳零。零向量無法正規化,建議視為輸入錯誤並拒絕,避免與合法的正交向量混淆。

同分結果可以保留輸入順序嗎?

只有輸入順序本身屬於明確契約時才可以。本文採用唯一識別碼遞增排序,確保候選資料的順序改變後,結果仍可重現。

把建置工作交給資料中心

租用專屬實體 Mac mini,將 Xcode 建置與 macOS CI 部署在全年穩定運作的主機上。依工作需求挑選規格,再依團隊所在地選擇節點。

選擇雲端 Mac

把建置工作交給資料中心

專屬實體 Mac mini,全年 365 天穩定運作。