← ~/blog
2026. 06. 15. Creative Engine

가까움을 숫자로 — 전부 뒤지지 않고 가장 가까운 것을 찾기

글자가 0% 겹치는 돈전지와 앞다리살이 코사인 0.93으로 묶인다. 밥비서에서 흩어진 재료를 모으며 거리를 재고, 전부와 비교하지 않고 찾고, 도메인이 미리 잘라준 청크를 쓴 이야기.

연재RAG에서 Agent까지2 / 5
가까움을 숫자로 — 전부 뒤지지 않고 가장 가까운 것을 찾기

지난 글의 끝에서 모든 조각이 좌표를 갖게 됐다. 재료 하나, 레시피 한 편이 768개의 숫자로 된 한 점이 됐고, 의미가 가까우면 점도 가깝게 놓인다는 것까지 봤다. 그런데 거기서 곧장 다음 질문이 떨어진다 — 두 점이 ‘얼마나’ 가까운지를 어떻게 숫자 하나로 재는가. 수백만 개 중에서 가까운 것을, 전부 뒤지지 않고 어떻게 찾는가. 그리고 긴 문서는 애초에 어디서 잘라 점으로 만드는가.

이 글은 그 세 질문에 답한다. 좌표를 만드는 이야기가 아니라, 만들어진 좌표 위에서 가까움을 찾아내는 이야기다. 표본은 지난 편과 같다. 돼지고기 앞다리살, 돈전지, 앞다리살 — 글자는 제각각인데 같은 부위를 가리키는 세 표기다.

거리가 아니라 방향을 잰다 — 코사인 유사도

두 점이 가깝다는 걸 재는 가장 순진한 방법은 자로 잰 직선거리다. 그런데 의미 검색에서는 거리보다 방향을 본다. 임베딩 공간에서 한 벡터의 ‘길이’(원점에서 얼마나 먼가)는 종종 의미보다 부차적인 것 — 글자 수나 강조 정도 같은 데 휘둘린다. 정작 의미는 벡터가 어느 방향을 가리키는가에 담긴다. 그래서 두 벡터가 이루는 각도를 재고, 그 각도의 코사인 값을 유사도로 쓴다. 코사인 유사도(cosine similarity)다.

값의 범위는 늘 -1에서 1 사이다. 같은 방향이면 1, 직각이면 0, 정반대면 -1. 계산도 단순하다. 두 벡터의 같은 자리 숫자끼리 곱해 모두 더하고(내적), 각 벡터의 길이로 나눠 방향만 남긴다.

function cosine(a: number[], b: number[]): number {
  let dot = 0, na = 0, nb = 0;
  for (let i = 0; i < a.length; i++) {
    dot += a[i] * b[i];      // 같은 자리끼리 곱해 더함 (내적)
    na += a[i] * a[i];
    nb += b[i] * b[i];
  }
  return dot / (Math.sqrt(na) * Math.sqrt(nb));  // 길이로 나눠 '방향만' 남김
}

이 한 줄짜리 숫자가 검색의 심장이다. 돼지고기 앞다리살돈전지를 각각 768차원으로 만든 뒤 이 함수에 넣으면, 글자는 0% 겹쳐도 0.93 같은 값이 나온다. 돼지고기 앞다리살고등어라면 0.2쯤. 우리를 가로막았던 ‘글자 0% 겹침’ 문제가, 드디어 하나의 숫자로 풀린다.

그런데 0.93이 정확히 무엇을 뜻하나. 여기서 정직해질 필요가 있다. 코사인 값에는 절대적인 의미가 없다. 0.93이 ‘93% 같은 재료’라는 뜻이 아니다. 그 값은 오직 상대적으로만, 그리고 그 모델과 도메인 안에서만 의미를 갖는다. 어떤 모델에서는 무관한 두 문장도 0.7이 예사고, 어떤 도메인에서는 0.95는 넘어야 비로소 ‘같다’고 본다.

그래서 등장하는 게 임계값(threshold)이다. “코사인이 이 선 이상이면 같은 재료로 본다”는 경계. 밥비서에서 우리는 이 선을 손으로 그었다. 돼지 앞다리·돈전지는 묶이고 돼지 등심은 안 묶이는 경계를 찾아 임계값을 올렸다 내렸다 했다. 그 튜닝이 고됐던 데는 이유가 있다 — 임계값은 본질적으로 놓치지 않기와 잘못 묶지 않기 사이의 트레이드오프를 한 숫자로 누르는 일이기 때문이다. 너무 낮추면 돼지 등심까지 끌려 들어오고, 너무 높이면 돈전지 같은 희귀 표기를 놓친다. 단 하나의 전역 임계값으로 모든 재료 쌍을 완벽히 가르는 선은 대개 존재하지 않는다. 이 한계가, 나중에 거칠게 거른 뒤 더 정밀한 잣대로 다시 보는 단계(rerank)가 필요해지는 씨앗이다.

전부와 비교하지 않는 기술 — ANN과 인덱스

코사인으로 두 점의 가까움은 잴 수 있게 됐다. 이제 현실의 벽이다. 새 표기 돈전지가 들어왔을 때, 이게 기존 어느 정규 재료와 같은지 알려면 코사인을 어디에 다 돌려야 하나? 등록된 모든 재료와 일일이. 이게 전수 비교(brute-force)다.

재료가 수천 개면 견딜 만하다. 그런데 레시피 본문이나 일반 문서로 코퍼스가 커져 벡터가 100만 개라면, 질의 하나마다 768차원 내적을 100만 번 돌려야 한다. 질의가 쏟아지면 산수가 무너진다. 정확하지만 너무 느리다. 여기서 타협이 나온다 — 굳이 정확한 1등을 보장하지 않아도 된다면? 거의 가장 가까운 것들만 빠르게 건져도 검색 품질은 충분할 때가 많다. 이 발상이 근사 최근접 이웃(ANN, Approximate Nearest Neighbor)이다. 정확도를 아주 조금 내주고 속도를 수백~수천 배 얻는 거래.

ANN을 가능케 하는 건 인덱스다. 벡터들을 미리 ‘가까운 것끼리’ 정리해 둬서, 검색 때 전부가 아니라 유망한 일부만 보게 만드는 자료구조다. 대표적으로 두 갈래가 있다.

HNSWIVFFlat
발상가까운 벡터끼리 그래프로 잇고, 이웃을 따라 점프하며 내려감공간을 미리 여러 구역으로 나누고(군집), 질의가 속한 몇 구역만 뒤짐
강점빠르고 놓침이 적음메모리 가볍고 구축 단순
약점메모리 많이 씀, 구축 느림군집 경계 근처에서 놓칠 수 있음

우리는 이 벡터들을 pgvector에 담고, 재료 임베딩 컬럼에 IVFFlat 인덱스를 걸었다. IVFFlat은 먼저 벡터들을 수백 개 구역으로 군집해 나누고, 각 구역마다 중심점(centroid)을 하나씩 정해 둔다. 질의가 들어오면 모든 벡터와 비교하는 대신 질의에 가장 가까운 centroid 몇 개를 먼저 고르고, 그 몇 구역 안의 벡터만 추려 비교한다. 100만 개를 다 보는 대신 가장 가까운 10개 구역의 1만 개만 보는 식이다.

HNSW가 아니라 IVFFlat을 고른 건 “가장 빠른 인덱스”가 아니라 “이 문제에 맞는 인덱스”를 고른 결과다. 재료 정규화는 코퍼스를 쌓는 배치성 작업이라 1ms를 다투지 않고, 재료 수가 폭발적이지도 않다. 그런 자리에는 HNSW의 속도보다 IVFFlat의 가벼움·단순함이 맞았다. 인덱스 선택은 성능 경쟁이 아니라 트레이드오프 판단이다.

-- pgvector: <=> 는 코사인 거리(작을수록 가깝다).
-- 인덱스가 '전수'가 아니라 '몇 구역만' 보게 한다.
SELECT id, name
FROM ingredients
ORDER BY emb <=> $1   -- $1 = 질의 재료의 임베딩
LIMIT 5;

겉보기엔 평범한 ORDER BY지만, IVFFlat 인덱스가 있으면 Postgres는 이 정렬을 전체 행 전수가 아니라 몇 구역으로 좁혀 처리한다. 이때 몇 구역을 뒤질지는 probes 값이 정한다 — 5로 두면 가장 가까운 5개 구역만, 50으로 올리면 50개 구역을 본다. 늘릴수록 더 많은 벡터를 비교해 정확하지만 느려지고, 줄이면 빠르지만 구역 경계 너머의 정답을 놓칠 수 있다. 코사인 임계값과 똑같은 결의, 또 하나의 정확도↔속도 손잡이다.

자르지 않으면 찾을 수 없다 — 청킹

지금까지 우리는 줄곧 ‘조각’을 임베딩한다고 말했다. 이제 그 조각이 어디서 오는지 물을 차례다. 왜 문서를 통째로 임베딩하지 않고 잘라야 하는가.

이유는 지난 편의 풀링에 있다. 임베딩 모델은 입력이 아무리 길어도 결국 벡터 하나로 뭉갠다. 레시피 한 편(재료·단계·팁·영양)을 통째로 넣으면 그 모든 의미가 768개 숫자 하나로 평균돼 버린다. 그러면 “이 레시피의 매운맛 조절법”처럼 특정 대목을 묻는 질의가 들어와도 레시피 전체의 뭉뚱그린 평균 벡터와만 비교하게 되고, 정작 답이 있는 한 문단의 신호가 나머지에 희석돼 묻힌다. 반대로 한 문장씩 너무 잘게 자르면 “달걀 2개”처럼 맥락 없는 파편이 되어, 그게 어느 요리의 무슨 단계인지 잃는다.

그래서 청킹(chunking)은 본질적으로 크기의 트레이드오프다. 너무 크면 의미가 희석되고 토큰이 낭비되며(주입할 때 비싸진다), 너무 작으면 맥락이 끊겨 조각만으로는 쓸모가 없다. ‘하나의 자족적인 의미 단위’를 찾는 게 청킹 전략의 핵심이고, 일반 RAG는 긴 PDF를 어디서 자를지를 두고 늘 골머리를 앓는다. 고정 크기로 자르되 경계 손실을 줄이려 살짝 겹치거나, 문단→문장 순으로 자연 경계를 우선하거나, 주제가 바뀌는 지점을 임베딩으로 감지해 자르거나, 작게 잘라 찾고 그 부모 덩어리를 통째로 먹이거나 — 전략은 여럿이지만 다 같은 고민을 푼다.

그런데 밥비서를 보면 흥미로운 사실이 드러난다. 우리는 이 고민을 거의 하지 않아도 됐다. 데이터가 이미 자연스러운 단위로 떨어져 있었기 때문이다. 레시피 한 편이 그 자체로 완결된 한 청크고, 재료 하나가 정규화의 한 단위다. 거친 레시피 초안을 이름·재료·단계·태그라는 정형 템플릿으로 다듬은 작업이, 사실은 ‘의미 단위로 미리 잘라 구조화하는’ 청킹의 가장 깨끗한 형태였다. 일반 RAG가 PDF를 어떻게 자를지 앓는 동안, 우리 도메인은 처음부터 잘려 있었던 셈이다. 그래서 임베딩을 ‘재료명’이라는 더없이 짧고 자족적인 단위에만 쓰면 됐다.

이건 청킹을 건너뛴 게 아니라, 청킹을 데이터 정형화 단계로 흡수한 것이다. 도메인이 자연 청크 경계를 줄 때, 가장 영리한 청킹 전략은 그 경계를 그대로 존중하는 것이다.

손잡이가 같은 모양인 이유

세 절을 지나고 보면 같은 형태가 세 번 반복됐다. 코사인 임계값은 놓침과 오묶음 사이의 손잡이였고, IVFFlat의 probes는 정확도와 속도 사이의 손잡이였고, 청크 크기는 의미 희석과 맥락 단절 사이의 손잡이였다. 셋 다 “어느 한쪽을 공짜로 가질 수 없다, 어디서 멈출지 고르는 일”이라는 같은 모양이다. 벡터 검색을 안다는 건 알고리즘 이름을 외우는 게 아니라, 이 손잡이들이 무엇과 무엇을 맞바꾸는지를 아는 것이다.

한 가지는 지난 편에서처럼 분명히 해 둔다. 이 거리 재기·인덱스·청킹은 모두 코퍼스를 빚는 빌드타임의 일이다. 사용자에게 나가는 주 단위 식단을 짜는 코어 엔진은 이것과 별개로, LLM 없이 도는 결정론 알고리즘이다. 벡터 검색은 데이터를 정리하는 자리에서 일했고, 식단을 짜는 자리에는 재현 가능한 규칙이 일한다. 둘을 섞지 않는 게 우리 설계의 전제다.

여기까지가 ‘가까운 것을 찾아내는’ 이야기다. 그런데 찾았다고 끝이 아니다. 가까운 후보 다섯을 건졌을 때, 그중 무엇을 어떤 순서로 고를지, 무엇을 추려 모델에게 먹일지는 또 다른 문제다. 다음 편(3부)에서 ‘찾은 것을 먹이다’ — 랭킹과 rerank, 그리고 주입과 생성으로 들어간다. 검색은 거름망이 아니라 줄 세우기라는 관점에서.

이름 없이 먼저 풀었던 문제에 정확한 이름을 붙이는 이 작업은, 결국 같은 손잡이를 다른 도메인에 옮겨 다는 지도다.

#engineering #vector-search #ann #chunking #rag #bobbiso
← 목록으로