Prologue: 벡터 검색의 환상과 차가운 현실
요즘 AI 서비스를 만든다고 하면 열에 아홉은 RAG(Retrieval-Augmented Generation, 검색 증강 생성)를 구축합니다. 저 역시 내 서비스 내부의 엄청난 양의 기술 문서를 자연어로 똑똑하게 찾아주는 챗봇을 만들기 위해 신나게 RAG 파이프라인을 구축했습니다.
처음에는 벡터 데이터베이스(Vector DB)와 텍스트 임베딩(Embedding) 조합이 마치 마법 지팡이인 줄 알았습니다. 사용자가 질문을 던지면 그 의미(Semantic)를 수학적으로 비교해 가장 유사한 문서를 척척 찾아주었으니까요.
하지만 실무 서비스를 배포하고 유저들의 실제 로그를 보면서 현실의 벽에 부딪혔습니다.
유저들은 늘 친절하고 아름다운 서술형 문장으로 질문하지 않았습니다. 때로는 "Error 504", "auth-client-init", "pg_dump" 같은 아주 구체적인 에러 코드나 고유 API 키워드만 툭 던졌습니다.
그런데 벡터 검색은 이 구체적인 단어들을 제대로 잡아내지 못했습니다. 예를 들어, auth-client-init 에러에 대한 문서를 찾아야 하는데, 벡터 검색은 "인증(Authentication)과 초기화(Initialization)에 대한 일반적인 설명서"를 추천해 주었습니다. 의미적으로는 유사할지 몰라도, 유저가 당장 해결해야 하는 특정 에러 코드 매칭에는 완전히 실패한 것입니다.
"왜 의미를 그렇게 잘 안다면서 고작 에러 코드 하나 매칭을 못 하지?"
머리를 싸매고 공부한 끝에, 결국 텍스트의 '의미'를 찾는 벡터 검색과 '특정 단어'를 찾는 키워드 검색은 상호보완적이어야 하며, 이 둘을 합쳐야만 쓸만한 RAG 검색 품질이 나온다는 것을 배웠습니다.
Concept: Hybrid Search와 Reranking
RAG 성능을 비약적으로 끌어올리기 위한 두 가지 핵심 열쇠는 바로 **Hybrid Search(하이브리드 검색)**와 **Reranking(재정렬)**입니다.
1. Hybrid Search: BM25와 벡터의 결혼
하이브리드 검색은 전통적인 키워드 기반 검색 알고리즘인 BM25와 현대적인 **벡터 의미론 검색(Vector Semantic Search)**을 함께 사용하는 방식입니다.
- BM25 (Sparse Vector): 문장 내 단어의 빈도와 중요도를 따지는 전통적인 키워드 매칭 방식입니다. 특정 제품명, 함수명, 오류 코드, 일련번호 등 '정확한 텍스트 매칭'에 압도적으로 강력합니다.
- Vector Search (Dense Vector): 단어의 외형이 달라도 문맥적 의미가 유사한 문서를 찾습니다. "로그인 안 됨"이라는 질문에 "인증 오류", "세션 만료" 관련 문서를 찾아내는 능력이 탁월합니다.
이 둘을 융합하면, 구체적인 고유 명사 매칭과 유연한 의미적 이해를 동시에 수행하는 이상적인 검색기가 탄생합니다.
2. Reranking: 진짜 중요한 정보만 맨 위로
검색을 통해 수십 개의 관련 문서를 찾아냈다고 해서 곧바로 LLM에게 쏟아부으면 안 됩니다. LLM은 컨텍스트가 너무 길어지면 비용이 올라가고 속도가 느려질 뿐만 아니라, 문맥의 중간에 위치한 정보를 무시하는 경향("Lost in the Middle" 현상)이 있습니다.
그래서 필요한 것이 **Reranker(재정렬 모델)**입니다.
Reranker는 검색 단계에서 가볍고 빠르게 찾아낸 상위 2030개의 문서들을 대상으로, 사용자의 질문과의 실제 밀접도를 무겁고 정교하게 재평가합니다. 그 결과로 진짜 핵심적인 35개의 문서만 추려내어 LLM의 컨텍스트로 주입합니다.
이 구조를 공부하고 나니, 그동안 RAG가 헛소리(Hallucination)를 하던 이유가 이해되었습니다. 쓰레기 정보가 컨텍스트에 섞여 들어가니 LLM도 헷갈렸던 것입니다. 핵심 정보만 족집게처럼 골라주는 Reranker는 RAG 성능 최적화의 필수 코스였습니다.
Deep Dive: 융합과 정렬의 기술적 매커니즘
하이브리드 검색과 재정렬을 내 시스템에 구현할 때 마주쳤던 구체적인 기술적 문제와 해결 과정을 정리했습니다.
1. 서로 다른 점수의 합산: RRF (Reciprocal Rank Fusion)
BM25 점수(보통 0에서 수십 사이의 스케일 없는 값)와 벡터 코사인 유사도 점수(0~1 사이)는 단위가 달라서 단순히 합산할 수 없습니다. 사과 3개와 물 500ml를 더해 503을 만드는 격이니까요.
이때 가장 대중적이고 효과적인 점수 병합 알고리즘이 바로 **RRF(상호 순위 융합)**입니다. RRF는 점수의 절댓값이 아닌 **'검색 결과 내에서의 순위(Rank)'**를 기반으로 최종 점수를 재계산합니다.
RRF_Score(d) = Σ[m∈M] 1 / (k + r_m(d))
여기서 $r_m(d)$는 검색 시스템 $m$에서의 문서 $d$의 순위이고, $k$는 극단적인 순위 차이에 의한 왜곡을 방지하기 위한 상수(보통 60)입니다. 양쪽 검색 결과에서 모두 상위권에 위치한 문서가 압도적인 점수를 얻게 되는 구조입니다.
2. 크로스 인코더(Cross-Encoder) 기반의 Rerank
임베딩 모델은 질문과 문서를 각각 독립적으로 숫자로 변환한 뒤 나중에 유사도를 비교하는 '바이 인코더(Bi-Encoder)' 방식입니다. 속도가 매우 빠르지만 정교함은 떨어집니다.
반면 Rerank 모델은 질문과 문서를 하나의 입력으로 통째로 묶어서 어텐션(Attention) 연산을 수행하는 '크로스 인코더(Cross-Encoder)' 방식입니다. 두 텍스트의 상호 연관성을 훨씬 정교하게 짚어내기 때문에, 바이 인코더가 놓친 미세한 관계를 잡아내어 최종 순위를 재조정합니다.
Application: Node.js/TypeScript로 구현하는 RAG 파이프라인
내 검색 엔진에 하이브리드 검색 결과 병합(RRF)과 Cohere Rerank API를 호출하는 로직을 직접 구현해 보았습니다.
interface SearchResult {
id: string;
content: string;
score: number;
}
// 1. RRF 알고리즘 구현
function reciprocalRankFusion(
vectorResults: SearchResult[],
bm25Results: SearchResult[],
k: number = 60
): SearchResult[] {
const rrfScores: Record<string, { doc: SearchResult; score: number }> = {};
const applyRrf = (results: SearchResult[]) => {
results.forEach((doc, index) => {
const rank = index + 1;
const scoreContribution = 1 / (k + rank);
if (!rrfScores[doc.id]) {
rrfScores[doc.id] = { doc, score: 0 };
}
rrfScores[doc.id].score += scoreContribution;
});
};
applyRrf(vectorResults);
applyRrf(bm25Results);
// RRF 점수 기준으로 내림차순 정렬
return Object.values(rrfScores)
.sort((a, b) => b.score - a.score)
.map(item => ({
...item.doc,
score: item.score
}));
}
// 2. RAG 검색 실행 함수
async function searchKnowledgeBase(query: string) {
// 1단계: 병렬로 두 검색기 실행
const [vectorDocs, bm25Docs] = await Promise.all([
retrieveVectorSearch(query, 20),
retrieveBM25Search(query, 20)
]);
// 2단계: RRF로 검색 결과 하이브리드 병합
const hybridDocs = reciprocalRankFusion(vectorDocs, bm25Docs);
// 3단계: Cohere Rerank API를 통한 재정렬
const documentsForRerank = hybridDocs.map(d => d.content);
const response = await fetch("https://api.cohere.com/v1/rerank", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.COHERE_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
query: query,
documents: documentsForRerank,
top_n: 3, // 최종적으로 가장 연관 깊은 3개만 추출
model: "rerank-multilingual-v3.0"
})
});
const rerankData = await response.json();
// 재정렬된 인덱스 바탕으로 최종 문서 추출
const finalContext = rerankData.results.map((r: any) => {
return hybridDocs[r.index];
});
return finalContext;
}
이 파이프라인을 도입한 뒤 실제 유저들의 실패 사례 중 하나였던 "auth-client-init 에러" 검색을 다시 테스트해 보았습니다.
- 이전 (Vector 단독): 일반적인 JWT 인증 방식 설명, 클라이언트 초기 세팅 방법이 상위권에 배치되어 에러 코드 내용 누락.
- 이후 (Hybrid + Rerank): BM25가
auth-client-init키워드를 잡아 상위 후보군에 올렸고, Rerank 모델이 질문과의 매칭 연관도를 검증해 해당 에러 대응 가이드 문서를 1순위로 올려놓았습니다.
Summary: RAG는 결국 90%의 검색과 10%의 생성이다
RAG 애플리케이션을 만들 때 흔히 저지르는 실수는 가장 좋은 최신 LLM(예: GPT-4o, Claude 3.5 Sonnet)을 쓰면 모든 문제가 해결될 것이라 믿는 것입니다. 하지만 이는 시험장에 엉뚱한 참고서를 들려주고 100점을 맞아오라고 요구하는 것과 같습니다.
RAG의 본질은 결국 **"얼마나 고품질의 컨텍스트를 LLM의 입력창에 꽂아 넣어주느냐"**에 달려 있습니다. 즉, RAG 엔지니어링의 핵심 역량 90%는 데이터를 정제하고 원하는 문서를 정확하게 찾아내는 **검색 단계(Retrieval)**에 있습니다.
기존 벡터 단독 검색에 하이브리드 필터와 크로스 인코더 Reranking을 추가하는 작업은 비용도 적게 들고 구현도 간단하지만, 챗봇의 대답 정확도를 눈에 띄게 끌어올려 주었습니다. AI 시대의 개발이라도 결국 데이터 파이프라인과 검색 공학이라는 근본적인 기술을 얼마나 튼튼히 다지는지가 완성도를 결정짓는다는 것을 깊이 배웠습니다.