내혜택 RAG 시스템 운영기 — 11,600개 정책을 어떻게 검색 가능하게 만들었나
pgvector 하이브리드 검색과 Ollama 임베딩으로 11,600개 정부 혜택 정책을 검색하는 내혜택 서비스의 실제 운영 경험을 공유합니다.
내혜택(myhyetaek.graxel.ai)을 만들면서 가장 고민했던 문제는 간단했습니다. 11,600개가 넘는 정부/지자체 혜택 문서를 어떻게 사용자 질문 하나로 정확하게 찾아줄 것인가. 키워드 검색만으로는 "30대 1인 가구 주거" 같은 자연어 질문에 답할 수 없었습니다. 이번 글에서는 RAG(Retrieval-Augmented Generation) 시스템을 실제로 설계하고 운영하면서 겪은 선택과 트레이드오프를 정리합니다.
왜 하이브리드 검색인가
처음에는 순수 벡터 검색만으로 충분할 것이라고 생각했습니다. 문서를 임베딩하고, 질의도 임베딩해서 코사인 유사도로 정렬하면 끝이라는 단순한 가정이었죠. 그러나 실제 데이터를 넣고 나니 두 가지 문제가 드러났습니다. 첫째, "청년도약계좌"처럼 고유명사가 들어간 질의는 벡터 유사도가 낮게 나와 엉뚱한 문서가 상위에 올라왔습니다. 둘째, 지자체명(예: "성남시")이 정확히 일치해야 하는 케이스에서 벡터 검색은 오히려 노이즈가 되었습니다.
결국 PostgreSQL의 tsvector 기반 Full-Text Search와 pgvector의 HNSW 인덱스를 결합한 하이브리드 검색으로 전환했습니다. Supabase의 하이브리드 검색 가이드가 출발점이 되었지만, 우리 도메인에 맞게 RRF(Reciprocal Rank Fusion) 가중치를 여러 번 조정해야 했습니다.
실제 쿼리 구조
다음은 프로덕션에서 돌아가는 Rust Axum + SQLx 스니펫의 단순화된 버전입니다.
WITH vector_hits AS (
SELECT id, 1.0 / (50 + rank) AS score
FROM (
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS rank
FROM policies
WHERE embedding IS NOT NULL
ORDER BY embedding <=> $1
LIMIT 100
) v
),
fts_hits AS (
SELECT id, 1.0 / (50 + rank) AS score
FROM (
SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(tsv, q) DESC) AS rank
FROM policies, plainto_tsquery('simple', $2) q
WHERE tsv @@ q
LIMIT 100
) f
)
SELECT id, SUM(score) AS score
FROM (SELECT * FROM vector_hits UNION ALL SELECT * FROM fts_hits) u
GROUP BY id ORDER BY score DESC LIMIT 20;
RRF의 묘미는 두 검색 결과의 "순위"만 섞기 때문에 스코어 스케일을 맞출 필요가 없다는 점입니다. 상수 50은 데이터셋 크기에 따라 30~80 사이에서 튜닝했고, 결과적으로 50이 가장 안정적이었습니다.
임베딩 — 3-Tier Ollama Fallback
임베딩은 처음부터 OpenAI를 쓸까 고민했지만, 11,600개 문서를 배치로 돌릴 때마다 비용이 무시할 수 없는 수준이었습니다. 그래서 선택한 것이 Ollama의 nomic-embed-text(768차원) 모델입니다. Oracle ARM 서버를 메인으로, Old Mac과 New Mac을 각각 두 번째, 세 번째 fallback으로 묶었습니다.
- Primary: Oracle ARM 24GB — 배치 크롤러와 같은 물리 서버라 네트워크 지연 최소화
- Secondary: Old Mac (M4 Pro 24GB) — TB5 브릿지를 통한 사내망 호출
- Tertiary: New Mac (M4 Pro 64GB) — 재난 복구용, 평소에는 로컬 개발 전용
실제로 Oracle 서버에서 Ollama 프로세스가 OOM으로 죽은 적이 두 번 있었고, 그때마다 Rust 클라이언트의 fallback 로직 덕분에 사용자는 아무것도 눈치채지 못했습니다. nomic-embed-text 공식 카드에 나온 스펙대로 8,192 토큰까지 받을 수 있어서 긴 정책 본문도 청킹 없이 통째로 임베딩했습니다(물론 매우 긴 공고는 2,000자 단위로 잘랐습니다).
청킹 전략에서 얻은 교훈
초기에는 500자 고정 청킹을 썼는데, 정책 문서 특유의 "자격 요건 → 신청 방법 → 제출 서류" 구조가 절반에서 잘려버리는 문제가 있었습니다. 이걸 의미 단위 청킹(semantic chunking)으로 바꾸면서 검색 품질이 체감적으로 올라갔습니다. 구체적으로는 H2/H3 마커와 "□", "※" 같은 한국 공문서 특수 기호를 경계로 잘랐습니다.
"청크를 잘게 쪼개면 리콜은 올라가지만 프리시전이 떨어진다"는 격언을 몸으로 배운 과정이었습니다.
LLM 응답 — Groq의 매력
검색된 상위 5개 정책을 컨텍스트로 넣어 최종 답변을 만드는 단계에서는 Groq의 llama-3.3-70b-versatile을 씁니다. Groq를 택한 이유는 단 하나, 속도입니다. 토큰 생성 속도가 분당 수백 토큰대라 사용자 체감 응답 시간이 2초 이내로 들어옵니다. 이 차이는 "AI 채팅"이라는 경험에서 결정적입니다.
앞으로의 과제
여전히 해결하지 못한 문제들이 남아 있습니다. 지자체별 조례 개정이 잦아서 크롤링 주기를 더 줄이고 싶지만 Oracle 무료 티어의 I/O 한계가 있습니다. 또 "소득 구간별 자격 재계산"은 아직 JsonLogic 시뮬레이터가 완전히 커버하지 못합니다. 자세한 서비스 비전과 기술 스택은 소개 페이지와 내혜택 서비스 가이드에서 이어서 다룹니다. 피드백이 있다면 문의 페이지로 부담 없이 보내주세요.
공유하기
이어 읽으면 좋은 글
같은 주제와 태그를 기준으로 GRAXEL 운영 맥락을 더 깊게 볼 수 있는 글입니다.
Rust Axum + Cloudflare Tunnel로 policy-api.graxel.ai 운영하기
Oracle ARM 서버의 Rust Axum API를 Cloudflare Tunnel로 외부에 공개하고 무중단 배포까지 안정화한 실전 기록입니다.
1인 개발자 SaaS 모노레포 vs 멀티레포 — Graxel 운영 1년 후 다시 보는 결정
pnpm과 Turborepo로 구축한 모노레포 아키텍처가 1인 개발자에게 정말 정답이었을까요? 1년간의 뼈저린 운영 회고와 실패담.
Cloudflare Pages 무료 티어로 SaaS 시작하기 — 진짜 1년 비용 후기
1인 개발자가 Cloudflare Pages 무료 티어로 1년간 portal, myhyetaek 등 5개 서비스를 운영하며 지출한 실제 비용과 뼈아픈 실패담을 공개합니다.