하이브리드 검색을 이용한 RAG 문서 Q&A 시스템 구축하기 (임베딩 API 불필요)
요약
임베딩 API와 GPU 없이 scikit-learn과 numpy만을 활용하여 구축한 하이브리드 검색 기반 RAG 시스템 DocuMind를 소개합니다. TF-IDF와 BM25를 결합하여 키워드 검색의 정확도와 시맨틱 검색의 장점을 모두 잡은 아키텍처를 제안합니다.
핵심 포인트
- 임베딩 API 비용과 지연 시간을 줄이는 로컬 기반 하이브리드 검색 구현
- TF-IDF와 BM25를 결합한 가중치 융합(Weighted Fusion) 방식 사용
- 문맥 유지를 위해 재귀적 분할(Recursive Splitting) 청킹 전략 채택
- 벡터 DB 없이도 효율적인 문서 Q&A 파이프라인 구축 가능
프로덕션 품질의 RAG (Retrieval-Augmented Generation, 검색 증강 생성) 시스템을 구축하면서 한 가지 배운 점이 있습니다. 바로 어떤 LLM (Large Language Model)을 선택하느냐보다 검색 (Retrieval) 단계가 더 중요하다는 것입니다. 이 포스트에서는 답변을 생성하기 전에 적절한 컨텍스트 (Context)를 찾기 위해 하이브리드 검색 (Hybrid Retrieval, TF-IDF + BM25)을 사용하는 문서 Q&A 시스템인 DocuMind를 어떻게 구축했는지 설명하겠습니다. GPU는 필요하지 않습니다. 유료 임베딩 (Embedding) API도 필요 없습니다. 오직 scikit-learn, numpy, 그리고 무료 LLM 티어만 사용합니다. GitHub: github.com/hajirufai/documind
단순한 RAG의 문제점
대부분의 RAG 튜토리얼은 다음과 같은 패턴을 따릅니다:
- 문서 청킹 (Chunking)
- OpenAI/Cohere를 사용하여 청크 임베딩 (Embedding)
- Pinecone/ChromaDB에 저장
- 코사인 유사도 (Cosine Similarity)로 Top-K 검색
- GPT-4에 전달
이 방식은 작동하지만, 다음과 같은 실질적인 약점이 있습니다:
- 임베딩 API는 규모가 커질수록 비용이 발생하며 (지연 시간도 추가됨)
- 순수 시맨틱 검색 (Semantic Search)은 정확한 키워드를 놓칠 수 있습니다 — "What is the ROI?"라고 물었을 때, 시맨틱 검색은 "return on investment"에 관한 청크를 반환할 수는 있지만, 문자 그대로 "ROI is 45%"라고 적힌 청크는 놓칠 수 있습니다.
- 벡터 데이터베이스 (Vector Database)는 관리해야 할 인프라를 추가합니다.
DocuMind는 다른 접근 방식을 취합니다. 무료 로컬 라이브러리만을 사용하여 시맨틱 검색과 키워드 검색의 장점을 결합한 하이브리드 검색을 사용합니다.
아키텍처 개요
문서 (Document) → 파싱 (Parse) → 청킹 (Chunk) → 인덱싱 (Index, TF-IDF + BM25)
↓
질문 (Question) → 하이브리드 검색 (Hybrid Search) → Top-K 청크 (Chunks) → LLM → 인용된 답변 (Cited Answer)
파이프라인은 5단계로 구성됩니다:
- 파싱 (Parse) — PDF, Markdown, TXT 또는 CSV에서 텍스트 추출
- 청킹 (Chunk) — 중첩되는 조각들로 재귀적으로 분할
- 인덱싱 (Index) — 이중 인덱스 구축 (TF-IDF 벡터 + BM25 토큰 인덱스)
- 검색 (Retrieve) — 두 가지 방법을 모두 사용하여 청크 점수를 매기고 가중치 융합 (Weighted Fusion)으로 결합
- 생성 (Generate) — 컨텍스트 + 질문을 모든 OpenAI 호환 LLM으로 전송
실제 코드를 통해 각 요소를 자세히 살펴보겠습니다.
스마트한 청킹: 단순히 고정된 크기로 나누는 것이 아님
대부분의 튜토리얼은 N 글자마다 텍스트를 나눕니다. 이는 문장 중간을 끊어버리고, 컨텍스트를 잃게 하며, 좋지 않은 검색 결과를 초래합니다.
DocuMind는 재귀적 분할 (recursive splitting)을 사용합니다. 즉, 먼저 문단 구분(paragraph breaks)을 시도하고, 그다음 문장(sentences), 그다음 단어(words) 순으로 시도합니다:
def recursive_split (
text : str ,
chunk_size : int = 800 ,
chunk_overlap : int = 200 ,
separators : list [ str ] | None = None ,
) -> list [ str ] :
if separators is None :
separators = [ " \n\n " , " \n " , " . " , " ! " , " ? " , " ; " , " , " , " " ]
if len ( text ) <= chunk_size :
return [ text . strip ()]
if not text . strip () :
return []
for sep in separators :
parts = text . split ( sep )
if len ( parts ) <= 1 :
continue
chunks = []
current = ""
for part in parts :
candidate = ( current + sep + part ) if current else part
if len ( candidate ) <= chunk_size :
current = candidate
else :
if current :
chunks . append ( current . strip ())
if len ( part ) > chunk_size :
# 더 세밀한 구분자를 사용하여 재귀 호출
remaining = separators [ separators . index ( sep ) + 1 :]
sub_chunks = recursive_split ( part , chunk_size , chunk_overlap , remaining )
chunks . extend ( sub_chunks )
current = ""
else :
current = part
if current . strip ():
chunks . append ( current . strip ())
if chunks :
return _add_overlap ( chunks , chunk_overlap , text )
# 최후의 수단: 강제 분할 (hard split)
return [ text [ i : i + chunk_size ]. strip () for i in range ( 0 , len ( text ), chunk_size - chunk_overlap )]
청크 간의 중첩 (overlap, 기본값 200자)은 경계 부분에서 컨텍스트 (context)가 손실되지 않도록 보장합니다. 또한 자연스러운 경계에서 먼저 분할함으로써, 각 청크는 더욱 의미론적 일관성 (semantically coherent)을 갖게 됩니다.
하이브리드 검색 엔진 (The Hybrid Retrieval Engine)
이것이 핵심적인 혁신입니다. DocuMind는 하나의 검색 방법만을 선택하는 대신, 다음 두 가지를 모두 사용합니다:
TF-IDF (의미론적 유사 검색)
Bigram을 활용한 TF-IDF는 용어의 동시 출현 (co-occurrence) 패턴을 포착합니다. 이는 밀집 임베딩 (dense embeddings)과 같은 "진정한" 의미론적 검색은 아니지만, sublinear_tf=True와 ngram_range=(1,2)를 사용하면 유의어와 관련 용어를 놀라울 정도로 잘 처리합니다:
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
self .
tfidf_vectorizer = TfidfVectorizer ( max_features = 10000 , stop_words = "english" , ngram_range = ( 1 , 2 ), # Unigrams + bigrams sublinear_tf = True , # Logarithmic TF scaling ) self . tfidf_matrix = self . tfidf_vectorizer . fit_transform ( texts ) # 질의 시점: query_vec = self . tfidf_vectorizer . transform ([ query ]) scores = cosine_similarity ( query_vec , self . tfidf_matrix ). flatten () BM25 (키워드 검색) BM25는 Elasticsearch의 기반이 되는 알고리즘입니다. 스마트한 문서 길이 정규화(document-length normalization)를 통해 정확한 키워드 매칭에 탁월합니다: from rank_bm25 import BM25Okapi tokenized = [ re . findall ( r "\w+" , text . lower ()) for text in texts ] self . bm25 = BM25Okapi ( tokenized ) # 질의 시점: tokens = re . findall ( r "\w+" , query . lower ()) scores = self . bm25 . get_scores ( tokens ) 둘을 결합하기: 가중치 융합(Weighted Fusion) 하이브리드 검색은 두 점수 세트 모두를 [0, 1]로 정규화한 다음 이를 결합합니다: def search ( self , query : str , top_k : int = 5 ) -> list [ RetrievalResult ]: semantic_results = self . search_semantic ( query , top_k = len ( self . chunks )) keyword_results = self . search_keyword ( query , top_k = len ( self . chunks )) # 점수 정규화 norm_semantic = normalize ( semantic_scores ) norm_keyword = normalize ( keyword_scores ) # 청크별 가중치 조합 for chunk in self . chunks : combined [ cid ] = alpha * sem + ( 1 - alpha ) * kw # alpha=0.6 기본값 반환 sorted ( combined , reverse = True )[: top_k ] alpha=0.6일 때, 검색 결과는 60%가 의미론적(semantic)이고 40%가 키워드 기반입니다. 이는 설정 가능합니다 — 전문 용어가 많은 기술 문서의 경우 키워드 가중치를 높이거나, 대화형 문서의 경우 의미론적 가중치를 늘릴 수 있습니다. 이것이 작동하는 이유?
| Query | TF-IDF가 찾는 것 | BM25가 찾는 것 | Hybrid(하이브리드)가 찾는 것 |
|---|---|---|---|
| "machine learning performance" | ML 정확도에 관한 청크(Chunks) | 모델 평가에 관한 청크(Chunks) | "performance"가 문자 그대로 포함된 청크 |
| "ROI of the Q3 campaign" | 일반적인 마케팅 청크(Chunks) | 정확한 ROI 언급 | 특정 ROI 청크 + 문맥(Context) |
| "How do I test Python code?" | 테스트 방법론 청크(Chunks) | "pytest", "unittest"가 포함된 청크 | 완전한 테스트 가이드 |
플러그형 LLM 생성 (Pluggable LLM Generation)
DocuMind는 모든 OpenAI 호환 API와 함께 작동합니다. 기본값은 Groq의 무료 티어(초당 300개 이상의 토큰을 생성하는 Llama 3.3 70B)입니다:
def generate_answer ( question , results , conversation , config ):
context = "\n\n".join ( f " [Source { i + 1 } ] { r . chunk . text } " for i , r in enumerate ( results ) )
messages = [
{ " role " : " system " , " content " : SYSTEM_PROMPT },
* conversation [ - 6 :],
{ " role " : " user " , " content " : f " Context: \n { context } \n\n Question: { question } " }
]
response = httpx . post (
f " { config . api_base } /chat/completions ",
headers = { " Authorization " : f " Bearer { config . api_key } " },
json = {
" model " : config . model ,
" messages " : messages ,
" temperature " : 0.1
}
)
return response . json ()[ " choices " ][ 0 ][ " message " ][ " content " ]
제로 비용 모드 (Zero-cost mode): API 키가 설정되지 않은 경우, DocuMind는 가장 관련성이 높은 청크(Chunks)를 추출적 답변(Extractive answer)으로 직접 반환합니다. 여전히 유용하며, 완전히 무료입니다.
CLI 경험 (The CLI Experience)
저는 DocuMind가 터미널에서도 전문적인 느낌을 주기를 원했습니다:
# 문서 인제스트 (Ingest documents)
$ documind ingest report.pdf notes.md data.csv
📄 Ingested report.pdf → 23 chunks ( 4,521 words ) in 89ms
📄 Ingested notes.md → 8 chunks ( 1,203 words ) in 12ms
📄 Ingested data.csv → 45 chunks ( 2,890 words ) in 34ms
# 질문하기 (Ask questions)
$ documind ask "What were the key findings?"
🔍 Retrieved 5 relevant chunks ( hybrid search, 14ms )
The key findings include:
1. Revenue grew 23% YoY driven by...
2. Customer retention improved to 94%...
출처: [ 1] report.pdf ( p.3, score: 0.89 ) [ 2] report.pdf ( p.7, score: 0.76 ) [ 3] notes.md ( score: 0.61 )
메모리가 있는 대화형 채팅
$ documind chat
표(tables), 진행 바(progress bars), 색상 출력(colored output)을 위해 Rich를 사용하여 구축되었습니다.
Web UI
웹 인터페이스는 Tailwind CSS + Alpine.js를 사용합니다. 빌드 단계나 npm 없이 HTML만으로 구성됩니다:
- 드래그 앤 드롭 문서 업로드
- 스트리밍 응답(streaming responses)을 지원하는 실시간 채팅
- 어떤 청크(chunks)가 사용되었는지 보여주는 소스 카드
- 다크 모드
- 모바일 반응형
모두 내장된 http.server 모듈을 사용하는 단일 Python 파일( web.py )에서 제공됩니다. 프론트엔드를 위한 추가 종속성(dependencies)이 전혀 없습니다.
테스트
API 키 없이 실행
모든 테스트는 API 키 없이 실행됩니다. 테스트 스위트(test suite)는 추출 모드(extractive mode)를 사용합니다:
@pytest.fixture
def pipeline ( tmp_path ):
config = Config ( data_dir = str ( tmp_path ), api_key = "" ) # LLM 없음
return DocuMindPipeline ( config )
def test_ingest_and_query ( pipeline , sample_doc ):
result = pipeline . ingest ( sample_doc )
assert result . chunks_created > 0
answer = pipeline . query ( " What is this about? " )
assert len ( answer . sources ) > 0
assert answer . answer # 청크로부터의 추출된 답변
청킹(chunking), 인제스션(ingestion), 검색(retrieval) 및 전체 파이프라인(pipeline)을 다루는 20개의 테스트가 있으며, 모두 2초 이내에 통과합니다.
내가 배운 점
검색 품질(Retrieval quality) > LLM 품질(LLM quality). 훌륭한 컨텍스트(context)를 가진 평범한 LLM이 나쁜 컨텍스트를 가진 강력한 LLM을 이깁니다. 최적화 예산을 검색(retrieval)에 투자하세요.
하이브리드 검색(Hybrid search)은 복잡성을 감수할 가치가 있습니다. 코드는 순수 의미론적 검색(semantic search)보다 약 50줄 정도 더 많을 뿐이지만, 혼합 쿼리(mixed queries)에서 검색 품질이 눈에 띄게 향상됩니다.
임베딩 API(embeddings APIs)는 필요하지 않습니다. 바이그램(bigrams)을 사용한 TF-IDF는 문서 Q&A의 90% 사례를 처리할 수 있습니다. 임베딩 API는 진정으로 교차 언어(cross-lingual) 또는 깊은 의미론적 매칭(deep semantic matching)이 필요할 때를 위해 아껴두세요.
청킹 전략(Chunking strategy)이 중요합니다. 오버랩(overlap)을 포함한 재귀적 분할(Recursive splitting)은 단순한 고정 크기 분할(fixed-size splits)보다 훨씬 더 나은 결과를 만들어냅니다. 추가되는 코드는 그만한 가치가 있습니다.
LLM 없이도 작동하게 만드세요. 추출 방식의 폴백(extractive fallback)은 누구나 DocuMind를 클론(clone)하여 즉시 사용할 수 있음을 의미합니다.
가입도, API 키도, 비용도 필요 없습니다. 이는 시도해 보는 데 있어 진입 장벽을 낮춰주며, 시도해 보는 것이 바로 별(star)을 얻는 길입니다. 직접 시도해 보세요:
git clone https://github.com/hajirufai/documind.git
cd documind
pip install -r requirements.txt
documind ingest sample_docs/* .md sample_docs/* .csv
documind ask "What are Python testing best practices?"
또는 Docker를 사용하여:
docker compose up
http://localhost:8080 접속
전체 소스 코드는 GitHub에서 확인할 수 있습니다: hajirufai/documind
실제로 작동하는 프로젝트를 만드는 것
튜토리얼을 수집하는 것보다 중요합니다. 만약 RAG를 배우고 있다면, 처음부터 직접 구축해 보세요. 그러면 모든 트레이드오프 (tradeoff)를 이해하게 될 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기