같은 검색어인데 어제는 문서 A가 먼저 나오고 오늘은 문서 B가 앞선다면, 모델부터 다시 학습시키지는 마세요. 후보 입력 순서, 영벡터, 부동소수점 오버플로 때문에 iOS 시맨틱 검색에서 회귀가 발생한 것처럼 보일 수 있습니다. 클라우드 Mac에서 고정 벡터로 테스트해 검색 계층을 먼저 검증한 뒤 모델 출력을 확인하면 원인 범위를 훨씬 좁힐 수 있습니다.
검색 계약을 먼저 확정하고 모델 출력 연결하기
이번에는 메모리 내 정확 코사인 검색만 검증하며, 근사 인덱스는 다루지 않습니다. 이미 생성된 벡터를 입력으로 사용하고 문서 식별자와 점수를 출력합니다. 모델 추론은 별도 테스트로 분리해 한 번의 실패로 두 구현 계층을 동시에 살펴봐야 하는 상황을 피합니다.
계약에는 다섯 가지 규칙을 둡니다. 벡터는 비어 있지 않고 차원이 같아야 하며, 모든 원소가 유한해야 합니다. 영벡터는 거부합니다. 점수 내림차순으로 정렬하되 동점이면 고유 식별자 오름차순으로 정렬합니다. 임계값과 같은 점수도 결과에 포함합니다. k가 0이면 빈 결과를 반환하고, 음수이면 오류를 내며, 후보 수보다 크면 조건을 충족하는 모든 항목을 반환합니다.
영벡터는 “쿼리와 관련이 없다”는 뜻이 아닙니다. 방향 자체를 정의할 수 없으므로 코사인 점수가 0인 직교 벡터와 같은 것으로 취급하면 안 됩니다.
예제에서는 입력 차원의 상한을 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가 0이어도 잘못된 데이터가 있으면 오류가 발생합니다. 운영 API에서 검증을 생략하고 즉시 반환하려면 그 동작을 별도 계약으로 명시해야 합니다. 테스트와 운영 구현이 서로 다르게 해석하도록 두면 안 됩니다.
기하학적 샘플로 정렬 경계 검증하기
기본 샘플에는 실제 텍스트가 필요하지 않습니다. 방향이 같으면 점수는 1, 반대면 -1, 직교하면 0입니다. 양수를 곱해 크기를 바꿔도 방향은 달라지지 않아야 합니다. 코드의 후보 a와 b는 모두 쿼리와 방향이 같습니다. 입력 순서를 뒤집어도 식별자순으로 정렬되는지 확인하면 순회 순서에 의존하는 구현을 찾아낼 수 있습니다.
다음 항목도 추가로 확인하세요.
k에 0, 1, 후보 수, 후보 수보다 큰 값을 각각 지정합니다.- 임계값을 -1, 0, 1로 설정해 경계값 자체가 포함되는지 확인합니다.
- 빈 배열, 무한대 값, 중복 식별자, 잘못된 차원은 반드시 오류로 처리되어야 합니다.
- 고정 샘플로 유사도의 대칭성과 양수 배율에 대한 불변성을 검증합니다.
수치 단언에는 허용 오차를 사용할 수 있지만, 정렬 비교자에서는 “차이가 허용 오차보다 작으면 같다”는 방식을 쓰면 안 됩니다. 근사적 동등성은 추이성을 만족하지 않아 정렬에 필요한 조건을 깨뜨릴 수 있습니다. 서비스 요구사항상 점수 구간별로 정렬해야 한다면 양자화 규칙을 먼저 명확히 정한 뒤 정수 구간 값을 비교하세요.
참조 구현을 클라우드 검증에 연결하기
명령줄 개발 도구가 설치된 환경에서 다음을 실행합니다.
xcrun swift vector-contract.swift
스크립트는 성공하면 정상 종료하고, 단언이 실패하거나 처리되지 않은 오류가 발생하면 실패 상태를 반환합니다. CI는 이 종료 상태를 보존해야 하며, 뒤이어 성공한 명령의 상태로 덮어쓰면 안 됩니다. 컴파일 후 실행해야 한다면 일반 디버그 빌드를 사용하고, 사전 조건 검사를 제거하는 최적화 옵션은 추가하지 마세요.
참조 구현은 벡터를 하나씩 정규화하고 전체 결과를 정렬합니다. 소규모 샘플 검증에는 적합하지만 대규모 검색의 성능 최적화 방안은 아닙니다. 운영 환경에서 정규화된 벡터를 캐시한다면 캐시와 원본 벡터의 버전이 일치하는지도 추가로 테스트해야 합니다. 배치 연산이나 인덱스로 전환한 뒤에는 출력을 이 참조 결과와 대조하세요.
재현 가능한 실행 근거 남기기
실행할 때마다 커밋 식별자, 도구 체인 버전, 샘플 요약, 차원, 임계값, 예상 후보 순서를 저장하세요. 샘플에는 인위적으로 만든 벡터나 사용 권한을 확보한 비식별화 데이터만 사용하고, 사용자의 원문 텍스트를 공개 로그에 남기지 마세요.
알고리즘 회귀와 모델 드리프트 구분하기
고정 벡터 테스트는 통과하는데 실제 검색 결과가 달라졌다면, 그다음에 모델 버전, 토큰화 방식, 벡터 전처리를 확인합니다. 고정 벡터 테스트도 실패한다면 수치 연산 경로, 정렬, 후보 식별자를 먼저 점검하세요. 벡터를 다시 생성해 오류를 가려서는 안 됩니다.
마지막으로 검증 범위를 명확히 하세요. 정확 검색은 결과가 일치해야 하고, 근사 인덱스에는 별도의 재현율 기준이 필요합니다. 서로 다른 하드웨어에서는 부동소수점 값의 최하위 자릿수가 비트 단위까지 같지 않을 수 있습니다. 먼저 입력의 유효성, 명확한 경계 조건, 안정적인 동점 처리를 확보한 다음 데이터 규모를 늘려야 회귀 보고서가 수정 가능한 문제를 가리킬 수 있습니다.
자주 묻는 질문
영벡터의 코사인 유사도는 0으로 처리하나요?
아닙니다. 영벡터는 정규화할 수 없으므로 입력 오류로 거부해야 합니다. 유사도 0은 정상적인 직교 벡터의 결과와 구분해야 합니다.
점수가 같은 검색 결과는 어떻게 정렬하나요?
고유 식별자를 두 번째 정렬 기준으로 사용합니다. 후보 입력 순서가 바뀌어도 같은 결과 순서를 유지할 수 있습니다.
빌드 머신을 데이터센터에 맡기세요
전용 Mac mini를 대여해 Xcode 빌드와 macOS CI를 연중 가동되는 머신에서 실행하세요. 작업에 맞는 사양과 팀 위치에 맞는 리전을 선택할 수 있습니다.
클라우드 Mac 선택