
부제: "약"을 검색하면 "약을"이 안 걸리던 문제
한국어 문서 검색을 붙이는데, 기본 전문검색이 영 엉뚱하게 걸렸다.
결국 언어 특성에 맞는 토크나이저를 고르는 게 일의 절반이었다.
문제
PostgreSQL 기본 전문검색(tsvector, simple 토크나이저)은 공백으로 단어를 나눈다.
영어는 단어 사이가 공백이라 이게 통하는데, 한국어는 다르다.
"오늘 약을 먹었어요"
공백 토큰화 → ["오늘", "약을", "먹었어요"]
사용자가 "약"을 검색하면 약을이라는 덩어리와는 안 맞는다.
한국어는 어근에 조사·어미가 달라붙는 구조라 공백으로 자르면 "약 + 을"이 통째로 하나가 돼버린다.
영어식 어근 추출(stemming)도 답이 아니다. 그건 접사가 떨어지는 영어 형태에나 맞는 방식이라 결합형인 한국어엔 형태소 분석이 필요하다.
후보를 추려보니
한국어를 제대로 자를 방법을 몇 개 놓고 봤다.
- pg_bigm — 두 글자씩 잘라 매칭. PostgreSQL에 바로 붙지만 거짓 매칭이 너무 많았다.
- Nori — 형태소 분석기로 정확하지만 Elasticsearch 전용이라 PostgreSQL엔 못 쓴다. 검색용 별도 서비스를 세우는 건 과했다.
- KoNLPy 계열 / JVM 기반 — 정확한 것도 있지만 JVM을 얹거나 성능이 아쉬웠다.
그래서 MeCab + mecab-ko-dic으로 갔다.약을을 약(명사)과 을(조사)로 정확히 나눠주고, python-mecab-ko 패키지가 사전까지 함께 들고 있어서 컨테이너에 시스템 설치 없이 얹힌다. 속도도 문장당 밀리초 수준이다.
매 검색마다 분석하지 않는다
여기서 한 가지 결정을 했다.
검색이 들어올 때마다 문서를 형태소 분석하면 CPU가 계속 갈린다.
그래서 문서를 저장(청킹)할 때 한 번만 형태소 분석을 돌려 tsvector를 미리 만들어 뒀다. 별도 컬럼(content_tsv)에 담고 GIN 인덱스를 걸었다.
업로드 → 텍스트 추출 → 청킹 → MeCab 분석(여기서 한 번) → content_tsv 저장
쓰기 때 한 번 치르고 읽기(검색)는 미리 만든 인덱스만 본다.
MeCab tagger는 상태를 가진 C 객체라 매번 새로 만들면 낭비다. 그래서 싱글톤으로 프로세스당 하나만 띄웠다. 분석은 무거운 작업이라 별도 스레드로 돌려 메인 루프를 막지 않게 했다.
벡터 검색과 합치기
키워드 검색만으로는 부족해서 의미 기반 벡터 검색과 함께 쓴다.
두 갈래로 뽑는다. 임베딩으로 유사한 청크 50개, MeCab 키워드로 매칭되는 청크 50개.
그리고 이걸 RRF(순위 역수 합)로 합쳐 상위 몇 개만 남긴다.
1.0 / (60 + 벡터순위) + 1.0 / (60 + 키워드순위)
60은 흔히 쓰는 표준값이다. 이 방식의 좋은 점은 두 검색의 점수 스케일을 안 맞춰도 된다는 거다. 점수가 아니라 순위로 합치니까, 벡터 점수와 BM25 점수의 단위 차이를 신경 쓸 필요가 없고 가중치도 안 만진다.
결과
이제 "약"으로 검색하면 "약을"이 든 청크가 걸린다.
조사·어미가 붙어 있어도 어근이 같으면 매칭되니, 예전엔 통째로 놓치던 문서들이 검색된다.
공백으로 자르던 걸 형태소로 자른 것뿐인데, 한국어 검색의 체감이 확 달라졌다.
정리
- PostgreSQL 기본 전문검색은 공백 토큰화라 한국어("약을")를 제대로 못 자른다
- 영어 stemming이 아니라 형태소 분석이 필요하다 — 조사·어미를 어근에서 떼야 매칭된다
- 형태소 분석은 검색마다 말고 저장할 때 한 번 돌려 tsvector로 굳혀 두면 읽기가 싸진다
- 키워드와 벡터는 RRF로 합치면 점수 스케일·가중치를 안 맞춰도 균형이 잡힌다
도구를 언어에 맞추는 게 먼저였다. 나머지는 그 위에 얹혔다.
'AI' 카테고리의 다른 글
| [LLM] Gemini 무응답 hang 타임아웃 재시도 (0) | 2026.07.09 |
|---|---|
| [백엔드] 다국어 챗봇 타임존 단일 소스 LLM 컨텍스트 오염 (0) | 2026.07.09 |
| [프롬프트] Gemini System Prompt 고정 캐싱 전략 (1) | 2026.07.01 |
| [에이전트] ReAct 도구 순서 가드 read 후 write 강제 (0) | 2026.06.25 |
| [프롬프트] persona drift 대응 생성 지점 context 재주입 (0) | 2026.06.25 |