특허·법률·논문 검색을 위한 다국어 검색 엔진 설계 - BM25부터 RRF·Cross-encoder까지
다국어 임베딩 한 줄로 끝나지 않는 전문 도메인 검색 설계. 다국어 BM25, 형태소·서브워드 토크나이저, 다국어 유사도, 문장→문헌 단위 RRF 융합, OpenSearch+Qdrant·Elasticsearch+Milvus 두 스택 비교까지 실전 패턴을 정리합니다.
이 글은 한 독자분의 요청으로 작성되었습니다. "특허, 법률, 논문 등 정보 검색 영역에서 다국어 검색 — 단순한 다국어 임베딩 모델·LLM RAG가 아닌, 키워드 매칭(다국어 BM25), 다국어 유사도, 문장→문헌 RRF 전략을 다뤄달라" 는 요청이었습니다.
특허·법률·논문 검색은 일반 웹 검색과 결정적으로 다릅니다. 누락(recall miss) 한 건이 곧 무효심판에서의 패소, 변론에서의 핵심 판례 누락, 선행연구 조사 실패로 이어집니다. 동시에 사용자는 한국어로 묻지만 정답은 영문 청구항, 일문 판결문, 중문 학술지에 흩어져 있습니다.
이 글에서는 다음의 4가지 순서대로 설명하고자 합니다.
- 다국어 키워드 매칭 (BM25 + 언어별 analyzer)
- 교차언어 검색(CLIR)
- 다국어 의미 유사도
- 문장→문헌 RRF 융합과 Cross-encoder 재랭킹
기본 RAG 파이프라인은 RAG 완전 가이드에서 다뤘으니, 이 글은 검색 단계의 깊이 에만 집중하려고 합니다.
1. 왜 "다국어 임베딩 + RAG" 만으로는 부족한가
전문 도메인 검색이 일반 검색과 다른 결정적 이유 세 가지가 있습니다.
(1) 어휘 자체가 곧 정답. 특허 청구항의 "a plurality of", 법률의 "제3자", 논문의 "p < 0.05" 는 의미가 비슷한 다른 표현으로 대체될 수 없습니다. 임베딩 모델은 "의미가 비슷한" 텍스트를 가까이 모으도록 학습되었기 때문에, 약어·고유명사·법조문 번호·화학식 같은 정확 일치 어휘 에서 오히려 약합니다.
(2) Recall 1.0이 목표. 일반 검색은 top-3에 정답이 있으면 만족하지만, 특허 선행기술 조사는 세상 어딘가에 존재할지 모르는 한 건 을 놓치지 않는 것이 목표입니다. Precision은 사람이 후속 검토로 보완할 수 있지만, recall 손실은 시스템이 문제점을 숨길 수 있는 상황이 연출됩니다.
(3) 단위가 문장이 아니라 문헌. 특허 한 건은 청구항 수십 개, 명세서 수백 단락으로 구성됩니다. 사용자가 원하는 것은 "가장 관련 있는 청구항 1개" 가 아니라 "가장 관련 있는 특허 1건" 입니다. 청크 단위로 점수를 매겨놓고 그대로 보여주면 같은 특허의 청크 5개가 상위를 점령해버립니다.
이 세 가지 특성 때문에 "다국어 임베딩 모델에 모든 문서를 던져넣고 top-k를 LLM에 붙이는" 단순 RAG 구성은 전문 도메인에서 한계를 드러냅니다.
정리: 전문 도메인 검색은 어휘 정확도 × 도메인 recall × 문헌 단위 집계 라는 세 좌표축 위에서 평가되어야 한다.
2. 도메인별 검색 요구사항 분해
같은 "전문 도메인 검색"이라도 단위·랭킹 신호·평가 기준이 셋 다 다릅니다. 시스템을 시작하기 전에 도메인별로 명세를 끊고 들어가야 합니다.
| 항목 | 특허 | 법률 | 논문 |
|---|---|---|---|
| 검색 단위 | 특허 1건 (출원번호) | 판례 1건 또는 법령 1조 | 논문 1편 (DOI) |
| 청크 단위 | 청구항, 명세서 단락 | 판시사항·이유 단락 | 초록·섹션·figure caption |
| 강제 매칭 신호 | IPC 분류, 출원인 | 법원·심급·연도·인용 판례 | 저자·저널·인용 그래프 |
| 결정적 어휘 | 청구항 키워드, 도면 부호 | 법조문 번호, 판례번호 | 학명, 수식, 약어 |
| Recall 기준 | 1건도 놓치면 안 됨 (무효) | 핵심 판례 누락 = 패소 | 핵심 선행연구 누락 |
| 다국어 패턴 | en/ko/jp/zh 병렬 출원 | 자국어 중심 + 영문 요약 | 영문 본문 + 비영어 초록 |
이 표를 기준으로 인덱스 스키마, 가중치, 평가셋을 따로 설계해야 합니다. 같은 검색 엔진을 쓰더라도 특허 인덱스 / 법률 인덱스 / 논문 인덱스 는 별도로 두는 것이 운영상 더 좋습니다.
3. 다국어 키워드 매칭: BM25를 다국어로 끌어올리기
BM25는 90년대 알고리즘이지만 전문 도메인에서는 여전히 가장 강한 방법 입니다. 다국어로 확장할 때 핵심은 알고리즘 자체 가 아니라 언어별 analyzer 선택과 필드 가중치 입니다.
3.1 언어별 analyzer 매핑
| 언어 | OpenSearch / Elasticsearch analyzer | 토큰화 전략 | 주의 |
|---|---|---|---|
| 한국어 | nori (OpenSearch 기본 내장) | 형태소 분석 + 복합명사 분해 | nori_part_of_speech 로 조사·어미 제거 |
| 일본어 | kuromoji | 형태소 + 가나/한자 정규화 | 신자체↔구자체 변환 필요 |
| 중국어 | smartcn 또는 ik | 단어 분절 | 번체↔간체 정규화 필요 (stconvert) |
| 영어 | standard + english stemmer | 어간 추출 + lowercase | 도메인 약어는 stemmer 끄기 |
| 독·불·서 | 각 언어 stemmer | 어간 추출 | 합성어 분해 (hyphenation_decompounder) |
3.2 같은 한국어 문장에 대한 토큰화 비교
같은 입력에 대해 analyzer가 무엇을 토큰으로 내보내는지 보면 왜 선택이 중요한지 알 수 있습니다.
입력: "본 발명은 무선 통신 시스템에서의 채널 추정 방법에 관한 것이다."
| Analyzer | 토큰 |
|---|---|
standard (영어 기본) | 본, 발명은, 무선, 통신, 시스템에서의, 채널, 추정, 방법에, 관한, 것이다 |
nori (default) | 본, 발명, 은, 무선, 통신, 시스템, 에서, 의, 채널, 추정, 방법, 에, 관한, 것, 이다 |
nori + POS filter | 발명, 무선, 통신, 시스템, 채널, 추정, 방법 |
| CJK bigram (fallback) | 본발, 발명, 명은, 은무, 무선, … (n-gram) |
standard 는 "시스템에서의" 를 한 토큰으로 묶어버려 "시스템" 으로는 검색이 안 됩니다. nori + POS 필터는 의미 단어만 7개로 압축돼 BM25 점수가 가장 안정적입니다. CJK bigram은 사전이 없는 신조어·고유명사 fallback으로 항상 함께 적용하는 것이 안전합니다.
3.3 OpenSearch 인덱스 매핑 예시 (특허)
{
"settings": {
"analysis": {
"analyzer": {
"ko_nori": {
"type": "custom",
"tokenizer": "nori_tokenizer",
"filter": ["nori_part_of_speech", "lowercase", "ko_synonyms"]
},
"ko_bigram": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "cjk_bigram"]
},
"en_domain": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "english_minimal_stem", "ipc_synonyms"]
}
},
"filter": {
"ko_synonyms": { "type": "synonym_graph", "synonyms_path": "synonyms/ko-patent.txt" },
"ipc_synonyms": { "type": "synonym_graph", "synonyms_path": "synonyms/ipc.txt" }
}
}
},
"mappings": {
"properties": {
"application_no": { "type": "keyword" },
"ipc": { "type": "keyword" },
"filing_date": { "type": "date" },
"applicant": { "type": "keyword", "fields": { "text": { "type": "text", "analyzer": "ko_nori" } } },
"title_ko": { "type": "text", "analyzer": "ko_nori",
"fields": { "bigram": { "type": "text", "analyzer": "ko_bigram" } } },
"title_en": { "type": "text", "analyzer": "en_domain" },
"claims_ko": { "type": "text", "analyzer": "ko_nori" },
"claims_en": { "type": "text", "analyzer": "en_domain" },
"abstract_ko": { "type": "text", "analyzer": "ko_nori" },
"abstract_en": { "type": "text", "analyzer": "en_domain" }
}
}
}핵심 두 가지: (a) 같은 필드를 두 analyzer로 인덱싱 (title_ko + title_ko.bigram), (b) 언어별 필드를 분리 (*_ko, *_en) 해서 쿼리 시 사용자의 언어에 맞춰 가중치를 다르게 줍니다.
3.4 BM25F: 필드 가중치는 도메인이 결정
청구항은 명세서보다 무겁고, 제목은 초록보다 무겁습니다. 다중 필드 BM25 쿼리는 도메인 지식을 가중치로 인코딩합니다.
{
"query": {
"multi_match": {
"query": "무선 통신 채널 추정",
"type": "best_fields",
"fields": [
"title_ko^4",
"title_ko.bigram^1.5",
"claims_ko^3",
"abstract_ko^2",
"title_en^2",
"claims_en^1.5"
],
"tie_breaker": 0.3
}
}
}가중치는 도메인 평가셋(7장 참조)에 대해 grid search로 결정합니다. "청구항이 명세서보다 1.5~3배 중요하다" 는 직관은 거의 모든 특허 도메인에서 재현됩니다.
정리: 다국어 BM25 품질은 알고리즘이 아니라 analyzer × 필드 분할 × 동의어 사전 으로 결정된다.
4. 쿼리·번역·교차언어 매칭 (CLIR)
사용자가 한국어로 "무선 통신 채널 추정" 이라고 입력했을 때, 영문 특허 "channel estimation in wireless communication" 을 찾아야 합니다. 접근법은 다음과 같습니다.
| 전략 | 방식 | 장점 | 단점 |
|---|---|---|---|
| (A) Query Translation | 쿼리를 모든 인덱스 언어로 번역 후 각각 BM25 | 인덱스를 건드리지 않음, 단순 | 짧은 쿼리 번역 품질이 낮음 |
| (B) Document Translation | 모든 문서를 모든 언어로 사전 번역 후 단일 인덱스 | 검색 시 가장 빠름 | 인덱스 N배, 번역 비용 큼 |
| (C) Cross-lingual Embedding | 언어 무관 임베딩 공간에서 dense 검색 | 쿼리·문서 모두 단일 표현 | 정확 일치 어휘 손실 |
실전 권장: (A) + (C) 의 하이브리드. 쿼리는 도메인 사전(translation memory)으로 1차 번역, dense는 별도 다국어 인코더로 보완하고, 두 결과를 6장의 RRF로 융합합니다. (B)는 청구항·법조문처럼 번역 자체가 법적 의미를 바꿀 수 있는 영역에서는 피하는 편이 좋습니다.
4.1 도메인 사전 기반 쿼리 확장
LLM 번역은 "채널 추정" 을 "channel guess" 로 잘못 옮길 수 있습니다. 도메인 용어 사전(glossary)을 강제 매핑으로 하는 것이 더 안전합니다.
GLOSSARY_KO_EN = {
"채널 추정": ["channel estimation"],
"무선 통신": ["wireless communication", "radio communication"],
"직교 주파수 분할 다중화": ["OFDM", "orthogonal frequency-division multiplexing"],
"제3자": ["third party"],
"선행기술": ["prior art"],
}
def expand_query(q_ko: str) -> dict:
en_terms = []
for ko_term, en_list in GLOSSARY_KO_EN.items():
if ko_term in q_ko:
en_terms.extend(en_list)
return {"ko": q_ko, "en": " ".join(en_terms) or None}확장된 쿼리는 3.3절 매핑의 *_ko / *_en 필드에 각각 매치시켜 single multi-match 쿼리로 보냅니다.
4.2 보존해야 할 토큰
번역기에 절대 통과시키면 안 되는 토큰들입니다.
- 화학식·수식:
H2SO4,O(n log n) - 약어·고유명사:
OFDM,LSTM,K-NN - 법조문·판례번호:
대법원 2019다12345 - IPC/CPC 분류:
H04L 27/26 - 도면 부호:
100,100a
정규식으로 마스킹 → 번역 → 언마스킹 하는 전처리를 사이에 끼우는 것이 정석입니다.
5. 다국어 의미 유사도
키워드 검색만으로는 "채널 추정" 과 "채널 추정의 방법", "무선 채널 식별" 같은 표현 차이를 메우지 못합니다. 여기서 dense 검색이 들어옵니다. 단, 다국어 임베딩 모델 선택은 RAG 일반 가이드보다 까다롭습니다. 자세한 모델 비교는 임베딩 모델 가이드를 참고하고, 여기서는 도메인 검색 관점의 선택 기준만 정리합니다.
5.1 다국어 임베딩 모델 후보
| 모델 | 차원 | 지원 언어 | 강점 | 약점 |
|---|---|---|---|---|
| LaBSE | 768 | 109 | 짧은 쿼리·문장 정렬, BERT 기반 안정성 | 긴 문서 손해 |
| multilingual-e5-large | 1024 | 100+ | 쿼리/문서 비대칭 학습 (query: / passage: prefix) | prefix 강제 |
| BGE-M3 | 1024 | 100+ | dense + sparse + multi-vector 동시 출력 | 인덱싱 복잡 |
| Cohere embed-multilingual-v3 | 1024 | 100+ | API, 상용 품질 | 외부 호출 |
전문 도메인에서의 기본 선택: BGE-M3. 같은 방식으로 dense 벡터·sparse(lexical) 벡터·multi-vector(ColBERT 스타일) 세 가지를 한 번에 받아낼 수 있어, 키워드와 의미를 동일 모델에서 추출할 수 있습니다.
5.2 비대칭 길이 문제
특허 청구항은 한 문장이 500자를 넘기는 경우가 흔하고, 사용자 쿼리는 10자 미만입니다. 이 비대칭은 두 방향으로 해결합니다.
(a) 청크 단위 통일: 청구항을 마침표 + 세미콜론 으로 한 번 더 잘라 100200 토큰 단위로 통일.
(b) 쿼리 확장: 짧은 쿼리는 LLM으로 12 문장 hypothetical answer를 생성한 뒤 그것을 임베딩(HyDE).
def hyde_expand(query: str, llm) -> str:
prompt = f"""Write a one-paragraph patent abstract that would directly answer:
"{query}"
Use technical vocabulary. Do not add disclaimers."""
return llm.complete(prompt)
q_vec = embed(hyde_expand(user_query, llm))HyDE는 짧은 쿼리의 dense recall을 평균 8~15%p 끌어올립니다(BEIR/도메인 평가셋 기준).
5.3 도메인 적응
베이스 모델만으로는 IPC·법조문·학명 같은 도메인 어휘에서 부족합니다. 두 가지 옵션이 있습니다.
- LoRA fine-tune: 도메인 (query, positive doc, negative doc) 트리플을 1만
10만 건 수집해 contrastive loss로 12 epoch. 일반적으로 nDCG@10 5~10%p 향상. - In-context query rewriting: 모델은 그대로, 쿼리 전처리에서 도메인 사전·약어 풀어쓰기.
도메인 데이터가 부족할 때는 후자로 시작하고, 평가셋이 1만 건을 넘으면 전자로 넘어갑니다.
6. 문장→문헌 RRF 융합 전략 (이 글의 핵심)
3장(BM25), 5장(dense)에서 각각 top-K를 받았다고 합시다. 이제 두 문제가 남습니다.
- 점수 스케일이 다른 두 결과를 어떻게 결합 할 것인가
- 문장 단위로 검색해 같은 문헌의 청크가 중복으로 나올 때, 어떻게 문헌 단위 랭킹 으로 집계할 것인가
답은 둘 다 RRF(Reciprocal Rank Fusion) 입니다. 점수가 아닌 순위 만 사용하기 때문에 스케일이 다른 시스템을 안전하게 합칠 수 있고, 같은 문헌의 여러 청크 순위를 더하면 자연스럽게 문헌 단위 점수가 됩니다.
6.1 RRF 수식
RRF(d) = Σ 1 / (k + rank_r(d))
r ∈ R(d)d: 문서(혹은 문장 청크)R(d):d가 등장한 모든 랭킹 시스템k: 평탄화 상수, 보통 60rank: 1부터 시작
6.2 전체 파이프라인
전체 흐름을 다이어그램으로 정리하면 다음과 같습니다.
핵심 포인트는 문장 단위로 검색하고 문헌 단위로 집계 한다는 점입니다. BM25/dense 모두 청크에 점수를 매겨 top-100을 뽑고, 같은 application_no (특허) / case_id (판례) / doi (논문) 를 가진 청크들의 순위를 RRF로 합쳐 문헌 점수를 만듭니다.
6.3 RRF 구현
from collections import defaultdict
from typing import Iterable
def rrf_fuse(
rankings: list[list[tuple[str, str]]], # list of [(doc_id, chunk_id), ...]
k: int = 60,
weights: list[float] | None = None,
) -> list[tuple[str, float, list[str]]]:
"""문장 청크 순위 → 문헌 단위 RRF 점수.
rankings 각 리스트는 rank 순서로 정렬되어 있다고 가정한다.
"""
weights = weights or [1.0] * len(rankings)
doc_score: dict[str, float] = defaultdict(float)
doc_chunks: dict[str, list[str]] = defaultdict(list)
for ranking, w in zip(rankings, weights):
seen_in_run: set[str] = set()
for rank, (doc_id, chunk_id) in enumerate(ranking, start=1):
# 한 런(run) 안에서 같은 문헌의 두 번째 이후 청크는
# 약하게만 더해주고 싶다면 여기서 decay를 줄 수도 있다.
doc_score[doc_id] += w / (k + rank)
if chunk_id not in doc_chunks[doc_id]:
doc_chunks[doc_id].append(chunk_id)
fused = sorted(
((d, s, doc_chunks[d]) for d, s in doc_score.items()),
key=lambda x: x[1],
reverse=True,
)
return fused6.4 가중치와 k 튜닝
k=60 은 원논문(Cormack et al., 2009) 기본값이고 대부분 잘 동작합니다. 가중치는 도메인 평가셋에 대해 [1.0, 1.0, 1.0] 부터 시작해 [BM25, expanded BM25, dense] = [1.0, 0.6, 1.2] 정도까지 grid search 하는 것이 일반적입니다.
도메인별 경향:
- 특허: BM25 가중치를 1.2~1.5로 약간 높게 (어휘 정확도가 중요)
- 법률: BM25와 dense를 비슷하게 (조문 인용은 정확, 사실관계는 의미 검색)
- 논문: dense를 1.3~1.5로 (저자가 다른 표현을 쓰는 경우가 많음)
6.5 Cross-encoder 재랭킹
RRF 후 top-20~50만 cross-encoder에 다시 태웁니다. cross-encoder는 (query, passage) 쌍을 하나의 트랜스포머에 통과시켜 직접 점수를 내므로 비싸지만 정확합니다. 도메인 검색의 최종 nDCG는 거의 항상 cross-encoder를 켰을 때가 가장 높습니다.
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3", max_length=512)
def rerank(query: str, candidates: list[dict], top_n: int = 20) -> list[dict]:
# candidates: [{"doc_id": ..., "best_chunk_text": ...}, ...]
pairs = [(query, c["best_chunk_text"]) for c in candidates]
scores = reranker.predict(pairs, batch_size=32)
for c, s in zip(candidates, scores):
c["rerank_score"] = float(s)
return sorted(candidates, key=lambda x: x["rerank_score"], reverse=True)[:top_n]문헌 단위 cross-encoder 점수를 낼 때는 문헌 전체 가 아니라 해당 문헌에서 RRF 순위가 가장 높았던 청크 1개 를 대표로 넣는 것이 비용/정확도 균형상 가장 좋습니다.
정리: 점수 결합은 RRF, 단위 변환도 RRF, 마지막 정확도 끌어올림은 cross-encoder. 이 세 단계가 도메인 검색 품질의 80%를 결정한다.
7. 평가 — 도메인 검색을 어떻게 측정할 것인가
평가셋 없는 검색 튜닝은 도박과 같습니다. 도메인 검색 평가는 일반 IR과 두 가지가 다릅니다.
(a) 정답이 1건이 아니다. 한 쿼리에 관련 특허 50건이 있을 수 있습니다. binary relevance가 아니라 graded relevance(0/1/2/3)가 필요합니다.
(b) 도메인 지표가 따로 있다. "무효 가능성이 있는 prior art를 몇 % 잡았는가", "핵심 판례 Top-N에 포함되었는가" 같은 사업 지표를 별도로 추적해야 합니다.
7.1 핵심 지표
| 지표 | 정의 | 도메인 적용 |
|---|---|---|
| Recall@k | top-k에 정답이 몇 % 포함되는가 | 특허 prior art는 Recall@100 |
| nDCG@10 | graded relevance 가중 누적 이득 | 사용자 만족도 근사 |
| MRR | 첫 정답의 역순위 평균 | "한 건 빨리 찾기" UX |
| MAP | 평균 precision | 균형 잡힌 단일 숫자 |
| Coverage@k | 도메인별 must-have 문헌이 top-k에 들어왔나 (커스텀) | 법률 핵심 판례 hit률 |
7.2 다국어 평가 벤치마크
- MIRACL: 18개 언어 ad-hoc retrieval. 다국어 dense 모델 일반 성능 비교에 유용. 한국어 포함.
- mMARCO: MS MARCO를 13개 언어로 번역. 학습용으로 더 유용.
- BEIR: 영어 14개 도메인. 도메인 일반화를 보는 표준.
이 세 벤치마크는 일반화 측정용입니다. 도메인 최종 평가는 자체 평가셋이 필수입니다. 최소 200~500 쿼리, 각 쿼리당 평가자 2명 이상의 graded relevance가 권장됩니다.
7.3 평가 코드 (단순 예시)
import math
def ndcg_at_k(gains: list[int], k: int) -> float:
dcg = sum((2**g - 1) / math.log2(i + 2) for i, g in enumerate(gains[:k]))
ideal = sorted(gains, reverse=True)[:k]
idcg = sum((2**g - 1) / math.log2(i + 2) for i, g in enumerate(ideal))
return dcg / idcg if idcg > 0 else 0.0
def recall_at_k(retrieved_ids: list[str], gold_ids: set[str], k: int) -> float:
if not gold_ids:
return 0.0
return len(set(retrieved_ids[:k]) & gold_ids) / len(gold_ids)8. 레퍼런스 아키텍처: 두 스택 비교
운영 환경에서 자주 쓰는 두 조합을 같은 기능 단위로 비교합니다. 어느 쪽을 골라도 6장의 파이프라인은 그대로 적용됩니다.
| 책임 | 스택 A: OpenSearch + Qdrant | 스택 B: Elasticsearch + Milvus |
|---|---|---|
| Sparse(BM25) 인덱스 | OpenSearch (Apache 2.0, nori 내장) | Elasticsearch (ELv2, kuromoji/nori 플러그인) |
| Dense 인덱스 | Qdrant (Rust, payload 필터 강함) | Milvus (대규모, GPU 인덱스 지원) |
| Rerank | OpenSearch ML Commons 또는 외부 서비스 | ES inference API 또는 외부 서비스 |
| 운영 모델 | AWS OpenSearch Service, self-host | Elastic Cloud, Zilliz Cloud |
| 라이선스 | 모두 OSS-friendly | ES는 ELv2 / Milvus는 Apache 2.0 |
8.1 스택 A: OpenSearch + Qdrant
Dense: Qdrant에 청크 적재
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
qc = QdrantClient(url="http://qdrant:6333")
qc.recreate_collection(
collection_name="patent_chunks",
vectors_config=VectorParams(size=1024, distance=Distance.COSINE),
)
points = [
PointStruct(
id=f"{doc['application_no']}#{chunk['idx']}",
vector=embed(chunk["text"]),
payload={
"application_no": doc["application_no"],
"lang": chunk["lang"],
"field": chunk["field"], # "claim" | "abstract" | "spec"
"ipc": doc["ipc"],
"filing_date": doc["filing_date"],
"text": chunk["text"],
},
)
for doc, chunk in iter_chunks()
]
qc.upsert(collection_name="patent_chunks", points=points)Sparse: OpenSearch 멀티 매치
from opensearchpy import OpenSearch
os_client = OpenSearch(hosts=["https://opensearch:9200"])
def bm25_search(q_ko: str, q_en: str | None, size: int = 100):
fields = ["title_ko^4", "claims_ko^3", "abstract_ko^2"]
if q_en:
fields += ["title_en^3", "claims_en^2", "abstract_en^1.5"]
body = {
"size": size,
"_source": ["application_no", "field_id"],
"query": {
"multi_match": {
"query": q_ko if not q_en else f"{q_ko} {q_en}",
"type": "best_fields",
"fields": fields,
"tie_breaker": 0.3,
}
},
}
res = os_client.search(index="patent_chunks", body=body)
return [(h["_source"]["application_no"], h["_source"]["field_id"])
for h in res["hits"]["hits"]]Dense 검색 + RRF 결합
def dense_search(q_vec, size: int = 100, ipc_filter: list[str] | None = None):
flt = {"must": [{"key": "ipc", "match": {"any": ipc_filter}}]} if ipc_filter else None
res = qc.search(
collection_name="patent_chunks",
query_vector=q_vec,
limit=size,
query_filter=flt,
)
return [(p.payload["application_no"], p.id) for p in res]
def hybrid_search(query: str, llm, embedder, ipc_filter=None, top_n=20):
expanded = expand_query(query) # 4.1
bm25_a = bm25_search(expanded["ko"], None)
bm25_b = bm25_search(expanded["ko"], expanded["en"])
dense = dense_search(embedder.encode(hyde_expand(query, llm)), ipc_filter=ipc_filter)
fused = rrf_fuse([bm25_a, bm25_b, dense], k=60, weights=[1.0, 0.6, 1.2])
candidates = build_candidates(fused[:top_n * 3]) # best chunk per doc
return rerank(query, candidates, top_n=top_n)8.2 스택 B: Elasticsearch + Milvus
같은 기능을 Elasticsearch 8.x의 hybrid 쿼리와 Milvus 2.x로 구현하면 다음과 같습니다.
Milvus 컬렉션 정의
from pymilvus import MilvusClient, DataType
mc = MilvusClient(uri="http://milvus:19530")
schema = mc.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field("id", DataType.VARCHAR, is_primary=True, max_length=128)
schema.add_field("application_no", DataType.VARCHAR, max_length=32)
schema.add_field("lang", DataType.VARCHAR, max_length=4)
schema.add_field("field", DataType.VARCHAR, max_length=16)
schema.add_field("ipc", DataType.VARCHAR, max_length=16)
schema.add_field("text", DataType.VARCHAR, max_length=2048)
schema.add_field("vector", DataType.FLOAT_VECTOR, dim=1024)
mc.create_collection(collection_name="patent_chunks", schema=schema)
mc.create_index(
collection_name="patent_chunks",
index_params=[{"field_name": "vector", "index_type": "HNSW",
"metric_type": "COSINE", "params": {"M": 16, "efConstruction": 200}}],
)Elasticsearch: BM25 + kNN을 한 쿼리에서 (RRF는 자체 구현 권장)
ES 8.x는 rank: { rrf: ... } 를 지원하지만, 문장→문헌 집계는 자체 구현이 필요하므로 두 검색을 별도로 받아 Python 측에서 RRF를 돌리는 편이 단순합니다.
from elasticsearch import Elasticsearch
es = Elasticsearch("https://es:9200")
def bm25_search_es(q_ko, q_en, size=100):
body = {
"size": size,
"_source": ["application_no", "chunk_id"],
"query": {
"multi_match": {
"query": f"{q_ko} {q_en or ''}".strip(),
"fields": ["title_ko^4", "claims_ko^3", "abstract_ko^2",
"title_en^3", "claims_en^2"],
"tie_breaker": 0.3,
}
},
}
res = es.search(index="patent_chunks", body=body)
return [(h["_source"]["application_no"], h["_source"]["chunk_id"])
for h in res["hits"]["hits"]]
def dense_search_milvus(q_vec, size=100):
res = mc.search(
collection_name="patent_chunks",
data=[q_vec],
limit=size,
output_fields=["application_no"],
search_params={"metric_type": "COSINE", "params": {"ef": 128}},
)[0]
return [(hit["entity"]["application_no"], hit["id"]) for hit in res]이후 rrf_fuse() → rerank() 흐름은 스택 A와 동일합니다.
8.3 두 스택 선택 기준
| 상황 | 추천 |
|---|---|
| 한국어 BM25 비중이 높다 | 스택 A (OpenSearch nori 내장이 안정적) |
| 1억 청크 이상, GPU 인덱스 필요 | 스택 B (Milvus가 대규모에 강함) |
| AWS 단일 클라우드 | 스택 A (OpenSearch Service + 자체 Qdrant) |
| 이미 Elastic 라이선스 보유 | 스택 B |
| 페이로드 필터(IPC·법원·연도)가 매우 다양 | 스택 A (Qdrant payload index가 유연) |
8.4 운영 함정 체크리스트
- 한국어 두음법칙(
리어카/이어카)·외래어 표기(데이타/데이터) — synonym 사전에 양방향 등록 - 일본어 신자체/구자체(
国/國) —icu_normalizer - 중국어 번체/간체(
資料/资料) —stconvert필터 - 영문 약어 stemming 사고 (
SAS→sa) —keyword_marker로 보호 - 도면 부호·청구항 번호가 토큰화되며 사라지는 문제 —
word_delimiter_graph옵션 점검 - 인덱스 alias 운영 — 재인덱싱은 alias 스와프로 무중단
- dense 벡터 재계산 정책 — 임베딩 모델 버전을 메타데이터에 박아두기
- RRF k 값을 production에 박지 말 것 — 설정 파일로 분리
마치며
다국어 전문 도메인 검색은 "좋은 임베딩 모델 하나" 의 문제가 아닙니다. 언어별 analyzer → 도메인 사전 → 다국어 BM25 → 다국어 dense → 문헌 단위 RRF → cross-encoder 재랭킹 이라는 여섯 단계가 모두 제 역할을 해야 비로소 검색이 만들어집니다.
관련 글