📚 시리즈 — 한국어 RAG 검색 (Kiwi)
- ① 한국어 BM25·토크나이저 선택과 Kiwi 기본 설정
- ② 사용자·동의어 사전과 복합명사 처리
- ③ 검색 품질 개선·사전 관리·Vector Search 통합 — 현재 글
- ④ 부록 — 동의어 적용 위치·IATA 사전 생성/검증·품사·Nori 비교
8. 검색 품질 개선 기능
Kiwi 가 제공하는 옵션·API 중 검색 품질에 직접 기여하는 기능만 정리합니다. 품질에 영향이 없거나 미미한 옵션(예: Token 메타데이터 출력) 은 본 절에서 제외합니다.
8.1 언어모델 (model_type) — KNLM · SBG · CoNg
적용 시점: 양쪽 동일 — 본 가이드 표준
'cong'| 기본값:'none'(자동 선택)
| model_type | 정확도 | 속도 | 메모리 | 권장 시나리오 |
|---|---|---|---|---|
'knlm' |
기준선 | 가장 빠름 | 작음 | 대량 배치 색인 (정확도 < 처리량) |
'sbg' |
원거리 형태소 관계 보강 | KNLM 대비 ~30% 느림 | KNLM 대비 큼 | 긴 문장·문서 |
'cong' |
가장 높음 (모호성 해소) | KNLM 대비 약간 느림, ARM NEON 으로 +20% | 중간 | 본 가이드 표준 (v0.22+ 기본 포함) |
'cong-global' |
더 높음 | cong 대비 ~2배 |
큼 | 동음이의어 결정적 도메인 (v0.22+ 기본 포함) |
'none' / 'largest' |
— | — | — | 자동 선택 — 사용 가능한 가장 빠른 / 가장 큰 모델 자동 |
'cong'·'cong-global' 은 kiwipiepy v0.21.0 에서 도입되어 v0.22.0 부터 기본 배포 패키지에 포함 됩니다. 반면 'knlm'·'sbg' 는 v0.22+ 에서 기본 패키지에서 제외 되어 사용하려면 별도 다운로드가 필요합니다. x86-64 외 아키텍처에서는 최적화 커널이 제공되지 않으므로 운영 컨테이너 CPU 아키텍처 확인 후 채택하세요 (kiwipiepy 매뉴얼).
kiwi = Kiwi(model_type='cong')
8.2 오타 교정 (TypoTransformer) — v0.23.0+
적용 시점: 검색만 ON — 인덱싱 OFF (처리시간 ~2배 회피) | 기본값:
typos=None(OFF),typo_cost_threshold=2.5
| typos (프리셋) | 의미 |
|---|---|
'basic' |
형태소 내 단일 음절 오타 |
'continual' |
형태소 경계 연철 ("채글" → "책을") |
'lengthening' |
음절 늘림 ("지이인짜" → "진짜") |
'basic_with_continual' |
basic + continual |
'basic_with_continual_and_lengthening' |
세 가지 모두 |
typo_cost_threshold — 누적 교정 cost 상한. 높일수록 recall ↑ / 비용 ↑.
from kiwipiepy import Kiwi, basic_typos_with_continual
# 검색 시점: 사용자 쿼리 토큰화 — typos 인자 활성화
tokens_search = kiwi.tokenize(query, typos=basic_typos_with_continual, typo_cost_threshold=2.5)
8.3 종성 정규화 (normalize_coda)
적용 시점: 양쪽 동일 ON | 기본값:
normalize_coda=True(ON)
"되요/돼요", "안되/안돼" 같은 모바일·구어 종성 변이를 표준형으로 정규화합니다.
tokens = kiwi.tokenize("화물 보낼 수 있나요?", normalize_coda=True)
# "있나요" → 정규화된 분석으로 인덱싱·검색 양쪽 동일 토큰
8.4 매칭 옵션 (match_options)
적용 시점: 양쪽 동일 ON | 기본값:
Match.ALL(URL·EMAIL·SERIAL 등 모든 옵션 ON)
URL·EMAIL·SERIAL(전화번호 등 다중-하이픈 패턴), 해시태그·멘션 등을 별도 토큰으로 보존합니다. 기본 Match.ALL 유지 권장.
tokens = kiwi.tokenize("문의는 cs@koreanair.com 또는 010-1234-5678 로", match_options=Match.ALL)
# "cs@koreanair.com" (EMAIL), "010-1234-5678" (W_SERIAL) 이 별도 토큰으로 보존됨
항공 코드 (
KE001·B777·UN3480) 는 W_SERIAL 안 됨 — 실측 결과 Kiwi 가 영문 prefix + 숫자 패턴을 SL+SN 으로 분리. W_SERIAL 은 다중-하이픈 패턴 (전화번호 등) 만. 항공 코드 통째 토큰화는 §7.5 사용자 사전 NNP 등록 패턴 사용.
9. 사용자·동의어 사전 관리
본 절은 사용자·동의어 사전을 Databricks 환경에서 관리·배포하는 표준 패턴입니다. 인덱싱(§10.1 Pandas UDF) 과 검색(§10.2 Model Serving) 양쪽이 §10.3 공유 모듈을 import 해 동일 사전을 일관되게 사용한다는 요구가 전제입니다.
9.1 사전 형식 선택
| 사전 | 표준 형식 | 비고 |
|---|---|---|
| 사용자 사전 | TSV (Kiwi 정식) | form\ttag\tscore 3 컬럼, 이형태는 form\torig/tag\tscore |
| 동의어 사전 | CSV (OpenSearch Solr synonym 형식) | 한 라인 = 한 규칙. equivalent(콤마, 양방향) + directional(=>, 단방향) 두 모드 |
Kiwi 사용자 사전 TSV 스펙
# 컬럼: form\ttag\tscore (3컬럼 기본)
# 컬럼: form\torig/tag\tscore (이형태 변형)
# 컬럼: form\tm1/t1 + m2/t2 + ... (기분석)
UTF-8, Tab 구분, # 주석, score 실수(기본 0, 이형태 -3.0 ~ -5.0), tag 는 세종 태그. 4 컬럼 형식 금지 (§5.5 함정 정본 참조).
동의어 사전 CSV 스펙
본 가이드는 OpenSearch CSV (Solr synonym 형식) 를 동의어 사전 단일 표준으로 채택하며, 두 모드를 함께 지원합니다.
두 가지 모드 (Solr synonym 형식과 동일):
- equivalent (콤마) —
수하물,짐,캐리어. 모든 어휘가 상호 등가(양방향). 어느 어휘로 검색해도 그룹 전체가 매칭됩니다. 대부분의 동의어가 이 모드입니다. - directional (
=>) —대한항공,코리안에어 => KE,KAL. 좌측(LHS) 어휘로 검색하면 우측(RHS)을 원어 유지 + OR 추가 하고, 역방향은 없습니다. 한 방향으로만 확장할 때(포괄어→세부어 등) 사용합니다. 개념·근거는 §6.6.
작성 규칙:
| # | 규칙 | 예시 | 위반 시 |
|---|---|---|---|
| 1 | UTF-8 저장, LF 권장 — 한국어 안전성 | 수하물,짐,캐리어 (Excel·메모장 어떤 도구로 저장해도 동작) |
파일 자체 오류 → raise |
| 2 | # 으로 시작하는 라인은 주석 (무시) |
# A항공 화물 도메인 — 2026-05-21 |
— |
| 3 | 한 라인 = 한 규칙 (빈 라인은 무시) | 수하물,짐,캐리어AWB,운송장 (2 규칙) |
— |
| 4 | equivalent 모드 — 컴마(,) 로 어휘 나열, 모두 서로 같은 뜻 (양방향) |
수하물,짐,캐리어 (어느 어휘로 검색해도 매칭) |
— |
| 5 | 그룹 크기 최대 15 어휘 (16+ 는 OR 확장 폭발·의미 희석) | OK: 수하물,짐,캐리어 (3 어휘)위반: 16 어휘 이상 |
그룹 skip + WARNING (1-원소 행은 조용히 무시) |
| 6 | 한 어휘(정규 키)가 여러 규칙에 등장 금지 — equivalent 멤버·directional LHS 공통 | 위반: 1줄 수하물,짐,캐리어 + 2줄 짐,가방,수트케이스 (짐 중복) |
후행 규칙 skip + WARNING (선행 보존) |
| 7 | 다중 토큰 허용 (1어휘 최대 5 토큰) — 공백 포함 가능. load_synonyms 가 검색용 Kiwi 로 재토큰화해 소문자 형태소 공백조인 을 정규 키로 만듭니다 (붙임/띄움 동일 키로 수렴). 매칭 알고리즘·근거는 §6.5 |
OK: 위탁 수하물, 체크인 수하물 / 위탁수하물(붙임도 키 위탁 수하물 로 수렴) |
6 토큰 초과·재토큰화 후 빈 결과 그룹 skip + WARNING |
| 8 | directional 모드 — LHS => RHS (줄당 => 1개). LHS 어휘만 키 등록, 매칭 시 원어 유지 + RHS 추가. 역방향 없음 (§6.6) |
대한항공,코리안에어 => KE,KAL |
=> 2개↑·좌/우 한쪽 빔·합 16어휘↑ 시 규칙 skip + WARNING |
9.2 UC Volume 권장 디렉토리 트리
/Volumes/airag_<env>/dict/ # MANAGED volume, env ∈ {dev, staging, prd}
├── user_dict/
│ ├── current/ # 인덱싱·검색이 읽는 안정 버전
│ │ ├── airag_iata.tsv # IATA 항공사·공항 코드
│ │ ├── airag_aircraft.tsv # 기재 코드 (B777 등)
│ │ ├── airag_abbrev.tsv # 약어 (DGR, AVI 등)
│ │ ├── airag_compound.tsv # 복합명사 NNG화
│ │ └── airag_preanalyzed.tsv # 기분석 (add_pre_analyzed_word 동등)
│ │ # 카테고리 개수는 고정 아님 — airag_*.tsv glob 패턴에 맞으면 자동 로드
│ └── v20260517/ # 운영자 수동 백업 (§9.3, 회귀용)
├── synonyms/
│ ├── current/
│ │ ├── airag_cargo.csv # 화물 도메인 (Solr equivalent 모드)
│ │ ├── airag_pax.csv # 여객
│ │ ├── airag_booking.csv # 예약·발권
│ │ └── ... # airag_*.csv glob 자동 로드 (namespace 분리)
│ └── v20260517/
권한 분리 — Model Serving 은 자동 OAuth, 그 외만 명시 GRANT:
| 주체 | 사전 접근 시점 | 필요 권한 |
|---|---|---|
| 인덱싱 Job SP | 인덱싱 런타임 (UC Volume FUSE → 사용자 사전 TSV) | USE CATALOG + USE SCHEMA + READ VOLUME |
| log_model 호출 주체 (CI Job 의 SP 또는 개발자/운영자 계정) | 모델 등록 시점 (UC Volume → 사전·동의어 → artifact 동봉) | USE CATALOG + USE SCHEMA + READ VOLUME |
| Model Serving endpoint SP (system-assigned) | 런타임 접근 없음 (artifact 가 모델 이미지에 prebake). VS·임베딩은 resources=[...] 로 자동 OAuth |
불필요 |
따라서 GRANT 는 위 두 주체 (인덱싱 실행 주체 + log_model 호출 주체) 에만 명시 — Model Serving endpoint SP 는 대상 아님:
-- <subject> = 인덱싱 Job SP id 또는 log_model 호출자 SP id (또는 group)
GRANT USE CATALOG ON CATALOG airag_prd TO `<subject>`;
GRANT USE SCHEMA ON SCHEMA airag_prd.dict TO `<subject>`;
GRANT READ VOLUME ON VOLUME airag_prd.dict.user_dict TO `<subject>`; -- 사용자 사전 TSV
GRANT READ VOLUME ON VOLUME airag_prd.dict.synonyms TO `<subject>`; -- 동의어 CSV (log_model 호출자만 필요, 인덱싱은 불필요하지만 운영 단순화로 함께 부여 가능)
-- dev / staging 환경에도 동일 패턴 (`airag_dev.dict`, `airag_staging.dict`) 적용
9.3 버저닝 — 운영자 수동 백업
본 가이드는 자동 버저닝 (심볼릭 current 포인터·Delta Time Travel 등) 을 강제하지 않습니다. 사전 변경 빈도가 낮고 Model Registry 가 artifact 동봉으로 사전 스냅샷을 간접 보존 하므로 자동 버저닝 추가 가치가 작습니다.
운영자 수동 백업 — 사전 PR 머지 직전에 current/ 를 v<YYYYMMDD>/ 디렉토리로 복사:
# 백업 (PR 머지 전)
databricks fs cp -r /Volumes/airag_prd/dict/synonyms/current /Volumes/airag_prd/dict/synonyms/v20260521
# 복구 (회귀 발견 시)
databricks fs cp -r /Volumes/airag_prd/dict/synonyms/v20260521 /Volumes/airag_prd/dict/synonyms/current
Model Registry version (모델 v1·v2·v3·...) 이 그 시점의 사전 스냅샷 으로 1:1 대응 — 별도 사전 버전 식별자는 두지 않습니다.
9.4 사전 갱신 — 재배포로 반영
본 가이드는 hot reload 를 지원하지 않습니다. 사전 변경 빈도가 낮고 immutable 패턴 (§10) 과 잘 맞습니다. 인덱싱과 검색의 재배포 방식이 다릅니다 — 각각 §10 정본 참조:
| 영역 | 재배포 방식 | 정본 |
|---|---|---|
| 인덱싱 (Pandas UDF Job) | 사전 변경 → Job 재실행 → 새 Delta 테이블 재생성. Job 시작 시점에 새 사전 자동 로드 (UC Volume FUSE 직접 read) | §10.1 사전 갱신 → 다음 Job 자동 반영 |
| 검색 (Model Serving) | 사전 변경 → log_model 호출 주체가 새 사전을 artifact 동봉 → 새 model version 등록 → serving-endpoints update-config → rolling update |
§10.2 사전 갱신 → 다음 배포 자동 반영 |
운영자 수동 백업은 §9.3.
9.5 샘플 파일
본 절은 최소 예시입니다. OurAirports + OpenFlights 무료 소스 기반 운영급 사전 구축 절차와 sandbox 성능·정확도 검증 사례는 부록 B 참조.
airag_iata.tsv — IATA 항공사·공항 코드
# ⚠ 행 순서 규칙: NNP 등록 행이 이형태 매핑 행보다 반드시 위에 있어야 합니다.
# 역순이면 load_user_dictionary 가 ValueError("cannot find the original morpheme") 로 실패.
# ⚠ 라틴 코드 score: 양수 권장 (라틴 SL 기본 우선순위에 묻히지 않도록)
KE NNP 5.0
KAL NNP 5.0
ICN NNP 5.0
GMP NNP 5.0
7C NNP 5.0
JJA NNP 5.0
OZ NNP 5.0
AAR NNP 5.0
# 이형태 매핑 (Kiwi 내부 base_form 표시 — BM25 토큰 정규화 효과는 없음.
# `대한항공` → `KE` 정규화는 `airag_*.csv` equivalent 그룹으로 표현하세요 (예: KE,대한항공,KAL).)
대한항공 KE/NNP -5.0
제주항공 7C/NNP -5.0
아시아나항공 OZ/NNP -5.0
airag_compound.tsv — 복합명사 NNG화
# split_complex=True 에 의해 분해되지 않으므로
# 정책 B (혼합 색인) 를 쓸 때는 add_pre_analyzed_word 동등 행도 함께 등록 권장 (§5.3)
초과수하물 NNG -2.0
위탁수하물 NNG -2.0
# 기분석 (불규칙 활용) — add_pre_analyzed_word 와 동등 동작
탑승하시겠습니다 탑승/NNG + 하/XSV + 시/EP + 겠/EP + 습니다/EF -1.0
airag_cargo.csv (동의어 사전 — OpenSearch Solr equivalent 모드, 도메인별 namespace)
# A항공 화물 도메인 동의어 (§9.1 작성 규칙)
# 한 라인 = 한 규칙. 어휘 구분 = 컴마. 두 모드: equivalent(콤마, 양방향) · directional(=>, 단방향).
# 어휘는 공백 포함 다중 토큰 허용 (1어휘 최대 5 토큰). load_synonyms 가 재토큰화로 정규 키 생성 (§6.5).
# equivalent(양방향) — 일반 어휘 그룹
수하물,짐,캐리어,가방
마일리지,마일,스카이패스,포인트
환불,환급,리펀드
위험물,위험품,DGR,위험물질,DG
반려동물,펫,동물,AVI
# IATA 약어 ↔ 한국어 풀이름
AWB,에어웨이빌,운송장
ULD,단위적재용기
KE,대한항공,KAL
7C,제주항공,JJA
OZ,아시아나항공,AAR
# 복합명사 — 붙임/띄움 모두 가능 (재토큰화로 동일 키 수렴 — 예: 위탁수하물 = 위탁 수하물)
위탁 수하물,체크인 수하물,위탁수하물
기내 수하물,캐리온,휴대 수하물
초과 수하물,익세스 배기지
# directional(단방향, =>) — LHS 질의 시 RHS 를 OR 추가(원어 유지). 역방향 없음 (§6.6)
# 용도: 포괄어 질의를 세부 품목으로 확장하되, 세부 품목 검색이 포괄어 문서까지 끌어오는 것 방지
위험물 포장 => 포장 등급, 포장 지침, UN 번호
10. Kiwi 토큰화 — 인덱싱·검색 패턴
Kiwi 토큰화는 인덱싱·검색 양쪽이 §10.3 모듈을 import 해 동일한 코어 함수를 호출합니다. wrapper 만 다릅니다.
| 패턴 | Wrapper | 본 가이드 절 |
|---|---|---|
| 인덱싱 (Databricks Job) | @pandas_udf Iterator 패턴 |
§10.1 |
| 검색 (Model Serving) | Python 함수 직접 호출 | §10.2 |
코어 함수는 §10.3 의 tokenize_chunk (인덱싱) / tokenize_query (검색) 두 가지. PySpark Python worker 의 동작 원리·Job 실행 timeline 은 부록 C 참조.
세 가드 — 인덱싱·검색 공통 불변 조건
§10.1·§10.2 코드는 다음 3가지를 동시에 만족해야 합니다.
| # | 가드 | 위반 시 증상 | 충족 방법 |
|---|---|---|---|
| 1 | 매번 구축 금지 | 호출마다 Kiwi() 재생성 → init 비용(수백 ms)이 응답 시간 지배 |
module-global _kiwi lazy init (§10.1 UDF / §10.2 search 함수) |
| 2 | 변경 반영 | 사전(사용자 사전 TSV·동의어 CSV) 갱신이 반영 안 됨 | Job/endpoint 재기동 시 새 process spawn → 새 init → sorted(glob(...)) 로 current/ 재읽기 |
| 3 | 동시성 안전 | 1 process 안 다중 thread 가 동일 Kiwi 인스턴스로 over-subscription | worker/replica 당 1 Kiwi + num_workers=1 (§10.3 KIWI_CONFIG) |
§10.1·§10.2 코드 주석의 # 가드 1·3 표기는 해당 라인이 어느 가드를 충족하는지 의 cross-ref 입니다. 가드 2 는 §10.1 "사전 갱신 → 다음 Job 자동 반영" / §10.2 "사전 갱신 → 다음 배포 자동 반영" 흐름으로 충족됩니다.
10.1 인덱싱 — Pandas UDF (Job)
왜 Pandas UDF 인가
| 옵션 | Kiwi 적용 | 평가 |
|---|---|---|
| SQL UDF | ✗ Python 라이브러리 호출 불가 | 불가 |
| UC Python UDF | △ row-by-row 호출, 무거운 init 비효율 | 대량 배치 부적합 |
@pandas_udf (Iterator) |
✓ Arrow batch + worker reuse + Kiwi singleton | ★ 권장 |
PySpark @udf |
△ pickle row-by-row, 느림 | 일회성 디버그만 |
@pandas_udf 가 Arrow batch 직렬화 + Iterator 패턴 으로 (i) pickle 대비 10~100배 빠른 IPC + (ii) 워커당 Kiwi 1 인스턴스 보존 + (iii) Stage 간 worker 재사용으로 cold start 비용 분산 — 세 가지 효과를 동시에 얻습니다.
코드 — Iterator 패턴 (세 가드 충족)
# kiwi_udf.py — 노트북·Job (Classic Job Cluster 또는 Serverless for Jobs) 안에서 import. UC Volume FUSE 지원 환경.
from typing import Iterator
import pandas as pd
from pyspark.sql.functions import pandas_udf
from kiwipiepy import Kiwi
from kiwi_common import KIWI_CONFIG, INDEX_TOKENIZE_OPTS, SEARCH_TAGS, bootstrap_dictionary, kiwi_search_tokens
# 사전 경로 — 노트북·Job 은 UC Volume FUSE 직접 read 가능 (§10.2 Model Serving 과 다름)
USER_DICT_GLOB = "/Volumes/airag_prd/dict/user_dict/current/airag_*.tsv"
# 가드 1·3 — module-global lazy init, 워커당 1 Kiwi 인스턴스 (num_workers=1 는 §10.3 KIWI_CONFIG)
_kiwi: Kiwi | None = None
def _get_kiwi() -> Kiwi:
global _kiwi
if _kiwi is None:
_kiwi = Kiwi(**KIWI_CONFIG)
bootstrap_dictionary(_kiwi, USER_DICT_GLOB)
return _kiwi
@pandas_udf("string")
def tokenize_chunk_udf(it: Iterator[pd.Series]) -> Iterator[pd.Series]:
k = _get_kiwi()
for batch in it:
# kiwi_search_tokens = 토큰화 → §4.2 병합 → SEARCH_TAGS 필터 (검색 측 §10.2 와 동일 경로)
yield batch.apply(lambda text: " ".join(kiwi_search_tokens(k, text, INDEX_TOKENIZE_OPTS)))
# Job 실행 코드 한 줄
df = (spark.read.table("airag_prd.cargo.manual_chunks")
.withColumn("chunk_tokenized", tokenize_chunk_udf("chunk")))
df.write.mode("overwrite").saveAsTable("airag_prd.cargo.manual_chunks_tok")
함정 — driver 에서
_get_kiwi()호출 금지 (v4.0 실측). 디버그·sanity 검증 목적으로 driver(노트북 cell·Job main) 에서_get_kiwi()호출 시 모듈-글로벌_kiwi가 driver 프로세스에 Kiwi C++ 객체로 채워집니다. 이후@pandas_udf직렬화 단계에서 cloudpickle 이 closure 의_kiwi를 pickle 시도하다 Kiwi pybind11 객체 unpickleable 로 실패합니다.운영 규칙:
_get_kiwi()는 워커 안에서만 호출. driver 의 sanity 검증은 별도 local Kiwi 인스턴스 (모듈-글로벌 변수 안 건드림) 로 수행.```python
sanity_kiwi = Kiwi(KIWI_CONFIG) bootstrap_dictionary(sanity_kiwi, USER_DICT_GLOB) print([t.form for t in sanity_kiwi.tokenize("위탁수하물 23kg", INDEX_TOKENIZE_OPTS)])
✗ 금지 — _get_kiwi() 가 모듈-글로벌 _kiwi 를 driver 에 채움
tokens = _get_kiwi().tokenize(...)
```
Compute 권장 — Classic Job Cluster (A항공 채택) · Serverless for Jobs 도 일반적으로 권장
Job 마다 fresh state 인 compute 가 운영을 단순화합니다. 두 옵션 모두 worker stale 함정이 자동 해소되며, A항공 의 사설 네트워크 운영 요건 (Back-end PrivateLink + customer VPC 안 S3·Bedrock endpoint 로 통일) 때문에 Classic Job Cluster 가 채택되었습니다.
| Compute | _kiwi stale 위험 | 운영 부담 | A항공 채택 | 일반 권장 |
|---|---|---|---|---|
| Job compute (Classic) | ✓ 없음 (Job lifecycle 묶임) | 중간 (executor·cores·메모리 명시) | ★ 채택 (Back-end PrivateLink + customer VPC 안 S3 endpoint 경로) | OK |
| Serverless for Jobs | ✓ 없음 (Job 마다 자동 fresh) | 최소 (cluster spec 불필요) | ✗ (Serverless egress → NCC S3 PE Rule 필요해져 운영 단순성 ↓) | ★ 사설 네트워크 요건이 없을 때 권장 |
| All-purpose cluster (Interactive) | ⚠️ 있음 (Python worker 재사용) | 운영 복잡 | ✗ | 비추 |
- Classic Job Cluster 채택 시: 워커가 customer-managed VPC 안 EC2 라 UC Volume FUSE 직접 read + S3 Gateway/Interface endpoint + Bedrock Interface endpoint 모두 customer VPC 안 경로로 처리 (NCC 무관).
- Serverless for Jobs 채택 시: cluster 관리 부담 0 + worker stale 함정 자동 해소. 단 Serverless egress → S3/Bedrock 사설 호출에 NCC PE Rule 추가 필요.
- ⚠️ All-purpose cluster (Interactive) 재사용 시: Python worker 재사용 (
worker.reuse=true) 으로 이전_kiwi유지 → 새 TSV 안 읽힘.
사전 갱신 → 다음 Job 자동 반영
TSV 갱신 (CI 가 UC Volume current/ 에 atomic write)
↓
Job 재실행 (Serverless for Jobs / Job compute)
↓
새 Executor + 새 Python worker spawn
↓
첫 task → _get_kiwi() → _kiwi is None → Kiwi() + bootstrap_dictionary()
↓
sorted(glob(USER_DICT_GLOB)) — current/ 다시 읽음 → 새 TSV 자동 로드
토폴로지 — 인덱싱 파이프라인

UC Volume TSV 는 Job 시작 시점 에 worker 가 bootstrap_dictionary 로 1회 로드. VS Delta Sync 는 Kiwi 무관 — 토큰화된 Delta 컬럼을 source 로 BM25·HNSW 색인을 자동 구축합니다.
10.2 검색 — Python 함수 (Model Serving)
왜 RAG 에이전트 내부 임베드인가
| 옵션 | Kiwi 적용 | 평가 |
|---|---|---|
| RAG 에이전트 (LangGraph) 내부 임베드 | ✓ module-global lazy init + replica 단위 1 Kiwi | ★ 권장 |
| 별도 Kiwi Model Serving endpoint | ✓ 가능 | 매 검색마다 network hop (직렬화·HTTP·cold start) — Kiwi 토큰화(µs~ms) 보다 큼 |
검색 시점 토큰화·동의어 확장은 사용자 사전·동의어 사전이 stable 하다는 전제에서 RAG 에이전트 endpoint 안에 한 모듈로 포함하는 것이 단순합니다. 별도 endpoint 분리는 다른 서비스가 동시에 Kiwi 토큰화를 필요로 할 때만 정당화됩니다.
코드 — module-global lazy init (세 가드 충족)
# kiwi_search.py — Model Serving (MLflow PyFunc) 안에서 import.
# Model Serving 컨테이너는 UC Volume FUSE 를 지원하지 않음.
# → 사전은 MLflow artifact 동봉 으로 모델 이미지에 prebake (§10.1 노트북의 FUSE 직접 read 와 다름).
from kiwipiepy import Kiwi
from kiwi_common import (
KIWI_CONFIG, QUERY_TOKENIZE_OPTS, SEARCH_TAGS,
bootstrap_dictionary, kiwi_search_tokens, load_synonyms, expand_query_for_bm25,
)
# 가드 1·3 — replica 당 1 Kiwi (process-global lazy init)
_kiwi: Kiwi | None = None
_syn_automaton = None # load_synonyms 가 채움 — Aho-Corasick 자동자
_syn_tok2id: dict = {} # load_synonyms 가 채움 — 토큰→id 매핑
_user_dict_glob: str | None = None
_synonym_csv_glob: str | None = None
def configure_paths(user_dict_glob: str, synonym_csv_glob: str) -> None:
"""배포 시점에 사전 경로 주입 — MLflow PyFunc `load_context` 안에서 1회 호출."""
global _user_dict_glob, _synonym_csv_glob
_user_dict_glob = user_dict_glob
_synonym_csv_glob = synonym_csv_glob
def _get_kiwi() -> Kiwi:
global _kiwi
if _kiwi is None:
if _user_dict_glob is None:
raise RuntimeError("configure_paths() 호출 후 사용")
_kiwi = Kiwi(**KIWI_CONFIG)
bootstrap_dictionary(_kiwi, _user_dict_glob)
return _kiwi
def _get_syn():
global _syn_automaton, _syn_tok2id
if _syn_automaton is None:
if _synonym_csv_glob is None:
raise RuntimeError("configure_paths() 호출 후 사용")
# 검색용 Kiwi 인스턴스로 재토큰화 정규화 (사용자 사전 로드된 동일 인스턴스)
_syn_automaton, _syn_tok2id, _ = load_synonyms(_get_kiwi(), _synonym_csv_glob)
return _syn_automaton, _syn_tok2id
def tokenize_query(query: str) -> str:
"""검색 질의 토큰화 + 동의어 OR 확장 → BM25(FULL_TEXT) 입력 문자열 반환."""
k = _get_kiwi()
# kiwi_search_tokens = 토큰화 → §4.2 병합 → SEARCH_TAGS 필터 (인덱싱 측 §10.1 과 동일 경로 — 토큰 대칭)
tokens = kiwi_search_tokens(k, query, QUERY_TOKENIZE_OPTS)
automaton, tok2id = _get_syn()
return expand_query_for_bm25(tokens, automaton, tok2id)
# LangGraph node 예시 — 앙상블의 FTS(BM25) 브랜치. Semantic 브랜치는 원문 임베딩으로
# query_vector + query_type="ANN" 을 별도 호출하며 동의어 확장을 적용하지 않습니다 (§6.5).
def retrieve_node_fts(state):
q_expanded = tokenize_query(state["query"])
# self-managed 인덱스 FTS 호출 — 순수 BM25 채널 (벡터 채널 비관여)
hits = vs_index.similarity_search(query_text=q_expanded, columns=["chunk", "doc_id"],
num_results=20, query_type="FULL_TEXT")
return {"hits": hits}
MLflow PyFunc load_context 안 사전 경로 주입 (Model Serving)
class RagModel(mlflow.pyfunc.PythonModel):
def load_context(self, context):
import kiwi_search
# MLflow artifact 동봉 디렉토리에서 glob 패턴 구성
kiwi_search.configure_paths(
user_dict_glob=context.artifacts["user_dict_dir"] + "/airag_*.tsv",
synonym_csv_glob=context.artifacts["synonym_dir"] + "/airag_*.csv",
)
# 첫 호출이 lazy init — 사전 로드. 워밍업 권장:
_ = kiwi_search.tokenize_query("워밍업")
mlflow.pyfunc.log_model(artifacts={...}) 로 UC Volume 사전을 모델 이미지에 prebake. resources=[DatabricksVectorSearchIndex, DatabricksServingEndpoint] 명시 필수.
사전 갱신 → 다음 배포 자동 반영
TSV·동의어 CSV 갱신 (CI 가 UC Volume current/ 에 atomic write)
↓
RAG 에이전트 endpoint 재배포 (mlflow log_model — UC Volume → *artifact 동봉* → register → endpoint update)
↓
새 replica spawn → 첫 호출 → _get_kiwi() / _get_syn() → 새 사전 로드
↓
기존 replica drain (traffic shift) — 사용자 무중단 (rolling update)
토폴로지 — 검색 파이프라인

UC Volume 사전은 endpoint 부팅 시점 에 1회 로드 (_kiwi·_syn module-global lazy init). 사전 갱신은 endpoint 재배포 + rolling update 로 무중단 반영됩니다.
MLflow PyFunc 배포 시 resources=[...] 명시 필수
검색 함수가 Vector Search·Foundation Model API 등 다른 Databricks 자원 을 호출하면 mlflow.pyfunc.log_model(..., resources=[...]) 에 자원 목록을 선언해야 합니다. 누락 시 Model Serving 컨테이너에 자격 증명이 주입되지 않아 VectorSearchClient() 가 Reading Databricks credential configuration in model serving failed 로 실패하고 endpoint 가 부팅 자체를 못합니다.
from mlflow.models.resources import DatabricksVectorSearchIndex, DatabricksServingEndpoint
mlflow.pyfunc.log_model(
name="rag_agent",
python_model=RagModel(),
artifacts={
"user_dict_dir": "/Volumes/airag_prd/dict/user_dict/current", # 사전 TSV 디렉토리 동봉
"synonym_dir": "/Volumes/airag_prd/dict/synonyms/current", # 동의어 CSV 디렉토리 동봉
},
code_paths=["kiwi_common.py"], # 공유 모듈 동봉 → Serving 컨테이너에서 import
pip_requirements=["kiwipiepy>=0.23.1", "pyahocorasick>=2.1", "databricks-vectorsearch>=0.40", ...],
resources=[
DatabricksVectorSearchIndex(index_name="<catalog>.<schema>.<index>"),
DatabricksServingEndpoint(endpoint_name="databricks-qwen3-embedding-0-6b"),
],
)
resources에 선언된 자원은 endpoint 배포 시 자동으로 자격 증명 (OAuth scope) 이 컨테이너에 주입됨 → 코드 안에서VectorSearchClient()가 PAT 없이 동작- MLflow 2.18+ Agent Framework 의 일부 (
mlflow.models.resources). 추가 자원 클래스:DatabricksSQLWarehouse,DatabricksFunction,DatabricksGenieSpace등 - 본 가이드는 MLflow PyFunc 의 전체 패키징 코드는 다루지 않습니다 — 에이전트 배포는 별도 가이드 영역. 다만
resources=[...]누락이 토크나이저·동의어 운영을 통째로 차단하므로 최소 필수 패턴 으로 본 절에 명시합니다.
10.3 공유 모듈 (§10.1·§10.2 양쪽 import)
§10.1 (인덱싱 UDF) 과 §10.2 (검색 함수) 가 모두 import 하는 단일 모듈입니다.
공유 모듈 정본 =
source/airag-rag/kiwi-tokenizer/kiwi_common.py— 아래 코드(토큰화kiwi_search_tokens·merge_derived_nouns, 사용자사전bootstrap_dictionary, 동의어load_synonyms·expand_query_for_bm25)의 실제 파일이며, 본문 코드는 그 사본입니다. 인덱싱(§10.1)·검색(§10.2) 노트북이 모두import kiwi_common합니다. Model Serving 에는log_model(code_paths=["kiwi_common.py"])로 동봉됩니다.ahocorasick(pyahocorasick>=2.1)은 동의어 전용이라load_synonyms안에서 lazy import — 인덱싱 측은 pyahocorasick 없이도 본 모듈을 import 합니다.
# kiwi_common.py — Kiwi 공유 모듈 (인덱싱 §10.1 · 검색 §10.2 양쪽 import)
import csv
import logging
from glob import glob
from kiwipiepy import Kiwi, basic_typos_with_continual, Match
# ahocorasick(pyahocorasick>=2.1)은 동의어 전용 — load_synonyms 안에서 lazy import
# (인덱싱 노트북이 pyahocorasick 없이도 본 모듈을 import 가능).
logger = logging.getLogger(__name__)
KIWI_CONFIG = dict(
model_type='cong', # 언어모델 — 정확도·속도 균형
load_default_dict=True, # Kiwi 내장 사전 로드
integrate_allomorph=True, # 이형태 통합
num_workers=1, # UDF / Model Serving 안 over-subscription 차단
)
# 인덱싱 측 — 오타 교정 OFF
INDEX_TOKENIZE_OPTS = dict(
match_options=Match.ALL, # 매칭 옵션 — URL·EMAIL·SERIAL(전화번호) 보존
split_complex=True, # 복합명사 분해
normalize_coda=True, # 종성 정규화
saisiot=False, # 사잇소리 분리 OFF (양쪽 동일)
typos=None, # 오타 교정 OFF (인덱싱 측)
)
# 검색 측 — 오타 교정 ON. INDEX_TOKENIZE_OPTS 의 typos=None 을 override 해야 하므로
# dict() 호출이 아닌 **dict literal** 사용 (dict() 는 동일 키 중복 시 TypeError 발생).
QUERY_TOKENIZE_OPTS = {
**INDEX_TOKENIZE_OPTS,
"typos": basic_typos_with_continual, # 오타 교정 ON (검색만)
"typo_cost_threshold": 2.5,
}
# 동의어 키 빌드용 — 오타 교정 OFF (사전 어휘는 이미 정규형; 질의측 오타교정이 키로 수렴)
KEY_TOKENIZE_OPTS = INDEX_TOKENIZE_OPTS
# BM25 색인 화이트리스트 — 명사·외국어·숫자·한자만 색인
SEARCH_TAGS = {"NNG", "NNP", "SL", "SN", "SH"}
# 파생·전성 명사 후처리 병합용 (§4.2 정본)
STEM_TAGS = {"VV", "VA", "XSV", "XSA", "VX"} # 어간류(용언·파생접미사) — ETN 병합
NOUN_TAGS = {"NNG", "NNP"} # 명사 — XSN(파생접미사) 흡수
# 다중 토큰 동의어 1어휘 최대 토큰 수. 초과 어휘는 그룹 skip
SYN_MAX_TOKENS = 5
# 사전에 없는 질의 토큰 id (키에 절대 없는 uint32 — Aho-Corasick 매칭 충돌 불가)
_UNK_ID = 0xFFFFFFFF
# 사전 경로는 *환경별 호출자가 주입* — §10.3 공유 모듈은 path 의존성 없음.
# - 노트북·Job (인덱싱): UC Volume FUSE 직접 read (§10.1)
# - Model Serving (검색): MLflow artifact 동봉 (§10.2)
# Model Serving 컨테이너는 UC Volume FUSE 미지원 → artifact 동봉 필요.
def bootstrap_dictionary(kiwi: Kiwi, user_dict_glob: str) -> None:
"""사용자 사전 부트스트랩 — 인덱싱·검색 양쪽이 공통 호출.
호출자가 환경에 맞는 glob 경로 주입 (FUSE 또는 artifact). 카테고리별 TSV 파일들
(airag_iata.tsv·airag_aircraft.tsv·airag_compound.tsv 등) 을 sorted(glob(...)) 으로 일괄 로드.
"""
paths = sorted(glob(user_dict_glob))
if not paths:
# fail-fast — 사전 0건 매치 (UC Volume 마운트 실패·prefix 오타·artifact 키 불일치 등)
raise FileNotFoundError(f"사용자 사전 0건: {user_dict_glob}")
for path in paths:
# fail-fast — IOError 면 Python 기본 propagation 으로 즉시 종료
kiwi.load_user_dictionary(path)
def merge_derived_nouns(text: str, tokens: list) -> list[tuple[str, str]]:
"""파생·전성 명사를 원문 표면형 NNG 1토큰으로 병합 (§4.2 정본).
- 어간(STEM_TAGS) + ㅁ/음/기(ETN) → 알림, 찾기 (전성명사)
- 명사(NOUN_TAGS) + 파생접미사(XSN) → 할인권, 선생님 (파생명사)
Kiwi 가 어근 형태소를 분해해 SEARCH_TAGS 화이트리스트에서 탈락시키므로, start/len 으로
원문 substring 을 떠서 단일 NNG 로 되붙입니다. 명사 핵 뒤 XSN 은 연속 흡수합니다.
인덱싱·검색 양쪽이 호출해 토큰 대칭을 보장합니다 (§5.6). 반환: (form, tag) 튜플 리스트.
(의존명사 NNB(예: 당뇨'식')는 과병합 위험으로 미대상 — 사용자 사전 §5.2 로 처리.)
"""
out, i, n = [], 0, len(tokens)
while i < n:
t = tokens[i]
j = None
if i + 1 < n and t.tag in STEM_TAGS and tokens[i + 1].tag == "ETN":
j = i + 1 # 어간 + ETN 핵
elif t.tag in NOUN_TAGS:
j = i # 명사 핵
if j is not None:
k = j + 1
while k < n and tokens[k].tag == "XSN": # 뒤따르는 파생접미사 흡수
k += 1
if k > j + 1 or j > i: # 실제 병합 대상이 있을 때만
last = tokens[k - 1]
out.append((text[t.start:last.start + last.len], "NNG")) # start/len = 전 버전 안전
i = k
continue
out.append((t.form, t.tag)); i += 1
return out
def kiwi_search_tokens(kiwi: Kiwi, text: str, opts: dict) -> list[str]:
"""토큰화 → §4.2 병합 → SEARCH_TAGS 필터. 인덱싱(§10.1)·검색(§10.2) 공통 경로.
josa·어미 제거 + 파생·전성 명사(알림·할인권 등) 표면형 보존. 양쪽이 본 함수를 거쳐
인덱스·질의 토큰 대칭을 보장합니다.
"""
toks = kiwi.tokenize(text, **opts)
return [form for form, tag in merge_derived_nouns(text, toks) if tag in SEARCH_TAGS]
def kiwi_key_tokens(kiwi: Kiwi, term: str, opts: dict) -> list[str]:
"""[키 빌드측] 동의어 CSV 1어휘 → 구두점 제외한 모든 form 리스트.
POS 화이트리스트(SEARCH_TAGS) 미적용 — 단독 명사가 MAG/VV 등으로 오태깅돼도 form 은
동일하므로 구두점만 제외하면 질의측 내용어 form 과 정렬됩니다 ('짐' 단독 → MAG 라
화이트리스트를 적용하면 탈락하는 함정 회피). 질의측은 josa·어미가 섞여 SEARCH_TAGS
필터가 필요하지만, CSV 어휘는 curated 명사구라 구두점 제외로 충분합니다.
"""
return [t.form for t in kiwi.tokenize(term, **opts)
if any(ch.isalnum() for ch in t.form)]
def load_synonyms(kiwi: Kiwi, synonym_csv_glob: str):
"""검색 시점만 호출 — CSV(equivalent + `=>` 단방향) → Aho-Corasick 자동자 빌드.
검색에 쓰는 동일 Kiwi 인스턴스(사용자 사전 로드됨)로 각 어휘를 *재토큰화* 해
소문자 형태소 공백조인을 정규 키로 만들고, 토큰 시퀀스를 pyahocorasick(KEY_SEQUENCE)
자동자에 등록합니다. 붙임/띄움/사용자사전 등록 여부와 무관하게 질의·키·인덱스
granularity 가 일치합니다 (예: "위탁수하물"·"위탁 수하물" → 키 "위탁 수하물").
단일 토큰 동의어도 길이-1 키로 함께 등록. 반환: (automaton, tok2id, stats).
두 모드 (한 줄 = 한 규칙, §9.1):
- equivalent (콤마) : `수하물,짐,캐리어` — 모든 어휘 상호 등가(양방향).
- directional (`=>`) : `대한항공,코리안에어 => KE,KAL` — LHS 어휘만 키로 등록,
매칭 시 *원어 유지 + RHS 추가*. 역방향 없음. 검색 시점 확장이라(§6.4) 원어를
보존해 recall 을 지킵니다(Solr `=>` 의 index-time 치환과 다른 점). 문법은
synonym_graph explicit mapping 동일(`=>` 줄당 1개, 좌/우 콤마 나열).
"""
import ahocorasick # 동의어 전용 lazy import (검색 워커만 설치)
paths = sorted(glob(synonym_csv_glob))
if not paths:
# fail-fast — 사전 0건 매치 (UC Volume 마운트 실패·prefix 오타·artifact 키 불일치 등)
raise FileNotFoundError(f"동의어 사전 0건: {synonym_csv_glob}")
A = ahocorasick.Automaton(ahocorasick.STORE_ANY, ahocorasick.KEY_SEQUENCE)
tok2id, _nxt = {}, 0
seen_keys = set()
stats = {"loaded": 0, "skipped": 0, "by_rule": {}, "dropped_members": 0}
def _tid(t):
nonlocal _nxt
if t not in tok2id:
tok2id[t] = _nxt; _nxt += 1
return tok2id[t]
def _skip(rule: str, path: str, lineno: int, row: list[str]) -> None:
logger.warning("skip [%s] %s:%d %s", rule, path, lineno, row)
stats["skipped"] += 1
stats["by_rule"][rule] = stats["by_rule"].get(rule, 0) + 1
# 각 어휘 재토큰화 → 소문자 형태소 공백조인 = 정규 키 (붙임/띄움 동일 키 수렴).
# 소문자화로 case-insensitive 매칭(질의도 .lower()). FULL_TEXT BM25 도 색인·질의를
# 분석 시 소문자화하므로 인덱싱 측 원본 케이스는 그대로 둬도 무방.
# 정규 키는 인덱싱·질의와 동일 경로(kiwi_search_tokens: §4.2 병합 + SEARCH_TAGS)로 빌드한다.
# search 가 빈 결과면(단독 토큰화 시 명사 외 품사로 태깅돼 화이트리스트에서 전부 탈락)
# kiwi_key_tokens(구두점만 제외하고 표면형 유지)로 빌드한다.
def _canon_terms(terms, path, lineno, aligned):
# aligned=True: 어휘별로 kiwi_search_tokens 를 쓰되 빈 결과면 kiwi_key_tokens 로 대체.
# aligned=False: 모든 어휘를 kiwi_key_tokens(미정렬 표면형)로 빌드.
# 키가 비거나 5토큰 초과인 어휘는 그 어휘만 건너뛰고 나머지로 그룹을 구성한다.
cs = []
for term in terms:
toks = ((kiwi_search_tokens(kiwi, term, KEY_TOKENIZE_OPTS)
or kiwi_key_tokens(kiwi, term, KEY_TOKENIZE_OPTS))
if aligned else kiwi_key_tokens(kiwi, term, KEY_TOKENIZE_OPTS))
if not toks or len(toks) > SYN_MAX_TOKENS:
if aligned: # 1차 빌드에서만 로깅 (충돌 재빌드 중복 방지)
rule = "empty" if not toks else f"oversize-tokens(>{SYN_MAX_TOKENS})"
logger.warning("drop-member [%s] %s:%d %r", rule, path, lineno, term)
stats["dropped_members"] += 1
continue # 불량 어휘만 제외, 그룹은 유지
cs.append(" ".join(t.lower() for t in toks))
return list(dict.fromkeys(cs)) # 그룹 내 정규형 중복 제거
for path in paths:
with open(path, encoding="utf-8-sig") as f:
for lineno, row_raw in enumerate(csv.reader(f), 1):
row = [t.strip() for t in row_raw if t.strip()]
if not row or row[0].startswith("#"):
continue # 빈 줄·주석 행
# ── 단방향(`=>`) 모드 — synonym_graph explicit mapping ──
# csv.reader 가 콤마로 먼저 쪼개므로(예: '코리안에어 => KE' 가 셀 한가운데) row 를
# 재결합해 '=>' 로 LHS/RHS 를 다시 나눈다. LHS 어휘만 키로 등록 → 역방향 없음(단방향).
joined = ",".join(row)
if "=>" in joined:
sides = joined.split("=>")
if len(sides) != 2: # '=>' 는 줄당 1개만
_skip("explicit-multi-arrow", path, lineno, row); continue
lhs_terms = [t.strip() for t in sides[0].split(",") if t.strip()]
rhs_terms = [t.strip() for t in sides[1].split(",") if t.strip()]
if not lhs_terms or not rhs_terms: # 한쪽이 비면 매핑 불가
_skip("explicit-empty-side", path, lineno, row); continue
if len(lhs_terms) + len(rhs_terms) > 15: # 그룹 최대 15 어휘
_skip("oversize-group", path, lineno, row); continue
lhs = _canon_terms(lhs_terms, path, lineno, aligned=True)
rhs = _canon_terms(rhs_terms, path, lineno, aligned=True)
if not lhs or not rhs: # 재토큰화 후 한쪽이 전부 소멸
_skip("explicit-empty-side", path, lineno, row); continue
dup = [c for c in lhs if c in seen_keys] # LHS 키는 한 번만 정의(중복 금지)
if dup:
_skip(f"dup-token({','.join(dup)})", path, lineno, row); continue
for c in lhs: # LHS 어휘만 키 등록 = 단방향
seen_keys.add(c)
ids = tuple(_tid(t) for t in c.split())
out = list(dict.fromkeys([c, *rhs])) # 원어(c) 유지 + RHS 추가 (검색 시점 recall 보존)
A.add_word(ids, (out, len(ids)))
stats["loaded"] += 1
continue
# ── equivalent(양방향) 모드 — §9.1 작성 규칙 검증 (위반 그룹은 _skip) ──
if len(row) < 2:
continue # 어휘 1개 = 동의어 없음 → 무시
if len(row) > 15: # 그룹 최대 15 어휘
_skip("oversize-group", path, lineno, row); continue
def _canon(aligned):
cs = _canon_terms(row, path, lineno, aligned)
return cs if len(cs) >= 2 else None # 정규형 2개 미만 = 동의어 없음
# aligned 키를 먼저 만들고, 그 키가 이미 등록된 키와 겹치면 미정렬 키로 다시 만들어 본다.
canon = _canon(aligned=True)
if canon is None:
_skip("empty-or-collapsed", path, lineno, row); continue
if any(c in seen_keys for c in canon): # 한 어휘는 한 그룹에만 등장
alt = _canon(aligned=False) # 미정렬 키로 재빌드
if alt is not None and not any(c in seen_keys for c in alt):
canon = alt
else:
dup = [c for c in canon if c in seen_keys]
_skip(f"dup-token({','.join(dup)})", path, lineno, row); continue
# 유효 그룹 — Aho-Corasick 자동자 등록 (어느 어휘로 검색해도 그룹 전체 매칭 = 양방향)
for c in canon:
seen_keys.add(c)
ids = tuple(_tid(t) for t in c.split()) # 정규 키 → 토큰 id 튜플
A.add_word(ids, (canon, len(ids))) # 값 = (확장 그룹, 키 길이)
stats["loaded"] += 1
if len(A):
A.make_automaton()
logger.info("동의어 사전 로드: loaded=%d, skipped=%d, dropped_members=%d, by_rule=%s, keys=%d",
stats["loaded"], stats["skipped"], stats["dropped_members"], stats["by_rule"], len(seen_keys))
return A, tok2id, stats
def expand_query_for_bm25(tokens: list[str], automaton, tok2id: dict) -> str:
"""검색 시점 OR 확장 — Aho-Corasick 단일 스캔 → greedy 최장 비중첩 + 토큰 단위 중복 제거.
입력은 *이미 Kiwi 토큰화 + SEARCH_TAGS 필터를 거친* 토큰 리스트.
출력은 개별 토큰(다중 토큰 확장값도 분해) + 중복 제거 — FULL_TEXT BM25 의
query_text 중복 term 선형 증폭(×2·×3, 실측)에 의한 점수 왜곡 방지.
"""
best = {} # 시작위치 → (그룹, 최장 길이)
if len(automaton):
seq = tuple(tok2id.get(t.lower(), _UNK_ID) for t in tokens)
for end, (grp, ln) in automaton.iter(seq): # AC 단일 스캔 = 모든 매칭
st = end - ln + 1
if st not in best or ln > best[st][1]:
best[st] = (grp, ln)
out, seen, i, n = [], set(), 0, len(tokens)
while i < n:
if i in best: # greedy 최장 비중첩 소비
grp, step = best[i]
else: # miss → 원형 토큰 (소문자)
grp, step = [tokens[i].lower()], 1
for phrase in grp:
for tk in phrase.split(): # 다중 토큰 확장값 → 개별 토큰
if tk not in seen: # 중복 제거 (토큰 단위)
seen.add(tk); out.append(tk)
i += step
return " ".join(out)
옵션 불일치 방지 — 인덱싱·검색 사이 옵션 드리프트는 매칭 누락의 가장 흔한 원인입니다. 단일 bootstrap_dictionary() 와 두 옵션 dict 를 모듈로 추출해 양쪽이 import 하도록 강제하세요. 예외는 typos 한 가지뿐입니다 (검색만 ON). 동의어 사전 (load_synonyms + expand_query_for_bm25) 은 검색 시점만 호출.
typo 교정과 사용자 사전 상호작용 — bootstrap_dictionary 가 등록한 form 은 typo correction 후보보다 우선 매칭됩니다. 예: "무개" 가 사용자 사전 NNG 로 등록되어 있으면 cong 모델에서 "무게" 로 교정되지 않습니다 — 운영팀이 의도한 교정이 안 일어나면 사용자 사전부터 점검하세요.
📚 시리즈 — 한국어 RAG 검색 (Kiwi)
- ① 한국어 BM25·토크나이저 선택과 Kiwi 기본 설정
- ② 사용자·동의어 사전과 복합명사 처리
- ③ 검색 품질 개선·사전 관리·Vector Search 통합 — 현재 글
- ④ 부록 — 동의어 적용 위치·IATA 사전 생성/검증·품사·Nori 비교
'Databricks > RAG' 카테고리의 다른 글
| RAG 품질 ③ 아키텍처 — LangGraph 기반 Agentic RAG 검색 처리 흐름 (0) | 2026.07.04 |
|---|---|
| RAG 품질 ① 평가 — MLflow Evaluation 기반 검색·생성 품질 정량 측정 (0) | 2026.07.04 |
| 한국어 RAG 검색 (Kiwi) ④ 부록 — 동의어 적용 위치·IATA 사전 생성/검증·품사·Nori 비교 (0) | 2026.06.28 |
| 한국어 RAG 검색 (Kiwi) ② 사용자·동의어 사전과 복합명사 처리 (0) | 2026.06.28 |
| 한국어 RAG 검색 (Kiwi) ① 한국어 BM25·토크나이저 선택과 Kiwi 기본 설정 (0) | 2026.06.28 |
댓글