Dense Embedding은 텍스트의 의미를 숫자 벡터로 변환하여 "뜻이 비슷하면 가까운 벡터"가 되게 하는 방식이고, BM25 인덱스는 텍스트에 포함된 단어 자체의 출현 빈도와 희귀도로 점수를 매기는 방식이다. 전자는 "동의어나 다른 표현"을 찾을 수 있고, 후자는 "정확히 그 단어가 있는 문서"를 찾을 수 있다.
선택 이유:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
embeddings = model.encode(
texts,
batch_size=64,
normalize_embeddings=True, # L2 정규화 → 코사인 유사도 = 내적
)
L2 정규화가 핵심인 이유:
@)이 됨scores = embeddings @ query_embedding.T # (9586,) 벡터, <100ms
여기서 numpy는 Python의 수치 계산 라이브러리로, 대량의 숫자 배열을 빠르게 연산할 수 있다. 위의 @ 연산자 한 줄로 9,586개 벡터와의 유사도를 한번에 계산하는 것이 numpy 덕분이다.Dense embedding만으로는 정확한 키워드 매칭이 약하다. "甲木"을 검색했는데 의미적으로 유사한 "양의 기운"이 올라오는 식이다. 정확한 용어 매칭이 중요한 도메인에서는 BM25가 필수 보완재.
# 한국어: Kiwi 형태소 분석기 (명사/동사/형용사만 추출)
def tokenize_ko(text: str) -> list[str]:
kiwi = Kiwi()
tokens = []
for token in kiwi.tokenize(text):
if token.tag.startswith(("NN", "VV", "VA", "SL", "SH", "SN")):
min_len = 1 if token.tag.startswith("NN") else 2
if len(token.form) >= min_len:
tokens.append(token.form)
return tokens
# 중국어: jieba 분절 (불용어 제거)
def tokenize_zh(text: str) -> list[str]:
return [w.strip() for w in jieba.cut(text)
if w.strip() and w not in _ZH_STOP]
# 영어: regex + stopwords (100개)
def tokenize_en(text: str) -> list[str]:
words = re.findall(r"[a-zA-Z]+", text.lower())
return [w for w in words if w not in _ENGLISH_STOP and len(w) > 2]
한국어 1글자 명사를 허용하는 이유:
오행(금/수/목/화/토), 천간(갑/을/병/정) 등 1글자 명사가 도메인의 핵심 용어이다. 일반 NLP에서는 1글자 명사를 노이즈로 제거하지만, 여기서는 min_len = 1 if NN 조건으로 명사만 허용.
IT 서비스에서의 동일한 문제:
IT 도메인에도 짧은 약어가 핵심 용어인 경우가 많다: CI, CD, DB, VM, LB, DR, QA 등. 영어 토크나이저에서 len(w) > 2 필터를 적용하면 이런 2글자 약어가 모두 제거된다. 도메인에 따라 토크나이저의 최소 길이 제한을 조정하거나, 중요 약어를 불용어 예외 목록에 추가해야 한다.
JSONL(JSON Lines)은 한 줄에 하나의 JSON 객체를 저장하는 형식으로, 전체를 메모리에 올리지 않고도 한 줄씩 스트리밍 처리할 수 있어 대량의 청크 데이터를 다루기에 적합하다. 일반 JSON 배열([{...}, {...}])은 파일 전체를 파싱해야 하지만, JSONL은 특정 줄만 읽거나 끝에 추가(append)하는 것이 자유롭다.
data/
├── chunks.jsonl # 원본 청크 (chunk_id, source, page, lang, text)
├── chunks_meta.jsonl # 검색 결과 반환용 메타+텍스트
├── embeddings.npy # Dense 벡터 (9586 × 384, float32)
├── bm25_index.pkl # BM25Okapi 직렬화
├── tokenized_corpus.pkl # 사전 토큰화 코퍼스 (검색 시 재토큰화 방지)
└── .last_build_count # 마지막 빌드 시 청크 수 (변경 감지용)
전체 리빌드 시간: 9,586 청크 기준 약 3~5분 (M1 Mac 로컬)
쿼리: "갑목 인월 용신 조후"
│
┌──────────┼──────────┐
▼ │ ▼
┌──────────┐ │ ┌──────────┐
│ BM25 │ │ │ Dense │
│ top-K │ │ │ top-K │
└────┬─────┘ │ └────┬─────┘
│ │ │
└─────┬─────┘─────────┘
│
합집합 (K=max(20, top_k×4), 기본 top_k=5 시 최대 40개)
│
▼
┌─────────────────────┐
│ Stage 2: Reranker │
│ bge-reranker-v2-m3 │
│ (Cross-Encoder) │
└──────────┬──────────┘
│
top-K 결과 반환
BM25와 Dense 검색은 근본적으로 다른 방식이고, 쿼리의 성격에 따라 어느 쪽이 더 효과적인지가 달라진다. 고정 비율(예: BM25 40% + Dense 60%)로 합산하면, 특정 유형의 쿼리에서 최적이 아닌 결과를 얻게 된다.
문제를 구체적으로 보자:
| 쿼리 유형 | BM25가 잘하는 것 | Dense가 잘하는 것 | 고정 비율의 문제 |
|---|---|---|---|
"窮通寶鑑 甲木 寅月" (한자 키워드) |
정확히 이 한자가 있는 문서를 찾음 | 의미적으로 비슷한 한국어 해석문을 가져옴 (노이즈) | Dense 비중이 높으면 엉뚱한 한국어 해석이 상위에 올라옴 |
"갑목 일간이 봄에 태어났을 때 보충해야 할 기운" (서술형) |
"갑목", "봄" 단어만 매칭하여 단편적 결과 | 문장 전체의 의미를 파악하여 "목 기운이 왕성할 때 금으로 조절" 같은 답을 찾음 | BM25 비중이 높으면 관련성 낮은 "갑목" 포함 문서가 상위에 올라옴 |
"도화살" (짧은 전문용어) |
"도화살"이 정확히 있는 문서를 찾음 | "도화살" 임베딩이 "연애", "매력" 같은 일반적 의미로도 매칭됨 | 한쪽에 치우치면 정확성 또는 다양성을 잃음 |
이 프로젝트의 적응형 가중치:
def _adaptive_weights(query, lang):
hanja_ratio = (쿼리 내 한자 비율)
domain_term_count = (도메인 사전에 매칭된 용어 수)
if hanja_ratio > 0.3: # 한자 쿼리
return 0.6, 0.4 # BM25↑: 한자는 정확한 글자 매칭이 핵심
elif len(query) <= 8 and domain_term_count >= 1: # 짧은 전문용어
return 0.5, 0.5 # 균등: 양쪽 모두 필요
elif len(query) > 20 and domain_term_count == 0: # 긴 서술형
return 0.25, 0.75 # Dense↑: 의미 파악이 핵심
else:
return 0.4, 0.6 # 기본: Dense 약간 우세
Production 시사점:
적응형 가중치는 사주명리학에만 필요한 것이 아니다. 예를 들어:
GET /api/v2/users → BM25 강화, "사용자 목록을 가져오는 방법" → Dense 강화쿼리의 성격을 런타임에 분석하여 가중치를 자동 조절하면, 다양한 쿼리 패턴에 모두 대응할 수 있다.
이 프로젝트의 가장 핵심적인 기법. 한국어 쿼리가 중국어 원전을 찾을 수 있게 해주는 장치:
# 143개 키(209쌍)의 한↔漢 매핑 테이블 (일부)
_KOREAN_TO_HANJA = {
"갑목": ["甲木"],
"용신": ["用神"],
"도화살": ["桃花煞", "桃花"],
"합충형파해": ["合沖刑破害", "合冲刑破害"], # 정체자+간체자 모두 포함
"인신충": ["寅申沖", "寅申冲"],
...
}
쿼리 처리 과정:
입력 쿼리: "갑목 인월 용신"
↓ Kiwi 토큰화
토큰: ["갑목", "인", "월", "용신"]
↓ 복합 도메인 용어 추출 (형태소 분석 우회)
추가: ["갑목", "용신"] # 이미 포함됨 확인
↓ 한→漢 확장
확장: ["갑목", "인", "월", "용신", "甲木", "寅", "用神"]
↓ BM25 검색 (확장된 토큰으로)
IT 서비스에서의 동일한 패턴:
IT 환경에서도 같은 의미의 용어가 한글/영어/약어로 혼용된다. 쿼리 확장 테이블 없이는 "쿠버네티스"로 검색했을 때 "Kubernetes" 또는 "K8s"로 쓰인 문서를 찾을 수 없다:
_IT_TERM_EXPANSION = {
"쿠버네티스": ["Kubernetes", "K8s", "k8s"],
"도커": ["Docker", "docker"],
"로드밸런서": ["Load Balancer", "LB", "L4", "L7"],
"배포": ["deploy", "deployment", "릴리즈", "release"],
"장애": ["incident", "outage", "장애 대응", "postmortem"],
"인증": ["authentication", "auth", "OAuth", "SSO"],
"캐시": ["cache", "Redis", "Memcached", "CDN"],
"모니터링": ["monitoring", "Grafana", "Prometheus", "옵저버빌리티", "observability"],
...
}
이 테이블 하나로 "배포 롤백 방법"이라는 한국어 쿼리가 "deployment rollback procedure"로 쓰인 영어 문서도 찾을 수 있게 된다. 도메인 사전 구축은 일회성 비용이지만, 검색 재현율 향상 효과는 영구적이다.
복합 도메인 용어 추출이 필요한 이유:
Kiwi가 "대운"을 "대"+"운"으로 분리하거나, "궁통보감"을 잘못 분절하는 경우가 있다. 사전에 등록된 복합어를 형태소 분석 전에 먼저 매칭하여 보존:
def _extract_compound_terms(query: str) -> list[str]:
# 길이 내림차순 정렬 → 긴 용어 우선 매칭 (합충형파해 > 합충 > 합)
sorted_terms = sorted(_KOREAN_TO_HANJA.keys(), key=len, reverse=True)
found = []
for term in sorted_terms:
if len(term) >= 2 and term in query:
found.append(term)
return found
같은 출처에서 여러 청크가 상위에 올라오면 답변이 편향된다. 동일 소스의 2번째 청크부터 5%씩 감쇠:
# 같은 소스 2번째부터 5%씩 감쇠 (최대 15%)
penalty = min(seen * 0.05, 0.15)
adjusted = original_score * (1.0 - penalty)
예: 梁湘润_명리전승반필기.pdf에서 5개 청크가 상위 10에 들어도, 다양성 페널티로 다른 소스 청크가 끼어들어 여러 관점의 해석을 얻을 수 있다.
IT 서비스에서의 동일한 문제:
"결제 타임아웃"을 검색했을 때, 결제팀 Confluence 스페이스의 청크 10개가 상위를 독점하면 인프라팀이나 QA팀이 작성한 다른 관점의 문서를 놓친다. 소스 다양성 페널티를 적용하면 결제팀 문서 3개 + 인프라팀 장애 대응 문서 2개 + QA 테스트 시나리오 1개처럼 균형 잡힌 결과를 얻을 수 있다.