**FAISS** (Meta)
운영 모델: 라이브러리 (in-process)
특징: 가장 빠름. persist는 직접. 단일 머신분류: Layer 12 - AI 시스템 & LLM 애플리케이션 | 선수지식: L11-70 (토크나이저·임베딩), L12-10 (LLM API)
임베딩 운영은 텍스트·이미지·문서 조각을 의미 벡터로 바꾸고, 저장하고, 빠르게 찾고, 최신성과 권한까지 유지하는 시스템이다. RAG(Retrieval-Augmented Generation, 외부 근거를 검색해 생성 입력에 붙이는 방식)·의미 검색·추천은 “모델이 답을 잘 생성하는가” 이전에 “필요한 근거를 안전하게 찾아오는가”에서 품질이 갈린다.
처음 읽을 때는 용어를 아래 순서로 잡으면 덜 헷갈린다.
768d는 숫자가 768개인 768차원 벡터라는 뜻이며, 각 차원을 사람이 직접 해석하기보다는 벡터 사이의 관계를 사용한다.1 - similarity라 작을수록 가깝다. 인덱스와 query가 같은 규칙·정규화 계약을 써야 한다.임베딩 운영이 어려운 이유는 “벡터로 저장한다” 자체가 아니라, 검색 품질·latency·storage·권한·최신성이 서로 맞물리기 때문이다.
P99는 요청의 99%가 그 시간 안에 끝나는 지연 백분위다. MRL(Matryoshka Representation Learning) 은 앞쪽 차원만 잘라도 쓸 수 있도록 표현을 학습하는 방식이고, Matryoshka embedding은 이 학습 계약을 가진 임베딩이다. 이 기능은 모델별 계약이다. Vanilla BGE-M3의 공식 dense 출력은 1024d이고 카드에는 native Matryoshka 절단이 명시되지 않는다. 반면 Voyage 4 계열은 256/512/1024/2048d 출력과 Matryoshka 절단을 공식 지원한다. 따라서 BGE-M3 1024d와 MRL 지원 모델의 256d를 같은 구성처럼 섞지 않는다.
| 운영 벽 | 왜 생기는가 | 대응 메커니즘 | 없으면 보이는 실패 신호 |
|---|---|---|---|
| Exact NN latency wall | query마다 모든 벡터와 거리 계산을 한다 | ANN, HNSW, IVF, DiskANN | 검색 P99가 수백 ms~초 단위로 튀고 concurrency를 못 버팀 |
| fp32 storage wall | 벡터 수 × 차원 × 4 bytes가 곧 RAM·SSD 비용이 된다 | quantization, Matryoshka, PQ | 1억 벡터 × 1024d fp32 = 약 400GB |
| dense lexical blind spot | 의미 압축은 고유명사·코드 식별자·법령 번호를 흐릴 수 있다 | BM25, sparse, hybrid, RRF | ”SOC2-2026”, 함수명, 조항 번호 exact match가 빠짐 |
| chunk/context wall | 검색 단위와 LLM context 단위가 다르다 | structural/recursive chunking, overlap | 정답 문장이 있어도 잘린 chunk가 근거로 쓰기 어려움 |
| first-stage precision 부족 | ANN·dense는 후보를 넓게 잡지만 최종 순위까지 정확하지 않다 | reranker, context packing | top-100에는 정답이 있는데 top-10에 안 들어옴 |
| stale embedding | 원본 수정과 embedding 재계산은 별도 파이프라인이다 | outbox, CDC(Change Data Capture), versioning, blue-green | 문서는 바뀌었는데 답변은 옛 정책을 인용 |
| permission leak | 앱이 ANN 후보를 받은 뒤 권한을 거르면 이미 권한 밖 문서가 전달된다 | server-side tenant/ACL filter, namespace, audit | HTTP 200·latency 정상인데 다른 tenant 후보가 앱에 도달 |
여기서 RRF는 dense 검색과 BM25처럼 서로 다른 ranking 목록을 원점수 대신 순위 기반으로 섞는 방법이다. 정확한 공식보다 “서로 단위가 다른 점수를 그냥 더하지 않는다”는 감각을 먼저 잡으면 된다.
RAG에서 LLM은 보통 마지막 생성 단계만 맡는다. 그 전에 vector store가 잘못된 chunk를 가져오거나, 오래된 version을 가져오거나, 권한 없는 문서를 가져오면 모델은 그 오류를 자연어로 매끄럽게 포장한다. 그래서 이 문서의 핵심 질문은 “어떤 벡터 DB가 좋은가”가 아니라 “어떤 실패를 줄이려고 이 메커니즘이 존재하는가”다.
첫 회독에서는 세부 인덱스와 벤더 비교를 모두 외우려 하지 않는다. 먼저 vector store -> chunking -> hybrid search -> reranker -> freshness -> permission filter가 어떤 실패를 줄이는지 잡고, DiskANN·HNSW 파라미터·object-storage-native 벤더 비교는 “선택 심화”로 표시된 부분에서 이름과 쓰임새만 확인해도 충분하다.
bi-encoder 임베딩(L11-70)은 텍스트를 의미 벡터로 매핑하는 데 성공했지만, 운영 단계에서 세 가지 벽에 부딪힌다. 첫 회독에서는 아래 세 문장만 잡아도 된다.
이 토픽의 세 축(ANN · quantization · hybrid+rerank)은 각 한계에 1:1 대응한다. 즉, “더 멋진 벡터 DB”를 외우기 전에 느린 검색, 비싼 저장, 고유명사 누락이라는 세 실패를 먼저 기억한다.
세 축은 독립 최적화가 아니다. ANN이 너무 좁게 후보를 만들면 reranker는 정답을 복구할 수 없고, quantization이 후보 순서를 크게 흔들면 hybrid가 보완해야 할 폭도 커진다. chunking이나 모델 버전이 바뀌면 같은 문서도 기본적으로 다른 좌표에 놓이므로 index 계약과 gold 평가를 함께 갱신해야 한다. 단, Voyage 4 계열처럼 벤더가 cross-model 호환을 명시한 모델군은 좁은 예외이며, 이 경우에도 차원·quantization과 retrieval 회귀를 검증한다. 결국 운영 철학은 싸고 넓은 후보 생성에서 recall을 지키고, 비싸고 좁은 재정렬에서 precision을 높이며, 권한과 version은 두 단계 모두의 경계로 강제한다는 것이다.
아래 숫자는 성격이 서로 다르다. 출처에서 corpus·metric·hardware를 복원할 수 있는 값은 해당 논문의 조건부 관찰로, 그렇지 않은 값은 이 문서의 가상 planning scenario 또는 초기 측정 예로 표시한다. 어느 값도 제품의 보편 성능 계약이나 선택 cutoff가 아니다.
1M × 768d fp32, 200 query에서 Flat 12.4s·18.7GB·exact-candidate recall 100%, HNSW 0.95s·13.2GB·exact-candidate recall@10 0.962를 관찰했다고 가정한다. Flat의 100%는 같은 corpus·distance의 exact top-k 기준이지 gold relevance accuracy가 아니다. 이 수치로 다른 환경의 latency나 memory를 예측하면 안 된다.candidate recall 96% @ throughput 44×였다는 값도 출처 조건을 복원할 수 없는 별도 초기 관찰 예다. 앞 bullet과 같은 실행에서 나온 값으로 합치거나 HNSW의 보편 배수로 인용하지 않는다.SIFT1B bigann의 10억 개 128차원 uint8 이미지 descriptor와 Euclidean distance를 사용했다. 10,000 query를 16개 thread로 실행한 단일 workstation 조건에서 64GB RAM과 retail SSD를 사용해 >5,000 QPS, 평균 latency <3ms, 95%+ 1-recall@1을 보고했다. 이는 해당 corpus·index·hardware의 관찰이며, DiskANN이 Vamana graph와 full-precision vector를 SSD에 두고 압축 vector를 RAM에 두는 설계를 검증한 사례다.500GB ≈ $2,000/월과 DiskANN 32GB RAM + SSD ≈ $400/월은 원출처와 가격표 시점을 복원할 수 없는 비용 가정이다. 메모리 상주 index를 SSD-resident index와 비교하는 계산 연습으로만 사용하며, DiskANN이나 특정 제품의 일반 비용으로 해석하지 않는다.1B × 768d × 4 bytes의 raw vector payload는 약 3TB다. HNSW graph를 포함한 4~5TB RAM은 graph 설정에 따라 달라지는 이 문서의 용량 planning 범위다. → Quantization(§3.7)이 등장한 이유: int8은 숫자당 byte 기준 4×, packed binary는 bit 기준 32× raw payload를 줄일 수 있다. metadata·index·replica·backup은 이 비율에 포함되지 않는다.10M 기사 embedding을 fp32에서 int8로 바꿔 vector DB 비용이 **$5,600/월 → $1,400/월(75% ↓)**가 되고 사용자 검색 품질 변화가 감지되지 않았다는 값은 원출처의 모델·corpus·hardware·가격 조건을 복원할 수 없는 초기 예다. binary 단독 품질 90~98%, fp32 rescoring 후 99%+도 같은 성격의 가상 관찰 범위이며 보편 손실률이 아니다.+10~30%는 모델·dataset·후보 폭을 고정한 초기 관찰 범위로만 사용한다. BM25로 lexical match를 보완하고 reranker가 top-100을 정렬한다는 메커니즘은 유지하되, 실제 개선 폭은 자체 gold query로 다시 측정한다.이 토픽이 사라지면 RAG는 brute force NN의 초 단위 latency, TB급 RAM 요구, 고유명사 검색 불가 상태로 즉시 회귀한다.
Vector store는 벡터만 저장하는 테이블이 아니다. 운영에서는 최소 다섯 가지를 함께 책임진다.
embedding: 검색에 쓰는 숫자 벡터doc_id / chunk_id: LLM에게 보여줄 원본 근거로 되돌아가기 위한 IDmetadata: tenant_id, acl, language, doc_type, version, updated_atindex: Flat, HNSW, IVF, DiskANN 같은 검색 구조consistency policy: upsert, delete, version 교체, reindex 중 어떤 결과를 보장하는지이 중 metadata filter가 특히 중요하다. vector similarity가 아무리 높아도 사용자가 볼 수 없는 문서라면 검색 후보가 되면 안 된다. “일단 검색하고 나중에 권한 필터”는 RAG에서는 위험하다. 후보가 reranker나 prompt에 들어가는 순간 권한 밖 정보가 모델 입력으로 넘어갈 수 있기 때문이다.
아래 표는 제품 순위표가 아니라 대표 운영 모델을 훑는 용도다. 첫 회독에서는 도구 이름을 외우지 말고 라이브러리, RDBMS, managed SaaS, self-host vector DB, 검색 엔진 통합, object-storage-native처럼 어떤 운영 부담을 가져가는지만 본다.
운영 모델: 라이브러리 (in-process)
특징: 가장 빠름. persist는 직접. 단일 머신운영 모델: RDBMS
특징: SQL·트랜잭션 통합. 차원·filter·update 부하를 Postgres와 함께 측정운영 모델: SaaS
특징: managed, 운영 부담 없음. 비싸짐운영 모델: self-host or cloud
특징: hybrid search 빌트인, GraphQL운영 모델: self-host or cloud
특징: Rust 기반, payload filter 강함운영 모델: self-host or cloud (Zilliz)
특징: 분산 indexing·serving, shard·replica 운영운영 모델: embedded / server
특징: prototype에 편함운영 모델: self-host
특징: 검색 엔진 + vector·ranking 통합운영 모델: self-host or AWS
특징: 검색·분석 엔진 + vector/hybrid 통합운영 모델: SaaS (object storage)
특징: object storage native, serverless 검색vector count만으로 제품 후보나 migration 시점을 정하지 않는다. 후보마다 같은 vector 차원, filter 분포, update/delete trace, 동시성, cold/warm cache 조건을 재생하고, 허용 memory 안의 P95/P99와 latency-recall curve를 비교한다. 측정 결과와 함께 데이터·검색 계층의 운영 ownership, SQL·트랜잭션 결합 필요, backup·복구 책임이 최종 선택 기준이다.
이 선택 기준은 제품 순위표가 아니라 운영 부담의 분해다. 이미 Postgres에서 원본 문서와 권한을 관리한다면 pgvector가 단순하고, 검색팀이 OpenSearch analyzer와 dashboard를 운영 중이면 OpenSearch의 진입 비용이 낮다. 반대로 tenant별 namespace가 많고 대부분이 long-tail cold corpus라면 object-storage-native 계열의 문제의식이 맞을 수 있다. 서비스별 기능·가격·제한은 바뀌므로, 여기서는 “저장 구조와 운영 모델이 어떤 실패를 줄이는가”를 중심으로 읽는다.
OpenSearch는 원래 검색·분석 엔진이다. Lucene 기반 inverted index, analyzer, BM25, aggregation, dashboard, log/observability 생태계가 강하고, 여기에 k-NN vector search와 neural/hybrid search 기능이 붙었다. 그래서 “문서 검색 + 로그 분석 + faceted search + 운영 대시보드 + vector search”를 한 시스템에서 다루고 싶을 때 자연스럽다.
Turbopuffer는 반대로 LLM/RAG 시대의 vector·full-text first-stage retrieval 문제에서 출발한 서비스다. RAG 코퍼스가 커져도 모든 tenant와 namespace가 항상 hot하지는 않다. 기존 vector DB나 search cluster처럼 많은 데이터를 RAM/SSD에 계속 올려두면 long-tail 데이터까지 비싼 compute/storage를 점유한다. Turbopuffer의 핵심 가정은 “상태는 object storage에 싸게 두고, query compute는 stateless하게 두며, 자주 쓰는 namespace만 NVMe/memory cache로 데운다”이다.
해결 메커니즘은 세 부분이다.
| 기준 | Turbopuffer | OpenSearch | 선택 판단 |
|---|---|---|---|
| 정체성 | object-storage-native vector/full-text DB | open-source search/analytics suite + vector engine | RAG retrieval 전용이면 Turbopuffer, 검색 플랫폼이면 OpenSearch |
| 저장 구조 | object storage + NVMe/memory cache | Lucene index/shard 중심 cluster 운영 | long-tail·cold corpus는 Turbopuffer 경제성이 좋음 |
| 검색 기능 | ANN, exact kNN, BM25, sparse vector, filter, multi-query/RRF | BM25, analyzer, k-NN, neural query, hybrid query, search pipeline, aggregation | rich query DSL·분석·faceting은 OpenSearch가 강함 |
| 운영 부담 | cluster/shard를 덜 관리하지만 namespace·cache·consistency trade-off 이해 필요 | shard sizing, JVM/heap, refresh, replica, ML node, index lifecycle 관리 필요 | 운영팀이 이미 OpenSearch를 잘 쓰면 OpenSearch 진입 비용이 낮음 |
| latency | warm query는 낮지만 cold query·cache miss가 tail latency가 됨 | provisioned cluster면 예측 가능하지만 capacity 비용을 계속 지불 | 사용자별 namespace가 많고 접속 전 prewarm 가능하면 Turbopuffer 적합 |
| 쓰기 특성 | object storage WAL 덕분에 durable·high throughput, 대신 write commit latency와 async indexing 고려 | near real-time indexing, bulk/index refresh 튜닝 중요 | heavy update/delete workload는 사전 벤치 필수 |
| 2단계 ranking | 앱 코드에서 rerank·business logic 조립 권장 | search pipeline/query DSL 안에 많은 검색 로직을 넣기 쉬움 | 검색 로직을 코드로 유지하고 싶으면 Turbopuffer 쪽이 단순 |
실무 결론: “이미 OpenSearch로 로그·문서 검색·분석을 운영하고 있고, vector search를 추가하고 싶다”면 OpenSearch가 자연스럽다. “B2B SaaS처럼 tenant별 namespace가 많고, 대부분은 가끔만 검색되며, RAG 후보군을 저렴하게 뽑는 것이 핵심”이면 Turbopuffer가 등장한 문제의식과 맞다. 단, Turbopuffer도 cold query, write latency, built-in reranker/embedding 부재, 상용 서비스 의존성을 감수해야 한다.
벡터 검색은 “의미”라는 추상어를 곧바로 검색하지 않는다. 기본 계약은 같은 임베딩 모델이 만든 query vector와 document vector를 같은 차원에 놓고, cosine·dot product·Euclidean distance 중 하나로 비교하는 것이다. 벤더가 cross-model 호환을 명시적으로 보장한 모델군만 이 기본 계약의 예외가 될 수 있다. Cosine similarity는 두 벡터의 방향을 보고 양의 배율 변화에는 불변이다. 즉 lambda > 0일 때 cos(q, lambda*x) = cos(q, x)다. 반면 **dot product(내적)**는 방향과 크기를 함께 반영하고, **Euclidean distance(L2 거리)**는 좌표 사이의 직선거리를 보므로 정규화하지 않은 벡터의 크기 변화에 따라 순위가 달라질 수 있다.
모든 query와 document를 L2 norm 1인 unit vector로 정규화하면 cosine similarity와 dot product 값이 같아져 두 검색의 순위도 같다. unit vector x, y에서는 ||x-y||^2 = 2 - 2*cos(x,y)이므로 cosine을 크게 하는 순위와 L2 또는 L2 제곱을 작게 하는 순위도 같다. 이 동치는 양쪽 벡터를 같은 방식으로 unit normalization했다는 계약 안에서만 성립한다.
따라서 distance metric은 구현 옵션이 아니라 데이터 계약이다. document를 cosine 기준으로 색인해 놓고 query에만 다른 정규화나 호환 보장 없는 모델을 쓰면 요청은 성공해도 순위 의미가 깨진다. 또한 cosine 0.8이 모든 모델·도메인에서 같은 품질을 뜻하지 않으며, cross-model cosine threshold가 호환성을 증명하지도 않는다. 임베딩 공간은 모델이 학습한 상대 좌표계이므로, 호환 여부는 공식 벤더 계약으로, 품질 threshold는 자체 gold dataset으로 확인한다. **Gold dataset(골드 데이터셋)**은 query마다 사람이 검수한 관련 문서와 근거를 기록해 둔 고정 평가 집합이다. 모델·index·chunking을 바꿔도 같은 질문과 정답으로 회귀를 비교하게 해준다.
작은 손계산으로 경계를 확인해 보자. query q=[1, 0], 문서 a=[0.8, 0.6], b=[0, 1]이면 세 벡터의 길이는 모두 1이다. cos(q,a)=dot(q,a)=0.8, cos(q,b)=dot(q,b)=0이고, L2(q,a)^2=0.4, L2(q,b)^2=2이므로 세 metric 모두 a를 먼저 둔다. 반면 정규화하지 않은 c=[2, 0]을 넣으면 cos(q,c)=1이라 cosine은 c를 a보다 앞에 두지만, L2(q,c)=1이고 L2(q,a)=sqrt(0.4), 약 0.632이므로 L2는 a를 c보다 앞에 둔다. dot product도 dot(q,c)=2로 크기에 영향을 받는다. 이 반례가 cosine은 양의 scale에 불변이고, cosine·dot·L2 순위 동치가 unit vector에만 성립한다는 뜻이다.
NN(Nearest Neighbor, 최근접 이웃) 검색은 query vector와 가장 가까운 문서 vector를 찾는 일이다. Exact NN은 모든 벡터와 거리를 계산하므로 정확하지만, 코퍼스가 커질수록 latency가 선형으로 증가한다. 1M개 문서, 768차원 벡터라면 query 하나마다 대략 1,000,000 × 768개의 차원 비교가 필요하다. query가 200개만 동시에 들어와도 “정확하지만 느린” 방식은 챗봇 UX의 검색 단계로 쓰기 어렵다.
**ANN(Approximate Nearest Neighbor, 근사 최근접 이웃)**은 이 벽을 낮추기 위해 등장했다. 근사의 의미는 “아무 결과나 대충 찾는다”가 아니라, 정확한 top-k를 매번 보장하지 않는 대신 가까울 가능성이 높은 후보를 훨씬 빨리 찾는다는 뜻이다. 그래서 ANN의 품질은 항상 latency와 recall@k를 같이 봐야 한다.
이 문서의 실험은 아래 다섯 문서를 공통으로 사용한다. 실제 임베딩 대신 결과가 항상 같은 2차원 벡터를 써서, 프레임워크나 provider 없이도 순위를 손으로 검산한다. query는 tenant acme의 SOC2-2026 감사 정책이고 q=[1,0]이다. 사람이 검수한 정답 문서 집합은 G={d1,d3}라고 두자.
| ID | tenant | 문서 요약 | vector | cos(q,d) | gold 관련 문서인가 |
|---|---|---|---|---|---|
| d1 | acme | SOC2-2026 증적 보관 7년 | [0.8, 0.6] | 0.800 | 예 |
| d2 | beta | SOC2-2026 비공개 예외 | [1.0, 0.0] | 1.000 | 권한 밖 |
| d3 | acme | SOC2-2026 감사 일정과 제출 의무 | [0.9, 0.1] | 약 0.994 | 예 |
| d4 | acme | SOC2-2026 증적 통제 개요 | [0.7, 0.7] | 약 0.707 | 아니오 |
| d5 | acme | 환불 정책 | [0.0, 1.0] | 0.000 | 아니오 |
전체에서 dense top-2를 먼저 뽑으면 [d2,d3]이다. DB executor가 ANN index scan 뒤 같은 SQL 안에서 WHERE tenant='acme'를 적용하면 애플리케이션에는 [d3] 하나만 반환된다. 권한 밖 d2가 DB 경계를 넘지는 않았으므로 이것만으로 permission leak은 아니지만, recall=1/2=50%와 requested K보다 적은 underfill이 생긴다. 반환된 행만 분모로 보는 precision_returned=1/1=100%와 요청한 두 자리를 분모로 고정하는 precision@2=1/2=50%가 갈리는 반례다.
반대로 acme를 후보 생성 전에 pre-filter한 뒤 top-2를 뽑으면 [d3,d1]이고 recall, precision_returned, precision@2가 모두 100%다. 엔진이 구조상 executor-side post-filter를 한다면 후보 폭을 2에서 4로 늘린 뒤 filter하고 top-2를 잘라 [d3,d1]을 회복할 수 있지만, 더 많은 graph 탐색과 latency를 지불한다. 이 문제와 response-side permission leak을 구분해야 한다. 애플리케이션이 [d2,d3]을 전달받아 reranker·prompt·cache·log에 넣은 뒤 최종 응답 직전에 d2를 거르면, 화면에 d2가 보이지 않아도 권한 밖 데이터가 이미 신뢰 경계를 넘어 leak이 발생한 것이다.
여기서 **recall(재현율)**은 gold 정답 중 검색한 비율이고, **precision(정밀도)**은 검색 결과 중 정답 비율이다. candidate 폭을 늘리면 보통 recall은 오르지만 오답 후보와 계산량도 늘어난다. 보안 filter는 후보 폭으로 타협할 대상이 아니며 반드시 강제하고, 언어·날짜 같은 품질 filter만 recall을 보며 완화한다.
ANN benchmark에서 자주 쓰는 exact-candidate recall@k는 이 gold relevance recall과 분모가 다르다. 같은 corpus, query vector, distance metric의 Flat exact top-k를 정답 후보 집합으로 두고 ANN이 그중 몇 개를 재현했는지 본다. 따라서 Flat은 정의상 exact-candidate recall이 100%지만, Flat top-k 자체가 사람의 gold 관련 문서를 놓쳤다면 relevance accuracy는 낮을 수 있다. 아래 Flat 100%, HNSW 95~99% 표기는 전자만 뜻하며, HNSW 범위는 §2.5 가상 planning scenario의 조건부 예시다.
작은 숫자로 감을 잡으면 다음과 같다.
Flat exact: 모든 점과 cosine 계산 exact-candidate recall@10 = 100% (같은 corpus·distance의 기준선) 데이터가 커질수록 latency = O(N)
HNSW: 이웃 그래프를 따라 가까운 후보만 탐색 exact-candidate recall@10 = 95~99% (§2.5 가상 planning scenario의 초기 목표 예시) latency는 efSearch와 graph 품질에 좌우| 알고리즘 | 메모리 | latency | Flat 대비 exact-candidate recall 예시 | 비고 |
|---|---|---|---|---|
| Flat | 100% (원본) | O(N) | 100% (정의상) | brute force 기준선. 차원·동시성·SIMD·filter 조건을 측정 |
| HNSW | ~1.5× 원본 (§2.5 가상 planning scenario) | 빠른 경우가 많음 | 95~99% (§2.5 가상 planning scenario) | graph·parameter·데이터 분포에 의존 |
| IVF | ~원본 | k-means cluster + 탐색 | 90~95% | centroid·nprobe·데이터 분포에 의존 |
| IVF-PQ | 원본의 4~32× ↓ | 빠른 경우가 많음 | 85~95% | Product Quantization 구성과 rescoring 여부에 의존 |
| DiskANN | 디스크 사용 | SSD 특성에 의존 | 95%+ (§2.5 NeurIPS 2019 SIFT1B 관찰) | Vamana graph·SSD·cache·query workload의 benchmark 관찰 예 |
| SCANN | 메모리·디스크 | 하드웨어에 의존 | 95%+ | partition·quantization·hardware별 benchmark 관찰 예 |
HNSW는 “가까운 친구의 친구를 따라가면 더 가까운 점을 찾을 수 있다”는 그래프 탐색이다. 위쪽 sparse layer에서는 멀리 건너뛰고, 아래쪽 dense layer에서는 후보를 촘촘히 훑는다. centroid를 학습하는 단계가 없어 새 분포에서도 바로 build를 시작할 수 있고, 같은 메모리급 ANN 중 speed-recall 곡선이 좋은 경우가 많다. 대가는 각 vector의 이웃 edge를 보관하는 메모리, 높은 build 비용, 삽입·삭제가 누적될 때 graph 품질과 update 비용을 관리해야 한다는 점이다. 그래프 탐색이 실무에서 sublinear하게 동작하는 경우가 많아도 입력 분포와 graph 품질에 무관한 O(log N) 보장은 아니다.
IVF는 철학이 다르다. 대표 표본으로 centroid를 학습해 벡터 공간을 여러 cell로 나누고, 각 vector ID를 가장 가까운 cell의 inverted list에 넣는다. query 때는 가까운 cell을 nprobe개만 열어 그 안에서 후보를 찾는다. nprobe가 작으면 빠르지만 cell 경계 반대편의 실제 이웃을 놓칠 수 있고, 크게 하면 recall과 latency가 함께 올라 exact search에 가까워진다. HNSW의 후보 폭이 efSearch라면 IVF의 후보 폭은 주로 nprobe와 cell 크기가 만든다.
centroid training은 IVF의 장점이자 lifecycle 경계다. bulk ingest 전에 대표 표본으로 한 번 학습해 inverted list를 병렬로 채우기 쉽고, HNSW의 graph edge 메모리를 피하며, IVF-PQ처럼 compression을 결합하기도 좋다. 반면 새 tenant·언어·상품군이 들어와 vector 분포가 바뀌면 특정 cell만 비대해지거나 query가 잘못된 cell을 보게 된다. list 크기 편향, 같은 nprobe에서 recall 하락, scanned candidates 급증이 distribution drift 신호다. 이때 nprobe만 키우는 것은 임시 완화이고, 대표 표본 재수집·centroid retraining·index rebuild를 검토해야 한다.
toy corpus에서 d1과 d3이 서로 다른 IVF cell 경계에 걸렸다고 하자. nprobe=1이 d3의 cell만 보면 d1은 reranker 입력에도 들어오지 않는다. nprobe=2로 두 cell을 보면 recall이 회복되지만 조회 범위가 늘어난다. 이 예는 HNSW와 IVF의 내부 구조는 달라도, 후보 폭을 줄여 latency를 얻고 recall을 내주는 ANN 철학은 같다는 것을 보여준다.
| 조건 | HNSW가 유리한 이유 | IVF 계열이 유리한 이유 |
|---|---|---|
| 빠른 query와 높은 recall | 무학습 graph 탐색의 speed-recall 곡선이 좋은 경우가 많음 | 충분한 nprobe가 필요해지면 탐색 cell과 후보 수가 늘어남 |
| 메모리가 빠듯함 | graph edge overhead 때문에 불리 | edge 메모리가 없고 IVF-PQ compression을 결합하기 쉬움 |
| 대규모 bulk build | graph 연결 생성이 CPU·시간을 많이 사용 | centroid 학습 뒤 list assignment를 병렬 bulk 처리하기 쉬움 |
| 지속적인 insert/delete | 온라인 삽입은 가능하지만 graph 품질·삭제 누적 관리 필요 | 새 데이터가 기존 분포와 비슷할 때 list append가 단순 |
| 데이터 분포가 자주 바뀜 | centroid retraining은 없지만 graph recall은 계속 측정 | centroid drift를 측정하고 주기적 retraining·rebuild가 필요 |
| 강한 압축이 핵심 | 별도 quantization을 붙여야 함 | IVF-PQ로 coarse cell 탐색과 codebook compression을 함께 설계 |
M: HNSW graph의 이웃 연결 수 (16~64). 클수록 recall 가능성과 메모리·build/update 비용이 함께 오른다. 기존 graph의 M을 바꾸려면 rebuild가 필요하다.efConstruction: HNSW 노드를 넣을 때 좋은 이웃을 찾는 build-time 탐색 폭 (100~500). 높이면 build가 느려지지만 graph 품질이 좋아질 수 있다. 기존 노드에는 소급되지 않으므로 전체 효과를 비교하려면 rebuild한다.efSearch: query마다 유지하는 후보 폭 (50~500). query/session-time knob라 rebuild 없이 바꿀 수 있고, 늘리면 보통 recall과 latency가 함께 오른다.nprobe는 query-time knob라 rebuild 없이 latency-recall 곡선을 조절한다.운영 실험 시작값으로 M=32, efConstruction=200, efSearch=100을 둘 수 있지만 범용 정답은 아니다. efSearch를 query-time sweep해 한계를 먼저 찾고, 그래도 graph recall이 부족할 때만 rebuild 비용을 감수하고 M·efConstruction 조합을 비교한다.
초기 실험 트리거 (silent failure 예방):
코퍼스 raw payload × 1.5는 §2.5 가상 planning scenario의 graph 설정별 초기 용량 추정 예시일 뿐이며, 그대로 강행했을 때 OOM 또는 swap과 P99 급등이 나타나는지 RSS·page fault로 확인한다.efSearch를 100→200처럼 sweep하고, 그래도 미달이면 M 16→32 조합을 rebuild해 비교할 수 있다. 숫자는 시작점이며, efSearch를 올려 latency만 증가하면 embedding·chunking·hybrid·filter를 분리한다.Chunking은 원본 문서를 embedding과 검색에 맞는 조각으로 나누는 일이다. 임베딩 모델은 보통 문장 또는 짧은 문서 조각을 하나의 vector로 압축한다. 30페이지 정책 문서 전체를 한 vector로 만들면 여러 주제가 한 좌표에 섞이고, 한 문장을 너무 잘게 자르면 LLM이 답변에 필요한 조건과 예외를 잃는다.
worked example로 보면 더 쉽다.
원문: 환불은 결제 후 7일 안에 가능하다. 단, 이미 사용한 크레딧은 환불 대상에서 제외한다. 엔터프라이즈 계약은 별도 약관을 따른다.
나쁜 chunk: "환불은 결제 후 7일 안에 가능하다."
좋은 chunk: "환불은 결제 후 7일 안에 가능하다. 단, 이미 사용한 크레딧은 제외한다."나쁜 chunk는 recall에는 걸릴 수 있지만, 답변에는 필요한 예외 조건이 빠진다. 반대로 여러 섹션을 한 chunk에 넣으면 “환불” query에 계약, 보안, 결제 실패 문장까지 같이 들어와 context precision이 떨어진다. chunking은 검색 recall과 LLM context precision 사이의 균형점이다.
chunking은 저장 전처리로 끝나지 않고 검색 단위를 정의한다. gold dataset의 정답이 문서 ID만 가리키면 어떤 chunk가 근거 문장을 포함해야 하는지 평가할 수 없으므로, 가능하면 query -> relevant_doc_id -> evidence span을 함께 기록한다. 그러면 작은 chunk는 evidence span을 잘라 먹는지, 큰 chunk는 관련 없는 문장을 얼마나 함께 싣는지 분리해서 볼 수 있다.
또 하나의 반례는 overlap을 무조건 늘리는 것이다. 800토큰 chunk에 400토큰 overlap을 주면 경계 문맥은 보존되지만 같은 근거가 여러 chunk로 복제된다. top-5가 사실상 같은 문단의 복사본으로 채워지면 문서 recall은 높아 보여도 정보 다양성과 context precision은 낮다. overlap은 경계 손실을 줄이는 보험이지, dedupe와 chunk identity 없이 늘릴 무료 품질 노브가 아니다.
| 방식 | 설명 | 장점 | 단점 |
|---|---|---|---|
| Fixed-size | N 토큰 단위 (예: 512) | 단순, 빠름 | 의미 단위 깨짐 |
| Recursive | paragraph → sentence → token 순 분할 | 의미 구조 부분 보존 | LangChain RecursiveCharacterTextSplitter 표준 |
| Semantic | 임베딩 거리 기반 분할 | 의미 단위 보존 | 비용 ↑, latency ↑ |
| Structural | markdown heading·HTML 구조 | 문서 구조 보존 | 구조 없는 텍스트엔 무력 |
| Sliding window | overlap 두고 슬라이드 | 경계 정보 보존 | 중복 ↑ |
숫자 감각도 같이 잡아야 한다. 800토큰 chunk를 top-10으로 그대로 넣으면 검색 context만 8,000토큰이다. 여기에 system prompt, user query, citation format, 이전 대화가 붙으면 16k context 모델에서도 여유가 빠르게 줄어든다. 그래서 production에서는 retrieve top-100 → rerank top-10 → context top-5~7처럼 후보군과 최종 context를 분리한다.
Late chunking(Jina, 2024~2025 계열)은 문서 전체를 먼저 encoder에 통과시킨 뒤 token-level 표현에서 chunk vector를 추출하는 접근이다. chunk 경계 밖의 앞뒤 문맥을 어느 정도 반영할 수 있어 context-aware chunking에 가깝다. 단, 비용과 모델 지원 여부가 붙으므로 “기본 chunking 대체재”라기보다 긴 문서에서 경계 손실이 실제 품질 병목일 때 검토한다.
Dense search는 문장의 의미를 벡터 거리로 찾는다. “휴가 신청 절차”와 “연차 요청 방법”처럼 표현이 달라도 의미가 가까운 문서를 잘 찾는다. 하지만 고유명사, 법령 번호, 에러 코드, 함수명처럼 정확한 문자열이 중요한 query에서는 의미 압축이 오히려 단서를 흐릴 수 있다.
BM25는 query 단어와 문서 단어가 얼마나 잘 겹치는지, 단어가 얼마나 희귀한지, 문서가 얼마나 긴지를 고려하는 lexical ranking이다. 의미가 비슷한 paraphrase에는 약하지만, "SOC2-2026", "ERR_AUTH_401", "Article 17"처럼 exact match가 중요한 단서에는 강하다.
BM25가 단순 문자열 포함 검사와 다른 이유는 세 가지다. 여러 문서에 흔한 단어보다 드문 단어의 IDF(Inverse Document Frequency, 역문서 빈도)를 크게 보고, 한 문서에 같은 단어가 반복돼도 점수를 무한히 선형 증가시키지 않으며, 긴 문서가 단어를 많이 포함했다는 이유만으로 유리해지지 않도록 길이를 보정한다. 그래서 정책은 거의 모든 문서에 있어 약한 신호이고 SOC2-2026은 드물어 강한 신호가 된다. 반대로 연차 요청 방법으로 휴가 신청 절차를 찾는 paraphrase에는 단어 겹침이 적어 dense search가 더 강하다.
Hybrid search는 둘 중 하나를 고르는 것이 아니라 서로의 blind spot을 보완한다.
1. BM25 (lexical) → top-1002. Dense embedding (semantic) → top-1003. RRF (Reciprocal Rank Fusion): score = Σ 1/(k + rank_i)4. 합쳐서 top-K 반환작은 반례:
| query | dense만 썼을 때 생길 수 있는 실패 | BM25가 보완하는 단서 |
|---|---|---|
SOC2-2026 보관 정책 | ”보안 감사 정책” 일반 문서가 위로 옴 | SOC2-2026 exact match |
getUserById timeout | ”사용자 조회 지연” 문서와 섞임 | 함수명 getUserById |
제17조 환불 예외 | 환불 일반 가이드가 위로 옴 | 제17조와 “예외” exact term |
ERR_BILLING_042 | billing 관련 FAQ만 찾고 코드 누락 | 에러 코드 문자열 |
공통 toy corpus에서도 dense 순위는 [d2,d3,d1,d4,d5]이고 권한 pre-filter 뒤에는 [d3,d1,d4,d5]다. lexical 순위는 exact term 수에 따라 [d1,d4,d3,d5]로 둔다. k=60이면 d1은 dense 2위·lexical 1위라 1/62+1/61=0.032522, d3은 dense 1위·lexical 3위라 1/61+1/63=0.032266이다. 따라서 d1의 RRF 점수가 엄격하게 더 크며, 동률을 입력 순서로 깨서 개선처럼 보인 결과가 아니다. 같은 점수가 생길 수 있는 일반 경우에는 fixture처럼 document ID 오름차순을 deterministic tie-break로 둔다. 기본 RRF는 각 검색기의 reciprocal rank를 가중치 없이 더하므로 모든 검색기에 같은 weight를 준다. k는 검색기별 weight가 아니라 순위 기여 곡선의 모양을 정한다. 작을수록 상위 순위 사이의 점수 차이가 가팔라져 상위권 영향이 커지고, 클수록 순위 차이가 평탄해진다. RRF의 목적은 한 검색기를 승자로 정하는 것이 아니라 원점수 단위가 다른 검색기들의 순위를 사용해 서로 다른 누락 원인을 가진 후보 합집합을 안정적으로 만드는 것이다.
k를 낮추면 상위 순위의 기울기가 가팔라지고 높이면 순위 기여가 평탄해진다.α·score_dense + (1-α)·score_bm25로 합치거나, weighted RRF를 쓴다. raw score는 단위가 달라 그대로 더하지 않는다.semantic retrieval 품질은 한 번에 한 기법으로 해결되지 않는다. production에서는 recall을 먼저 확보하고, rerank·filter로 precision을 회복하는 순서가 안정적이다.
1. Query rewriting: 사용자 표현을 문서 표현으로 정규화2. Hybrid search: dense가 놓치는 고유명사·식별자·숫자를 BM25로 보완3. Metadata filter: tenant, permission, product, language, updated_at 범위 제한4. Rerank: top-50~100 후보를 cross-encoder로 정렬5. Context packing: parent chunk, dedupe, 최신 문서 우선순위 적용tenant_id, ACL 같은 보안 조건은 권한 밖 후보가 DB 또는 검색 서비스의 신뢰 경계를 넘기 전에 강제한다. DB executor가 ANN index scan 뒤 같은 query의 WHERE를 적용하면 leak은 막아도 underfill과 recall 손실이 생길 수 있다. 반면 앱이 권한 밖 후보를 받은 뒤 reranker·prompt·응답 직전에 거르는 response-side filter는 이미 데이터가 전달됐으므로 permission leak이다. doc_type, language, updated_at 같은 품질 조건은 별도로 측정하고 필요하면 fallback으로 완화한다.retrieve top-100 → rerank top-10 → context top-5~7이다.updated_at decay 또는 version filter를 적용한다.Reranker는 1차 검색이 만든 후보를 query와 함께 다시 읽고 관련도를 재점수화하는 모델이다. bi-encoder는 query와 document를 따로 벡터화해 미리 색인할 수 있어 빠르지만, query-document 상호작용을 하나의 거리로 압축한다. cross-encoder reranker는 query와 document를 함께 넣어 “이 query에 이 문서가 답인가”를 직접 판단하므로 정확하지만 후보마다 추론 비용이 든다.
Stage 1 (retrieve): bi-encoder로 top-100 후보 (10ms)Stage 2 (rerank): cross-encoder reranker로 top-10 (50~100ms)예를 들어 query가 “엔터프라이즈 환불 예외”이고 1차 검색 top-3이 아래와 같다고 하자.
1. "일반 환불은 결제 후 7일 안에 가능하다"2. "엔터프라이즈 계약은 별도 약관을 따른다"3. "크레딧 사용분은 환불 대상에서 제외한다"dense retrieve는 세 문서를 모두 관련 후보로 잡을 수 있지만, 최종 답변에 가장 중요한 문서는 2번일 가능성이 크다. reranker는 query와 후보 문장을 함께 읽고 2번을 위로 올린다. 단, 2번이 top-100 안에 없었다면 reranker는 찾을 수 없다. 그래서 순서는 항상 recall 확보 → rerank로 precision 회복이다.
candidate 폭에도 상한과 하한이 있다. top-10만 rerank하면 1차 검색 11위의 정답은 영원히 사라지고, top-1000을 rerank하면 문서마다 cross-encoder 추론을 해 latency와 비용이 급증한다. 따라서 retrieve N은 reranker 모델의 설정값이 아니라 exact/Flat 기준 recall 곡선과 latency budget이 만나는 지점이다. first-stage recall@100은 충분한데 최종 precision@10이 낮을 때 reranker를 의심하고, recall@100부터 낮다면 ANN·hybrid·chunking·filter를 먼저 고친다.
초기 관찰 예: 일부 공개 benchmark와 사내 평가에서는 reranker 뒤 recall@10이 10~30% 개선되기도 한다. 개선 폭은 embedding·reranker 모델, 언어·도메인, first-stage 후보 폭, gold 정의에 의존하므로 고정 ROI 계약이 아니다. 같은 top-N 후보와 자체 gold query에서 precision@10·NDCG@10·추가 latency를 함께 측정한다.
문서 하나 = 단일 vector 대신 token별 vector 집합.
ColBERT: 각 query token이 각 document token과 max 유사도, 합 = 점수L11-70 §3.10·3.11을 운영 시점으로.
임베딩 검색의 storage 공식은 단순하다.
저장 공간 = vector_count × dimension × bytes_per_number그래서 1B × 768d × fp32(4 bytes)는 원본 벡터만 약 3TB다. HNSW graph와 metadata, replica까지 포함하면 “벡터를 저장했다”가 아니라 “RAM과 SSD 예산을 잠갔다”에 가까워진다. Quantization은 숫자 하나를 더 적은 bit로 표현해 이 벽을 낮추고, 차원 축소는 숫자 개수 자체를 줄인다. int8 같은 scalar quantization은 각 숫자의 표현 정밀도를 낮추고, **PQ(Product Quantization)**는 vector를 여러 부분 벡터로 나눈 뒤 각 부분을 가까운 codebook centroid ID로 저장한다. 둘 다 원래 좌표를 근사하므로 storage 절감은 거리 순서가 일부 뒤집힐 수 있다는 대가와 교환한다.
1536d fp32 → 256d 1-bit의 1536×32/256=192×는 Matryoshka 절단과 binary quantization을 함께 지원하는 compatible model에서, bit가 빈틈없이 pack된다고 가정한 raw vector payload의 최대 이론 절감값이다. metadata, HNSW·IVF index, alignment, 원본 fp32 rescore vector, replica와 backup은 계산에 들어 있지 않다. 따라서 192×를 전체 storage나 월 비용 절감으로 읽으면 안 된다.
핵심 오해는 “압축하면 무조건 싸고 좋다”이다. 압축은 1차 후보를 싸게 많이 뽑는 데 강하지만, 단일 단계 검색의 최종 판단까지 맡기면 작은 의미 차이가 사라질 수 있다. 압축 벡터 단독과 압축 벡터로 넓게 retrieve → 원본 fp32 또는 reranker로 verify를 둘 다 후보로 두고, 같은 gold query에서 recall·latency·storage 곡선을 비교해 더 단순한 구성이 SLO를 만족하면 단일 단계를 유지한다.
toy corpus에서 d1의 cosine 0.800과 d4의 약 0.707은 여유가 있지만, 실제 top-k 경계에서 두 문서 점수가 0.801과 0.800이라면 int8 반올림만으로 순서가 바뀔 수 있다. top-1000 후보에는 둘 다 남아 fp32 rescoring으로 회복할 수 있지만, 압축 벡터 top-1만 반환하면 정답을 영구히 잃는다. quantization 평가는 평균 cosine 오차보다 gold 정답이 후보 집합에서 빠지는가를 recall@k로 봐야 한다.
| 기법 | raw vector payload 절감 | 공개 benchmark의 품질 변화 예 | 판단 기준 |
|---|---|---|---|
| fp32 → fp16 | 2× | ~0인 사례 | model·distance 구현과 자체 recall로 확인 |
| fp32 → int8 | 4× | <1%인 사례 | top-k 경계 query slice와 rescore 필요 여부 확인 |
| fp32 → binary (1bit) | 32× | ~5% 손실 사례 | 후보를 넓게 뽑고 fp32 rescoring 전후를 비교 |
| Matryoshka 256d | 4× (vs 1024d), 6× (vs 1536d) | 가상 초기 관찰 1~3% 손실 | MRL을 명시한 모델의 공식 차원과 자체 gold recall 확인 |
| PQ (Product Quant) | 4~32× (구성) | 5~15% 손실 사례 | codebook·subvector 수·rescore 폭별 latency-recall 측정 |
10M 기사, $5,600/월 → $1,400/월(75% ↓), 품질 변화 미감지 값은 출처가 복원된 Cohere 고객 사례가 아니라 이 문서의 planning scenario다. 실제 의사결정에서는 같은 모델·corpus·hardware·가격표로 fp32와 int8을 다시 측정한다.Freshness는 원본 문서 변경이 검색 결과에 반영되기까지의 시간과 정확성이다. RAG에서는 “문서 DB는 최신인데 vector store는 옛날”이 매우 흔하다. 모델은 검색된 근거가 최신인지 스스로 알지 못하므로, stale embedding은 자연어로 그럴듯하게 포장된 오래된 답변이 된다.
갱신은 단순히 UPDATE vector 문제가 아니다. 원본 문서가 바뀌면 chunk 경계가 바뀔 수 있고, chunk hash가 바뀌면 embedding도 바뀌며, 이전 version vector는 검색에서 제외돼야 한다. 또한 검색 중인 query와 reindex job이 동시에 돌 때 어떤 version을 볼지 정해야 한다.
각 패턴의 실패 모드는 다르다. append-only는 삭제와 수정에 약하고, soft delete는 orphan vector cleanup이 필요하며, versioning은 검색 후 latest filter만으로는 오래된 chunk가 top-k를 점유할 수 있다. freshness가 제품 요구사항이면 “매일 새벽 reindex”보다 변경 이벤트를 잃지 않는 파이프라인을 먼저 설계해야 한다.
**Stale index(오래된 인덱스)**는 현재 원본 version이 아직 색인되지 않았거나 폐기한 version이 계속 검색되는 상태를 통칭한다. stale embedding은 그중 벡터 표현이 오래된 경우이고, chunk 목록·metadata·ANN graph가 원본과 어긋난 경우도 stale index다. HNSW처럼 쓰기와 삭제가 누적되는 구조에서는 soft-delete 표식만 늘고 graph가 즉시 조밀하게 재구성되지 않을 수 있으므로, freshness lag뿐 아니라 deleted ratio와 rebuild 후 recall 차이도 본다.
갱신의 핵심 철학은 vector를 원본과 별개인 재생성 가능한 파생 인덱스로 보는 것이다. 원본 DB commit은 성공했는데 embedding 호출이 실패할 수 있으므로 둘을 하나의 원자적 쓰기라고 가정하지 않는다. 대신 변경 사실을 잃지 않는 outbox/CDC, 중복 처리에도 같은 결과를 만드는 idempotency key, old/new index를 동시에 검증하는 blue-green 전환으로 불일치를 드러내고 복구한다.
예를 들어 d1의 보관 기간이 7년에서 5년으로 바뀌었다고 하자. 새 chunk를 version=2로 넣었지만 version=1을 후보 단계에서 제외하지 않으면 두 문서가 동시에 top-k를 차지한다. 답변 직전에 latest 하나만 남겨도 old chunk가 후보 자리를 선점해 다른 관련 문서 d3을 밀어낼 수 있다. 따라서 version filter도 tenant filter처럼 후보 생성 단계에 적용하고, 전환 중에는 source_version, embedding_model_version, chunker_version을 함께 기록해야 stale 원인을 구분할 수 있다.
문서 원본이 DB·CMS·S3·Git 등 여러 곳에 있으면 “문서가 바뀌었는데 벡터는 옛날 것”이 가장 흔한 장애가 된다. RAG freshness는 batch cron보다 변경 이벤트를 잃지 않는 파이프라인으로 설계한다.
source DB/CMS commit → outbox row 또는 CDC event → embedding job queue → chunk 재생성 + embedding → vector upsert(new version) → old version soft delete → freshness lag metric 기록document_changed 이벤트를 같은 DB commit에 넣는다. 이벤트 발행 실패로 색인이 누락되는 문제를 줄인다.doc_id + version + chunk_hash로 upsert를 멱등화한다. 같은 이벤트가 두 번 와도 vector가 중복 생성되면 안 된다.event_time → indexed_at p95/p99로 본다.검색 품질은 production traffic의 클릭만으로 판단하기 어렵다. 클릭은 순위와 UI에 영향받고, 권한 밖 문서는 클릭되기 전에 노출 자체가 사고다. 먼저 버전 관리되는 gold dataset으로 offline 회귀를 재고, 실제 traffic에서는 latency·freshness·권한 audit·사용자 행동을 함께 본다.
검색한 정답 수 / 전체 정답 수검색한 정답 수 / returned. underfill을 벌점으로 보지 않으므로 단독 품질 지표로 쓰지 않는다.검색한 정답 수 / kMRR = (1/|Q|) · Σ_q 1/rank_q이며 relevant result가 없으면 그 query의 기여는 0이다.공통 toy corpus의 executor-side post-filter 결과 [d3]은 반환된 한 건만 분모로 보는 precision_returned가 100%여서 좋아 보이지만 recall은 50%다. precision@2처럼 요청한 두 자리를 분모로 고정하면 빈 한 자리도 실패로 보아 50%다. 반대로 후보를 [d3,d1,d4,d5]까지 넓히면 recall은 100%지만 precision@4는 50%다. 이 때문에 first stage는 returned K와 recall@50·@100으로 누락을 보고, rerank와 context 단계는 precision@10·@5와 NDCG로 순서를 본다. 한 지표만 최적화하면 결과는 깨끗하지만 정답을 놓치는 검색 또는 정답은 있으나 noise가 너무 많은 context가 된다.
운영자가 자주 만나는 함정이다. 공통점은 HTTP 에러가 나지 않는다는 점이다. 검색은 200으로 응답하고 latency도 정상인데, recall·freshness·권한이 조용히 깨진다. 그래서 명령어보다 먼저 “어떤 현상이 어떤 개념 실패를 뜻하는가”를 읽어야 한다.
model_version별 vector 수를 집계하고, 공식 모델 문서에 source/target cross-model 호환 계약이 있는지 확인한다. 보장이 없으면 새 모델로 대상 corpus 전체를 별도 index에 재임베딩하고, 같은 원문 query도 모델별로 따로 임베딩해 각자의 index에서 retrieval 평가를 수행한다. Voyage 4 계열처럼 보장이 있으면 기존 vector 재사용을 실험할 수 있지만, 동일한 지원 dimension, dtype/quantization, 정규화·distance 계약을 확인하고 recall@k·nDCG@k·latency를 검증한 뒤 cutover한다.vector_count / source_doc_count 비율이 평소(예: 5±1 chunks/doc) 대비 2× 이상 튀면 중복 chunking 또는 reindex 누적이 원인.WHERE tenant_id=...를 나중에 적용해 LIMIT K보다 적게 반환한다. 권한 밖 row가 앱으로 전달되지 않았다면 permission leak은 아니지만, recall과 returned K가 조용히 낮아진다. 감지: requested K 대비 returned K, exact/pre-filter 기준 recall, iterative scan 전후를 비교한다. 복구: 더 넓은 탐색, iterative scan, partial index, partition·namespace를 검토한다.assert all(doc.tenant_id == ctx.tenant_id for doc in results)를 강제하고 tenant cross-leak audit를 돌린다. 복구: 메타데이터 tenant_id NOT NULL, tenant/ACL server-side filter, partial index·partition·namespace를 적용한다.executor-side underfill, response-side permission leak, over-filtering은 모두 filter 주변에서 보이지만 원인과 경계가 다르다. executor-side underfill은 DB 안에서 권한 조건을 지켰어도 ANN 탐색 폭 때문에 결과 수와 recall이 줄어드는 문제다. response-side leak은 권한 검사가 앱 전달 이후라 기밀성이 깨진 문제다. over-filtering은 language, doc_type, updated_at 같은 품질 조건이 과도한 문제다. 보안 필터(tenant_id, ACL)는 절대 완화하지 않고, 품질 필터만 recall을 보며 fallback으로 완화한다.
아래 값은 자체 sweep을 시작하기 위한 파라미터 예다. 인덱스 선택은 vector count 구간이 아니라 차원, hardware, 동시성, filter selectivity, update 비율, memory budget에서 측정한 latency-recall curve로 정한다. 같은 corpus·distance의 Flat으로 exact-candidate recall을 만들고, 별도의 gold relevance와 latency를 함께 측정한다.
| 기법 | 초기 관찰·실험 시작점 | 깨지는 조건 |
|---|---|---|
| Flat (exact) | 동일 workload의 exact-candidate recall 100% 기준선 | P95/P99·CPU가 budget을 넘을 때 ANN과 비교 |
| HNSW M=16 | graph memory와 build/update 비용 측정 시작값 | 차원·graph memory·filter·update rate가 예산을 넘으면 IVF·DiskANN 비교 |
| HNSW efSearch=100 | candidate recall ~95% 관찰 예 | 자체 latency-recall 곡선에서 ef=50~500을 sweep하고 평탄점 선택 |
| IVF | 큰 데이터셋·bulk build 비교 후보 | 작은 데이터에서 HNSW가 더 나은 사례도 있으므로 build·latency-recall 곡선으로 비교 |
| DiskANN | 메모리 부족 환경 | SSD tail latency·update/build 특성이 병목이거나 초저지연 P99 요구가 엄격할 때 검증 필요 |
| RRF (k=60) | 무가중 합의 시작값 예 | k가 작으면 상위 순위 영향이 가팔라지고, 크면 순위 기여가 평탄해짐 |
| BGE-M3 통합 | 공식 1024d에서 다국어·dense+sparse+multi-vector 운영 | native 256d 절단으로 간주하지 않고, 단일 dense만 필요하면 multi-mode 오버헤드도 비교 |
| Reranker | top-100 → top-10은 시작 구성 예 | 모델·hardware별 추가 latency가 budget을 넘거나 first-stage recall이 낮으면 구성 재검토 |
| 보안 metadata filter | tenant·ACL 강제 | executor post-filter면 underfill, 앱에서만 걸면 leak |
| 품질 metadata filter | 언어·문서 유형·시간 제한 | 과도하면 recall 손실, fallback 기준 없으면 정답 누락 |
| Binary embedding | raw payload 32×, 가상 초기 관찰에서 품질 ~95% | 모델·corpus·hardware별로 후보 폭과 fp32 rescore 전후를 다시 측정 |
| Matryoshka 256d | Voyage 4처럼 명시적 지원 모델의 시작점. 1024d 대비 raw payload 4× | 모델 공식 차원 우선, 가상 1~3% 손실 관찰도 자체 gold recall로 재검증 |
아래 임계값은 alert·sweep을 시작하기 위한 예시다. 평상시 baseline과 SLO가 다르면 50%+, 200ms, 90%, 100→200 같은 숫자도 함께 바꾼다.
| 증상 | 정량 시그널 | 원인 | 복구 |
|---|---|---|---|
| Stale embedding | 검색 결과 옛 정보 | 문서 수정 후 reindex 누락 | CDC + 임베딩 큐 자동화 |
| Orphan vectors | vector count > document count | 삭제 미반영 | soft delete + 주기적 cleanup |
| 모델 버전 변경 후 검색 깨짐 | 새 경로 retrieval 회귀 | 비호환 공간 또는 차원·표현 불일치 | 호환 계약 확인, 없으면 전체 재임베딩·cutover |
| ANN post-filter underfill | returned K·recall 동시 하락 | index scan 뒤 DB WHERE | iterative scan, partial index·partition |
| Permission leak | 앱에 다른 tenant 후보 전달됨 | response-side filter | server-side tenant/ACL filter, audit |
| Over-filtering | filter 후 recall@50 급락 | language/date/doc_type 과도함 | 보안 필터와 품질 필터 분리, fallback |
| Hybrid 가중치 실수 | dense·BM25 한쪽만 적용 | RRF 또는 α 미설정 | 도메인별 가중치 default 표준화 |
| Reranker latency 초과 | P99 > 200ms | top-100 너무 많음 | top-50으로 줄임, 작은 reranker (MiniLM) |
| HNSW recall 낮음 | recall@10 < 90% | efSearch 부족 | efSearch ↑(100→200), M ↑(16→32) |
학습해야 할 원리는 한 문장이다. DB executor가 approximate index로 제한된 후보를 탐색한 뒤 같은 query 안에서 WHERE tenant_id = ...를 적용하면 LIMIT 10보다 적게 반환할 수 있다. 권한 밖 row가 DB에서 앱으로 나오지 않았다면 response-side permission leak은 아니지만, 검색 시스템은 성공한 것처럼 보인 채 RAG의 후보 수와 recall을 잃는다.
조건부 관찰 예: live row 중 tenant=acme 비율이 약 10% tenant와 vector 이웃 분포가 대략 독립이고 acme live row가 10개 이상 hnsw.ef_search = 40, iterative scan 비활성 graph·분포·dead tuple·scan limit 영향이 작다면 통과 건수 직관은 40×10%≈4 한 실행에서 LIMIT 10인데 returned=4를 관찰할 수 있음진단 순서는 명령어보다 개념으로 기억한다.
LIMIT K보다 적게 반환되는지 본다.ef_search 또는 iterative scan을 키웠을 때 K개로 회복되는지 본다. returned=10은 충분한 tenant row와 탐색 한도 안에서 qualifying row를 찾았을 때의 조건부 관찰이지 deterministic expected가 아니다.실제 psql 관찰 명령은 선택 부록 A에 접어 둔다. 본문 결론은 두 가지다. 권한 필터는 response 직전이 아니라 DB·검색 서비스 경계에서 강제하고, DB executor 내부에서 index scan 뒤 filter되는 구현은 별도의 underfill·recall 문제로 측정한다.
벡터 검색 시스템 = “근사 검색 + 정확 재정렬 + 압축”. 다른 검색 시스템과 같은 패턴.
| 임베딩 운영 구성요소 | 일반 시스템 매핑 |
|---|---|
| Vector store (FAISS, pgvector) | DB·search engine (Elasticsearch) |
| ANN index (HNSW) | DB index (B-tree, LSM-tree) |
| Chunking | document partitioning, sharding |
| Hybrid search (BM25 + dense) | full-text + structured query |
| RRF (Reciprocal Rank Fusion) | weighted vote, ensemble ranking |
| Cross-encoder rerank | 2-stage cache lookup, allowlist + verify |
| Quantization (int8, binary) | columnar compression, bloom filter |
| Stale embedding | cache invalidation, MV refresh |
| Permission filter | row-level security, RBAC |
일반 공식: “근사 검색(빠름·낮은 정확) → 정확 재정렬(느림·정확)“의 2단계 패턴이 RAG·검색·추천·anti-spam 시스템 전반에 공통이다. 이 위에 갱신·권한·압축이 추가된다.
가상 workload (vendor-independent): 사내 정책 1M 문서, 1024d, tenant filter selectivity 중앙값 5%, 하루 update/delete 1%, index memory budget 8GB, P95 < 80ms, gold relevance recall@10 > 0.8, 한국어 - raw vector payload: 1M × 1024d × fp32 = 4GB선택지: A. pgvector + HNSW: PostgreSQL 트랜잭션·권한 SQL 통합 후보 - 가상 관찰: warm cache, efSearch=100에서 latency P95 ~50ms B. Qdrant self-host: 검색 계층 독립 확장·payload filter 후보 C. pgvector + BGE-M3 hybrid (공식 1024d dense + sparse + RRF): - 가상 관찰: 한국어 고유명사·법령 정확도 개선, latency +20ms - 경계: vanilla BGE-M3를 256d Matryoshka 모델로 취급하지 않음 D. pgvector + Voyage 4 계열 256d binary + BM25 hybrid + 2-stage rerank: - Voyage가 256d와 binary 출력을 공식 지원하는 별도 후보 - raw vector payload: 1024d fp32 4GB → 256d 1-bit 약 32MB (128×↓) - 가상 관찰: 이 fixture의 gold relevance recall@10 0.84
가상 선택: D. 같은 filter·update trace로 A와 B를 비교했을 때 둘 다 latency-recall·memory budget을 만족했다는 가정 아래, 원본 정책과 권한의 PostgreSQL 트랜잭션 경로를 재사용하는 이점으로 pgvector를 고른다. 임베딩 후보는 BGE-M3를 잘라 쓴 것이 아니라 공식 256d·binary 계약이 있는 Voyage 4 계열이다.대안 비선택: A 단독은 lexical 보완 없음, B는 이 가상 조건에서 성능 탈락이 아니라 PostgreSQL과 운영 계층을 분리할 필요가 아직 작음, C는 self-host·multi-mode 장점이 있지만 공식 1024d raw payload가 이 가상 memory 목표보다 큼.silent failure 모니터링 (§3.12): - stale embedding: CDC 큐 - permission leak: tenant_id metadata filter - reranker latency: top-50 cap
결과 (가상): raw vector payload 약 32MB, P95 75ms, gold relevance recall@10 0.84.이 가상 workload의 측정 조건은 1M chunk, BGE-M3 baseline의 1024d와 Voyage 4 후보의 256d, warm cache, 100개 gold query, top-100 retrieve → top-10 rerank다. 이 숫자는 용량 계산과 재현 조건일 뿐 제품 선호를 만들지 않는다. 실제 후보 비교에서는 vector 차원, cold cache, tenant별 namespace 크기와 filter selectivity, update/delete rate, index 포함 memory budget, reranker latency를 고정한 뒤 pgvector와 Qdrant의 latency-recall curve를 재측정한다.
§3.1 vector store + §3.4 hybrid + §3.7 quantization + §3.11 깨지는 조건 + §3.12 silent failure 모두 적용.
엔지니어가 임베딩 인프라를 운영할 때 다음에 도움된다.
| 개념 A | 개념 B | 차이점 |
|---|---|---|
| Flat (exact) | HNSW (ANN) | 같은 corpus·distance의 exact-candidate recall 100% 기준선 vs ANN의 조건부 recall |
| HNSW | IVF | 그래프 vs cluster 기반. HNSW가 일반적으로 빠름·정확 |
| Bi-encoder | Cross-encoder | 1단계 (빠름·낮은 정확) vs 2단계 (느림·정확) |
| Bi-encoder | ColBERT (multi-vec) | 단일 vector vs token별 vector + late interaction |
| Dense | BM25 (sparse) | semantic vs lexical. 두 누락이 공존할 때 hybrid 유리 |
| Fixed chunk | Semantic chunk | 단순·빠름 vs 의미 단위 보존·비용 ↑ |
| Recall@k | NDCG@k | 들어왔는가 vs 순위까지 고려 |
| FAISS | pgvector | 라이브러리 (in-process) vs Postgres extension (SQL) |
좋은 retrieval 실험은 도구 설치 체크가 아니라 한 변수만 바꾸고 같은 query·gold·부하에서 결과를 비교하는 일이다. 먼저 2050개 query의 작은 gold dataset으로 평가 코드가 맞는지 확인하고, 도메인과 실패 유형을 대표하는 100500개로 늘린다. query마다 relevant document와 evidence span, tenant, 허용 version을 기록하면 품질·권한·freshness를 한 기준에서 볼 수 있다.
실험 순서는 아래처럼 고정한다.
| 실험 축 | 고정할 것 | 바꿀 것 | 함께 볼 지표 | 결론으로 연결하는 법 |
|---|---|---|---|---|
| Exact vs ANN | 같은 1만 vector·query·distance | Flat, HNSW, IVF | exact-candidate recall@10, gold relevance recall@10, P50/P95 | Flat의 exact-candidate recall 100%와 gold relevance를 분리한다. HNSW 1ms 미만·99%는 예상치일 뿐이다 |
| HNSW 후보 폭 | M, corpus, query | efSearch=50,100,200,500 | recall@10과 latency 곡선 | recall이 평평해진 뒤 latency만 늘면 model·chunking·hybrid를 의심한다 |
| IVF 후보 폭 | centroid와 corpus | nprobe | recall@10, scanned candidates | cell 경계 누락이 회복되는 최소 nprobe를 찾는다 |
| Filter 위치 | toy corpus와 gold G={d1,d3} | server pre-filter, executor post-filter, response-side filter | returned K, recall@K, 앱 전달 전 cross-tenant count | executor underfill과 response-side leak을 분리하고 품질 filter만 fallback을 허용한다 |
| Chunking | 같은 PDF와 query | fixed-512, recursive, semantic; 256/512/1024 token | evidence recall, context precision, 중복률 | 정답 span 절단과 top-k 중복을 분리해 본다 |
| Hybrid | 같은 dense/BM25 후보 | RRF k=60, dense:BM25 0.3:0.7과 0.7:0.3 | recall@10, exact-term hit, NDCG@10 | 한국어 법률·코드처럼 exact term이 많을수록 lexical 기여를 따로 본다 |
| Reranker | 같은 first-stage top-100 | cross-encoder 유무, top-50/100 후보 | recall@100, precision@10, 추가 latency | recall@100이 낮으면 reranker가 아니라 1차 검색을 고친다 |
| Quantization | 같은 1,000 vector와 fp32 exact 결과 | fp32, int8, binary, Voyage 4 같은 명시적 MRL 모델의 256d | recall@10, raw payload, total storage, rescore latency | BGE-M3 1024d는 별도 기준선이다. 192×는 1536d fp32→256d 1-bit raw payload 최대 이론값이다 |
| 갱신 | 같은 100개 source document | 50% 수정 후 reindex/upsert, 중복·역순 event 100건 | freshness lag, stale/orphan/duplicate count | doc_id+version+chunk_hash 멱등성과 old version 제외를 별도로 검증한다 |
이 표의 핵심은 각 기법을 “좋아졌다”로 평가하지 않는 것이다. ANN은 latency를 줄이는 대신 exact 결과 대비 얼마나 잃었는지, reranker는 같은 후보 안에서 precision을 얼마나 회복했는지, quantization은 bytes를 줄이는 대신 후보 경계를 얼마나 흔들었는지 역할별로 묻는다.
| 관측 결과 | 먼저 세울 가설 | 다음 분리 실험 |
|---|---|---|
| HNSW recall이 낮다 | efSearch가 작거나 graph 연결이 부족하다 | efSearch를 먼저 올리고, 부족하면 M을 올려 latency와 같이 본다 |
efSearch를 올려도 recall이 안 오른다 | ANN보다 embedding·chunking·filter가 병목이다 | Flat exact도 낮은지, filter 전 recall이 높은지 확인 |
| Hybrid가 dense보다 나쁘다 | BM25 analyzer가 한국어를 잘못 자르거나 가중치가 크다 | dense/BM25 단독 순위와 token 분석 결과를 분리한다 |
LIMIT K보다 적게 반환된다 | executor-side ANN post-filter가 후보를 소진했다 | exact/pre-filter 기준과 iterative scan 전후 returned K를 비교한다 |
| 앱에 다른 tenant 후보가 전달된다 | response-side까지 보안 filter가 지연됐다 | tenant/ACL을 DB·검색 서비스 경계에서 강제하고 audit한다 |
| 품질 filter 뒤 정답이 사라진다 | language/date/doc_type 조건이 과도하다 | tenant/ACL은 유지하고 품질 조건만 단계적으로 완화한다 |
| Reranker 효과가 미미하다 | bi-encoder가 이미 강하거나 reranker가 domain mismatch다 | first-stage recall@100과 top-10 순위 변화, 추가 latency를 비교한다 |
| Binary 뒤 품질이 폭락한다 | 압축 top-k가 너무 좁아 fp32가 복구할 후보가 없다 | binary 후보 폭을 늘리고 fp32 rescore를 붙인다 |
| 최신 문서와 옛 문서가 함께 나온다 | version filter가 후보 생성 뒤에 적용된다 | version pre-filter와 blue-green index 결과를 비교한다 |
외부 패키지 없이 공통 toy corpus의 거리·filter·RRF·모델 mismatch를 재현하는 코드는 선택 부록 B에 둔다. 실제 DB별 진단은 선택 부록 A처럼 개념을 이해한 뒤 필요한 경우에만 펼친다.
pgvector 공식 문서 기준으로 HNSW 같은 approximate index에서는 index scan 뒤에 WHERE filter가 적용될 수 있다. live row 중 tenant가 10%이고 tenant가 vector 이웃 분포와 대략 독립이라면 hnsw.ef_search=40에서 40×10%=4는 underfill의 평균적 직관이 된다. 그러나 ef_search가 정확히 40개 row를 반환한다는 뜻은 아니며 결과 수는 graph 연결, query 주변 vector 분포, tenant별 row 수, dead tuple·MVCC visibility, iterative scan 설정과 hnsw.max_scan_tuples 같은 scan limit에 따라 달라진다.
psql "$DATABASE_URL" -XAtc "SET hnsw.ef_search = 40;SELECT 'returned=' || COUNT(*)FROM ( SELECT id FROM rag_chunks WHERE tenant_id = 'acme' ORDER BY embedding <=> '[0.1,0.2,0.3]'::vector LIMIT 10) s;"조건부 관찰 예:
SETreturned=4이 값은 fixture처럼 결정적이지 않다. 같은 SQL에서도 tenant vector가 query 주변에 몰려 있으면 10개를 채울 수 있고, 반대로 graph가 나쁘거나 dead tuple이 많거나 scan limit에 먼저 닿으면 4개보다 적을 수 있다. LIMIT 10인데 10개 미만이면 tenant filter가 선택적이거나 approximate 탐색 폭이 부족하거나 tenant별 index/partition이 빠진 가설을 세우고, 먼저 후보 폭을 늘려 비교한다.
hnsw.iterative_scan은 pgvector 0.8.0 이상에서만 사용할 수 있다. 아래 두 번째 비교를 실행하기 전에 설치된 extension version을 확인하고, extversion이 0.8.0 미만이면 iterative scan을 설정하지 말고 pgvector를 먼저 업그레이드한다.
psql "$DATABASE_URL" -XAtc "SELECT extversion FROM pg_extension WHERE extname = 'vector';"
psql "$DATABASE_URL" -XAtc "SET hnsw.iterative_scan = strict_order;SET hnsw.ef_search = 200;SELECT 'returned=' || COUNT(*)FROM ( SELECT id FROM rag_chunks WHERE tenant_id = 'acme' ORDER BY embedding <=> '[0.1,0.2,0.3]'::vector LIMIT 10) s;"충분한 acme live row가 있고 확대된 탐색이 scan limit 안에서 그 row를 찾았을 때의 조건부 관찰 예:
SETSETreturned=10returned=10은 보장값이 아니다. qualifying tenant row 자체가 10개 미만이거나, graph·분포·dead tuple 때문에 탐색이 비효율적이거나, iterative scan이 hnsw.max_scan_tuples·memory limit에 닿으면 여전히 underfill될 수 있다. 10개로 회복되면 ef_search/iterative scan과 latency를 함께 측정한다. 여전히 부족하면 tenant가 소수라면 partial HNSW index(WHERE tenant_id = 'acme'), tenant가 많다면 partition/namespace 분리를 검토한다. Qdrant 계열에서는 tenant_id payload index를 먼저 만들고 ingest 후 만든 경우 HNSW rebuild까지 확인한다.
본문의 손계산을 코드로 검산하고 싶을 때만 실행한다. 이 코드는 실제 embedding 품질이나 BM25 구현을 대신하지 않는다. 숫자와 corpus를 고정해 DB executor post-filter는 높은 precision_returned에도 recall과 precision@K를 잃을 수 있다, response-side filter는 권한 밖 후보를 앱에 전달한다, 비동률 RRF는 dense와 다른 1위를 만들 수 있다, 동률 RRF는 명시한 ID tie-break로 결정해야 한다, 미승인 model version 혼합은 별도 실패다라는 다섯 경계를 재현한다.
python3 - <<'PY'from math import sqrt
docs = [ {"id": "d1", "tenant": "acme", "model": "embed-v2", "text": "SOC2-2026 evidence retention 7 years", "v": [0.8, 0.6]}, {"id": "d2", "tenant": "beta", "model": "embed-v2", "text": "SOC2-2026 private exception", "v": [1.0, 0.0]}, {"id": "d3", "tenant": "acme", "model": "embed-v2", "text": "SOC2-2026 audit schedule submission", "v": [0.9, 0.1]}, {"id": "d4", "tenant": "acme", "model": "embed-v1", "text": "SOC2-2026 evidence control overview", "v": [0.7, 0.7]}, {"id": "d5", "tenant": "acme", "model": "embed-v2", "text": "refund policy", "v": [0.0, 1.0]},]query = {"tenant": "acme", "text": "SOC2-2026 evidence retention", "v": [1.0, 0.0]}gold = {"d1", "d3"}
def cosine(a, b): dot = sum(x * y for x, y in zip(a, b)) return dot / (sqrt(sum(x * x for x in a)) * sqrt(sum(y * y for y in b)))
def dense(rows): return sorted(rows, key=lambda d: (-cosine(query["v"], d["v"]), d["id"]))
def lexical_score(row): terms = set(query["text"].lower().replace("-", " ").split()) words = set(row["text"].lower().replace("-", " ").split()) return len(terms & words)
def rrf(rows, k=60): dense_rank = {d["id"]: i + 1 for i, d in enumerate(dense(rows))} lexical = sorted(rows, key=lambda d: (-lexical_score(d), d["id"])) lex_rank = {d["id"]: i + 1 for i, d in enumerate(lexical)} scores = { d["id"]: 1 / (k + dense_rank[d["id"]]) + 1 / (k + lex_rank[d["id"]]) for d in rows } ranked = sorted(rows, key=lambda d: (-scores[d["id"]], d["id"])) return ranked, scores
def rrf_from_rankings(rankings, k=60): rank_maps = [ {doc_id: index + 1 for index, doc_id in enumerate(ranking)} for ranking in rankings ] ids = sorted({doc_id for ranking in rankings for doc_id in ranking}) scores = { doc_id: sum(1 / (k + ranks[doc_id]) for ranks in rank_maps if doc_id in ranks) for doc_id in ids } return sorted(ids, key=lambda doc_id: (-scores[doc_id], doc_id)), scores
def metrics(rows, k): ids = {d["id"] for d in rows} hits = len(ids & gold) return { "recall": hits / len(gold), "precision_returned": hits / len(rows) if rows else 0.0, f"precision@{k}": hits / k, }
ann_top2 = dense(docs)[:2]db_post = [d for d in ann_top2 if d["tenant"] == query["tenant"]]allowed = [d for d in docs if d["tenant"] == query["tenant"]]pre = dense(allowed)[:2]hybrid, rrf_scores = rrf(allowed)tie_rankings = [["z", "a"], ["a", "z"]]tie_result, tie_scores = rrf_from_rankings(tie_rankings)assert tie_scores["a"] == tie_scores["z"]assert tie_result == ["a", "z"], tie_result
print("db_post_filter_top2", [d["id"] for d in db_post], metrics(db_post, 2))print("pre_filter_top2", [d["id"] for d in pre], metrics(pre, 2))print("response_side_candidates_top2", [d["id"] for d in ann_top2])print("response_side_cross_tenant", [d["id"] for d in ann_top2 if d["tenant"] != query["tenant"]])print("dense_top1", dense(allowed)[0]["id"])print("rrf_d1_d3", {doc_id: round(rrf_scores[doc_id], 6) for doc_id in ("d1", "d3")})print("hybrid_top1", hybrid[0]["id"])print("rrf_tie_break", tie_result)print("model_versions", sorted({d["model"] for d in allowed}))PY예상 출력:
db_post_filter_top2 ['d3'] {'recall': 0.5, 'precision_returned': 1.0, 'precision@2': 0.5}pre_filter_top2 ['d3', 'd1'] {'recall': 1.0, 'precision_returned': 1.0, 'precision@2': 1.0}response_side_candidates_top2 ['d2', 'd3']response_side_cross_tenant ['d2']dense_top1 d3rrf_d1_d3 {'d1': 0.032522, 'd3': 0.032266}hybrid_top1 d1rrf_tie_break ['a', 'z']model_versions ['embed-v1', 'embed-v2']db_post_filter_top2가 하나만 남는 것은 DB 내부 후보 생성과 filter 순서가 만든 underfill이다. response_side_cross_tenant=['d2']는 앱이 권한 밖 후보를 받은 별도의 permission leak이다. d1과 d3의 RRF 점수는 동률이 아니므로 hybrid_top1=d1은 stable sort의 우연이 아니라 lexical 신호가 실제로 순위를 바꾼 결과다. 반대로 입력 ranking [z,a]와 [a,z]에서는 a와 z의 RRF 점수가 실제로 같고, assertion이 document ID 오름차순인 ['a','z']를 결정적 결과로 강제한다. model_versions가 둘인 것은 또 다른 문제다. 먼저 공식 cross-model 호환 계약을 확인한다. 보장이 없으면 새 모델로 corpus 전체를 별도 index에 재임베딩하고, 원문 query를 old/new 모델로 각각 임베딩해 대응 index에서 shadow retrieval을 수행한 뒤 단일 모델로 cutover한다. Voyage 4처럼 명시적 보장이 있으면 새 모델 query와 기존 호환 vector를 함께 쓰는 경로도 평가할 수 있지만, dimension·quantization·distance 계약과 retrieval 품질을 먼저 검증한다. 어느 경우에도 cross-model cosine threshold만으로 호환을 판정하지 않는다. 이 원인들을 하나의 “검색 품질 저하”로 묶으면 filter 폭만 키우거나 권한 경계를 놓치거나 불완전한 reindex를 하는 잘못된 복구를 선택할 수 있다.
최종 수정: 2026-07-14