Bi-encoder 기반 시맨틱 검색의 작동 원리, 임베딩 모델 선택, 문서 청킹 전략, ANN 검색, 벡터 데이터베이스 연동을 Python 구현과 함께 다룹니다.
시맨틱 검색의 핵심은 Bi-encoder 아키텍처입니다. "Bi"라는 이름이 말해주듯, 두 개의 독립된 인코더가 쿼리와 문서를 각각 별도로 벡터화합니다.
이 구조의 핵심 장점은 문서 벡터를 미리 계산해 둘 수 있다는 점입니다. 수백만 개의 문서를 실시간으로 인코딩할 필요 없이, 인덱싱 시점에 한 번만 벡터를 생성하면 됩니다. 검색 시에는 쿼리 하나만 인코딩하면 되므로 밀리초 단위의 응답이 가능합니다.
Bi-encoder에서 쿼리 인코더와 문서 인코더는 동일한 모델을 공유하는 경우가 대부분입니다. 일부 비대칭 모델(E5 등)은 쿼리에 "query:" 접두사를, 문서에 "passage:" 접두사를 붙여 역할을 구분합니다.
벡터 간의 유사도를 측정하는 방법은 크게 세 가지입니다.
import numpy as np
def cosine_similarity(a, b):
"""코사인 유사도: 방향의 유사성 측정 (-1 ~ 1)"""
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
def dot_product(a, b):
"""내적: 방향 + 크기 반영"""
return np.dot(a, b)
def euclidean_distance(a, b):
"""유클리드 거리: 값이 작을수록 유사"""
return np.linalg.norm(a - b)대부분의 임베딩 모델은 정규화된 벡터를 생성하므로, 코사인 유사도와 내적이 동일한 결과를 낳습니다. Elasticsearch와 같은 검색 엔진에서는 기본적으로 코사인 유사도를 사용합니다.
임베딩 모델은 시맨틱 검색의 품질을 좌우하는 가장 중요한 요소입니다. 모델 선택 시 고려해야 할 기준은 다음과 같습니다.
| 모델 | 차원 | 최대 토큰 | 한국어 | 용도 |
|---|---|---|---|---|
| all-MiniLM-L6-v2 | 384 | 256 | 제한적 | 영어 범용, 경량 |
| multilingual-e5-large | 1024 | 512 | 우수 | 다국어 검색 |
| bge-m3 | 1024 | 8192 | 우수 | 다국어, 긴 문서 |
| ko-sroberta-multitask | 768 | 512 | 최적화 | 한국어 특화 |
| text-embedding-3-large | 3072 | 8191 | 우수 | OpenAI API |
한국어 검색 시스템에서는 다국어 모델 중 한국어 성능이 검증된 모델을 선택하는 것이 중요합니다. MTEB(Massive Text Embedding Benchmark) 리더보드에서 한국어 태스크 성능을 확인하는 것을 권장합니다.
from sentence_transformers import SentenceTransformer
# 한국어 검색에 적합한 다국어 모델
model = SentenceTransformer("intfloat/multilingual-e5-large")
# E5 모델은 접두사가 필요합니다
query_text = "query: 파이썬으로 웹 크롤러 만들기"
doc_text = "passage: Python을 활용한 웹 스크래핑 튜토리얼입니다."
query_vec = model.encode(query_text, normalize_embeddings=True)
doc_vec = model.encode(doc_text, normalize_embeddings=True)
similarity = query_vec @ doc_vec
print(f"유사도: {similarity:.4f}")임베딩 모델의 최대 토큰 수를 초과하는 텍스트는 잘리게 됩니다. 긴 문서를 다루는 경우 반드시 문서 청킹을 적용해야 합니다. bge-m3처럼 8192 토큰을 지원하는 모델도 있지만, 긴 입력은 임베딩 품질이 저하될 수 있습니다.
긴 문서를 임베딩 모델에 맞는 크기로 분할하는 과정을 **청킹(Chunking)**이라 합니다. 청킹 전략은 검색 품질에 직접적인 영향을 미칩니다.
가장 단순한 방식으로, 일정한 문자 또는 토큰 수로 텍스트를 분할합니다. 오버랩(overlap)을 두어 문맥이 끊기는 것을 완화합니다.
def fixed_size_chunk(text: str, chunk_size: int = 500, overlap: int = 100) -> list[str]:
"""고정 크기 청킹 (문자 기준)"""
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunk = text[start:end]
chunks.append(chunk)
start = end - overlap
return chunks문단, 섹션, 문장 경계를 기준으로 분할하는 방식입니다. 의미적으로 완결된 단위를 유지하므로 임베딩 품질이 높아집니다.
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=100,
separators=["\n\n", "\n", ". ", " ", ""],
length_function=len,
)
chunks = splitter.split_text(long_document)문서를 여러 수준의 청크로 분할하여 저장합니다. 예를 들어 섹션 단위의 큰 청크와 문단 단위의 작은 청크를 모두 인덱싱하면, 검색 시 세밀한 매칭과 넓은 맥락을 동시에 활용할 수 있습니다.
| 전략 | 장점 | 단점 | 적합한 경우 |
|---|---|---|---|
| 고정 크기 | 구현 간단, 균일한 크기 | 의미 단절 가능 | 구조 없는 텍스트 |
| 의미 단위 | 맥락 보존 | 크기 불균일 | 구조화된 문서 |
| 계층적 | 다양한 수준의 검색 | 인덱스 크기 증가 | 긴 기술 문서 |
수백만 개의 벡터에서 유사한 벡터를 찾는 것은 계산 비용이 높습니다. ANN(Approximate Nearest Neighbor, 근사 최근접 이웃) 알고리즘은 약간의 정확도를 희생하여 검색 속도를 극적으로 향상시킵니다.
**HNSW(Hierarchical Navigable Small World)**는 현재 가장 널리 사용되는 ANN 알고리즘입니다. 그래프 기반 구조로, 여러 계층의 노드 연결을 통해 빠르게 최근접 이웃을 탐색합니다.
HNSW의 핵심 파라미터는 두 가지입니다.
벡터 공간을 여러 클러스터로 나누고, 검색 시 쿼리 벡터와 가장 가까운 클러스터만 탐색합니다. nprobe 파라미터로 탐색할 클러스터 수를 조절하여 속도와 정확도의 균형을 맞춥니다.
시맨틱 검색 시스템을 구축하려면 벡터를 저장하고 검색할 데이터베이스가 필요합니다. 전용 벡터 데이터베이스를 사용하거나, Elasticsearch처럼 벡터 검색을 지원하는 기존 검색 엔진을 활용할 수 있습니다.
| 솔루션 | 유형 | ANN 알고리즘 | 특징 |
|---|---|---|---|
| Elasticsearch | 검색 엔진 | HNSW | 키워드+벡터 통합 |
| OpenSearch | 검색 엔진 | HNSW, IVF | AWS 관리형 제공 |
| Pinecone | 전용 벡터 DB | 자체 구현 | 완전 관리형 |
| Weaviate | 전용 벡터 DB | HNSW | 스키마 기반 |
| Qdrant | 전용 벡터 DB | HNSW | Rust 기반, 고성능 |
| pgvector | DB 확장 | IVFFlat, HNSW | PostgreSQL 호환 |
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
from sentence_transformers import SentenceTransformer
# 클라이언트 및 모델 초기화
client = QdrantClient(host="localhost", port=6333)
model = SentenceTransformer("intfloat/multilingual-e5-large")
# 컬렉션 생성
client.create_collection(
collection_name="documents",
vectors_config=VectorParams(size=1024, distance=Distance.COSINE),
)
# 문서 인덱싱
documents = [
{"id": 1, "text": "파이썬 웹 프레임워크 Flask 입문", "category": "backend"},
{"id": 2, "text": "React 상태 관리 패턴 비교", "category": "frontend"},
{"id": 3, "text": "PostgreSQL 쿼리 최적화 가이드", "category": "database"},
]
points = []
for doc in documents:
embedding = model.encode(f"passage: {doc['text']}", normalize_embeddings=True)
points.append(
PointStruct(
id=doc["id"],
vector=embedding.tolist(),
payload={"text": doc["text"], "category": doc["category"]},
)
)
client.upsert(collection_name="documents", points=points)
# 검색
query = "데이터베이스 성능 튜닝"
query_vector = model.encode(f"query: {query}", normalize_embeddings=True)
results = client.search(
collection_name="documents",
query_vector=query_vector.tolist(),
limit=5,
)
for result in results:
print(f"Score: {result.score:.4f} | {result.payload['text']}")프로덕션 환경에서는 Elasticsearch처럼 키워드 검색과 벡터 검색을 모두 지원하는 솔루션이 운영 복잡성을 줄여줍니다. 6장에서 Elasticsearch의 AI 검색 통합을 자세히 다룹니다.
이번 장에서는 시맨틱 검색 아키텍처의 핵심 요소를 살펴보았습니다. Bi-encoder가 쿼리와 문서를 독립적으로 벡터화하여 빠른 검색을 가능하게 하는 원리, 임베딩 모델 선택 시 고려할 기준, 문서를 적절한 크기로 나누는 청킹 전략, HNSW 등의 ANN 알고리즘, 그리고 벡터 데이터베이스를 활용한 실제 구현까지 다루었습니다.
다음 장에서는 이렇게 구축한 검색 시스템의 품질을 어떻게 측정하고 평가하는지, Precision, Recall, NDCG 등의 검색 품질 메트릭과 오프라인/온라인 평가 방법론을 다루겠습니다.
이 글이 도움이 되셨나요?
Precision, Recall, NDCG, MRR, MAP 등 검색 품질 메트릭의 원리와 계산법, 오프라인/온라인 평가 방법론, A/B 테스트와 평가 데이터셋 구축을 다룹니다.
키워드 검색에서 시맨틱 검색, 하이브리드 검색으로 이어지는 검색 기술의 진화 과정과 AI 검색 시스템의 핵심 구성요소를 살펴봅니다.
쿼리 분류, 의도 인식, 엔티티 인식부터 LLM 기반 쿼리 확장, HyDE(가상 문서 생성), 다국어 처리까지 쿼리 이해 파이프라인을 다룹니다.