2편에서 키워드 검색은 사전을 키우는 만큼 똑똑해진다고 정리했다. 하지만 사전에 없는 말은 여전히 못 찾는다. 이번 편은 뜻으로 찾는 벡터 검색이다. (1편: 개념 정리 · 2편: 키워드 검색)
이번에는 회사 PC에서 직접 돌렸다. 그런데 첫 줄부터 막혔다. 모델을 내려받는 단계에서 SSL 인증서 오류가 났다. ㅡ,.ㅡ; 그 이야기부터 시작한다.
회사 PC에서 돌리려다 SSL 인증서에서 막혔다
벡터 검색에는 임베딩 모델이 필요하다. 보통 Hugging Face라는 사이트에서 내려받는다. 라이브러리 설치(pip)는 잘 됐는데, 모델을 받는 순간 이런 오류가 났다.
[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate in certificate chain
사이트가 막힌 게 아니었다. 회사망의 보안 장비가 HTTPS 통신을 중간에서 검사하면서 회사 자체 인증서로 바꿔 끼우는데, 브라우저와 Windows는 이 인증서를 믿지만 파이썬은 자기 인증서 목록만 써서 연결을 끊은 거다. 최신 pip는 Windows 인증서 저장소를 쓰기 때문에 pip만 통과했던 것이고.
해결은 파이썬도 Windows 인증서 저장소를 쓰게 하는 것이다. truststore라는 작은 라이브러리로 된다.
python -m pip install truststore
# 코드 맨 위에 두 줄
import truststore
truststore.inject_into_ssl() # 파이썬이 Windows 인증서 저장소(회사 인증서 포함)를 쓰게 함
인터넷에 흔히 보이는 "인증서 검증 끄기(verify=False)"는 보안상 쓰면 안 된다. 회사에서 따라 해 볼 사람은 여기서 거의 다 한 번씩 막힐 거다.
임베딩: 문장을 384개의 숫자로 바꾼다
벡터 검색의 핵심은 임베딩이다. 문장을 넣으면 숫자 목록(벡터)이 나온다. 이번에 쓴 multilingual-e5-small 모델은 문장 하나를 숫자 384개로 바꾼다. 뜻이 비슷한 문장끼리 비슷한 숫자가 나오도록 학습된 모델이라, 숫자끼리 비교하면 뜻이 얼마나 가까운지 알 수 있다.
가까움은 보통 코사인 유사도로 잰다. 두 벡터가 같은 방향을 가리킬수록 1에 가깝다. 코드는 생각보다 짧다.
import truststore; truststore.inject_into_ssl() # 회사망이면 꼭 (아래 설명)
from sentence_transformers import SentenceTransformer, util
model = SentenceTransformer("intfloat/multilingual-e5-small") # 처음에 약 470MB 내려받음
docs = [
"머신러닝 모델 학습용 GPU서버 사용 신청 절차",
"기계학습 기초 교육 과정 안내",
"인공지능 기반 문서 분류 시스템 운영 매뉴얼",
"출장비 정산 규정 및 영수증 제출 방법",
"딥러닝 프레임워크 설치 가이드",
]
# 1) 문서를 미리 벡터로 바꿔 둔다 (e5 모델은 문서 앞에 'passage: ')
doc_vecs = model.encode(["passage: " + d for d in docs], normalize_embeddings=True)
# 2) 질문도 벡터로 바꾼다 (질문 앞에는 'query: ')
q_vec = model.encode("query: 회사 돈으로 쓴 택시비 돌려받는 법", normalize_embeddings=True)
# 3) 코사인 유사도로 가까운 순서대로 줄 세운다
scores = util.cos_sim(q_vec, doc_vecs)[0].tolist()
for s, d in sorted(zip(scores, docs), reverse=True):
print(f"{s:.3f} {d}")
모델마다 지켜야 할 규칙이 있다. e5 모델은 질문 앞에 query: , 문서 앞에 passage: 를 붙여야 한다. 모델 설명서에 이렇게 학습했으니 안 붙이면 성능이 떨어진다고 적혀 있다.
실험 1: 겹치는 단어가 하나도 없는데 찾았다
1편·2편과 같은 가짜 문서 5개로 돌렸다. 각 질문의 1위만 뽑으면 이렇다.
| 질문 | 벡터 검색 1위 | 점수 | 키워드 검색이었다면 |
|---|---|---|---|
| 회사 돈으로 쓴 택시비 돌려받는 법 | 출장비 정산 규정 및 영수증 제출 방법 | 0.832 | 겹치는 단어가 없어 0건 |
| AI로 문서를 자동으로 나눠 주는 시스템 있어? | 인공지능 기반 문서 분류 시스템 운영 매뉴얼 | 0.876 | 동의어 사전이 있어야 찾음 |
| 기계학습 | 기계학습 기초 교육 과정 안내 | 0.884 | 찾음 |
| 머신러닝 | 머신러닝 모델 학습용 GPU서버 사용 신청 절차 | 0.869 | 찾음 (기계학습 문서는 0점) |
'택시비'와 '출장비', '돌려받는'과 '정산'은 글자가 하나도 안 겹친다. 그런데 찾았다. 2편에서 동의어 사전으로 겨우 풀었던 '머신러닝 = 기계학습'도 사전 없이 된다. '머신러닝'으로 찾으면 기계학습 교육 문서가 2위(0.838)로 바로 따라온다. 키워드 검색에서는 0점이던 문서다.
그런데 관계없는 문서도 0.78이다
결과를 처음부터 끝까지 보면 이상한 게 보인다. '머신러닝'을 물었는데 출장비 정산 규정이 0.793이다. 1위(0.869)와 차이가 0.08밖에 안 난다.
모델 설명서를 찾아보니 이 모델은 원래 점수가 0.7~1.0 사이에 몰린다고 한다. 학습 방식 때문에 생기는 정상적인 특성이고, 점수 자체가 아니라 순서만 의미가 있다고 적혀 있다.
이게 실무에서는 꽤 중요하다. 키워드 검색은 관계없는 문서에 0점을 준다. 벡터 검색은 관계없는 문서에도 그럴듯한 점수를 준다. 그래서 "0.8 넘으면 관련 문서"처럼 점수로 자르기가 어렵고, 결국 상위 몇 개(top-k)를 가져오게 된다. 질문에 맞는 문서가 없을 때도 뭔가는 가져온다는 뜻이다. RAG에서 이게 생성형 AI에게 그대로 넘어가면, 관계없는 문서를 근거로 답을 만들 수 있다.
실험 2: 문서번호와 R1·R2는 구분하지 못한다
실제로 우리 문서들은 R1, R2 식으로 버전이 올라가면서 같은 내용이 중복으로 검색되는 문제가 있었다. 벡터 검색은 이걸 어떻게 볼까?
| 질문 | 1위 | 2위 | 3위 |
|---|---|---|---|
| TD-2024-0137 | TD-2024-0137 장애 대응 절차 (0.885) | TD-2024-0131 운영 매뉴얼 R2 (0.867) | TD-2024-0131 운영 매뉴얼 R1 (0.866) |
| GPU 서버 운영 매뉴얼 R2 | …매뉴얼 R2 (0.916) | …매뉴얼 R1 (0.905) | 장애 대응 절차 (0.860) |
| GPU 서버 운영 매뉴얼 최신 버전 | …매뉴얼 R1 (0.899) | …매뉴얼 R2 (0.899) | 장애 대응 절차 (0.870) |
1위는 맞혔지만 차이가 아주 작다. 문서번호 0137을 물었는데 0131 문서가 0.018 차이로 따라온다. R2를 물으면 R1이 0.011 차이다. '최신 버전'으로 물으면 R1과 R2가 똑같이 0.899다. 벡터 검색은 R2가 R1보다 새것이라는 걸 모른다. 숫자 하나 다른 두 문서는 뜻이 거의 같기 때문이다.
이런 건 벡터 검색에 맡길 일이 아니다. 문서번호는 2편의 keyword 필드처럼 정확히 맞추고, '최신 버전'은 문서에 버전·날짜 정보를 따로 달아서 걸러야 한다(메타데이터 필터). 이건 4편에서 하이브리드로 묶을 때 다룬다.
실험 3: 긴 문서는 잘라서 넣는다 (청킹)
사내 문서는 몇 쪽, 몇십 쪽씩 된다. 통째로 벡터 하나로 만들면 두 가지 문제가 생긴다. 하나는 이 모델이 앞부분 512토큰까지만 읽고 나머지는 버린다는 것. 다른 하나는 벡터 하나에 여러 내용이 섞여 흐려진다는 것이다. 그래서 문서를 조각(청크)으로 잘라서 넣는다. 이걸 청킹이라고 한다.
GPU 서버 사용 규정을 5개 장으로 된 가짜 문서로 만들고, 장 단위로 잘랐다.
import re
# '제N장' 앞에서 자른다: 규정·매뉴얼은 장·조 단위가 곧 뜻의 단위다
chunks = [c.strip() for c in re.split(r"(?=제\d장)", long_doc) if c.strip()]
| 질문 | 통째로 넣은 문서 | 조각 1위 | 결과 |
|---|---|---|---|
| 개인정보가 있는 데이터를 올려도 돼? | 0.841 | 제4장 데이터 (0.869) | 정답 조각이 1위 |
| GPU 서버는 최대 며칠까지 쓸 수 있어? | 0.875 | 제2장 신청 (0.861) | 정답(제3장, "최대 2주")이 2위 안에 없음 |
첫 질문은 청킹이 제 역할을 했다. 개인정보 이야기가 든 제4장만 정확히 집어 왔다. 생성형 AI에게 문서 전체 대신 이 조각 하나만 넘기면 된다.
두 번째는 실패했다. '며칠까지'와 '최대 2주'를 이어 주지 못하고, '기간'이라는 말이 든 제2장을 1위로 올렸다. 작은 모델의 한계일 수도 있고, 조각이 너무 짧아서일 수도 있다. 같은 조각 5개에 2편의 키워드 검색(BM25)을 돌려 보니 반대로 제3장이 1위(2.52)였다. '최대'라는 단어가 그대로 겹치기 때문이다. 벡터 검색만 믿으면 안 되는 이유가 또 하나 생겼다.
청킹을 할 때 흔히 쓰는 요령은 이렇다.
- 뜻의 단위로 자른다: 글자 수로 뚝뚝 자르지 말고 장·조·소제목 단위로.
- 조금씩 겹치게 자른다: 경계에 걸린 문장이 잘리지 않게 앞뒤를 조금 겹친다.
- 조각마다 문서 제목을 붙인다: "GPU 서버 사용 규정 > 제3장 사용 시간"처럼 붙이면 조각만 봐도 무슨 문서인지 안다.
3만 건이면 얼마나 걸릴까: FAISS와 벡터 DB
마지막으로 속도다. 벡터 3만 개를 만들어 놓고 질문 하나와 전부 비교했다.
| 방법 | 1회 검색 시간 |
|---|---|
| numpy로 3만 개 전부 비교 | 1.43 ms |
| FAISS (IndexFlatIP) | 2.82 ms |
FAISS가 더 느리게 나왔다. 이번에 쓴 IndexFlatIP는 FAISS에서도 '전부 다 비교하는' 가장 단순한 방식이라 numpy와 하는 일이 같고, 질문 하나씩 돌리면 오히려 준비 시간이 더 든다. FAISS가 빛을 보는 건 수백만 건 이상에서 HNSW나 IVF 같은 근사 검색(전부 비교하지 않고 가까운 후보만 보는 방식)을 쓸 때다. 3만 건이면 그냥 다 비교해도 1~3ms다.
FAISS는 코드 안에서 쓰는 라이브러리다. 서비스에서는 보통 벡터 DB를 쓴다. 저장·수정·삭제, 그리고 "R2만", "2025년 이후 문서만" 같은 필터를 같이 해 주기 때문이다.
| 종류 | 예 | 이럴 때 |
|---|---|---|
| 라이브러리 | FAISS | 공부, 실험, 작은 규모 |
| 전용 벡터 DB | Qdrant, Milvus | 벡터 검색이 중심인 서비스 |
| RDB 확장 | pgvector (PostgreSQL) | RDB에 있는 정보와 같이 쓰고 싶을 때 |
| 검색엔진 내장 | Elasticsearch / OpenSearch 벡터 필드 | 이미 키워드 검색을 쓰고 있을 때 (하이브리드가 쉬움) |
자바로 치면 FAISS는 메모리에 올린 HashMap이고, 벡터 DB는 DB 서버다.
직접 돌려 보니 벡터 검색은 "뜻"에 강하고 "글자"에 약했다. 택시비로 출장비 규정을 찾는 건 키워드 검색이 절대 못 하는 일이다. 반대로 문서번호, R1·R2, '최대 2주' 같은 정확한 값은 키워드 검색이 더 잘한다. 둘을 합치는 게 다음 편, 하이브리드 검색이다.
이상 벡터 검색 제대로 알기 끝!


댓글
댓글 쓰기