“업무 관련 강의를 결제하려는데 얼마까지 지원되나요?” 문서에는 “직원 교육비 한도는 분기당 30만 원이다”라고 적혀 있다. 겹치는 단어가 없는데도 사람은 두 문장이 같은 일을 말한다는 것을 안다. 검색도 표현의 차이를 넘어 이 문서를 찾아야 하고, 임베딩은 그 일을 숫자 계산으로 바꾼다. 벡터 데이터베이스를 설치하기 전에 문서 다섯 개로 계산과 판정을 먼저 확인한다.
텍스트를 벡터로
벡터는 순서가 있는 숫자 묶음이다. [0.2, -0.4, 0.7]은 좌표 셋인 벡터고, 임베딩 함수는 문장이나 문서를 이런 숫자 묶음으로 바꾼다. 검색기는 질문의 벡터와 문서의 벡터를 비교한다.
해시와 목적이 다르다. 해시는 같은 바이트를 식별하지만, 임베딩은 비슷한 뜻의 문장이 가까이 오도록 학습된 결과다. 검색용 모델은 질문과 관련 문서를 가깝게 놓도록 대조 학습으로 훈련되는데, 여기서 쓰는 다국어 E5의 학습 방법은 기술 보고서에 있다. Multilingual E5 기술 보고서
문서 벡터는 미리 계산해 두고 질문이 오면 질문 벡터만 만들어 비교한다. 문서 내용이나 임베딩 모델이 바뀌면 저장된 벡터도 다시 만들어야 해서, 문서 변화를 관리하는 일이 검색 품질의 일부가 된다. 티켓 번호 IT-1042, 정책 코드, 이메일처럼 문자 자체가 식별자인 것은 임베딩이 아니라 정확 일치 검색이나 구조화 필드로 찾는다.
내적·길이·코사인 유사도
두 벡터의 내적은 같은 자리의 숫자를 곱해 더한 값이다. , 면 이다. 차원이 다르면 대응할 좌표가 없어 계산을 거부한다.
벡터의 길이(L2 노름)는 좌표의 제곱을 더해 제곱근을 취한 값이다. 의 길이는 5고, 각 좌표를 5로 나눈 은 길이가 1이다. 이 연산이 L2 정규화다. 방향은 그대로 두고 크기만 맞춘다.
코사인 유사도는 내적을 두 길이의 곱으로 나눈 값이다. 영벡터에는 적용할 수 없어서 예제는 점수 0으로 바꾸는 대신 오류를 낸다.
수학의 반대 방향은 자연어의 반의어와 다르다. “승인이 필요하다”와 “승인이 필요하지 않다”는 같은 주제와 단어를 공유해서 임베딩에서는 높은 유사도가 나온다. 점수는 표현 공간의 관계지 문장이 서로를 지지한다는 판정이 아니다. 두 벡터를 미리 길이 1로 정규화했다면 코사인은 내적과 같고, 유클리드 거리의 제곱은 라서 코사인 내림차순과 거리 오름차순이 일치한다. 정규화하지 않은 벡터에서는 이 관계가 없으니 저장소의 거리 설정을 바꿀 때 확인한다.
계산 함수는 vector_search.py에 있다. 큰 숫자를 바로 제곱하면 오버플로가 나서 먼저 최대 절댓값으로 나누고, NaN·무한대·빈 벡터·영벡터는 거부한다.
from vector_search import cosine
assert abs(cosine([1, 1], [1, 0]) - 2 ** -0.5) < 1e-12
assert cosine([1, 0], [-1, 0]) == -1.0
코사인 구현이 맞다는 것과 임베딩이 업무 관련성을 잘 표현한다는 것은 따로 검증한다.
E5 임베딩 어댑터
대표 구현은 intfloat/multilingual-e5-small이고 리비전 614241f622f53c4eeff9890bdc4f31cfecc418b3를 고정했다. 실행 환경은 로컬 추론과 같은 CPU float32, 4스레드다.
from embedding_model import Embedder
model = Embedder() # 최초 실행에는 다운로드가 필요하다.
question = model.encode(["교육비 지원 한도는?"], kind="query")[0]
document = model.encode(
["직원 교육비 한도는 분기당 30만 원이다."], kind="passage"
)[0]
E5는 질문 앞에 query: , 문서 앞에 passage: 를 붙이고 한국어에도 같은 접두사를 쓴다. 공식 예제대로 토큰별 출력을 마스크 평균으로 모으고 L2 정규화하며 입력은 512토큰까지다. 이 규칙은 이 모델의 것이라 다른 임베딩 모델에 그대로 쓰지 않는다. 호출부가 kind로 역할을 말하고 어댑터가 접두사를 붙이는 쪽이, 사용자가 접두사를 기억해 붙이는 쪽보다 일관성을 지키기 쉽다. E5 모델의 사용 예제와 제한
with self.torch.inference_mode():
hidden = self.model(**batch).last_hidden_state
mask = batch["attention_mask"].unsqueeze(-1).bool()
pooled = hidden.masked_fill(~mask, 0).sum(dim=1) / mask.sum(dim=1)
배치에는 길이가 다른 텍스트가 섞이고, 짧은 텍스트를 같은 길이로 맞추려고 넣은 패딩은 평균에서 빼야 한다. 분자는 패딩을 0으로 만든 뒤 토큰 축으로 더한 값, 분모는 유효한 토큰 수다. 같은 짧은 질문을 단독으로 처리한 벡터와 더 긴 질문과 묶어 처리한 벡터의 최대 좌표 차이는 약 로, 허용 오차 안이다. 긴 문서는 조용히 자르지 않는다. 접두사와 특수 토큰을 포함해 512토큰을 넘으면 거부한다. 문서 끝의 예외 조건을 잘라 내고 벡터를 만들면 저장된 원문과 벡터가 표현한 내용이 달라진다. 실습 한도로 텍스트당 20,000자, 배치당 16개도 둔다.
두 모델이 모두 384개의 숫자를 돌려줘도 좌표의 뜻은 다르다. 모델 A의 문서 벡터와 모델 B의 질문 벡터로 내적을 구할 수는 있지만 그 값에 검색 의미는 없다. 예제의 Vector에는 space와 values가 있고, 검색기는 공간 식별자가 다르면 재색인이 필요하다는 오류를 낸다. 모델을 고를 때는 한국어 지원 외에 실제 문서 길이, 전문 용어, 호출 지연, 차원 수, 데이터 전송 조건, 재색인 비용을 본다. MTEB는 여러 임베딩 작업을 평가하고 모든 작업을 지배하는 단일 방법이 없다고 보고했다. 어느 작업의 점수인지 구별하고 우리 질문 집합으로 다시 평가한다. MTEB 논문
전수 검색 기준선
문서 다섯 개면 색인 없이 모든 점수를 계산할 수 있다. 대규모 서비스의 최종 구현이 아니라 순위가 어디서 나왔는지 추적하고, 뒤에 근사 검색이 놓친 결과를 비교할 기준선이다.
from vector_search import Passage, search
from context_budget import Principal
p = Passage("edu-v2", "hanul", "edu-v2", "교육비 문서", document)
principal = Principal("hanul", "reader", "demo", frozenset({"edu-v2"}))
hits = search(question, [p], principal, k=1)
Passage에는 청크 식별자, 조직, 문서 식별자, 원문, 벡터가 있고 여기서는 짧은 문서 하나가 청크 하나다. Principal은 서버가 확인한 읽기 가능 문서 집합을 들고 온다. 클라이언트가 자기 권한 목록을 채우는 흐름이 아니다. 권한이 회수된 주체로 다시 부르면 그 문서가 결과에서 사라지는 경로를 테스트했다.
문서 수 , 차원 일 때 전수 계산은 대략 개의 좌표를 다루고, 전체 정렬 비용은 이다. k를 줄여도 계산하는 후보 수는 줄지 않는다. 벡터만 float32로 저장한다고 치면 백만 개의 384차원 벡터는 바이트, 약 1.536GB다. 실제 저장소에는 문서, 메타데이터, 색인 구조가 더 들어간다.
다섯 질문의 실측
가상 문서는 교육비, 휴가, 국내 출장 교통비, 계정 비밀번호, 노트북 고장 안내 다섯 개다. 원문과 질의, 정답 문서 식별자, 실제 상위 세 결과는 books/ai-engineering/experiments/embedding-cpu.json에 있다.
source books/ai-engineering/examples/.venv/bin/activate
python books/ai-engineering/examples/embedding_demo.py > /tmp/embedding-new.json
python books/ai-engineering/examples/embedding_demo.py --offline > /tmp/embedding-offline.json
최초 실행은 모델 다운로드를 포함해 로드에 약 41.22초, 문서 다섯 개의 임베딩 생성에 약 0.036초가 걸렸다. 단일 실행이고 워밍업이나 부하 시험은 없었다.
| 사례 | 질문의 핵심 | 실제 1위 | 코사인 | 판정 |
|---|---|---|---|---|
| E01 | 업무 강의 결제 지원액 | edu-v2 | 0.8750 | 정답 문서 검색 |
| E02 | 휴가 신청과 승인 | leave-v1 | 0.8383 | 정답 문서 검색 |
| E03 | 암호를 기억하지 못함 | account-v1 | 0.8703 | 정답 문서 검색 |
| E04 | 팀장 승인 없이 교육비 선결제 가능한가 | edu-v2 | 0.8964 | 반대 조건을 확인할 문서 검색 |
| E05 | 해외 출장 호텔 숙박비 한도 | travel-v1 | 0.8455 | 주제는 관련되지만 답을 담은 문서가 없음 |
E01은 ‘강의 결제’에서 ‘교육비’ 문서를, E03은 ‘암호’에서 ‘비밀번호 재설정’ 문서를 찾았다. 이 다섯 문서와 질문에서 본 결과고, 회사 고유 약어나 오탈자, 복합 질문까지 된다는 증명은 아니다. E04는 승인 없이 먼저 결제해도 되는지 묻는데, 검색된 문서는 구매 전 승인을 요구한다. 검색은 맞는 근거를 찾았고, 답변은 그 조건을 읽어 허용되지 않는다고 말해야 한다. 높은 점수가 사용자의 전제를 승인한 것이 아니다.
정답 문서 집합을 , 상위 개를 라고 하면 Recall@k는 이다. 정밀도는 반환한 결과 중 관련 결과의 비율이다. 관련성의 기준이 주제 연관인지 답변 근거인지 먼저 정한다. 이 실습의 relevant는 답변에 필요한 사실을 담은 문서다. E01–E04는 정답이 하나씩이고 모두 1위라 Recall@1이 각각 1이지만, 이것을 한국어 검색 정확도 100%라고 부르지 않는다. E05는 이 비어 분모가 0이니 정답 없는 사례는 따로 집계한다. 정답 문서가 둘 필요한 질문에서는 하나만 찾아도 ‘관련 결과 있음’은 통과하므로 재현율과 근거 완전성을 함께 본다. 정보 검색의 정밀도와 재현율
실패 분석은 정답 근거가 수집 자료에 있는지부터 본다. 없는 자료는 모델을 바꿔도 못 찾는다. 다음은 파싱이나 길이 제한으로 빠진 부분, 그다음 권한·시행일 필터, 임베딩 설정, 후보 수와 순위다. 한국어에서는 동의 표현, 조사와 어미, 띄어쓰기, 사내 약어와 영문 혼용, 그리고 ‘30만 원’과 ‘300만 원’, ‘가능’과 ‘불가능’처럼 숫자·부정이 바뀐 문서를 별도 사례로 둔다. 주제가 비슷해 순위가 높을 수 있어서 결과에 원문과 식별자를 남겨 검증한다. 정확 일치가 필요한 식별자는 필터나 키워드 검색으로 처리하고, 두 검색을 함께 쓰는 하이브리드와 재순위화는 이 기준선 위에서 비교한다.
분류·추천·이상 탐지
같은 벡터 비교를 검색 밖에서도 쓸 수 있지만 작업의 목표와 평가는 다르다. vector_applications.py는 사람이 만든 2차원 벡터로 세 계산을 보여 주는 예제다.
python books/ai-engineering/examples/vector_applications.py
| 작업 | 계산 | 예제 값 | 이 값이 아닌 것 |
|---|---|---|---|
| 분류 | 가장 가까운 대표 벡터를 후보로. 1위·2위 점수 차이도 반환 | 계정 , 장비 에 문의 → 계정, 차이 0.8835 | 확률 |
| 추천 | 관심 벡터에 가까운 문서 순서 | 관심 에 ‘설정 안내’가 ‘수리 안내’보다 먼저 | 유용함의 증명 |
| 이상 탐지 | 1 − 정상 대표들과의 최대 코사인 | → 약 1.7071 | 0~1의 이상 확률 |
분류에서는 대표 예시가 충분한지, 두 의도가 섞인 문의와 어느 범주에도 없는 입력이 있는지 보고, 임계값은 실제 자료에서 오분류와 유보율을 함께 재서 정한다. 추천에서는 읽은 자료 제외, 접근 권한, 다양성을 반영하고 클릭이 아니라 문의 해결률과 피드백을 본다. 이상 점수가 높다는 것은 악성이나 오류의 증명이 아니라 조사 순서다. 새 제품 문의, 다른 언어, 자료 분포의 변화도 멀게 나온다. 셋 다 입력 표현, 비교 규칙, 업무 판정을 나눠 두면 고칠 자리가 보인다.
실습: 검색 기준선 검증하기
python -m unittest discover -s books/ai-engineering/examples -p 'test_*.py' -q
python books/ai-engineering/examples/embedding_checks.py
- 첫 명령은 모델 없이 기하 관계, 극단적인 크기, 잘못된 벡터, 권한 회수와 조직 격리, 동점 정렬, 공간·차원 불일치, 중복 청크를 검사한다. 둘째 명령은 캐시의 실제 모델로 출력 차원과 정규화, 배치 패딩 차이, 빈 입력·잘못된 역할·토큰 한도 초과의 거부를 본다.
- E05의 답을 담은 해외 출장 숙박비 문서를 추가하고 같은 질문을 돌린다. 새 문서가 1위가 되는지 보되 다른 네 질문의 순위도 함께 본다.
- 그 문서의 읽기 권한을 빼고 결과에서 사라지는지 본다. 존재하는 자료와 이 사용자가 쓸 수 있는 자료가 다르다.
한 질문에서 허용된 문서 후보를 찾는 최소 경로가 생겼다. 문서가 길어지고 수정·삭제되며 여러 판이 공존할 때 청크와 벡터를 저장하고 갱신하는 일이 남았다.