Skip to content
This page has been auto-translated and may contain errors.View in English

임베딩

docs.scrimba.com

먼저 용어부터 정리하겠습니다. **임베딩(embedding)**이란 텍스트 조각을 대신하는 숫자 목록으로, 그 의미를 담아내어 비슷한 의미의 텍스트가 비슷한 숫자로 바뀌게 해줍니다. 이번 챕터에서는 단어를 컴퓨터가 비교할 수 있는 형태로 바꾸는 방법을 다루는데, 이것이 바로 검색이 키워드가 아닌 의미로 매칭될 수 있게 해주는 원리입니다.

텍스트를 벡터로

컴퓨터는 어떻게 "개"와 "강아지"가 관련 있고 "개"와 "스테이플러"는 관련 없다는 걸 알 수 있을까요? 이 단어들은 서로 겹치는 의미 있는 글자가 없습니다.

방법은 텍스트를 그 의미를 담은 숫자로 바꾸어, 비슷한 의미가 비슷한 숫자가 되게 하는 것입니다. 그 숫자 목록을 **임베딩**이라고 부르며, 벡터(vector)라고도 합니다. 모델이 텍스트를 읽고 이 벡터를 출력합니다.

숫자 자체를 직접 해석할 필요는 없습니다. 중요한 건 숫자들 사이의 관계입니다.

대략적인 그림을 그려보면, 모든 텍스트를 공간 속의 점으로 배치했다고 생각해보세요. 비슷한 의미를 가진 것들은 서로 가까이 위치합니다. "개"와 "강아지"는 같은 동네에 있고, "스테이플러"는 도시 반대편에 있습니다. 실제 임베딩은 우리가 그림으로 떠올릴 수 있는 2차원이나 3차원이 아니라 수백, 수천 차원을 사용하지만, 직관은 그대로입니다. 가깝다는 것은 비슷하다는 뜻입니다.

그 숫자들은 어디서 오는 걸까요? 그리고 왜 의미와 맞아떨어지는 걸까요? 임베딩 모델은 LLM의 작동 원리에서 다룬 예측 모델과 가까운 사촌 격으로, 방대한 텍스트로 훈련됩니다. 자신의 과업을 잘 수행하려면 어떤 단어와 개념이 비슷한 맥락에서 나타나는지를 학습해야 했는데, 이는 "단어는 그 단어가 어울리는 무리를 보면 알 수 있다"는 오래된 관찰과 같습니다.

"개"와 "강아지"는 비슷한 종류의 문장에 등장하기 때문에, 모델은 이 둘을 비슷하게 표현하도록 학습했습니다. "스테이플러"는 다른 무리와 어울리기 때문에 다른 곳에 위치합니다. 이 수백 개의 숫자는 학습된 의미의 방향들로, 각각 텍스트가 변화하는 어떤 축을 담아내지만, 사람이 읽을 수 있게 이름 붙여진 것은 하나도 없습니다. 숫자를 해독하는 게 아니라, 두 벡터가 얼마나 가까운지만 측정할 뿐입니다.

Juno텍스트를 벡터로 임베딩은 텍스트 조각의 의미를 나타내는 숫자 목록, 즉 벡터로, 방대한 텍스트로 훈련된 모델이 만들어냅니다. 모델은 비슷한 맥락에서 등장하는 단어와 개념을 서로 가까이 배치하도록 학습했기 때문에, 비슷한 의미는 비슷한 벡터를 갖게 됩니다. 숫자 자체를 읽는 게 아니라 두 벡터가 얼마나 가까운지만 측정하며, 그 가까움이 곧 의미를 반영합니다.

맞춤법이 아니라 의미로 매칭되는 검색을 원한다면, "환불받고 싶어요" 같은 표현이 환불 관련 문장을 찾아내야 합니다. 이를 위한 방법은 각 텍스트 조각을 벡터, 즉 비슷한 의미가 서로 가까이 위치하는 고정 길이 숫자 목록으로 바꾸는 것입니다. 임베딩 모델이 텍스트를 읽고 이 벡터를 출력하며, 핵심은 벡터 간의 거리가 의미의 유사도를 대신한다는 점입니다.

벡터는 정해진 길이, 즉 차원(dimension)을 가지며 보통 수백에서 수천 개 사이의 숫자로 구성됩니다. 같은 모델로 임베딩한 모든 텍스트는 같은 길이로 나오며, 이 덕분에 어떤 두 텍스트든 서로 비교할 수 있습니다. 이 차원들은 사람이 붙인 카테고리가 아니라 학습된 의미의 방향이므로, 47번 차원을 "격식 정도"라고 읽어낼 수는 없습니다. 벡터는 하나의 불투명한 점으로 취급하고 거리만 측정해야 합니다.

실제로 무언가를 임베딩하기 전에, 전체가 잘 작동할지를 결정짓는 단계가 있습니다. 바로 청킹(chunking)입니다. 문서 전체를 하나의 벡터로 임베딩하는 경우는 거의 없습니다. 20페이지짜리 매뉴얼을 하나의 벡터로 만들면, 그 안의 모든 주제가 뭉개져서 아무것도 제대로 매칭되지 않는 평균적인 점이 되어버리기 때문입니다. 대신 문서를 청크(chunk), 대략 한 단락에서 한 페이지 분량의 구절로 나누고, 각 청크를 따로따로 임베딩합니다. 문서 전체가 아니라 청크 단위로 임베딩해야 각 벡터가 하나의 명확한 아이디어를 담게 됩니다.

청크 크기는 여러분이 신중하게 정해야 하는 실질적인 트레이드오프입니다. 청크가 너무 크면 여러 아이디어가 평균으로 섞여서 검색이 느슉해집니다. 청크가 너무 작으면 문장을 의미 있게 만들어주던 주변 맥락이 사라집니다. 흔한 출발점은 청크당 수백 토큰에 이웃 청크 사이에 약간의 겹침을 두는 것으로, 이렇게 하면 경계에서 잘린 문장도 적어도 한쪽 청크에는 온전히 나타나며, 그 다음은 실제 검색 결과를 보면서 조정합니다.

Juno텍스트를 벡터로 임베딩은 텍스트를 고정 길이 벡터로 바꾸며, 이때 거리가 의미의 유사도를 대신하고, 같은 모델에서 나온 모든 벡터는 길이가 같아서 서로 비교할 수 있습니다. 차원은 라벨이 아니라 학습된 방향이므로 벡터를 하나의 불투명한 점으로 다루세요. 가장 중요한 핵심은 이것입니다. 문서를 단락 단위 구절로 나누어 각 청크를 임베딩하세요. 문서 전체를 하나의 벡터로 만들면 모든 것이 흐물흐물한 평균으로 섞여버리기 때문입니다.

의미로 매칭되는 검색은 하나의 아이디어에 근거합니다. 텍스트를 벡터 공간(vector space)(각 점이 숫자 목록으로 표현되는 고정 차원의 좌표 공간)에 사상하여, 의미적 유사도가 기하학적 근접성이 되게 만드는 것입니다. 임베딩 모델은 바로 이 사상 함수 역할을 합니다. 텍스트를 읽고 벡터를 반환합니다.

인덱싱, 검색, 랭킹 등 이 위에 쌓아 올리는 모든 것은 결국 이 벡터들에 대한 기하학입니다. 사상 자체가 잘못되면 그 이후 어떤 단계도 이를 되돌릴 수 없습니다.

첫 번째 설계 레버는 무엇을 임베딩하느냐이며, 이것이 바로 청킹(chunking)입니다. 파일 전체를 임베딩하는 대신 소스 문서를 임베딩 전에 구절 단위로 나누는 것입니다. 이유는 임베딩이 입력을 하나의 점으로 압축하는 손실 압축이기 때문입니다. 청크가 담고 있는 아이디어가 다양할수록 그것들은 모호한 중심점으로 평균화되어 아무것도 뾰족하게 검색해내지 못합니다. 청크 크기는 사용자가 질문할 단위에 맞게 정해야 하며, 보통 수백 토큰 분량의 구절이 적당하고, 경계에 걸쳐 있는 사실이 적어도 하나의 청크에는 온전히 남도록 약간의 겹침을 둡니다.

청킹 전략은 모델 선택보다도 검색 품질을 가르는 지점입니다.

  • 고정 크기 윈도우는 비용이 적게 들지만, 문장을 중간에서 잘라버립니다.
  • 구조를 인식하는 분할(제목, 단락, 코드 블록 기준)은 각 청크를 일관되게 유지하여 더 나은 검색 결과로 이어지는 경향이 있지만, 파이프라인이 더 복잡해집니다.

무엇을 선택하든, 청크의 출처와 위치를 벡터와 함께 저장하세요. 검색 시점에 어떤 사실이 어디서 왔는지 되짚어야 하는데, 벡터 하나만으로는 그것을 알 수 없기 때문입니다.

한 가지 더 새겨둘 특성은, 임베딩 차원이 해석 가능한 축이 아니라는 점입니다. 그래서 벡터를 읽어서 검색을 디버깅할 수는 없습니다. 임베딩을 디버깅하려면 검색 결과가 무엇을 가져오는지 살펴봐야 하며, 절대 숫자 자체를 읽어서는 안 됩니다. 쿼리가 잘못된 청크를 끌어온다면, 해법은 청킹, 모델, 또는 쿼리 자체에 있으며, 이를 검색 결과를 통해 살펴봐야 하지 좌표 자체를 봐서는 안 됩니다.

Juno텍스트를 벡터로 임베딩 모델은 텍스트를 벡터 공간에 사상하여 의미적 유사도가 기하학적 거리가 되게 하며, 이후 모든 단계는 이 점들에 대한 기하학입니다. 청킹은 검색 품질을 결정짓는 레버입니다. 사용자가 질문할 단위에 맞춰 청크 크기를 정하고, 가능하면 구조를 기준으로 분할하며, 각 청크의 출처와 위치를 벡터 옆에 저장하여 사실을 되짚을 수 있게 하세요. 벡터를 읽어서 디버깅할 수는 없으니, 검색이 엇나가면 반환된 결과를 살펴보고 숫자 자체는 보지 마세요.

임베딩 생성하기

임베딩 모델에게 벡터를 요청하는 방식은 일반적인 모델 호출과 비슷하지만, 엔드포인트와 모델이 다릅니다.

python
from openai import OpenAI

client = OpenAI()

EMBED_MODEL = "text-embedding-3-small"  # 임베딩 모델을 바꿀 지점

def embed(text):
    response = client.embeddings.create(model=EMBED_MODEL, input=text)
    return response.data[0].embedding  # 숫자 목록

vector = embed("작고 활발한 개")
print(len(vector))  # 예: 1536개의 숫자

결과는 숫자 목록입니다. 무엇보다 중요한 규칙 하나는, 항상 **같은 모델**로 만든 임베딩끼리 비교해야 한다는 것입니다. 서로 다른 모델에서 나온 벡터는 서로 다른 공간에 존재하며 비교할 수 없습니다. 마치 서로 다른 단위로 측정한 것과 같습니다. 모델을 섞으면 매번 말도 안 되는 점수가 나옵니다.

그리고 모든 모델 호출이 그렇듯, 토큰 단위로 비용이 청구되므로, 대규모 문서 모음을 임베딩하는 데는 수천 개의 문서로 실행하기 전에 미리 추정해볼 만한 비용이 발생합니다.

Juno임베딩 생성하기 임베딩은 임베딩 모델을 호출해서 생성하며, 여러분의 텍스트에 대한 숫자 목록을 돌려받습니다. 항상 같은 모델로 만든 벡터끼리 비교하세요. 서로 다른 모델이 만든 벡터는 비교할 수 없으니, 센티미터와 인치를 비교하는 것과 같습니다. 임베딩은 토큰 단위로 비용이 청구되므로, 대규모 문서 모음이라면 먼저 확인해볼 만한 실제 비용이 발생합니다.

호출 자체는 채팅 호출과 형태는 같은데 엔드포인트만 다릅니다. 텍스트를 보내면 벡터를 돌려받습니다.

python
from openai import OpenAI

client = OpenAI()

EMBED_MODEL = "text-embedding-3-small"

def embed_batch(texts):
    # 하나의 요청으로 여러 텍스트를 보내고, 텍스트당 벡터 하나씩 받는다
    response = client.embeddings.create(model=EMBED_MODEL, input=texts)
    return [item.embedding for item in response.data]

chunks = ["작고 활발한 개", "어린 개", "사무용품"]
vectors = embed_batch(chunks)  # 청크당 벡터 하나씩, 입력한 순서 그대로

이 형태는 제공업체마다 다르지만, 요청과 응답이라는 아이디어 자체는 어디서나 같습니다. 기본적인 호출을 넘어서 실무적으로 중요한 점이 두 가지 있습니다. 첫째, **배치(batch)**로 임베딩하세요. 대부분의 엔드포인트는 한 번의 요청으로 텍스트 목록을 받아 각각 벡터 하나씩 돌려주는데, 이는 청크마다 따로 호출하는 것보다 빠르고 저렴합니다. 요청 오버헤드가 줄고 속도 제한(rate limit)에도 덜 걸리기 때문입니다. 대규모 코퍼스를 인덱싱할 때, 배치 처리가 비용과 실제 소요 시간을 합리적인 수준으로 유지해줍니다.

둘째, 같은 모델을 써야 한다는 규칙에는 짝이 되는 규칙이 있습니다. 모델과 차원은 기본값이 아니라 하나의 선택입니다. 차원이 더 많은 큰 임베딩 모델은 더 세밀한 구분을 담아내지만 계산, 저장, 비교에 드는 비용이 더 큽니다. 데이터를 여전히 잘 구분해내는 가장 작은 모델과 차원을 고르세요.

일부 모델은 저장 공간을 줄이고 검색을 빠르게 하기 위해 정확도를 조금 희생하고 더 짧은 벡터를 요청할 수도 있게 해줍니다. 무엇을 선택하든 그것은 일종의 계약이 됩니다. 나중에 모델을 바꾸면 저장된 모든 벡터를 다시 생성해야 합니다.

Juno임베딩 생성하기 임베딩은 텍스트를 보내고 벡터를 돌려받는 하나의 요청으로 이루어지며, 이 형태는 제공업체가 달라도 동일하게 작동합니다. 가능하면 텍스트를 하나의 호출로 배치 처리하세요. 대규모 코퍼스에서는 요청 오버헤드와 속도 제한 부담을 줄여줍니다. 모델과 차원은 실질적인 선택입니다. 데이터를 여전히 잘 구분해내는 가장 작은 것을 고르세요. 그리고 모델이 일종의 계약이 된다는 점을 기억하세요. 모델을 바꾸면 저장해둔 모든 것을 다시 임베딩해야 합니다.

API 표면 자체는 단순합니다. 텍스트를 보내면 실수(float) 벡터를 받는데, 흥미로운 결정들은 이 호출 자체가 아니라 그 주변에 있습니다.

python
from openai import OpenAI

client = OpenAI()

EMBED_MODEL = "text-embedding-3-small"

def embed_batch(texts):
    # 요청 한 번에 여러 벡터를 입력 순서대로 돌려받는다
    response = client.embeddings.create(model=EMBED_MODEL, input=texts)
    return [item.embedding for item in response.data]

요청과 응답의 형태는 제공업체마다 다르지만, 모든 임베딩 엔드포인트는 이렇게 배치를 입력받아 벡터를 출력하는 동일한 계약을 따릅니다. 규모가 커지면 배치 처리는 있으면 좋은 것이 아니라, 인덱싱 작업이 끝나느냐 아니면 오후 내내 속도 제한에 걸려 멈추느냐를 가르는 결정적인 요소가 됩니다. 각 요청은 고정된 오버헤드를 지니고 속도 제한에 카운트되므로, 큰 배치로 보내서 그 부담을 나누고, 대규모 작업에서 필연적으로 발생하는 부분 실패에는 재시도로 대응해야 합니다.

모델과 차원은 장기적인 영향을 미치는 아키텍처 상의 결정입니다. 차원이 높은 벡터는 더 많은 구분을 저장하지만, 프로덕션에서 중요한 모든 축, 즉 벡터당 저장 공간, 인덱스 메모리, 비교당 시간이 모두 차원에 비례해서 늘어납니다. 일부 모델은 마트료시카 임베딩(Matryoshka embeddings)(더 짧은 길이로 잘라내도 여전히 사용할 수 있는 벡터)을 지원하는데, 이를 활용하면 정확도를 위한 전체 벡터를 유지하면서도 저렴한 1차 검색에는 잘라낸 접두부만 사용할 수 있습니다. 코퍼스가 커서 모델 품질보다 저장 공간과 쿼리 지연 시간이 제약 요인이 될 때 이런 선택이 가치를 발휘합니다.

나중에 문제가 되는 결정은 버전 관리입니다. 저장된 임베딩은 영원히 하나의 정확한 모델 버전에 묶여 있습니다.

서로 다른 두 모델 버전의 벡터는 비교할 수 없으므로, 새 쿼리를 최신 모델로 임베딩해서 오래된 모델로 만든 인덱스와 비교할 수는 없습니다. 그 결과는 조용히 나타납니다. 점수는 그럴듯해 보이지만 순위는 틀려 있습니다. 모델 버전을 인덱스 스키마의 일부로 취급하여 벡터와 함께 저장하고, 재임베딩을 이 챕터 뒤쪽 신선도(freshness) 논의에서 다룰 마이그레이션으로 계획하세요.

Juno임베딩 생성하기 임베딩 호출은 배치를 입력받아 벡터를 출력하는 계약으로, 형태는 제공업체마다 다르지만 아이디어는 같습니다. 재시도를 포함한 배치 처리가 대규모 인덱싱 작업을 끝까지 완료시키고 속도 제한에 걸려 멈추지 않게 해줍니다. 모델과 차원은 저장 공간, 메모리, 쿼리 지연 시간에 비례해서 영향을 미치므로, 품질이 아니라 규모가 제약일 때는 잘라낼 수 있는 벡터를 활용하세요. 버전은 영원한 결정입니다. 서로 다른 모델 버전의 벡터는 조용히 비교에 실패하므로, 버전을 스키마에 저장하고 재임베딩을 계획된 마이그레이션으로 다루세요.

유사도 측정하기

두 텍스트가 얼마나 관련 있는지 알아내려면, 두 벡터가 얼마나 가까운지 측정합니다. 표준적인 척도는 **코사인 유사도(cosine similarity)**로, -1에서 1 사이의 숫자를 반환합니다. 1에 가까울수록 더 비슷하고, 0에 가까우면 관련이 없습니다. 이를 사용하는 데 수학을 알 필요는 없습니다. 두 벡터가 가리키는 방향을 비교한다고 생각하면 됩니다.

여기서 의미를 담아내는 것은 방향이며, 그래서 코사인은 실제 거리보다 각도를 비교합니다. 환불에 관한 두 텍스트는 하나가 짧은 문구든, 다른 하나가 긴 단락이든 같은 방향을 가리킵니다.

python
import math

def cosine_similarity(a, b):
    dot = sum(x * y for x, y in zip(a, b))
    mag_a = math.sqrt(sum(x * x for x in a))
    mag_b = math.sqrt(sum(x * x for x in b))
    return dot / (mag_a * mag_b)

dog = embed("작고 활발한 개")
puppy = embed("어린 개")
stapler = embed("종이를 묶는 사무용품")

print(cosine_similarity(dog, puppy))    # 더 높음, 둘의 의미가 가깝다
print(cosine_similarity(dog, stapler))  # 더 낮음, 둘은 관련이 없다

이 숫자들은 직관을 확인해줍니다. 개와 강아지는 더 높은 점수를, 개와 스테이플러는 더 낮은 점수를 받습니다. 쿼리의 벡터를 저장된 벡터와 비교하는 것이 시맨틱 검색의 핵심입니다. 이 함수 하나가 이번 챕터 나머지 내용이 딛고 서는 발판입니다.

Juno유사도 측정하기 코사인 유사도는 두 벡터가 얼마나 가까운지를 -1에서 1 사이로 나타내며, 1에 가까울수록 더 비슷합니다. 수학을 몰라도 사용할 수 있습니다. 두 벡터가 가리키는 방향을 비교하는 것이므로, 같은 주제에 대한 짧은 문구와 긴 단락도 여전히 매칭됩니다. 쿼리의 벡터를 저장된 벡터와 비교하는 것이 시맨틱 검색의 핵심 동작입니다.

벡터를 갖게 되었으니, 이제 두 벡터가 얼마나 가까운지 말해주는 숫자가 필요합니다. 표준은 **코사인 유사도**입니다. 두 벡터 사이의 각도를 측정하되 길이는 무시하며, -1에서 1 사이의 값을 반환하는데 높을수록 더 비슷하다는 뜻입니다. 거리가 아니라 각도가 필요한 이유는, 같은 주제를 다룬 긴 구절과 짧은 문구가 서로 다른 크기의 벡터를 갖더라도 여전히 비슷하다고 나와야 하기 때문입니다.

python
def dot(a, b):
    return sum(x * y for x, y in zip(a, b))

# 벡터가 길이 1로 정규화되어 있다면 코사인 유사도는 내적과 같다
score = dot(query_vector, doc_vector)  # 나눗셈이 필요 없다

규모가 커질수록 효과를 보는 방법이 있습니다. 벡터를 저장할 때 길이 1로 **정규화(normalize)**하세요. 모든 벡터의 길이가 같아지면 코사인 유사도의 나눗셈이 사라지고, 점수는 단순한 내적이 되는데, 이는 차원마다 곱하고 더하는 연산 하나로 처리되어 큰 집합에서 훨씬 빠르게 실행됩니다. 저장할 때 한 번 정규화하고, 그 후에는 내적으로 비교하세요. 많은 임베딩 모델이 이미 정규화된 벡터를 반환하지만, 그냥 가정하지 말고 확인하세요.

장난감 예제와 다른 또 하나의 전환은, 가장 가까운 결과 하나만 원하는 경우는 거의 없다는 것입니다. top-k 검색, 즉 점수가 가장 높은 k개의 결과가 필요하며, 보통 3에서 10 사이입니다. 하나의 벡터는 근접한 오답일 수 있지만 두 번째와 세 번째가 함께 답을 담고 있을 수도 있으며, 작은 집합을 반환하는 것은 다음 단계가 작업할 여지를 남겨줍니다. 최상위 하나만 반환하면 나중에 필요할 맥락을 버리게 됩니다.

Juno유사도 측정하기 코사인 유사도는 두 벡터 사이의 각도를 -1에서 1 사이로 비교하며 길이는 무시하므로, 같은 주제에 대한 짧은 문구와 긴 구절도 여전히 매칭됩니다. 저장할 때 벡터를 길이 1로 정규화하면 비교가 빠른 내적으로 축소되며, 이는 큰 집합에서 누적되어 큰 차이를 만듭니다. 그리고 최상위 하나가 아니라 top-k, 즉 3에서 10개를 검색하세요. 답이 종종 처음 몇 개에 걸쳐 흩어져 있기 때문입니다.

유사도 척도가 필요한데, 코사인 유사도(두 벡터 사이 각도의 코사인값, -1에서 1 사이)가 기본 선택인 이유는 크기(magnitude)를 무시해서 문서 길이가 주제를 압도하지 않기 때문입니다. 이 선택에는 설계상 고려해야 할 성능상의 결과가 따릅니다. 원본 벡터에 대한 코사인 계산은 비교마다 내적 하나와 크기 계산 두 개의 비용이 들고, 이를 쿼리마다 인덱스에 있는 모든 벡터에 대해 지불해야 합니다.

python
import numpy as np

# 쓰기 시점에, 벡터당 한 번 정규화한다
def normalize(v):
    v = np.asarray(v, dtype=np.float32)
    return v / np.linalg.norm(v)

# 쿼리 시점에, 정규화된 벡터에 대한 코사인은 행렬-벡터 곱 하나로 계산된다
def search(query_vec, matrix):
    return matrix @ query_vec  # 행마다 점수 하나씩, 벡터화된 연산

최적화 방법은 쓰기 시점에 모든 벡터를 단위 길이로 **정규화**하는 것으로, 이렇게 하면 코사인 유사도가 내적과 동일해져서 Python 루프 대신 전체 코퍼스를 하나의 행렬-벡터 곱으로 점수 계산할 수 있습니다. 쓰기 시점에 정규화하면 유사도는 전체 인덱스에 대해 한 번에 계산되는 내적이 됩니다. 이것은 무차별 대입(brute-force) 기준선입니다. 정확하고 직접적이며 코퍼스 크기에 선형적으로 비례하는데, 대략 수십만 벡터 규모까지는 괜찮지만 그 이상에서는 무너지므로, 그때가 뒤에서 다룰 근사 인덱스로 옮겨갈 신호입니다.

장난감 예제 척도가 숨기고 있는 실제 프로덕션의 현실이 두 가지 있습니다. 첫째, argmax가 필요한 경우는 거의 없고, 점수 임계값을 가진 top-k가 필요합니다. 1위와 2위 결과 사이의 간격이 매칭이 얼마나 확신할 만한지를 알려주며, 최상위 결과조차 낮은 점수를 받았다면 여러분의 코퍼스에 그 쿼리에 실제로 답이 되는 것이 없다는 뜻입니다. 그럴 때는 최상위 결과를 무작정 돌려주는 대신 "좋은 매칭 없음"으로 반환해야 합니다. 둘째, 코사인 유사도는 절대적이 아니라 상대적입니다. 0.4라는 점수는 여러분의 코퍼스와 모델이 만들어내는 점수 분포와 비교해야만 의미가 있으므로, 블로그 글에서 본 마법의 숫자를 그대로 가져다 쓰지 말고 인덱스별로 임계값을 경험적으로 조정해야 합니다.

Juno유사도 측정하기 코사인 유사도는 크기를 무시하므로 길이가 주제를 압도하지 않지만, 무차별 대입 코사인 계산은 코퍼스 크기에 선형적으로 비례하며 대략 수십만 벡터까지만 버티고 그 이상에서는 근사 인덱스가 필요합니다. 쓰기 시점에 정규화하면 척도가 전체 코퍼스에 대해 하나의 행렬-벡터 곱으로 실행할 수 있는 내적이 됩니다. 임계값을 가진 top-k를 검색하고, 최상위 점수가 낮으면 차선이 아니라 매칭 없음으로 처리하며, 숫자가 절대적이 아니라 상대적이므로 인덱스별로 그 임계값을 조정하세요.

시맨틱 검색

지금까지의 조각들을 합치면 키워드가 아니라 의미로 검색할 수 있습니다. 문서를 한 번 임베딩해서 벡터를 저장하세요. 쿼리가 들어오면 그것도 임베딩한 다음, 벡터가 가장 가까운 문서를 반환합니다.

python
docs = [
    "계정 설정 페이지에서 비밀번호를 재설정하세요.",
    "환불 정책에 따라 30일 이내 반품이 가능합니다.",
    "청구 관련 문의는 이메일로 지원팀에 연락하세요.",
]

# 모든 문서를 미리 한 번씩 임베딩한다
index = [{"text": text, "vector": embed(text)} for text in docs]

def search(query):
    query_vector = embed(query)
    scored = [{"text": item["text"], "score": cosine_similarity(query_vector, item["vector"])} for item in index]
    return sorted(scored, key=lambda x: x["score"], reverse=True)[0]  # 가장 가까운 매칭

print(search("환불받고 싶어요"))
# 쿼리와 겹치는 키워드가 없어도 환불 정책 문장을 반환한다

"환불받고 싶어요"라는 쿼리는 문서와 중요한 단어를 하나도 공유하지 않는데도 환불 관련 문장을 찾아냅니다. 시맨틱 검색은 맞춤법이 아니라 의미로 매칭합니다. 키워드 검색이었다면 완전히 놓쳤을 겁니다. 쿼리와 문서가 공유하는 중요한 단어가 하나도 없기 때문입니다.

이 구조는 언제나 같습니다. 한 번 임베딩해서 벡터를 보관하고, 요청 시점에 쿼리를 임베딩하고, 점수를 매기고, 정렬하고, 가장 가까운 것을 반환합니다. 이것이 시맨틱 검색 루프이며, 이 핵북 나머지 부분에서도 계속 재사용하게 됩니다.

Juno시맨틱 검색 시맨틱 검색은 문서를 한 번 임베딩해서 벡터를 저장한 뒤, 들어오는 각 쿼리를 임베딩해서 코사인 유사도로 가장 가까운 문서를 반환합니다. 의미로 매칭되므로 "환불받고 싶어요"는 키워드를 하나도 공유하지 않는 "환불 정책" 관련 문장도 찾아냅니다. 이 의미 기반 매칭이 바로 키워드 검색이 할 수 없는 일입니다.

장난감 버전은 벡터를 Python 리스트에 저장하고 매 쿼리마다 모든 벡터에 점수를 매깁니다. 데모용으로는 잘 작동하지만 코퍼스가 커지는 순간 무너집니다. 선형 스캔은 매 쿼리마다 모든 벡터에 대해 전체 비교를 다시 실행해야 하고, 재시작할 때마다 인덱스를 메모리에서 다시 구축해야 하기 때문입니다.

해법은 **벡터 데이터베이스**입니다. 벡터를 보관하고 "이 쿼리 벡터와 가장 가까운 k개를 찾아라"는 요청에 빠르게 그리고 디스크로부터 답할 수 있도록 만들어진 저장소입니다. 여러분만의 리스트와 루프 대신, 벡터를 한 번 써넣으면 데이터베이스가 검색, 영속성, 개별 항목 갱신을 처리해줍니다.

python
# 형태는 벡터 DB마다 다르지만 패턴은 어디서나 같다
index.upsert([
    {"id": "doc-1", "vector": embed(chunk), "metadata": {"source": "faq.md", "section": "refunds"}}
    for chunk in chunks
])

results = index.query(vector=embed(user_question), top_k=5)  # 가장 가까운 5개, 1개가 아니다

데이터베이스가 리스트에서는 얻을 수 없는 것을 두 가지 제공합니다.

  • 한 번의 호출로 top-k 결과를 반환합니다. 최상위 하나가 아니라 가장 가까운 여러 개이며, 이것이 실제로 다음 단계에 넣어줄 것입니다.
  • 각 벡터 옆에 메타데이터, 즉 출처 파일, 섹션, 타임스탬프를 저장합니다. 그래서 결과가 어디서 왔는지 되짚을 수 있고 그것으로 필터링도 할 수 있습니다.

실제 데이터를 다루는 순간부터는 벡터를 Python 리스트가 아니라 벡터 DB에 저장하세요. 리스트는 원리를 배우기 위한 것이지, 실전에서 운영하라는 것이 아닙니다.

Juno시맨틱 검색 시맨틱 검색은 한 번 임베딩하고, 저장하고, 쿼리를 임베딩하고, 가장 가까운 것을 반환하는 것이지만, Python 리스트와 루프는 데모에서만 버틸 수 있습니다. 쿼리마다 모든 벡터를 스캔하고 재시작할 때마다 다시 구축하는 방식은 규모가 커지면 감당할 수 없기 때문입니다. 실제 데이터를 다루는 순간 벡터 데이터베이스로 옮기세요. 디스크로부터 빠른 top-k 검색을 수행하고 각 벡터 옆에 메타데이터를 저장해서 결과가 출처를 가리킬 수 있게 해줍니다. 리스트는 원리를 가르치고, 데이터베이스는 실전을 운영합니다.

한 번 임베딩하고, 저장하고, 쿼리를 임베딩하고, 점수를 매기고, 정렬하고, 반환하는 기준 루프는 정확하지만 규모를 견디지 못합니다. 무차별 대입 스캔은 쿼리당 코퍼스 크기에 선형적으로 비례하므로, 백만 개의 벡터와 실제 쿼리 빈도에서는 검색 하나당 여러분에게 없는 CPU 1초를 소비하게 됩니다. 프로덕션 검색에서 벌어지는 모든 일은 결국 쿼리를 모든 벡터와 비교하지 않도록 하는 것입니다.

바로 그것을 근사 최근접 이웃(approximate nearest neighbor) 검색이 해결해줍니다(ANN: 참 최근접 벡터를 놓칠 작은 위험을 감수하고 가장 가까운 벡터를 빠르게 찾는 알고리즘). 두 계열이 주로 쓰입니다.

  • HNSW(계층적 탐색 가능 소세계, Hierarchical Navigable Small World)는 벡터의 계층 그래프를 구축하고 이를 대략적인 것에서 세밀한 것으로 탐색하며, 메모리를 많이 쓰는 대신 쿼리 지연 시간이 낮습니다.
  • IVF(역파일, inverted file)는 벡터를 클러스터로 묶고 가장 가까운 클러스터만 검색하여 메모리는 덜 쓰지만, 데이터가 바뀌면 클러스터 중심을 재훈련해야 합니다.

수십만 개 이상의 벡터에서는 ANN이 선택이 아니라 인덱스 자체입니다. 여기서 조정해야 할 트레이드오프는 재현율(recall)(인덱스가 실제로 반환하는 진짜 최근접 이웃의 비율)과 지연 시간, 메모리 사이의 균형입니다. 재현율을 1.0에 가깝게 밀어붙이면 쿼리가 느려지고, 완화하면 조용히 관련 청크를 놓치게 됩니다.

장난감 루프가 완전히 숨기고 있는 실패 유형은 신선도(freshness)입니다. 인덱스는 스냅샷이므로, 원본 문서가 바뀌면 저장된 벡터는 이제 오래된 것이 되었는데도 여전히 최신인 것처럼 계속 검색됩니다. 문서 변경에 연결된 재임베딩 파이프라인이 필요합니다. 편집을 감지하고, 그 문서를 다시 청킹하고 다시 임베딩한 다음, 새 벡터를 upsert하고 오래된 벡터는 삭제해야 하며, 이상적으로는 콘텐츠 해시로 추적해서 편집이 있을 때마다 전체 코퍼스가 아니라 실제로 바뀐 것만 재임베딩해야 합니다. 오래된 벡터는 검색 시스템이 조용히 거짓말을 시작하는 방식입니다.

마지막 남은 틈은 정확 매칭입니다. 벡터 검색은 구조적으로 이를 놓치도록 설계되어 있습니다. 주문번호 A-10293이나 오류 코드에 대한 쿼리는 흐릿한 영역으로 임베딩되어, 그 문자열을 실제로 담고 있는 문서를 표면화하지 못할 수 있습니다. **하이브리드 검색(hybrid search)**은 키워드 검색(정확한 토큰을 정확히 잡아냄)과 벡터 검색(의미를 정확히 잡아냄)을 함께 실행하고 순위를 병합하여, 식별자, 이름, 희귀한 용어가 의미적 흐릿함에 묻히지 않게 해줍니다. 사용자가 정확하게 입력할 것들이 코퍼스에 포함되어 있다면 언제든 이를 활용하세요.

Juno시맨틱 검색 무차별 대입 스캔은 규모가 커지면 죽으므로, 수십만 벡터 이상에서는 근사 최근접 이웃을 실행하세요. 지연 시간이 낮은 대신 메모리를 많이 쓰는 HNSW, 또는 반대인 IVF 중에서 고르고, 재현율이 높을수록 속도가 느려진다는 것을 알고 재현율과 지연 시간을 조정하세요. 콘텐츠 해시를 이용해 문서 변경에 연결된 재임베딩 파이프라인을 구축하세요. 인덱스는 스냅샷이고 오래된 벡터는 최신인 것처럼 검색되기 때문입니다. 그리고 사용자가 주문번호 같은 정확한 문자열을 입력할 때는 하이브리드 키워드-벡터 검색을 추가하세요. 순수한 벡터 검색은 그런 것을 그냥 흐릿하게 지나쳐 버립니다.

임베딩이 적합하지 않은 경우

임베딩은 유사도 도구이지 사고 도구가 아닙니다. 올바른 도구를 고르기 위해 기억해야 할 몇 가지 **한계**가 있습니다.

  • 정확 매칭을 하지 못합니다. 정확한 주문번호나 오류 코드를 찾으려면 일반적인 키워드 검색이나 데이터베이스 검색이 더 낫습니다.
  • **추론(reason)**을 하지 못합니다. 두 가지가 관련 있다는 것을 알려주지만, 왜 그런지, 하나가 다른 하나로부터 논리적으로 따라오는지는 알려주지 않습니다.
  • 오래됩니다(stale). 문서가 바뀌면 다시 임베딩해야 하며, 그렇지 않으면 검색이 오래된 콘텐츠를 가리킵니다.

임베딩은 관련된 것을 찾아내지만, 무엇이 참인지 최신인지를 판단하지는 않습니다. 이 각각의 한계에는 더 나은 도구가 있으며, 어느 것을 써야 하는지 아는 것이 실력의 절반입니다.

Juno임베딩이 적합하지 않은 경우 임베딩은 유사도를 측정할 뿐, 추론하지 않고 정확 매칭도 하지 못합니다. 정확한 주문번호나 오류 코드에는 키워드 검색이나 데이터베이스 검색을 대신 쓰세요. 그리고 문서가 바뀌면 다시 임베딩하세요. 그렇지 않으면 검색이 오래된 콘텐츠를 가리키게 됩니다.

이렇게 잘 작동하는 도구를 쓸 때의 함정은 어디에나 갖다 쓰려고 한다는 것입니다. 어디서 멈추고 다른 것을 골라야 하는지 알려주는 세 가지 한계가 있습니다.

정확 매칭이 첫 번째입니다. 임베딩은 주문 ID나 오류 코드를 다른 모든 것과 똑같은 흐릿한 의미 공간에 사상하므로, A-10293은 자기 자신 위가 아니라 다른 주문번호 형태의 문자열들 근처에 위치합니다. 식별자, 코드, 그리고 사용자가 있는 그대로 입력하는 값에는 키워드 검색이나 데이터베이스 검색이 맞는 도구이며, 하이브리드 검색(키워드 검색과 벡터 검색을 함께 실행하는 것)이 둘을 동시에 얻는 방법입니다.

추론이 두 번째입니다. 유사도는 추론이 아닙니다. 두 청크가 높은 점수를 받았다는 것은 그것들이 관련된 내용에 관한 것이라는 뜻일 뿐, 하나가 질문에 답한다거나 다른 하나로부터 논리적으로 따라온다는 뜻은 아닙니다. 추론은 검색해온 청크를 넣어주는 모델에서 일어나는 것이지, 검색 자체에서 일어나는 것이 아닙니다. 검색은 후보를 찾아내고, 모델이 생각합니다.

신선도가 세 번째이며 가장 조용히 문제를 일으키는 것입니다. 여러분의 인덱스는 임베딩했던 그 시점의 스냅샷이므로, 편집된 문서는 다시 임베딩하기 전까지는 계속 옛 문구를 반환합니다. 변경 시 재임베딩을 시스템의 일부로 만들어야 합니다. 누군가 답이 이상하다고 알아차렸을 때 수동으로 하는 일이 되어서는 안 됩니다.

Juno임베딩이 적합하지 않은 경우 임베딩은 유사도 도구이므로, 주문 ID 같은 정확 매칭은 놓치며 이런 경우 키워드 검색이나 두 가지를 결합한 하이브리드가 필요합니다. 그리고 추론하지 않습니다. 청크를 넣어주는 모델이 그 일을 합니다. 가장 조용한 문제는 신선도입니다. 인덱스는 스냅샷이므로, 편집된 문서는 다시 임베딩하기 전까지는 계속 오래된 문구를 반환합니다. 누군가 잘못된 답을 알아차리길 기다리지 말고 재임베딩을 시스템에 내재화하세요.

임베딩이 어디서 한계에 부딫히는지 아는 것이 검색 시스템을 신뢰할 수 있게 만드는 핵심이며, 프로덕션에서 중요한 경계가 세 가지 있습니다.

정확 매칭은 조정으로 해결할 문제가 아니라 구조적인 사각지대입니다. 임베딩은 입력을 의미적 인접 지역 쪽으로 압축하므로, 식별자, 코드, SKU, 희귀한 고유명사는 자기 자신 위가 아니라 비슷하게 보이는 문자열 쪽으로 스미어집니다. 순수한 벡터 검색은 정확히 일치하는 것보다 근접한 오답을 더 높은 순위로 올릴 수 있습니다. 해법은 어휘 인덱스(키워드 또는 BM25, 고전적인 용어 빈도 기반 랭킹)와 벡터 인덱스를 결합하는 하이브리드 검색으로, 정확한 토큰은 정확하게 매칭하면서 의미는 의미적으로 매칭하여, 주문 조회를 기하학에 도박처럼 걸지 않게 합니다.

드리프트(drift)는 조용히 침식하는 경계입니다. **임베딩 드리프트**는 인덱스 뒤에 있는 모델 버전이 바뀔 때 생기는 불일치입니다. 두 버전에서 나온 벡터는 서로 다른 공간에 존재하여 비교할 수 없으므로, 새 모델로 임베딩한 쿼리를 오래된 모델로 구축한 인덱스와 비교하면 그럴듯해 보이는 엉터리 결과가 나옵니다. 모델 버전을 인덱스 스키마에 고정하고, 모델 업그레이드는 그냥 갈아끼우는 것이 아니라 완전한 재임베딩 마이그레이션으로 다루세요. 오류가 던져지지 않고 그저 결과가 조용히 나빠지기 때문입니다.

평가(evaluation)는 검색이 이미 실패하고 있을 때까지 사람들이 건너뛰는 경계입니다. 검색이 좋은지는 눈으로 봐서 판단할 수 없고 측정해야 합니다. 정답 청크가 알려진 쿼리 집합을 따로 떼어두고 recall@k(정답 청크가 상위 k개 안에 들어왔는가)와 정밀도(반환한 것 중 얼마가 실제로 관련 있었는가)를 측정하세요. recall@k를 측정하지 않는다면, 검색이 작동하는지 알 수 없습니다.

이 평가 세트는 또한 새로운 청킹 전략, 모델, 하이브리드 가중치가 실제로 도움이 되었는지, 아니면 한 가지 실패를 아직 눈치채지 못한 다른 실패로 바꿔놓은 것뿐인지를 알려줍니다. 검색 품질은 RAG가 서 있는 기반이므로, 감으로 하는 확인이 아니라 제대로 된 테스트를 받을 자격이 있습니다.

Juno임베딩이 적합하지 않은 경우 임베딩에는 정확 매칭에 대한 구조적인 사각지대가 있으므로, 식별자와 코드에는 기하학을 믿는 대신 어휘 인덱스와 벡터 인덱스를 결합하세요. 임베딩 드리프트는 조용한 문제입니다. 서로 다른 모델 버전의 벡터는 비교되지 않으므로, 버전을 고정하고 업그레이드는 완전한 재임베딩으로 다루세요. 그리고 따로 떼어둔 세트에서 recall@k로 검색을 측정하세요. 눈으로 봐서 작동 여부를 판단할 수 없고, 이 평가는 실제 개선과 그저 다른 실패로 옮겨간 것을 구분해주는 방법이기도 합니다.

실전에서

전체 흐름은 위의 search 함수 안에 담겨 있습니다. FAQ 항목 몇 개를 한 번 임베딩한 다음, 의미로 쿼리에 답합니다. 이 패턴은 규모가 어떻든 동일하며, 규모가 커질수록 저장 방식만 바뀝니다.

다음 챕터에서는 가장 가까운 매칭 결과를 가져와 모델에 넣어주어, 모델이 기억이 아니라 여러분의 문서로부터 답하게 합니다. 그 기법이 RAG이며, 지금 여러분이 작성한 것을 바로 딛고 서 있습니다.

Juno실전에서 시맨틱 검색은 이 흐름 전체입니다. 문서를 한 번 임베딩하고, 각 쿼리마다 임베딩해서 코사인 유사도로 가장 가까운 것을 반환합니다. 이 가장 가까운 매칭 결과를 모델에 넣어주면 기억이 아니라 여러분의 문서로부터 답할 수 있습니다. 그것이 검색(retrieval)이며, 다음 챕터에서 바로 이것을 딛고 다룹니다.

메커니즘은 정해졌습니다. 청킹하고, 배치로 임베딩하고, 벡터 DB에 저장하고, 유사도로 top-k를 검색합니다. 프로덕션으로 갈 때 바뀌는 것은 그 주변의 배관 작업입니다. 변경 시 재임베딩, 필터링할 메타데이터, 고정할 모델 버전 같은 것들입니다.

검색해온 청크가 끝은 아닙니다. 다음 챕터에서는 이를 모델에 컨텍스트로 넣어주어, 고정된 학습 내용이 아니라 여러분의 문서로부터 답하게 합니다. 그것이 RAG이며, 그 답변의 품질은 여기서 구축한 검색으로 상한선이 정해집니다.

Juno실전에서 이 루프는 청킹, 배치 임베딩, 벡터 DB 저장, top-k 검색이며, 프로덕션은 그 주변의 배관 작업입니다. 변경 시 재임베딩, 메타데이터 필터, 고정된 모델 버전 같은 것들입니다. 이렇게 검색해온 청크는 다음 챕터에서 모델의 컨텍스트가 되며, 이것이 RAG입니다. 그 답변은 여기서 구축한 검색만큼만 좋을 수 있습니다.

여기서 다룬 모든 것, 청킹, 정규화, ANN 인덱싱, 하이브리드 검색, 신선도, recall@k 평가는 검색 계층(retrieval layer)이며, 이것이 바로 AI 시스템에서 답변의 품질을 조용히 결정하는 부분입니다. 위에 올라앉은 모델은 검색이 건네주는 것에 대해서만 추론할 수 있으므로, 자신 있게 말하는 틀린 답변은 대체로 생성(generation)의 옷을 입은 검색 실패입니다.

이것이 다음 챕터로 이어지는 다리입니다. RAG는 이 검색 계층이 근거 있는 컨텍스트를 모델에 넣어주는 것이고, 여기서 다룬 모든 실패 유형, 오래된 벡터, 놓친 정확 매칭, 낮은 재현율은 그곳에서 틀린 답변으로 표면화됩니다. 검색을 측정 가능하고 신선하게 만들어 두면, RAG는 딛고 설 든든한 기반을 갖게 됩니다.

Juno실전에서 청킹부터 recall@k까지 이어지는 검색 계층이 답변의 품질을 결정합니다. 모델은 검색이 건네주는 것에 대해서만 추론하기 때문입니다. 자신 있게 말하는 틀린 답변은 대체로 위장한 검색 실패이므로, 오래된 벡터나 놓친 정확 매칭이 진짜 버그입니다. 다음 챕터의 RAG는 이 계층이 모델에 넣어주는 것이며, 여기서 구축한 것만큼만 견고합니다.