개인 블로그 280개 포스트를 대상으로 다양한 검색 방법론의 성능을 비교 실험하는 시리즈를 시작한다. 이 글에서는 실험 대상인 6가지 검색 방법의 원리를 정리하고, 테스트 데이터셋의 특성을 분석하며, 정량적·정성적 평가 기준과 실험 계획을 수립한다.
들어가며
RAG(Retrieval-Augmented Generation) 시스템에서 검색 품질은 최종 응답 품질을 직접적으로 좌우한다. 아무리 좋은 LLM을 써도 엉뚱한 문서를 가져오면 답변도 엉뚱하다.
그런데 "어떤 검색 방법이 가장 좋은가?"에 대한 답은 데이터 특성에 따라 크게 달라진다. 영어 위키피디아를 대상으로 한 벤치마크 결과가 한국어+영어 혼용 기술 블로그에 그대로 적용되지 않는다. 그래서 내 데이터로 직접 실험해보기로 했다.
이 시리즈에서는 내 블로그 280개 포스트를 테스트베드로 삼아 다양한 검색 방법을 구현하고, 어떤 방법이 — 그리고 어떤 조합이 — 가장 효과적인지 실험한다.
시리즈 구성
| 편 | 주제 | 내용 |
|---|---|---|
| (1) 실험 설계 (이 글) | 방법론 개요 + 실험 설계 | 검색 방법 원리, 데이터셋 분석, 평가 기준 |
| (2) 키워드 검색 | Ripgrep vs BM25 vs BM25+Kiwi | 정규식, TF-IDF, 한국어 형태소 분석 효과 |
| (3) 벡터 검색 | 임베딩 모델별 · 청킹 전략별 비교 | BGE-M3, multilingual-e5, OpenAI 등 |
| (4) 하이브리드 검색 | 조합별 성능 비교 | RRF, weighted fusion, 최적 조합 탐색 |
| (5) 결론 | 최종 결과 정리 | 데이터 특성별 권장 조합, 교훈 |
테스트 데이터셋: 내 블로그
실험 대상은 이 블로그 자체다. Obsidian으로 작성하고 Next.js로 렌더링하는 마크다운 기반 블로그이며, 검색 실험에 흥미로운 특성을 여럿 갖고 있다.
기본 통계
| 항목 | 값 |
|---|---|
| 총 포스트 수 | 280개 |
| 작성 기간 | 2023년 ~ 2026년 |
| 언어 비율 | 한국어 ~70%, 영어 ~20%, 혼용 ~10% |
| 평균 포스트 길이 | ~3,000–5,000 단어 (편차 큼) |
| 최장 포스트 | ~30,000 단어 |
| 포맷 | Markdown + YAML frontmatter |
카테고리 분포
content/
├── AI/ ← ~127개 (45%)
├── Dev/ ← 일반 개발
├── Study/ ← 학습 기록
├── Projects/ ← 프로젝트 회고
├── Tools/ ← 도구 사용법
├── Events/ ← 행사/컨퍼런스
└── Others/ ← 분류 외
AI 카테고리에 절반 가까이 쏠려 있다. 이 불균형은 검색 실험에서 중요한 변수가 된다 — "AI" 같은 일반적인 키워드로 검색하면 거의 모든 글이 걸리기 때문이다.
메타데이터 구조
모든 포스트에 YAML frontmatter가 있다:
---
title: '챗봇 성능 최적화: 캐싱 전략으로 비용 절감과 속도 향상'
date: 2025-09-07
tags:
- AI
- Caching
- Chatbot
draft: false
enableToc: true
description: 챗봇 시스템에서 캐싱 전략을 적용하여...
summary: ...
published: 2025-09-07
modified: 2025-09-07
---이 메타데이터는 그 자체로 검색 대상이기도 하고, 다른 검색 방법의 필터 조건으로도 활용된다.
검색 실험에 영향을 주는 데이터 특성
| 특성 | 설명 | 검색에 미치는 영향 |
|---|---|---|
| 한국어+영어 혼용 | 본문은 한국어, 기술 용어는 영어 | 형태소 분석 필수, 다국어 임베딩 모델 필요 |
| 교착어 | "데이터베이스를", "데이터베이스의", "데이터베이스" | 형태소 분석 없으면 같은 단어를 다르게 인식 |
| 기술 전문 용어 | "HNSW", "ReAct", "pgvector" | 일반 임베딩 모델이 이런 용어를 얼마나 잘 표현하는지 |
| 코드 블록 | Python, YAML, SQL 등 코드 포함 | 코드 내 키워드 검색 vs 자연어 검색 간 차이 |
| Obsidian 위키링크 | [[다른 글 제목]] 형태 내부 링크 | 문서 간 관계 그래프 구성 가능 |
| 길이 편차 | 1,000단어 ~ 30,000단어 | 긴 문서는 청킹이 필수, 짧은 문서는 통째로 검색 |
| 카테고리 불균형 | AI 45%, 나머지 분산 | 범용 키워드의 precision 저하 |
이 데이터셋은 "소규모 + 한국어 + 기술 블로그"라는 니치한 특성을 갖고 있다. 대규모 영어 벤치마크(MS MARCO, Natural Questions 등)와는 성격이 다르며, 바로 그 점이 직접 실험하는 이유다.
실험 대상: 검색 방법론 6가지
방법 1: Ripgrep — 정규식 키워드 검색
가장 원시적이면서 가장 확실한 방법. 텍스트를 있는 그대로 매칭한다.
원리:
- 파일 시스템을 직접 스캔하며 정규식 패턴과 일치하는 라인을 찾음
- Rust 기반으로 280개 파일을 10ms 이내에 전수 검색
- 매칭 횟수를 스코어로 활용 (많이 등장하는 파일 = 더 관련 있음)
예상 강점:
- 정확한 키워드, 에러 메시지, 함수명 검색에 최강
- 구현 가장 단순, 외부 의존성 거의 없음
- 코드 블록 내 검색에 강함
예상 약점:
- "에이전트"로 검색하면 "agent"를 못 찾음 (동의어 불가)
- "LLM 에이전트 아키텍처"로 검색하면 "ReAct 패턴" 글을 못 찾음 (시맨틱 불가)
- 한국어 조사에 취약: "데이터베이스를" ≠ "데이터베이스"
방법 2: BM25 — 통계적 키워드 랭킹
정보 검색(IR)의 클래식. TF-IDF를 개선한 확률적 랭킹 모델.
원리:
- : 단어 가 전체 코퍼스에서 얼마나 희귀한지 (희귀할수록 높은 가중치)
- : 문서 에서 단어 의 출현 빈도
- : 문서 길이 정규화 (긴 문서 페널티)
Ripgrep과 다른 점은 코퍼스 전체를 고려한 통계적 중요도를 반영한다는 것이다. "AI"처럼 모든 글에 있는 단어는 점수가 낮고, "HNSW"처럼 소수 글에만 있는 단어는 높다.
예상 강점:
- 키워드 관련도 랭킹이 Ripgrep보다 정교
- 문서 길이 정규화로 긴 글이 불리하지 않음
예상 약점:
- Ripgrep과 동일한 시맨틱 한계
- 한국어 토크나이징 문제 동일 (공백 분리 시)
방법 3: BM25 + 한국어 형태소 분석 (Kiwi)
방법 2에 한국어 형태소 분석기를 결합. 이 실험에서 가장 궁금한 비교 중 하나다 — 형태소 분석이 얼마나 차이를 만드는가?
원리:
- kiwipiepy로 텍스트를 형태소 단위로 분리
- 명사(NNG, NNP), 동사(VV), 형용사(VA), 외래어(SL)만 추출
- 추출된 형태소를 BM25 토큰으로 사용
입력: "벡터 데이터베이스를 비교했다"
공백 분리: ["벡터", "데이터베이스를", "비교했다"]
Kiwi 분석: ["벡터", "데이터베이스", "비교", "하다"]
실험 포인트:
- 형태소 분석 유무에 따른 한국어 검색 품질 차이는?
- 영어 기술 용어가 섞인 문장에서 Kiwi가 제대로 동작하는가?
- Kiwi vs 단순 정규식 토크나이저(
[a-zA-Z가-힣0-9]+) 성능 차이는?
방법 4: 벡터 검색 (임베딩 + VDB)
텍스트를 고차원 벡터로 변환하고, 벡터 간 거리로 유사도를 측정.
원리:
- 임베딩 모델이 텍스트 → 벡터 변환 (예: 1024차원)
- 쿼리도 같은 모델로 벡터 변환
- 코사인 유사도로 가장 가까운 벡터(=문서)를 검색
실험 변수 — 임베딩 모델:
| 모델 | 차원 | 한국어 지원 | 특징 |
|---|---|---|---|
| BGE-M3 | 1024 | 우수 | dense + sparse 동시 출력 |
| multilingual-e5-large | 1024 | 양호 | 범용 다국어 |
| OpenAI text-embedding-3-small | 1536 | 보통 | API 기반, 유료 |
| ko-sroberta-multitask | 768 | 우수 | 한국어 특화 |
실험 변수 — 청킹 전략:
| 전략 | 설명 |
|---|---|
| 문서 통째로 | 청킹 없이 문서 전체를 하나의 벡터로 |
| 고정 크기 (512 토큰) | 기계적으로 자르기 |
| 마크다운 시맨틱 (H2 기반) | 헤딩 구조를 기준으로 분할 |
| 문단 단위 | \n\n 기준 분할 |
실험 변수 — 벡터 DB:
Qdrant를 기본으로 사용한다. 선택 이유:
| 기준 | Qdrant | Pinecone |
|---|---|---|
| 무료 tier | 1GB, always-on | 2M 벡터, scale-to-zero |
| Cold start | 없음 | 1-2초 (idle 후) |
| 하이브리드 검색 | Named Vectors + RRF 네이티브 | 지원하지만 덜 유연 |
| 로컬 개발 | Docker / in-memory 모드 | 불가 |
Pinecone serverless는 idle 후 scale-to-zero되므로, 저트래픽 상황에서 첫 쿼리마다 cold start가 발생한다. 실험 중 반복 테스트에서도 이 지연이 거슬리므로, 로컬에서 바로 실행 가능한 Qdrant가 실험 환경으로도 유리하다.
예상 강점:
- "에이전트"로 검색 → "ReAct 패턴", "Plan-and-Execute" 글 발견 (시맨틱 유사도)
- 동의어, 다국어 매칭
예상 약점:
- 정확한 키워드 매칭은 오히려 부정확할 수 있음
- 임베딩 모델의 한국어 품질에 의존
- 청킹 전략에 따라 결과가 크게 달라질 수 있음
방법 5: 메타데이터 필터
frontmatter의 구조화된 데이터를 직접 활용하는 방법.
원리:
- 태그, 카테고리, 날짜 등 메타데이터로 정확한 필터링
- 다른 검색 방법과 결합 가능 (예: "2025년 이후 AI 카테고리에서 벡터 검색")
단독 vs 결합:
- 단독: "RAG 태그가 붙은 글 전부" → 정확하지만 랭킹 없음
- 결합: 벡터 검색 + 메타데이터 필터 → Qdrant payload filter로 동시 처리
실험 포인트:
- 메타데이터 필터를 pre-filter로 걸면 벡터 검색 정확도가 올라가는가?
- 태그의 품질(일관성)이 필터 효과에 얼마나 영향을 주는가?
방법 6: 하이브리드 검색 (Fusion)
위 방법들을 조합하고 결과를 통합.
퓨전 방법:
| 방법 | 원리 | 장점 |
|---|---|---|
| RRF (Reciprocal Rank Fusion) | 순위 기반, 로 합산 | 스코어 스케일 무관 |
| Weighted Sum | 각 방법의 정규화 점수를 가중 합산 | 방법별 가중치 조절 가능 |
| Cascade | 1차 검색 → 상위 N개만 2차 검색 | 비용 절감 |
실험할 조합:
| 조합 | 구성 |
|---|---|
| A | BM25+Kiwi + 벡터 검색 |
| B | Ripgrep + 벡터 검색 |
| C | BM25+Kiwi + 벡터 검색 + 메타데이터 필터 |
| D | 전체 5가지 통합 |
| E | Qdrant 네이티브 하이브리드 (dense + sparse in BGE-M3) |
조합 E가 특히 흥미로운 실험이다. BGE-M3는 한 번의 인코딩으로 dense vector와 sparse vector(lexical weights)를 동시에 출력한다. 이걸 Qdrant Named Vectors에 넣으면 별도 BM25 파이프라인 없이 하이브리드 검색이 가능하다. 과연 전통적인 BM25+Kiwi 대비 어떤 결과를 보이는지.
평가 기준
테스트 쿼리 셋
실험의 핵심은 "무엇으로 평가할 것인가"다. 다음 4가지 유형의 테스트 쿼리를 준비한다:
Type 1 — 정확 키워드 (Exact Keyword)
| 쿼리 | 기대 결과 |
|---|---|
create_react_agent | 해당 함수를 사용하는 글 |
ModuleNotFoundError | 에러를 다룬 글 |
pgvector | pgvector 관련 글 전부 |
Type 2 — 자연어 질문 (Natural Language)
| 쿼리 | 기대 결과 |
|---|---|
| "LLM 에이전트 아키텍처 비교" | ReAct, Plan-and-Execute, Supervisor 글 |
| "PDF에서 텍스트 추출하는 방법" | PDF 파서 비교, OCR 관련 글 |
| "벡터 DB 성능 차이" | pgvector vs Qdrant vs Milvus 글 |
Type 3 — 다국어 혼용 (Cross-lingual)
| 쿼리 | 기대 결과 |
|---|---|
| "agent 만들기" | 에이전트/agent 모두 포함된 글 |
| "RAG 파이프라인 구축" | RAG 관련 글 (한영 혼용) |
| "embedding model comparison" | 임베딩 모델 비교 글 (한국어 작성) |
Type 4 — 구조화 필터 (Structured)
| 쿼리 | 기대 결과 |
|---|---|
| "2025년 이후 RAG 관련 글" | 날짜 + 태그 필터 |
| "AI 카테고리 최신 5개" | 카테고리 + 정렬 |
| "LangChain 태그 글 중 성능 관련" | 태그 필터 + 시맨틱 |
정량 평가 지표
| 지표 | 설명 | 측정 방법 |
|---|---|---|
| Precision@K | 상위 K개 결과 중 관련 문서 비율 | 수동 레이블링 |
| Recall@K | 전체 관련 문서 중 상위 K개에 포함된 비율 | 수동 레이블링 |
| MRR (Mean Reciprocal Rank) | 첫 번째 관련 문서의 순위 역수 평균 | 자동 계산 |
| nDCG@10 | 순위 가중 관련도 점수 | 관련도 등급(0/1/2) 부여 |
| Latency | 쿼리 응답 시간 (ms) | 자동 측정 |
정성 평가
숫자만으로 포착하기 어려운 부분도 기록한다:
- Surprise: 예상 못한 관련 문서를 찾아준 경우 (좋은 serendipity)
- Noise: 명백히 무관한 문서가 상위에 나오는 경우
- Coverage: 쿼리 유형별로 어떤 방법이 강한지/약한지 패턴
- 한국어 특이 케이스: 형태소 분석 유무에 따른 체감 차이
Ground Truth 구축
280개 문서에 대한 완전한 ground truth를 만드는 것은 비현실적이다. 대신:
- 테스트 쿼리 30~50개를 유형별로 준비
- 각 쿼리에 대해 관련 문서를 수동으로 레이블링 (0: 무관, 1: 관련, 2: 매우 관련)
- 모든 검색 방법의 결과를 블라인드 셔플해서 평가 (어떤 방법의 결과인지 모르는 상태로)
자기 블로그라 편향이 생기기 쉽다. "내가 이 글 쓸 때 이 의도였으니까 관련 있어"라는 주관이 개입될 수 있다. 블라인드 평가로 최대한 완화하되, 본질적으로 N=1 실험이라는 한계는 인정한다.
실험 환경
인프라
| 구성 요소 | 기술 | 환경 |
|---|---|---|
| 벡터 DB | Qdrant | 로컬: Docker / in-memory, 운영: Qdrant Cloud Free |
| 임베딩 | BGE-M3 (기본), 비교 모델들 | 로컬 GPU or CPU |
| 형태소 분석 | kiwipiepy | 로컬 |
| 키워드 검색 | ripgrep | 로컬 (시스템 도구) |
| 에이전트 | LangGraph | 최종 통합 시 |
| 블로그 | Next.js + Obsidian content | Vercel |
코드 구조
실험 코드는 syshin0116.dev/agent/ 에 위치한다:
agent/src/agent/lib/
├── ripgrep_search.py # 방법 1: Ripgrep
├── bm25_search.py # 방법 2, 3: BM25 (±Kiwi)
├── vector_search.py # 방법 4: Qdrant 벡터 검색 (구현 예정)
├── frontmatter_index.py # 방법 5: 메타데이터 필터
├── hybrid_fusion.py # 방법 6: 하이브리드 퓨전 (구현 예정)
├── content_loader.py # 마크다운 로딩 + 파싱
├── wikilink_graph.py # 위키링크 그래프 탐색
└── types.py # 공통 타입
다음 편 예고
(2) 키워드 검색 실험에서는 Ripgrep, BM25(공백 분리), BM25+Kiwi 세 가지를 동일한 쿼리 셋으로 비교한다. 특히 한국어 형태소 분석의 효과를 정량적으로 측정하고, 키워드 검색만으로 어디까지 커버 가능한지 한계를 확인한다.