AEGLOSLAB ChatGPT 같은 AI를 만들고 서비스하기

제6부 응용: 프롬프트·RAG·에이전트

6.2 임베딩·벡터 검색

누군가 챗봇에 "환불 어떻게 해?"라고 묻는다. 정답이 실린 문서의 제목은 "반품 시 대금 반환 절차 안내"다. 두 문장은 겹치는 단어가 하나도 없다 — 질문의 "환불"도, 문서의 "반품·대금·반환"도 서로의 표현에 등장하지 않는다. 단어를 맞춰 찾는 전통 검색은 이 문서를 건지지 못한다. 그런데 사람은 한눈에 안다, 둘이 같은 이야기라는 것을. 이 장은 기계가 그 "같은 이야기"를 찾아내는 방법 — 철자가 아니라 뜻으로 검색하는 방법 — 을 밑근본까지 분석한다.

이 장이 남기려는 문장은 하나다. 텍스트를 벡터 공간의 한 점으로 옮겨 놓으면, "의미가 가까운가?"라는 애매한 물음이 "두 점이 가까운가?"라는 계산 가능한 산술로 바뀐다. 이 한 번의 번역이 의미 검색 전체를 지탱한다. 뜻을 점으로 만드는 일을 하는 것이 임베딩 모델이고, 수백만~수십억 개의 점 중에서 질의와 가까운 것을 빠르게 골라내는 일을 하는 것이 근사 최근접 이웃(Approximate Nearest Neighbor, ANN) 검색이다. 이 둘이 다음 장 RAG(6.3)의 검색기(retriever)를 이루는 토대이며, 이 장의 절반은 임베딩을 만드는 이야기, 나머지 절반은 그 벡터들 사이를 빠르게 항해하는 이야기다.

전체 경로는 색인 시점(offline)과 질의 시점(online)으로 갈린다. 왼쪽 절반이 문서를 검색 가능한 벡터로 바꿔 색인해 두는 일, 오른쪽 절반이 질의가 들어왔을 때 그 안에서 이웃을 찾는 일이다.

색인 시점 (offline) 질의 시점 (online) 문서 코퍼스 청킹 임베딩 모델 (bi-encoder) 문서 벡터들 (N × d) ANN 색인 구축 (HNSW · IVF · PQ) 사용자 질의 같은 임베딩 모델 질의 벡터 (d) ANN 검색 top-k 이웃 후보 청크 k개 재순위·컨텍스트 구성·생성 (6.3 · 6.4)
전체 경로 — 색인 시점(offline)과 질의 시점(online)

6.2.1 검색용 임베딩은 입력 임베딩이 아니다

이 장에서 다루는 임베딩은 1.2에서 배운 모델 입력 임베딩과 다른 물건이다. 이름이 같아서 뭉뚱그리기 쉬운데, 혼동하면 이 장 전체가 성립하지 않는다. 먼저 둘을 갈라 놓자.

입력 임베딩은 토큰 ID를 벡터로 바꾸는 조회 표다. 임베딩 행렬 $E$의 한 행을 꺼내 오는 것이고, 토큰 하나당 벡터 하나가 고정으로 대응한다. "bank"라는 토큰은 강둑이든 은행이든 언제나 같은 행을 받는다 — 문맥과 무관한 정적 벡터이며, 트랜스포머 첫 층에 들어갈 재료일 뿐이다. 그 자체로는 두 텍스트의 의미가 가까운지를 재는 데 쓸 수 없다. 애초에 그러라고 만든 물건이 아니기 때문이다.

검색용 임베딩은 문장·문단·문서 하나 전체를 의미가 담긴 벡터 하나로 압축한 것이다. 텍스트를 모델에 통과시켜 문맥을 반영한 뒤(그래서 "bank of the river"의 bank와 "bank account"의 bank가 서로 다른 위치로 간다) 하나의 벡터로 풀링하고, 결정적으로 두 텍스트의 의미 유사도가 벡터 사이 거리로 드러나도록 특별히 학습된다. 이 마지막 조건이 전부다. 벡터를 뽑는 것은 쉽지만, 그 벡터의 거리가 의미의 거리와 나란히 가도록 만드는 것은 별도의 학습을 요구한다.

입력 임베딩(1.2)검색용 임베딩(6.2)
단위토큰 1개문장·문단·문서 1개
문맥무문맥(조회 표)문맥 반영(인코더 통과 후 풀링)
벡터 개수시퀀스 길이 $n$개(토큰마다)텍스트 1개당 1개
학습 목적언어모델 손실의 일부의미 유사도를 거리로(대조학습)
쓰임트랜스포머 입력의미 검색·클러스터링·추천·중복제거

여기서 흔히 통용되는 오해 하나를 깨 둔다. "LLM에 문장을 넣으면 임베딩이 나온다"는 말은 반쪽만 맞다. ChatGPT류 디코더-온리 생성 모델의 마지막 은닉 상태를 평균 내면 벡터가 나오긴 한다. 그러나 그 벡터가 의미 유사도를 잘 반영하도록 학습된 것은 아니다. 생성 모델은 "다음 토큰을 잘 맞히도록" 최적화됐지 "비슷한 문장을 가깝게 놓도록" 최적화되지 않았다. 실제로 이렇게 뽑은 벡터는 검색 품질이 불균일하고, 특히 문장 길이나 표면적 단어 겹침 같은 의미와 무관한 신호에 크게 휘둘린다. 좋은 검색 임베딩은 검색을 목적으로 따로 학습한 임베딩 모델(embedding model, sentence encoder)에서 나온다. 이 구분이 이 장의 출발점이다.

문서 하나를 벡터 하나로 압축하는 일은 근본적으로 손실 압축이다. 긴 문서의 온갖 뉘앙스 — 여러 주제, 조건절, 예외 — 를 $d$차원(흔히 수백~수천) 하나에 밀어 넣으니 정보가 손실되는 것은 당연하다. 그런데도 이렇게 하는 이유는 오직 하나, 검색을 산술로 바꾸기 위해서다. 텍스트가 고정 차원 벡터가 되는 순간 "의미가 가까운가?"는 벡터 사이 거리 계산이라는 단순·병렬·색인 가능한 연산이 되고, 그래야 수백만 문서를 밀리초에 뒤질 수 있다. 이 압축을 거부하고 두 텍스트를 함께 모델에 넣어 상호작용시키면 훨씬 정확하지만, 질의마다 후보 전체를 다시 통과시켜야 해 규모가 커지면 불가능해진다. 검색 임베딩은 정확도를 조금 내주고 확장성을 산 설계이고, 다음 절이 그 거래를 정확히 보여 준다.

6.2.2 bi-encoder와 cross-encoder — 확장성을 산 설계

두 텍스트의 관련성을 재는 신경망 구조는 크게 둘로 갈린다. 이 갈림이 검색 시스템의 골격을 정한다.

cross-encoder는 질의와 문서를 [질의] [SEP] [문서]로 이어 붙여 한 번에 트랜스포머에 넣는다. 어텐션이 질의의 단어와 문서의 단어를 교차로 들여다보며 상호작용시키고, 마지막에 관련성 점수 하나를 낸다. 단어 수준의 미세한 상호작용을 다 보므로 정확하다 — "이 문서가 이 질의에 답하는가"를 가장 잘 판단한다. 대가는 명확하다. 질의–문서 쌍마다 트랜스포머 전체 순전파가 한 번씩 필요하다. 후보가 100만 개면 질의 하나에 100만 번의 순전파다. 온라인 검색으로는 애초에 성립하지 않는다.

bi-encoder(듀얼 인코더)는 질의와 문서를 각각 따로 인코더에 넣어 독립적인 벡터로 만든 뒤, 내적이나 코사인 같은 값싼 유사도로만 비교한다. 결정적인 차이는 문서 벡터를 미리 계산해 색인해 둘 수 있다는 것이다. 질의 시점에는 질의 하나만 인코딩하고 벡터 검색만 하면 되므로, 문서가 몇 개든 질의당 인코딩은 정확히 한 번이다.

bi-encoder — 검색용 (미리 색인 가능) cross-encoder — 재순위용 (6.4) 질의 인코더 질의 벡터 문서 인코더 (가중치 공유 가능) 문서 벡터 (오프라인 색인) 내적 / 코사인 (값싼 비교) 질의 &+ [SEP] &+ 문서 (이어 붙임) 트랜스포머 (교차 어텐션) 관련성 점수 1개

비용 차이를 숫자로 확정하자. 문서 100만 개, 질의 하나. cross-encoder로 전수 채점하려면 100만 번의 순전파다 — 인코딩 한 번을 몇 밀리초로 가정해도 수십 분이 걸린다. bi-encoder는 100만 개 문서 벡터를 색인 시점에 이미 만들어 뒀으므로, 질의 시점에는 질의 인코딩 한 번 + 벡터 검색(뒤에서 볼 ANN이면 수 밀리초)이다. 문서가 1억 개로 늘어도 질의당 인코딩은 여전히 한 번이다. 이 비대칭이 대규모 검색이 bi-encoder일 수밖에 없는 이유다.

그래서 실무의 표준은 둘을 역할 분담시키는 2단계 파이프라인이다. bi-encoder로 수백만 문서에서 top-$k$ 후보를 저렴하게 건져 recall을 확보하고(빠뜨리지 않는 것이 목표), 그 $k$개만 cross-encoder 재순위기(reranker)로 높은 비용을 들여 정밀 재정렬해 precision을 확보한다(위쪽 순위를 정확히 하는 것이 목표). $k$가 수십~수백이면 cross-encoder를 그만큼만 실행하므로 감당된다. 이 장은 앞 단계, 즉 bi-encoder 임베딩과 그 위의 ANN 검색을 다루고, cross-encoder 재순위는 6.4에서 깊이 다룬다. 지금 정리해 둘 것은 하나다 — 검색 임베딩이란 bi-encoder의 산물이다.

6.2.3 문장 하나의 벡터를 뽑는 법 — 풀링

인코더는 토큰마다 문맥화된 벡터를 하나씩 낸다(1.4). 128 토큰 길이의 문장을 넣으면 128개의 벡터가 나온다. 이것을 문장 하나를 대표하는 벡터 하나로 모으는 작업이 풀링(pooling)이고, 방식이 몇 가지 있다.

BERT류 인코더에서 흔한 것이 [CLS] 풀링이다. 입력 맨 앞에 특수 토큰 [CLS]를 두고, 그 자리의 최종 벡터가 문장 전체 요약을 담도록 학습한 뒤 그것을 문장 벡터로 쓴다. 평균 풀링(mean pooling)은 모든 토큰 벡터를 단순히 평균 낸다 — 단순한데도 실무에서 대단히 견고해 가장 널리 쓰인다. 디코더-온리 기반 임베딩 모델에서는 마지막 토큰 풀링(last-token)을 쓰는데, 인과 어텐션 때문에 오직 마지막 토큰만이 시퀀스 전체를 앞에서부터 다 본 상태라 그 자리에 요약이 모이기 때문이다. 앞쪽 토큰들은 뒤를 못 보므로 문장 전체를 대표할 수 없다 — 구조가 풀링 선택을 강제한다.

여기에 조용한 함정이 하나 있다. 풀링 방식은 모델이 학습된 방식과 반드시 일치해야 한다. 평균 풀링으로 학습된 모델의 벡터를 [CLS]로 뽑아 쓰면, 에러 하나 없이 성능만 서서히 붕괴한다. [CLS] 자리는 그 모델에서 아무 의미도 학습하지 않은 자리이기 때문이다. 임베딩 모델을 갖다 쓸 때 모델 카드가 지정한 풀링·정규화·지시 접두사(다음다음 절)를 글자 그대로 따르는 것이 실무 제1 수칙인 이유가 여기 있다. 검색이 이상한데 원인을 못 찾는 사고의 상당수가 이 불일치에서 온다.

6.2.4 대조학습 — 공간을 당기고 미는 힘

검색 임베딩의 목적은 한 문장으로 요약된다. 의미가 통하는 (질의, 문서) 쌍은 벡터가 가깝게, 무관한 쌍은 멀게 배치하라. 이 배치를 학습으로 강제하는 것이 대조학습(contrastive learning)이고, 검색 임베딩의 품질은 거의 전부 여기서 나온다.

한 질의 $q$에 대해 정답 문서인 긍정(positive) $d^+$ 하나와, 무관한 문서인 부정(negative) $d_j^-$ 여럿을 준비한다. 유사도 $\text{sim}(\cdot,\cdot)$(보통 코사인 또는 내적)을 써서 다음 InfoNCE 손실을 최소화한다.

$$ \mathcal{L}_{\text{InfoNCE}} \;=\; -\log \frac{\exp\!\big(\text{sim}(q, d^+)/\tau\big)}{\exp\!\big(\text{sim}(q, d^+)/\tau\big) \;+\; \sum_{j} \exp\!\big(\text{sim}(q, d_j^-)/\tau\big)} $$

이 식을 곱씹으면 정체가 드러난다. 이것은 긍정 문서를 정답 클래스로 삼는 소프트맥스 분류의 교차엔트로피와 똑같은 형태이다. 분자는 정답 로짓의 지수, 분모는 정답과 모든 오답 로짓의 지수 합 — 로짓이 곧 유사도인 소프트맥스다. 손실을 줄이려면 분자를 키우고 분모의 부정 항을 줄여야 하니, 학습은 $q$와 $d^+$를 당기고(sim↑) $q$와 각 $d_j^-$를 민다(sim↓). 아래 그림이 그 두 힘이 임베딩 공간에서 하는 일이다.

학습 전 (뒤섞임) q + 대조손실 학습 후 (정렬됨) q + q와 +는 붙고, 빨강(부정)은 멀어짐 질의 긍정 부정
대조학습 — 임베딩 공간에서 긍정은 당기고(pull) 부정은 민다(push)

온도 $\tau$는 분포의 날카로움을 조절한다. 유사도를 $\tau$로 나누므로, $\tau$가 작으면 로짓 차이가 증폭돼 소프트맥스가 뾰족해지고, 손실과 그 그래디언트가 가장 유사도 높은 부정 — 즉 가장 혼동하기 쉬운 오답 — 에 집중된다. 모델이 "거의 정답처럼 보이는 오답"을 떼어 내는 데 노력을 집중하게 되는 것이다. $\tau$가 크면 분포가 부드러워져 그래디언트가 여러 부정에 고루 퍼진다. 실무에서 $\tau$는 흔히 0.01~0.1 근처의 작은 값이며, 정확한 값은 모델·데이터에 맞춰 조정한다.

성능을 가르는 실제 축은 부정을 어떻게 고르느냐다. 여기서 두 기법이 결정적이다.

첫째, 배치 내 부정(in-batch negatives). 한 배치에 $B$개의 (질의, 긍정) 쌍이 있으면, 어떤 질의 입장에서 다른 쌍들의 긍정 문서는 전부 자기의 부정이다(그 문서들은 이 질의의 정답이 아니니까). 그래서 $B \times B$ 유사도 행렬을 한 번 계산하면 대각선이 긍정, 나머지가 부정이 되어, 질의 하나가 추가 비용 없이 $B-1$개의 부정을 얻는다. 배치를 4096으로 설정하면 질의마다 4095개의 부정이 추가 인코딩 없이 딸려 온다 — 그 4095개 문서는 어차피 자기 질의의 긍정으로 이미 인코딩됐기 때문이다. 이 부정들을 명시적으로 샘플링하려면 질의당 문서 4095개를 더 인코딩해야 하지만, 배치 트릭은 그 비용을 0으로 만든다. 대규모 배치가 임베딩 학습에서 그토록 중요한 이유가 이것이다 — 배치가 클수록 대비할 부정이 많아져 공간이 더 조밀하게 정렬된다.

둘째, 어려운 부정(hard negatives). 무작위로 뽑은 부정은 대개 너무 쉽다. "환불 문의"라는 질의에 대해 무작위 문서(예: 요리 레시피)는 이미 한참 멀리 있어, 모델이 그것을 더 밀어내 봐야 배우는 것이 없다. 정말 배워야 할 것은 겉보기엔 비슷한데 답이 아닌 문서를 떼어 내는 능력이다 — 예컨대 "환불" 대신 "결제 오류 정정"을 다루는 문서. 이런 어려운 부정은 흔히 BM25 같은 어휘 검색으로 상위에 올라오지만 실제 정답은 아닌 문서를 골라 만든다(hard negative mining). 좋은 hard negative를 넣어야 모델이 미세한 의미 차이를 배우고, 이 마이닝 품질이 임베딩 성능의 큰 몫을 차지한다.

학습 쌍 자체는 자연 발생하는 (질의, 관련 문서) 쌍에서 온다 — 제목과 본문, 질문과 답변, 인용, 클릭 로그 같은 것들. 여기에 사람 라벨링이나 LLM으로 합성한 쌍(3.9)을 더한다. 실제 레시피는 대개 다단계다: 방대한 약지도 쌍으로 대조 사전학습을 한 뒤, 라벨과 hard negative로 미세조정한다. 세부는 모델마다 다르지만, 바탕에 있는 목적은 언제나 위의 대조 손실 하나다.

6.2.5 비대칭 검색과 지시 접두사

질의와 문서는 생김새가 다르다. 질의 "환불 어떻게 해?"는 짧고 구어체인 반면, 정답 문서 "환불 정책 전문…"은 길고 격식체다. 두 텍스트가 형태는 딴판인데 의미로는 이어져야 한다 — 이것을 비대칭 검색(asymmetric retrieval)이라 한다. 많은 임베딩 모델이 이 비대칭성을 명시적으로 학습하고, 그 결과 입력에 역할 접두사를 붙일 것을 요구한다. 질의에는 "query: …", 문서에는 "passage: …"를 앞에 붙이거나, "Represent this sentence for retrieval: …" 같은 태스크 지시문을 붙이는 식이다.

이 접두사는 장식이 아니다. 모델이 그 접두사를 신호 삼아 "지금 이건 질의니까 검색어처럼 인코딩하라 / 이건 문서니까 색인 대상처럼 인코딩하라"를 구분하도록 학습됐기 때문에, 접두사를 빠뜨리거나 질의용과 문서용을 뒤바꾸면 같은 모델이라도 성능이 뚝 떨어진다. 게다가 에러 없이 조용히 떨어진다 — 벡터는 정상적으로 나오고 검색도 동작하는데 순위만 이상해진다. 구체적 접두사 문자열은 모델마다 다르므로 반드시 모델 카드를 확인해야 한다. 임베딩을 새로 도입할 때 가장 먼저 확인할 것이 풀링·정규화와 이 접두사 규약이다.

6.2.6 유사도 — 코사인·내적·유클리드, 그리고 정규화가 셋을 묶는 방식

벡터를 손에 넣었으면 "가깝다"를 숫자로 정의해야 한다. 표준은 셋이다. 벡터 $\mathbf{a}, \mathbf{b} \in \mathbb{R}^d$에 대해:

$$ \mathbf{a}\cdot\mathbf{b} = \sum_{i=1}^{d} a_i b_i \qquad \|\mathbf{a}-\mathbf{b}\|_2 = \sqrt{\sum_{i=1}^{d} (a_i - b_i)^2} \qquad \cos(\theta) = \frac{\mathbf{a}\cdot\mathbf{b}}{\|\mathbf{a}\|_2\,\|\mathbf{b}\|_2} $$

내적(inner product, IP)은 크면 가깝다는 신호이고, 방향과 크기(norm)를 함께 반영한다. 유클리드 거리(L2)는 작으면 가깝다는 신호이며, 두 점의 절대적 위치 차이다. 코사인 유사도는 두 벡터의 각도만 보고 크기는 무시하며, 값은 $[-1, 1]$ 범위, 1이면 완전히 같은 방향이다.

겉보기에 셋은 다른 물건 같지만, 정규화하면 사실상 하나로 묶인다. 벡터를 단위 노름으로 정규화($\hat{\mathbf{a}} = \mathbf{a}/\|\mathbf{a}\|$)하면 $\|\hat{\mathbf{a}}\| = \|\hat{\mathbf{b}}\| = 1$이므로 코사인 정의의 분모가 1이 되어

$$ \hat{\mathbf{a}}\cdot\hat{\mathbf{b}} = \cos(\theta) $$

즉 정규화 후 내적이 곧 코사인이다. 유클리드는 어떤가. 단위 벡터 사이의 거리 제곱을 펼치면

$$ \|\hat{\mathbf{a}}-\hat{\mathbf{b}}\|_2^2 = \|\hat{\mathbf{a}}\|^2 + \|\hat{\mathbf{b}}\|^2 - 2\,\hat{\mathbf{a}}\cdot\hat{\mathbf{b}} = 1 + 1 - 2\cos(\theta) = 2\big(1 - \cos\theta\big) $$

여기서 결정적인 사실이 떨어진다. 단위 구면 위에서는 유클리드 거리와 코사인이 단조로 대응한다 — 코사인이 클수록 유클리드 거리가 정확히 작아진다. 따라서 정규화만 해 두면 "코사인 최대 top-$k$"와 "L2 거리 최소 top-$k$"가 완전히 같은 순위를 준다. 실무적 함의가 크다. 벡터 DB가 L2 색인만 지원하더라도, 벡터를 정규화해서 넣으면 그 L2 색인이 그대로 코사인 검색이 된다. 반대로 정규화를 하지 않으면 내적은 노름에 휘둘려 순위가 어긋난다.

척도크기 반영각도 반영범위실무 용도
내적(IP)예예$(-\infty, \infty)$모델이 비정규화 벡터 전제일 때, MIPS
코사인아니오예$[-1, 1]$의미 유사도의 사실상 표준
유클리드(L2)예예$[0, \infty)$정규화하면 코사인과 등가

의미 검색이 코사인(또는 정규화된 내적)을 주로 쓰는 이유는 분명하다. 검색에서 중요한 것은 "무엇에 관한 내용인가"라는 방향이지, "벡터가 얼마나 센가"라는 크기가 아니다. 그런데 벡터의 크기는 의미와 무관한 요인 — 문서 길이, 단어 빈도, 모델의 편향 — 에 쉽게 변동한다. 코사인은 크기를 나눠 없애 방향만 보므로 이런 잡음에 강건하다. 그래서 대부분의 임베딩 모델은 아예 정규화된(단위 노름) 벡터를 출력하도록 학습되고, 이 경우 내적과 코사인이 같아지므로 벡터 DB에서 내적 색인을 그대로 쓰면 된다.

다만 "무조건 코사인"도 통념이다. 어떤 모델·태스크는 크기에 정보를 싣도록 학습된다 — 예컨대 벡터의 크기로 신뢰도나 특이성을 표현하는 경우다. 이런 모델을 코사인으로 정규화해 쓰면 애써 학습한 그 정보를 버리는 셈이 된다. 규칙은 하나다. 모델이 지정한 척도(코사인·내적·L2)를 그대로 따르고, 임의로 바꾸지 마라.

작은 예로 코사인이 크기 편향을 어떻게 지우는지 직접 보자. 질의 $\mathbf{q}=[1,2,2]$, 문서 $\mathbf{a}=[2,3,6]$, $\mathbf{b}=[10,20,20]$이다. 내적부터:

$$ \mathbf{q}\cdot\mathbf{a} = 2+6+12 = 20, \qquad \mathbf{q}\cdot\mathbf{b} = 10+40+40 = 90 $$

노름:

$$ \|\mathbf{q}\| = \sqrt{1+4+4} = 3, \quad \|\mathbf{a}\| = \sqrt{4+9+36} = 7, \quad \|\mathbf{b}\| = \sqrt{100+400+400} = 30 $$

코사인:

$$ \cos(\mathbf{q},\mathbf{a}) = \frac{20}{3\cdot 7} = \frac{20}{21} \approx 0.952, \qquad \cos(\mathbf{q},\mathbf{b}) = \frac{90}{3\cdot 30} = 1.000 $$

배울 점이 둘이다. 첫째, $\mathbf{b} = 10\cdot[1,2,2]$는 질의와 방향이 완전히 같아 코사인이 정확히 1이다 — 크기만 10배일 뿐이다. 둘째, 내적으로 줄을 세우면 $\mathbf{b}$(90)가 $\mathbf{a}$(20)를 압도하지만, 이 우위는 순전히 $\mathbf{b}$의 크기가 커서 생긴 것이다. 만약 $\mathbf{b}$가 단지 "같은 말을 10배 길게 반복한 문서"라면, 크기에 휘둘리는 내적은 그 장황한 문서를 과대평가한다. 코사인은 크기를 나눠 없애 이 편향을 지운다. 정규화하면 $\hat{\mathbf{b}} = [1/3, 2/3, 2/3] = \hat{\mathbf{q}}$이므로 정규화 내적이 곧 코사인 1이 되는 것도 확인된다.

6.2.7 정확 최근접이 왜 비용이 큰가 — 차원의 저주

이제 검색을 정식화하자. 문서 벡터 $N$개 $\{\mathbf{x}_1, \dots, \mathbf{x}_N\} \subset \mathbb{R}^d$와 질의 $\mathbf{q}$가 주어졌을 때, 가장 가까운 $k$개를 찾는다(k-최근접 이웃, k-NN).

정직한 방법 — 전수조사(brute force, flat) — 는 단순하다. 모든 $\mathbf{x}_i$와의 거리를 계산해 top-$k$를 고른다. 비용은 질의당 $O(Nd)$다. 이것이 얼마나 큰지 숫자를 넣어 보자. $N = 10^7$(천만 벡터), $d = 768$이면 질의 하나에 $10^7 \times 768 \approx 7.7 \times 10^9$번의 곱셈·덧셈이다. 그런데 이 연산의 실제 병목은 곱셈이 아니라 메모리 읽기다 — 거리를 계산하려면 천만 개 벡터를 전부 읽어야 하고, float32면

$$ 10^7 \times 768 \times 4\ \text{바이트} \approx 3.07 \times 10^{10}\ \text{바이트} = 30.7\ \text{GB} $$

를 매 질의마다 읽는다. 메모리 대역폭 3 TB/s인 GPU라도 벡터를 훑는 데만 약 10 ms가 걸린다. 천만 개는 그럭저럭이지만 1억 개면 307 GB를 읽어야 해 단일 GPU 메모리에 아예 안 들어가고, 10억 개면 3 TB를 읽어 질의당 초 단위로 번진다. 정확도는 완벽하지만 온라인 검색으로는 못 쓴다. 여기서도 5.1의 교훈이 되풀이된다 — 장벽은 연산이 아니라 메모리다.

"그럼 트리로 공간을 쪼개면 되지 않나?" 저차원에서는 맞다. 2·3차원 지도 검색이라면 kd-tree나 볼트리 같은 공간 분할 트리가 $O(\log N)$에 가깝게 답을 준다. 트리가 "이 가지 전체는 질의에서 멀다"고 판단해 전부 버릴(가지치기) 수 있기 때문이다. 그런데 임베딩은 $d$가 수백~수천이고, 여기서 차원의 저주(curse of dimensionality)가 이 트리들을 무력화한다.

핵심 현상은 거리 집중(distance concentration)이다. 차원이 커질수록 무작위 점들 사이 거리가 서로 비슷해져, 가장 가까운 점과 가장 먼 점의 거리 비가 1로 수렴한다. 왜 그런지는 직접 보인다. 각 성분이 독립 표준정규 $N(0,1)$인 벡터를 생각하면, 노름 제곱 $\|\mathbf{x}\|^2 = \sum_i x_i^2$은 자유도 $d$의 카이제곱 분포라 평균 $d$, 분산 $2d$다. 그러면 노름의 상대적 변동은

$$ \frac{\text{표준편차}(\|\mathbf{x}\|^2)}{\text{평균}(\|\mathbf{x}\|^2)} = \frac{\sqrt{2d}}{d} = \sqrt{\frac{2}{d}} \xrightarrow[d\to\infty]{} 0 $$

$d=768$이면 이 값이 $\sqrt{2/768} \approx 0.051$, 약 5%다. 즉 거의 모든 벡터의 노름이 $\sqrt{768}\approx 27.7$의 ±5% 안에 몰린다 — 점들이 두께 얇은 구껍질 위에 밀집해 쌓인다. 이렇게 모두가 비슷한 거리에 있으면 "명확히 가까운 최근접"이라는 개념 자체가 흐려지고, 트리가 "이 가지는 멀다"고 잘라 버릴 근거가 사라져 결국 거의 전수조사로 퇴화한다.

$$ \text{고차원에서}\quad \frac{\text{dist}_{\max} - \text{dist}_{\min}}{\text{dist}_{\min}} \xrightarrow[d\to\infty]{} 0 $$

결론은 명확하다. 고차원에서 정확 k-NN을 빠르게 하는 일반적 방법은 없다. 그래서 실무는 정확성을 조금 포기하고 속도를 얻는 길, 즉 근사 최근접 이웃(ANN)으로 간다. 목표는 "진짜 top-$k$의 대부분을 매우 빠르게 건진다"이고, 그 "대부분"의 비율을 recall이라 부른다.

$$ \text{recall@}k \;=\; \frac{|\,\text{ANN이 반환한 top-}k \;\cap\; \text{실제 top-}k\,|}{k} $$

예컨대 실제 top-10이 $\{d_1,\dots,d_{10}\}$인데 ANN이 그중 9개를 맞히고 하나를 놓쳐 무관한 문서를 삽입했다면 recall@10 = 0.9다. 실무에서 흔히 0.9~0.99 사이의 recall을 목표로 정하고, 그 대가로 정확 검색 대비 수십~수백 배의 속도를 얻는다. ANN에는 서로 발상이 다른 세 계열이 있다 — 그래프로 지름길을 놓는 HNSW, 공간을 나눠 일부만 뒤지는 IVF, 벡터를 압축해 메모리와 계산을 줄이는 PQ. 이들은 경쟁 관계가 아니라 조합된다(IVFPQ, HNSW+PQ). 하나씩 본다.

6.2.8 HNSW — 그래프로 이웃을 항해한다

HNSW(Hierarchical Navigable Small World)는 오늘날 가장 널리 쓰이는 ANN 색인이다. 발상은 벡터들을 그래프로 잇고, 질의에서 이웃 간선을 따라 탐욕적으로 걸어 내려가 최근접에 도달하는 것이다.

두 아이디어가 겹쳐 있다. 하나는 작은 세상(small world) 그래프다. 각 벡터를 노드로 두고 가까운 이웃 몇 개와 간선으로 잇는다. 질의에서 아무 노드나 시작해 "지금 노드의 이웃 중 질의에 더 가까운 곳으로" 계속 이동하는 탐욕적 걸음을 반복하면, 놀랍도록 빠르게 최근접 근처에 닿는다. 문제는 가까운 간선만 있으면 먼 거리를 한 걸음씩 기어가야 한다는 것이다. 그래서 두 번째 아이디어, 계층(hierarchy)을 추가한다. 위층일수록 노드를 성기게 두고 먼 거리를 잇는 간선만 남긴다. 검색은 맨 위층에서 큰 보폭으로 대략적 위치를 정하고, 한 층씩 내려가며 점점 조밀한 그래프에서 정밀하게 좁힌다. 스킵 리스트를 그래프로 일반화한 셈이다.

층 2 (성김) 진입점 층 1 층 0 (전체·조밀) 최근접(목표) 진입 정밀
HNSW — 위층(성김·장거리)에서 대략 위치를 정하고, 아래층(조밀)에서 정밀 탐색

검색은 진입점에서 시작해 각 층에서 "이웃 중 질의에 더 가까운 곳"으로 이동하다 더 나아질 데가 없으면 아래층으로 내려간다. 마지막 층 0에서는 하나만 그리디로 좇지 않고 후보 집합을 $ef$개까지 유지하며(beam 탐색) 넓게 훑어 recall을 높인다. 구축은 새 노드를 넣을 때 그 노드가 들어갈 최상위 층을 확률적으로 뽑고, 각 층에서 가까운 이웃 $M$개를 찾아 간선을 잇는 식으로 점진적으로 이뤄진다. 주요 조정 변수는 셋이다(이름은 구현마다 조금씩 다르다).

조정 변수의미키우면
$M$노드당 간선(이웃) 수recall↑, 메모리↑, 구축 느림
$ef_{\text{construction}}$구축 시 후보 폭그래프 품질↑, 구축 시간↑
$ef_{\text{search}}$검색 시 후보 폭(beam)recall↑, 질의 지연↑

이 중 $ef_{\text{search}}$가 실무에서 특히 유용하다. 질의 시점에 색인 재구축 없이 조절할 수 있는 조정 변수라, "이번 질의는 빠르게(작게) / 저번 질의는 정확하게(크게)"를 요청마다 실행할 수 있다. HNSW의 강점이 여기 있다 — 높은 recall에서도 지연이 매우 짧고, recall/지연 트레이드오프를 재색인 없이 즉석에서 조절하며, 삽입이 점진적이라 스트리밍 색인이 된다.

약점은 메모리다. HNSW는 벡터 원본과 그래프 간선을 함께 들고 있어야 한다. 앞서의 $N=10^7$, $d=768$, float32 상황에서 벡터만 30.7 GB인데, 간선은 노드당 대략 $2M$개(층 0은 이웃이 더 많다), $M=16$이면 층 0에서만 $10^7 \times 32 \times 4\ \text{바이트} \approx 1.28\ \text{GB}$가 더 붙는다. 여기서는 그래프 오버헤드가 벡터의 4% 남짓이지만, 요점은 벡터 원본 30 GB 전체가 메모리에 상주해야 한다는 것이다. $N$이 수억으로 커지면 이 벡터 저장이 곧 비용이자 한계가 되고, 그래서 대규모에서는 HNSW를 PQ와 결합해 벡터를 압축한다. 또 하나의 약점은 삭제다. 그래프에서 노드를 즉시 빼면 연결성이 상해 항법이 망가지므로, 보통 삭제 표시(tombstone)만 해 두고 검색 때 걸러 낸 뒤 주기적으로 그래프를 재구축한다.

6.2.9 IVF — 공간을 나눠 일부만 뒤진다

IVF(Inverted File index)는 발상이 다르다. 벡터 공간을 미리 여러 구역으로 나눠 두고, 질의가 속한 구역 주변만 뒤진다. HNSW가 그래프로 지름길을 찾는다면 IVF는 "볼 후보를 애초에 줄여" 전수조사를 회피한다.

구축은 두 단계다. 먼저 학습용 벡터로 k-means를 실행해 $n_{\text{list}}$개의 중심(centroid)을 얻는다. 각 중심은 하나의 셀(Voronoi 구역)을 대표한다. 그다음 모든 문서 벡터를 가장 가까운 중심의 리스트에 넣는다 — "중심 → 그 셀에 속한 벡터들"이라는 역파일(inverted file)이 만들어진다. 검색 때는 질의를 중심들과만 비교해 가까운 셀 $n_{\text{probe}}$개를 고르고, 그 셀들에 담긴 벡터에 대해서만 정밀 거리 계산을 해 top-$k$를 낸다.

c1 c2 c3 c4 c5 c6 질의 q nprobe = 2 (c2·c5 탐색) ■ 중심(centroid) · ● 데이터 벡터 · 파란 점선 = 실제 뒤지는 셀
IVF — 공간을 셀로 나누고, 질의가 속한 n_probe개 셀만 탐색

여기서 자연스러운 질문은 "셀을 몇 개로 나눠야 하나"다. 이것은 직접 최적점이 나온다. 벡터가 $n_{\text{list}}$개 셀에 고르게 흩어졌다고 하면 셀당 평균 $N/n_{\text{list}}$개다. 질의 하나의 비용은 두 몫의 합이다 — 중심 $n_{\text{list}}$개와 비교하는 비용, 그리고 뽑은 $n_{\text{probe}}$개 셀 안의 벡터를 스캔하는 비용:

$$ \text{비용} \;\propto\; n_{\text{list}} + n_{\text{probe}}\cdot\frac{N}{n_{\text{list}}} $$

$n_{\text{probe}}$를 고정하고 $n_{\text{list}}$로 미분해 0으로 두면 최적은 $n_{\text{list}} = \sqrt{n_{\text{probe}}\, N}$이다. $n_{\text{probe}}=1$이면 $n_{\text{list}} = \sqrt{N}$이라는 익숙한 어림이 나온다. $N=10^7$이면 $\sqrt{10^7}\approx 3163$개 셀이 좋고, 이때 질의당 거리 계산은 중심 3163개 + 셀 하나 약 3162개 ≈ 6325번으로, 전수조사 1000만 번의 약 1580분의 1이다. recall을 위해 $n_{\text{probe}}=8$로 늘리면 최적 $n_{\text{list}} = \sqrt{8\times 10^7}\approx 8944$가 되고, 질의당 중심 8944개 + 셀 8개 약 8944개 ≈ 17900번으로 여전히 전수조사의 약 560분의 1이다. 셀을 나눈 것만으로 두세 자릿수의 속도를 번 셈이다.

조정 변수는 둘이다. $n_{\text{list}}$가 많을수록 셀이 작아 셀당 스캔 비용은 줄지만, 뒤에 볼 경계 문제가 심해진다. $n_{\text{probe}}$는 질의 시점 조정 변수로, 키우면 recall↑·지연↑이고 $n_{\text{probe}} = n_{\text{list}}$면 전수조사와 같아진다(정확하지만 느림).

IVF의 근본 약점은 셀 경계다. 질의 바로 옆에 있는 실제 최근접이 하필 다른 셀에 배정돼 있고 그 셀이 $n_{\text{probe}}$에 안 들어가면, 그 최근접을 놓친다. 거리로는 코앞인데 셀이 달라서 못 보는 것이다. $n_{\text{probe}}$를 늘리거나, 벡터를 하나가 아닌 여러 인접 셀에 겹쳐 배정하는(soft assignment) 변형으로 완화한다. 그리고 IVF는 대개 단독으로 쓰지 않는다 — 셀 안의 벡터를 PQ 코드로 압축한 IVFPQ가 대규모·메모리 제약 환경의 표준 조합이다. 그 PQ가 다음 절이다.

6.2.10 PQ — 곱양자화로 벡터를 압축한다

HNSW·IVF가 "어디를 뒤질까"의 문제라면, PQ(Product Quantization, 곱양자화)는 "각 벡터를 얼마나 작게 저장하고 거리 계산을 얼마나 싸게 할까"의 문제다. 벡터를 원본 float로 들고 있으면 메모리가 급증하므로 — 앞서 봤듯 1억 개면 307 GB — 벡터를 코드북 인덱스로 압축한다. 원리는 5.2 양자화의 벡터판이다. 연속값을 유한한 격자로 옮겨 비트를 줄인다는 발상이 같다.

방법은 이렇다. $d$차원 벡터를 $m$개의 부분벡터(subvector)로 분할한다(각 $d/m$차원). 부분공간마다 미리 k-means로 $k^*$개의 대표 벡터(코드북, codebook)를 학습해 둔다 — 보통 $k^* = 256$, 즉 8비트다. 그러면 한 부분벡터는 자기가 속한 부분공간 코드북에서 가장 가까운 항목의 번호(1바이트)로 대체된다. 벡터 전체는 $m$개의 바이트, 곧 $m$바이트 크기의 코드가 된다.

원본 벡터 (d차원 float) 조각1 (d/m) 조각2 … 조각m 부분공간별 코드북 (각 k∗ = 256개 대표) 코드북1 c0,c1,…,c255 최근접 = #37 코드북2 c0,c1,…,c255 최근접 = #200 압축 코드 (m바이트) 37 200 … 12 ← 벡터 하나 = m바이트 (float 대비 극적 절약)
PQ — 벡터를 m개 부분벡터로 분할하고, 각 조각을 코드북 인덱스 1바이트로 압축

이름의 "곱(product)"은 표현력에서 온다. 표현 가능한 전체 벡터의 개수는 부분공간 코드북들의 곱, $(k^*)^m$이다. $k^*=256$, $m=96$이면 $256^{96}$개 — 사실상 무한대의 표현력을, 부분공간마다 256개 규모의 작은 코드북 $m$개만으로 얻는다. 이것이 PQ가 강력한 핵심이다. 하나의 거대한 코드북으로 $256^{96}$개 대표 벡터를 직접 학습·저장하는 것은 불가능하지만, 곱 구조로 분할하면 값싸게 얻는다.

거리 계산까지 저렴해진다. 질의는 압축하지 않고, 질의의 각 부분벡터와 그 부분공간 코드북 256개 항목 사이 거리를 미리 표(lookup table)로 계산해 둔다(비대칭 거리 계산, ADC). 그러면 어떤 문서 코드(바이트 $m$개)와의 거리는 표를 $m$번 조회해 더하기만 하면 된다 — 곱셈 없이 덧셈뿐이라 매우 저렴하다. 이 룩업이 PQ가 메모리뿐 아니라 계산까지 줄여 주는 지점이다.

절약을 숫자로 재 보자. 임베딩 차원 $d = 768$, 원본은 float32(4바이트)다. 벡터 하나의 원본 크기는

$$ 768 \times 4\ \text{바이트} = 3072\ \text{바이트} \approx 3\ \text{KB} $$

PQ를 $m = 96$개 부분벡터(각 $768/96 = 8$차원), 부분공간마다 $k^* = 256$(8비트 = 1바이트)로 가정하면, 압축 코드 크기는

$$ m \times \frac{\log_2 k^*}{8}\ \text{바이트} = 96 \times \frac{8}{8} = 96\ \text{바이트} $$

압축률은

$$ \frac{3072}{96} = 32\times $$

즉 메모리가 32분의 1로 준다. 규모로 옮기면 이 32배가 어디에 쓰는지가 보인다. 1억 개 벡터를 원본으로 들면 $10^8 \times 3072\ \text{B} \approx 307\ \text{GB}$라 여러 대에 분할해야 하지만, PQ 코드로는 $10^8 \times 96\ \text{B} \approx 9.6\ \text{GB}$로 흔한 서버 한 대의 RAM에 들어간다. 클러스터가 필요할지 단일 노드로 끝날지를 가르는 차이다.

코드북 자체도 저장해야 하지만 비용은 무시할 만하다. 코드북 전체 크기는

$$ \underbrace{m}_{96} \times \underbrace{k^*}_{256} \times \underbrace{(d/m)}_{8} \times \underbrace{4\ \text{B}}_{\text{float32}} = 96 \times 256 \times 8 \times 4 \approx 786\ \text{KB} $$

이며, 이 코드북은 전체 벡터가 공유하므로 데이터셋이 클수록 벡터당 상각 비용이 0으로 수렴한다. 실질 비용은 벡터당 96바이트 그대로다.

조정 변수의 방향은 이렇다. $m$을 키우면(부분벡터를 잘게) 코드가 길어져 압축률은 낮아지지만 정밀도가 오른다. 줄이면 더 압축되지만 정보 손실이 커진다. $k^*$를 키우면(8비트보다 큰 코드북) 부분공간 표현력이 오르는 대신 거리표가 커진다. 무엇을 택하든 PQ는 손실 압축이라 recall이 떨어진다. 그래서 실무의 표준은 PQ로 대충 후보를 좁힌 뒤 원본 벡터로 다시 정밀 채점하는(rerank) 조합이다 — 압축 벡터로 넓게 저렴하게 건지고, 원본으로 좁게 정확히 다시 세운다.

PQ는 단독보다 다른 색인과 결합할 때 위력을 낸다. IVFPQ는 IVF로 뒤질 셀을 좁히고(어디를) 각 셀 안 벡터는 PQ 코드로 저장한다(얼마나 작게) — FAISS의 대표 레시피이자 대규모·메모리 제약의 사실상 표준이다. HNSW+PQ는 HNSW의 빠른 그래프 탐색에 PQ의 메모리 절약을 더한다(그래프 간선 메모리는 남지만 벡터 저장이 준다). OPQ(Optimized PQ)는 PQ 전에 벡터를 직교변환으로 회전시켜 부분공간 사이 에너지를 고르게 만들어 양자화 오차를 줄이는 개선이고, ScaNN(Google)은 여기에 이방성(anisotropic) 양자화를 더해 내적 검색에 최적화한다(세부 성능은 벤치마크·버전에 따라 달라 여기 단정하지 않는다).

6.2.11 recall·지연·메모리의 삼각형

세 색인 계열은 목적이 달라 한 축으로 줄을 세울 수 없다. 대신 이들이 공통으로 갇혀 있는 긴장은 삼각형이다.

$$ \text{recall}\ \uparrow \quad\Longleftrightarrow\quad \text{지연}\ \uparrow \ \ \text{또는}\ \ \text{메모리}\ \uparrow $$

recall을 높이려면 더 넓게 뒤지거나($ef$·$n_{\text{probe}}$↑) 덜 압축해야(PQ 약하게) 하니 지연이나 메모리가 오른다. 메모리를 줄이려 강하게 압축하면 손실로 recall이 떨어진다. 지연을 줄이려 좁게 탐색하면 역시 recall이 떨어진다. 세 꼭짓점을 동시에 최적으로 만들 수는 없고, 어느 꼭짓점을 우선할지가 색인과 조정 변수 선택을 정한다.

색인핵심 발상recall/지연메모리업데이트잘 맞는 곳
Flat(정확)전수조사완벽 / 느림원본(큼)즉시소규모·정답 필요
HNSW그래프 항법최상 / 매우 빠름큼(그래프+원본)삽입 쉬움, 삭제 난지연 최우선, RAM 여유
IVF(+PQ)셀 분할좋음 / 빠름작음(PQ 시)재군집 필요대규모·메모리 제약
PQ(단독/결합)벡터 압축압축률로 조절최소코드북 재학습초대규모·메모리가 장벽

"어떤 ANN이 최고인가"라는 질문에는 답이 없다. ann-benchmarks류 공개 벤치마크는 데이터 분포·차원·목표 recall·하드웨어에 민감해 조건이 바뀌면 순위가 뒤집힌다. 어떤 데이터에서 HNSW가 IVFPQ를 이기고, 다른 데이터에서는 반대다. 색인은 당신의 $N$·$d$·recall 목표·지연 SLA·RAM 예산·업데이트 패턴에 맞춰 고르고, 반드시 당신의 데이터로 recall과 지연을 직접 측정해야 한다. 남의 리더보드는 어디서 출발할지 알려 줄 뿐이다.

6.2.12 벡터를 어디에 둘까 — 세 범주와 선택 기준

"임베딩을 어디에 저장하고 검색하나"라는 질문에는 세 범주의 답이 있고, 제품 순위가 아니라 범주의 성격을 이해하는 것이 핵심이다.

첫째는 ANN 라이브러리다. FAISS·hnswlib·ScaNN·Annoy 같은 것들로, 색인·검색 알고리즘 그 자체다. 빠르고 유연하지만 영속화·메타데이터 필터·분산·CRUD·인증을 스스로 주지 않는다 — 그것은 당신이 위에 추가해야 한다. 배치 작업, 연구, 인프라를 직접 통제하려는 팀에 맞는다. 둘째는 전용 벡터 DB다. Milvus·Qdrant·Weaviate·Pinecone·Vespa·Chroma 같은 것들로, 벡터 색인 위에 CRUD·메타데이터 필터링·영속성·수평 확장·복제·멀티테넌시·하이브리드 검색을 갖춘 완제품이다. 벡터가 서비스의 1급 시민이고 규모와 운영이 필요할 때다. 셋째는 기존 DB·검색엔진의 벡터 확장이다. pgvector(PostgreSQL)·Elasticsearch/OpenSearch의 kNN·Redis·MongoDB Atlas 같은 것들로, 이미 쓰는 저장소에 벡터 기능을 더한다. 운영·트랜잭션·백업·조인·기존 데이터와의 결합을 그대로 재사용하는 것이 최대 장점이며, 규모나 ANN 성숙도는 전용 DB에 못 미칠 수 있다.

벡터를 어디에 둘까? ① 라이브러리 FAISS · hnswlib · ScaNN ② 전용 벡터 DB Milvus · Qdrant · Weaviate · Pinecone ③ 기존 DB 확장 pgvector · Elasticsearch · Redis 색인 엔진만. 영속·필터·확장은 직접 CRUD·필터·분산· 하이브리드 완비. 새 시스템 도입 기존 스택 재사용. 운영 단순, 규모· 기능 한계 가능

특정 제품을 추천하는 것은 무의미하다 — 시장·성능·가격이 빠르게 바뀌기 때문이다. 대신 당신의 요구에 대고 재는 축을 준다. 규모와 성능: 벡터 수 $N$, 차원 $d$, 목표 recall과 지연 SLA, QPS가 억 단위인지 백만 단위인지. 업데이트 패턴: 대량 배치 재색인인지 실시간 스트리밍 삽입·삭제인지, HNSW 삭제나 IVF 재군집의 부담을 감당할 수 있는지. 메타데이터 필터링: "2024년 이후·부서=법무" 같은 조건과 벡터 검색을 함께 적용해야 하는지(먼저 거르고 검색하느냐 검색 후 거르느냐에 따라 정확도·성능 함정이 다르다). 하이브리드 검색: 어휘(BM25)와 벡터를 한 시스템에서 융합할 수 있는지. 운영 모델: 매니지드(SaaS)로 운영 부담을 살지, 자체 호스팅으로 통제·비용·데이터 주권을 쥘지. 일관성·내구성: 트랜잭션·백업·복제·장애 복구가 필요한지, DB급인지 캐시급인지. 비용: 메모리가 벡터 비용의 큰 몫이라 양자화 지원 여부가 곧 돈이다. 스택 적합성: 이미 PostgreSQL이나 Elasticsearch를 쓰면 세 번째 범주가 운영 복잡도를 크게 아낀다.

내 견해로는, 많은 팀에게 합리적 시작점은 이미 쓰는 저장소의 벡터 확장으로 출발했다가 규모나 기능이 한계에 부딪히면 전용 DB로 옮기는 것이다. "새 시스템 하나 = 새 장애점 + 새 운영 부담"이라 처음부터 전용 DB를 들이는 비용이 만만치 않기 때문이다. 물론 처음부터 초대규모가 확실하거나 벡터가 제품의 핵심이면 전용 DB로 바로 가는 편이 맞다. 어느 쪽이든 리더보드 1등을 좇지 말고 요구사항에 대고 골라야 한다 — 이것은 강요가 아니라 출발점 제안이다.

6.2.13 실무 결정 — 차원·정규화·업데이트·양자화

임베딩 차원 $d$는 클수록 좋다는 통념부터 깨자. 차원이 크면 표현력이 늘 수는 있지만, 저장과 검색 비용이 차원에 선형으로 늘고(벡터당 바이트, 거리 계산량) recall·지연·메모리 삼각형을 전부 악화시킨다. 차원은 품질과 비용의 거래이지 무조건 크게 설정할 축이 아니다. 최근 임베딩 모델 일부는 Matryoshka 표현학습(MRL)으로 하나의 벡터를 앞에서 잘라도(예: 1024→256) 의미가 유지되도록 학습된다. 그러면 저장·검색은 짧은 차원으로 하고 정밀 재랭크는 긴 차원으로 하는 식으로, 차원을 런타임 조정 변수처럼 쓸 수 있다. 실무 지침은 하나다 — 필요한 recall을 내는 최소 차원을 실측으로 찾아라. 차원을 반으로 줄여 품질이 거의 안 떨어지면 저장·검색 비용을 절반 아낀 것이다.

정규화는 색인 척도와 한 몸으로 다뤄야 한다. 모델이 정규화 벡터를 출력하면(대부분 그렇다) 내적 색인이 곧 코사인이니, 색인을 만들 때 거리 척도를 코사인이나 내적으로 맞춘다. 모델이 비정규화 벡터를 내는데 코사인을 쓰려면 색인에 넣기 전에 L2 정규화를 하고, 반드시 질의도 같은 방식으로 정규화해야 한다. 한쪽만 정규화하면 조용히 어긋난다. 이 척도·정규화 불일치는 검색 시스템의 가장 흔한 조용한 버그다 — 모델이 코사인 전제인데 비정규화 벡터를 L2 색인에 넣으면 6.2.6에서 본 크기 편향이 그대로 살아나 무관한 문서가 상위로 올라온다. 에러가 안 나므로 며칠씩 원인을 못 찾기도 한다.

색인은 한 번 만들고 끝나는 자산이 아니라 코퍼스와 함께 살아 움직인다. 삽입은 HNSW가 점진적이라 쉽다. IVF는 새 벡터를 기존 셀에 넣을 수 있지만, 분포가 크게 바뀌면 중심이 실제 분포와 어긋나 셀 균형이 붕괴하고 recall이 서서히 떨어져 주기적 재군집이 필요하다. 삭제는 앞서 봤듯 HNSW에서 tombstone 후 주기적 재구축이 표준이고, "삭제했는데 검색에 남아 있는" 사고를 막으려 필터로 이중 방어한다. 가장 큰 변화는 재임베딩이다. 임베딩 모델을 바꾸면 코퍼스 전체를 다시 임베딩해야 한다 — 벡터 공간 자체 전체가 달라져 신·구 벡터를 섞으면 거리가 무의미해지기 때문이다. 이것은 대규모 배치 작업이자 비용이므로, 임베딩 버전을 메타데이터로 기록하고 신·구 색인을 병행 운영하다 전환하는 식으로 모델 마이그레이션을 처음부터 설계에 넣어야 한다(8.1의 데이터 플라이휠과 이어진다).

양자화는 메모리를 직접 돈으로 바꾸는 수단이다(5.2의 원리를 벡터에 적용). 가장 간단한 첫 수는 스칼라 양자화(SQ)로, float32를 int8 등으로 성분별 축소해 4배를 아끼고 손실은 작다. PQ는 부분벡터 코드북으로 수십 배까지 압축하지만 손실이 커 원본 재랭크와 조합해야 한다. 극단은 이진 양자화로, 각 성분을 부호 1비트로 압축해 해밍 거리(비트 XOR)로 초고속 비교한다 — 손실이 크지만 1차 후보 필터로 쓴 뒤 원본으로 재랭크하면 유용하다. 세 방법의 공통 패턴은 같다. 압축 벡터로 넓게·저렴하게 후보를 건지고, 원본(또는 덜 압축한) 벡터로 좁게·정밀하게 재랭크한다. recall을 지키면서 메모리와 지연을 줄이는 표준이다.

6.2.14 청킹이 검색을 지배한다

임베딩과 ANN이 아무리 좋아도, 무엇을 하나의 벡터로 만들지를 잘못 정하면 검색이 망가진다. 문서를 검색 단위로 자르는 이 작업이 청킹(chunking)이고, 본격 논의는 6.3이지만 검색 품질의 근원이라 여기서 연결을 다룬다. 요점은 하나다 — 청크는 임베딩의 단위이자 검색의 단위다. 벡터 하나가 청크 하나를 대표하므로 청크 크기가 검색을 지배한다.

청크가 너무 크면(예: 문서를 전부) 한 벡터에 여러 주제가 섞여 의미가 희석된다. 질의가 문서의 단 한 문장과만 관련돼도 그 문장의 신호가 나머지 수백 문장에 평균으로 흐려져, 정작 정답 문서의 벡터가 질의와 멀어진다 → recall 저하. 게다가 검색에 성공해도 불필요하게 긴 청크를 LLM 컨텍스트로 넘겨 토큰 비용이 오른다. 반대로 청크가 너무 작으면(예: 한 문장) 벡터는 선명하지만 맥락이 잘린다. 대명사·생략·표 참조로 앞뒤에 의존하는 조각은 그 자체로 답이 못 되고("그 값은 3.2였다"는 문장은 "그 값"이 무엇인지 없으면 무용하다), 청크 수가 늘어 색인·검색 부담도 커진다.

완화책은 의미 경계(문단·절)를 존중해 자르기, 경계에서 정보가 잘리지 않도록 청크를 조금 겹치기(overlap), 검색은 작은 청크로 정밀하게 하되 LLM에 넘길 컨텍스트는 그 청크가 속한 부모 문서로 확장하기(parent-document, small-to-big) 등이다. 상세와 평가는 6.3·6.4이 맡는다. 여기서 붙들 교훈은 실무적이다 — 검색이 나쁠 때 임베딩 모델이나 ANN 조정 변수를 바꾸기 전에 청킹을 먼저 의심하라. 현장에서 검색이 안 되는 이유는 임베딩보다 청킹·전처리인 경우가 훨씬 많다.

6.2.15 하이브리드와 벤치마크 — 임베딩 하나로는 끝나지 않는다

밀집(dense) 임베딩 검색은 강력하지만 구멍이 있다. 정확한 토큰 일치에 약하다는 점이다. 제품 코드 A1B-2231, 희귀 고유명사, 전문 약어, 정확한 인용문처럼 철자 그대로가 중요한 질의에서, 의미 임베딩은 "비슷해 보이는 것"으로 흐른다 — A1B-2231을 물었는데 A1B-2230을 가까이 놓는 식이다. 학습에서 못 본 신조어나 사내 용어처럼 도메인 밖 어휘도 의미 벡터가 불충분하다. 임베딩은 뜻으로 뭉뚱그리는 데 강하지 정확히 지적하는 데는 약하다.

전통적 어휘 검색, 특히 BM25(TF-IDF 계열의 확률적 랭킹)는 정확히 이 약점을 메운다. 단어 일치에 강하고 희귀어에 견고하다. 그래서 실무의 표준은 하이브리드다 — 밀집(의미) 점수와 희소(어휘) 점수를 RRF(Reciprocal Rank Fusion) 같은 방법으로 융합해 양쪽 장점을 취한다.

$$ \text{BM25 (어휘·정확 일치)} \;\;+\;\; \text{임베딩 (의미·유사)} \;\;\xrightarrow{\ \text{RRF 등}\ }\;\; \text{하이브리드 랭킹} $$

원리·구현·재순위(cross-encoder rerank)·평가는 6.4에서 본격적으로 다룬다. 이 장에서 남길 결론은 밀집 임베딩이 어휘 검색과 결합할 때 가장 강하다는 것이다.

마지막으로 임베딩 모델을 고를 때 벤치마크를 읽는 법이다. 임베딩 모델은 흔히 MTEB(Massive Text Embedding Benchmark) 같은 종합 벤치마크의 점수·순위로 비교되는데, 유용하지만 함정이 많다. MTEB는 검색·분류·클러스터링·의미유사도(STS)·재순위 등 성격이 딴판인 여러 태스크의 평균이라, 평균 1등이 당신의 태스크(예컨대 검색) 1등이라는 보장이 없다 — 태스크별로 나눠 봐야 한다. 더 큰 함정은 도메인과 언어다. 공개 벤치마크는 대개 일반·영어 위주 데이터라, 법률·의료·코드·사내 문서 같은 특정 도메인이나 한국어에서는 순위가 완전히 뒤집힐 수 있다. 한국어 검색 품질은 모델마다 편차가 크고, 영어에서 최고인 모델이 한국어에서 평범한 일이 흔하다. 여기에 공개 벤치마크는 학습 데이터에 누출됐을 수 있어(4.1) 점수가 부풀 수 있고, 리더보드는 몇 달 단위로 바뀐다. 특정 순위를 설계의 골격으로 삼으면 안 되는 이유다.

그래서 "범용 임베딩 하나만 잘 고르면 검색은 끝"이라는 생각은 틀렸다. 검색에 좋은 모델이 클러스터링엔 아닐 수 있고, 영어에 좋은 모델이 한국어엔 약할 수 있으며, 질의–문서 비대칭과 지시 접두사를 무시하면 좋은 모델도 망가지고, 밀집 임베딩만으로는 정확 일치와 희귀어에 약하다. 임베딩은 검색이라는 시스템의 한 부품일 뿐이다 — 청킹·필터링·재순위·하이브리드가 함께 좋아야 검색이 좋다. 실무 원칙으로 명시하자면, 후보 모델 두세 개를 골라 당신의 질의와 문서로 만든 소규모 평가셋에서 recall@k와 nDCG를 직접 재라. 그 숫자가 어떤 리더보드보다 신뢰할 만하다(검색 평가 방법은 6.4).

이렇게 텍스트를 의미 벡터로 만들고 그 벡터들 사이를 빠르게 항해하는 두 축을 세웠다. 다음 장은 이 임베딩·벡터 검색을 검색기로 삼아 청킹 → 검색 → 컨텍스트 구성 → 생성으로 잇는 RAG의 기본기를 세우고, "RAG가 환각을 없앤다"는 통념이 어디까지 맞고 어디서 깨지는지를 따진다.