본문 바로가기
Databricks/RAG

한국어 RAG 검색 (Kiwi) ③ 검색 품질 개선·사전 관리·Vector Search 통합

by 여행을 떠나자! 2026. 6. 28.

📚 시리즈 — 한국어 RAG 검색 (Kiwi)

 

 

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_udfArrow 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)

댓글