pgvector 하이브리드 검색 구현 — Vector + FTS로 한국어 정책 검색
11,600건의 정부 정책을 코사인 유사도 + tsvector 풀텍스트 + RRF 랭킹으로 결합한 실제 쿼리와 튜닝 과정을 기록했습니다.
내혜택 서비스의 정책 검색 기능을 만들면서, 벡터만으로는 부족하고 FTS만으로도 부족하다는 결론에 도달했습니다. 둘을 RRF(Reciprocal Rank Fusion)로 합쳤을 때 비로소 사용자 의도에 가까운 결과가 나왔습니다. 제가 어떻게 튜닝했는지 정리합니다.
문제 상황
정책 문서 11,600건을 PostgreSQL 17 + pgvector에 저장해뒀습니다. 초기에는 단순하게 코사인 유사도만 썼는데, 두 가지 문제가 있었어요.
- 숫자/고유명사에 약함 — '청년도약계좌'를 검색하면 '청년적금' 같은 유사 개념이 먼저 나와 정작 정확 매칭이 뒤로 밀립니다.
- 단답형 쿼리에 혼란 — '월세'만 검색하면 문맥이 없어서 관련성 낮은 문서가 상위에 올라옵니다.
스키마 구성
CREATE TABLE policies (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
embedding vector(768),
tsv tsvector GENERATED ALWAYS AS (
to_tsvector('simple', title || ' ' || body)
) STORED
);
CREATE INDEX policies_embedding_idx ON policies
USING hnsw (embedding vector_cosine_ops);
CREATE INDEX policies_tsv_idx ON policies USING gin(tsv);
임베딩은 nomic-embed-text 768차원을 씁니다. HNSW 인덱스는 IVFFlat보다 빌드 시간이 길지만 검색 품질이 안정적입니다. pgvector 공식 문서는 github.com/pgvector/pgvector에서 볼 수 있습니다.
한국어 FTS 설정
PostgreSQL 기본 korean dictionary는 설치되어 있지 않거나 품질이 약합니다. 저는 simple config를 쓰고, 쿼리 쪽에서 n-gram 변형을 시도합니다. 이게 오히려 한국어에서는 잘 돌아갔어요.
RRF 쿼리
벡터 검색 상위 50건과 FTS 상위 50건을 각각 뽑아, RRF 공식(1/(k+rank))으로 결합합니다. k=60이 관례입니다.
WITH vec AS (
SELECT id, row_number() OVER (ORDER BY embedding <=> $1) AS r
FROM policies ORDER BY embedding <=> $1 LIMIT 50
),
fts AS (
SELECT id, row_number() OVER (ORDER BY ts_rank_cd(tsv, q) DESC) AS r
FROM policies, plainto_tsquery('simple', $2) q
WHERE tsv @@ q LIMIT 50
)
SELECT p.*, COALESCE(1.0/(60+vec.r),0) + COALESCE(1.0/(60+fts.r),0) AS score
FROM policies p
LEFT JOIN vec ON vec.id=p.id
LEFT JOIN fts ON fts.id=p.id
WHERE vec.id IS NOT NULL OR fts.id IS NOT NULL
ORDER BY score DESC LIMIT 20;
여기서 $1은 쿼리 임베딩, $2는 원문 쿼리 문자열입니다. 결과는 Rust Axum이 JSON으로 내려주고, Next.js 쪽에서 스트리밍 렌더링합니다.
튜닝 과정에서 얻은 것
- HNSW
ef_search— 기본 40은 너무 낮았습니다. 100~150으로 올렸을 때 recall이 체감 개선됐습니다. 쿼리 비용은 20~30% 증가. - 임베딩 정규화 — 저장 전에 L2 norm을 1로 맞춰뒀습니다. 코사인 거리 계산이 내적으로 단순화되어 쿼리가 빨라집니다.
- FTS 상위 개수 — 30건에서는 놓치는 케이스가 있었고, 100건은 불필요한 noise가 들어왔습니다. 50이 스위트스팟.
- 쿼리 확장 — 사용자가 단답형으로 묻는 경우가 많아, LLM으로 쿼리를 한 번 확장한 뒤 임베딩합니다. 예: '월세' → '청년 월세 지원 제도'.
지연과 자원
Oracle ARM 4 OCPU에서 전체 파이프라인(임베딩 생성 + 벡터 검색 + FTS + RRF)이 평균 180~260ms입니다. Next.js에서 사용자가 체감하는 TTFB는 300~400ms 수준이라 실시간 검색으로 쓸 만합니다. Supabase로 이전하려다 ARM/pgvector의 조합이 오히려 싸고 유연해서 Oracle에 남겨뒀습니다 — 관련 배경은 Oracle ARM 운영기 참고.
Supabase 공식 가이드와의 차이
Supabase 공식 문서의 pgvector 가이드(supabase.com/docs/guides/ai)는 단일 벡터 검색 위주입니다. 실제 한국어 서비스에서는 FTS를 섞지 않으면 고유명사 매칭이 약하다는 점을 꼭 기억하세요.
다음 단계
지금은 reranker 없이 RRF로만 돌리고 있는데, 상위 20건에 경량 cross-encoder reranker를 붙이면 개선 여지가 있어 보입니다. 이건 다음 분기에 실험할 예정입니다. 임베딩 인프라 자체는 Ollama 프로덕션 글에서 이어지니 함께 읽어보시길 권합니다.
공유하기
이어 읽으면 좋은 글
같은 주제와 태그를 기준으로 GRAXEL 운영 맥락을 더 깊게 볼 수 있는 글입니다.
Ollama로 로컬 LLM 서빙 — deepseek-r1 14B를 실제 프로덕션에 쓰는 법
3-tier fallback(Oracle→Old Mac→New Mac) 구조로 Ollama를 프로덕션에서 운영하는 실제 구성. nomic-embed-text 임베딩과 HTTP 11434 포트 운영 팁.
Oracle Cloud Always Free ARM 24GB로 프로덕션 API 서빙하기
4 OCPU, 24GB RAM, 200GB 디스크를 영구 무료로 받아 pgvector PostgreSQL 17 + Rust Axum API + Ollama를 Cloudflare Tunnel로 서빙한 기록.
내혜택 RAG 시스템 운영기 — 11,600개 정책을 어떻게 검색 가능하게 만들었나
pgvector 하이브리드 검색과 Ollama 임베딩으로 11,600개 정부 혜택 정책을 검색하는 내혜택 서비스의 실제 운영 경험을 공유합니다.