Ollama·LangChain으로 API 비용 없이 사내 문서 Q&A 구현 — 하이브리드 검색과 리랭킹으로 프라이버시와 검색 품질을 동시에
클라우드 LLM API를 쓰다 보면 두 가지가 마음에 걸립니다. 하나는 월말에 날아오는 청구서고, 다른 하나는 "우리 내부 문서가 OpenAI 서버에 올라가는 거잖아?"라는 불안감이죠. 저도 처음 RAG 파이프라인을 프로덕션에 올렸을 때, 법무팀에서 "이 계약서 내용이 외부로 나가는 거 아니에요?"라는 질문을 받고 식은땀을 흘린 기억이 납니다.
Ollama는 LLM 추론부터 임베딩 생성까지 전 과정을 로컬 머신 안에서 끝낼 수 있고, OpenAI API와 호환되는 엔드포인트를 제공해 기존 코드를 거의 수정하지 않아도 됩니다. 단, 완벽한 해결책은 아닙니다. CPU만 있는 환경에서 7B 모델은 1~5 token/s 수준이라 실시간 응답을 기대하기 어렵고, GPU가 없다면 14B 이상 모델은 현실적으로 느립니다. 그 트레이드오프를 알고 시작하는 게 중요합니다.
이 글은 Python 기초 지식이 있고 로컬 RAG를 처음 시도해보려는 분을 대상으로 합니다. Ollama + LangChain으로 사내 문서를 인덱싱하고, 하이브리드 검색과 리랭킹으로 검색 품질까지 끌어올리는 세 단계 파이프라인을 다룹니다. 글을 다 읽고 나면, 계약서나 내부 문서에 질문하는 챗봇이 여러분의 로컬 머신에서 돌아가고 있을 겁니다.
목차
핵심 개념
RAG가 해결하는 문제
RAG(Retrieval-Augmented Generation)는 LLM의 "지식이 학습 시점에서 멈춰 있다"는 한계를 우회하는 아키텍처 패턴입니다. 외부 문서를 실시간으로 검색해서 프롬프트에 끼워 넣는 방식이라, 학습 컷오프 이후 데이터도 참조할 수 있고, 검색된 사실에 근거한 답변이 나오니 할루시네이션도 눈에 띄게 줄어듭니다.
| 문제 | RAG의 해결 방식 |
|---|---|
| 지식 최신성 | 학습 컷오프와 무관하게 외부 문서 실시간 참조 |
| 할루시네이션 | 검색된 사실 기반 답변 생성으로 사실 오류 감소 |
| 비용·프라이버시 | 완전 로컬 운용으로 API 비용 제로, 데이터 외부 유출 없음 |
세 단계로 이해하는 파이프라인
Ollama RAG는 구조적으로 세 단계로 나뉩니다. 어느 단계에서 품질 문제가 생겼는지 파악하는 것만으로도 디버깅 시간이 절반으로 줄어듭니다.
1단계 — 인덱싱(Indexing)
문서를 일정 크기의 청크로 자르고, Ollama 임베딩 모델로 벡터화한 뒤 로컬 벡터 DB에 저장합니다. 보통 한 번 실행하거나 문서가 업데이트될 때만 다시 돌립니다.
2단계 — 검색(Retrieval)
사용자 쿼리를 동일한 임베딩 모델로 벡터화하고, 벡터 DB에서 유사도가 높은 청크를 꺼냅니다. 검색 품질이 최종 답변 품질을 거의 결정합니다.
3단계 — 생성(Generation)
검색된 청크를 프롬프트에 주입하고 Ollama가 로컬 LLM으로 응답을 생성합니다.
Ollama의 핵심 인터페이스:
ollama pull(모델 다운로드),ollama run(대화형 실행), HTTP REST API(포트 11434). OpenAI 호환 엔드포인트(/v1/embeddings,/v1/chat/completions)를 지원해 LangChain, LlamaIndex 등 기존 RAG 프레임워크와 코드 수정 없이 연결됩니다.
임베딩 모델 선택
솔직히 저도 처음엔 그냥 nomic-embed-text 하나 박고 시작했는데, 한국어 문서를 다루면서 검색 결과가 영 시원찮아서 다시 벤치마크를 뒤지게 됐습니다.
| 모델 | 파라미터 | 컨텍스트 | 벡터 차원 | 추천 상황 |
|---|---|---|---|---|
qwen3-embedding:8b |
8B | 8192 | 4096 | 한국어·다국어 혼합 문서 (MTEB 다국어 1위) |
nomic-embed-text |
137M | 8192 | 768 | 영어 문서, 빠른 처리 속도 우선 |
bge-m3 |
567M | 8192 | 1024 | 중·한·영 다국어 균형 |
mxbai-embed-large |
335M | 512 | 1024 | 영어 특화, 품질 우선 |
2025년 6월에 출시된 qwen3-embedding:8b가 MTEB 다국어 리더보드에서 70.58점으로 1위를 기록했습니다. 70점대는 OpenAI text-embedding-3-large와 경쟁하는 수준으로, 이전 로컬 임베딩 모델들이 60점대 초중반에 머물렀던 것과 비교하면 유의미한 도약입니다. 제 경험상 한국어가 섞인 문서라면 nomic-embed-text 대비 검색 히트율 차이가 체감될 정도입니다.
MTEB(Massive Text Embedding Benchmark): 검색·분류·군집화 성능을 여러 데이터셋으로 평가하는 임베딩 모델 표준 벤치마크. 숫자가 높을수록 다양한 검색 시나리오에서 더 좋은 성능을 기대할 수 있습니다.
한 가지 주의할 점이 있습니다. 임베딩 모델을 한 번 선택하면 이후 교체 시 벡터 DB 전체를 재인덱싱해야 합니다. 처음부터 신중하게 고르고, 모델명은 코드에 하드코딩하지 말고 설정 파일로 빼두는 것을 권장합니다.
청킹 전략이 생각보다 더 중요하다
최근 arXiv 연구(2505.21700)에서 흥미로운 결과가 나왔는데, 임베딩 모델 간 성능 격차(약 5%)보다 청킹 전략 변경에 따른 성능 격차(약 7%)가 더 크다는 겁니다. 비싼 임베딩 모델로 바꾸기 전에 청킹부터 점검해볼 만합니다.
| 시나리오 | 권장 청크 크기 | 방식 |
|---|---|---|
| FAQ, 단문 쿼리 | 128~256 문자 | 고정 크기 |
| 기술 문서, 매뉴얼 | 512~1024 문자 | 고정 크기 + overlap |
| 장문 분석, 법률 문서 | 부모 1024 + 자식 256 문자 | 계층적 청킹 |
여기서 단위를 "문자(character)" 로 명시한 이유가 있습니다. LangChain의 RecursiveCharacterTextSplitter는 기본적으로 문자 수로 청크 크기를 계산합니다(length_function=len). 512 문자는 한국어 기준 약 170~250 토큰 수준입니다. "512 토큰짜리 청크"라고 생각하고 운용하면 실제로는 훨씬 작은 청크가 생겨 검색 품질에 영향을 줄 수 있습니다. 토큰 기준으로 운용하고 싶다면 tiktoken이나 HuggingFace 토크나이저를 length_function에 직접 넣어야 합니다.
사전 준비
코드 예시를 따라 하기 전에 아래 환경을 갖춰두면 됩니다.
1. Ollama 설치 및 모델 준비
# ollama.com에서 Ollama 설치 후
ollama pull qwen3-embedding:8b # 임베딩 모델 (~5GB)
ollama pull qwen3:14b # 생성 모델 (~9GB)
# 코드 실행 전 반드시 Ollama 서버를 먼저 띄워야 합니다
ollama serve
ollama serve를 먼저 실행해두지 않으면 코드에서ConnectionRefusedError가 납니다. 터미널을 하나 더 열어 서버를 띄워두거나, 시스템 시작 시 자동 실행되도록 설정해두시면 좋습니다.
2. Python 패키지 설치
pip install langchain-ollama langchain-chroma langchain-community \
rank-bm25 sentence-transformers pypdflangchain-ollama, langchain-chroma는 버전 간 API 변경이 잦은 편입니다. 버전 충돌이 생기면 LangChain 공식 문서에서 호환 버전을 확인해보시면 좋습니다.
하드웨어 참고사항: CPU만 있는 환경에서 7B 모델은 1~5 token/s 수준으로 느립니다. 빠른 응답이 필요하다면 GPU(VRAM 16GB 이상) 환경에서 14B 이상을 쓰는 것을 권장합니다. 아래 예시 코드는 어떤 환경에서도 동작하지만, 응답 속도는 하드웨어에 따라 크게 달라집니다.
실전 적용
기본 Q&A 챗봇 구현
법무·컴플라이언스 요건이 있는 환경에서 가장 많이 쓰이는 패턴입니다. 계약서, 정책 문서, 매뉴얼을 인덱싱하고, 쿼리가 외부로 나가지 않으면서 질문에 답하는 구조입니다.
from langchain_community.document_loaders import PyPDFLoader, TextLoader
from langchain_ollama import OllamaEmbeddings, ChatOllama
from langchain_chroma import Chroma
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
# 문서 로딩 (PDF 또는 텍스트 파일)
loader = PyPDFLoader("contracts/sample.pdf")
# loader = TextLoader("docs/manual.txt", encoding="utf-8") # 텍스트 파일인 경우
docs = loader.load()
# 로컬 임베딩 모델
embeddings = OllamaEmbeddings(model="qwen3-embedding:8b")
# 청크 분할: 512 문자, 50 문자 overlap
# length_function=len은 문자 수 기준 (토큰 수와 다름 — 한국어 512자 ≈ 170~250 토큰)
splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=50,
length_function=len,
)
chunks = splitter.split_documents(docs)
# 나중에 임베딩 모델을 교체할 때 추적할 수 있도록 메타데이터로 기록
for chunk in chunks:
chunk.metadata["embedding_model"] = "qwen3-embedding:8b"
# 벡터 저장 (로컬 디스크에 영속)
# 동일한 persist_directory로 재실행하면 기존 DB를 덮어씁니다
vectorstore = Chroma.from_documents(
chunks,
embeddings,
persist_directory="./db"
)
# MMR 검색: 관련성 높으면서 중복 없는 결과 반환
retriever = vectorstore.as_retriever(
search_type="mmr",
search_kwargs={"k": 5, "fetch_k": 20}
)
prompt = ChatPromptTemplate.from_template("""
아래 컨텍스트만을 바탕으로 질문에 답해주세요.
컨텍스트에 없는 내용은 "문서에서 확인할 수 없습니다"라고 답하세요.
컨텍스트: {context}
질문: {question}
""")
llm = ChatOllama(model="qwen3:14b")
chain = (
{"context": retriever, "question": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
response = chain.invoke("연차 신청 절차가 어떻게 되나요?")
print(response)| 코드 포인트 | 설명 |
|---|---|
search_type="mmr" |
Maximal Marginal Relevance — 관련성과 다양성을 동시에 고려해 중복 청크를 걸러냄 |
fetch_k=20 |
내부적으로 20개를 뽑은 뒤 MMR 알고리즘으로 최종 5개를 선택 |
persist_directory |
ChromaDB가 디스크에 저장되어 서버 재시작 후에도 재인덱싱 불필요 |
embedding_model 메타데이터 |
임베딩 모델 교체 시 어떤 모델로 인덱싱됐는지 추적 가능 |
왜 ChromaDB인가? Qdrant나 Weaviate도 훌륭하지만, ChromaDB는 Docker 없이 Python 패키지만으로 즉시 동작합니다. 처음 RAG를 시도할 때 환경 구성을 최소화할 수 있다는 게 가장 큰 장점입니다. 프로덕션에서 고급 필터링이나 더 높은 성능이 필요해지면 그때 Qdrant 이전을 검토해보시면 좋습니다.
BM25 + 의미 검색 하이브리드로 정확도 높이기
실무에서 자주 맞닥뜨리는 상황인데, 순수 벡터 검색만으로는 "제3조"나 "SKU-2024-001" 같은 키워드를 못 잡는 경우가 있습니다. BM25(키워드 검색)와 Dense Embedding(의미 검색)을 함께 쓰는 하이브리드 검색이 이럴 때 도움이 됩니다.
아래 코드는 기본 Q&A 챗봇 구현에서 만든 chunks를 재사용합니다. 별도 문서 로딩 없이 이어서 실행할 수 있습니다.
from langchain.retrievers import BM25Retriever, EnsembleRetriever
from langchain_ollama import OllamaEmbeddings
from langchain_chroma import Chroma
embeddings = OllamaEmbeddings(model="qwen3-embedding:8b")
# Dense retriever: 의미 기반 검색
# persist_directory를 분리해 앞선 예시의 DB를 덮어쓰지 않도록
vectorstore = Chroma.from_documents(
chunks, # 앞선 예시에서 분할한 청크 재사용
embeddings,
persist_directory="./db_hybrid"
)
dense_retriever = vectorstore.as_retriever(search_kwargs={"k": 10})
# BM25 retriever: 키워드 기반 검색
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 10
# 앙상블: RRF(Reciprocal Rank Fusion)로 두 결과를 융합
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, dense_retriever],
weights=[0.4, 0.6] # 의미 검색에 더 높은 가중치
)
results = ensemble_retriever.invoke("제3조 계약 해지 조건")RRF(Reciprocal Rank Fusion): 여러 검색 결과 목록을 순위 기반으로 합산해 최종 순위를 결정하는 알고리즘. 각 방식의 약점을 서로 보완해 단일 검색보다 일관적으로 좋은 결과가 나오는 경향이 있습니다.
weights=[0.4, 0.6]은 시작점이지 정답이 아닙니다. 키워드 매칭이 중요한 법률·규정 문서라면 BM25 가중치를 더 높이고, 자연어 질문이 많은 FAQ라면 Dense 쪽을 높여보시면 좋습니다. 제 경험상 초기값으로 50:50 혹은 40:60에서 시작해 Ragas 점수로 비교하면 감이 잡히기 시작합니다.
크로스인코더 리랭킹으로 상위 결과 정밀도 높이기
top-k 검색 결과를 그대로 LLM에 넘기기보다, 크로스인코더(cross-encoder)로 한 번 더 정렬하면 정밀도가 눈에 띄게 올라갑니다. 아래 코드는 BM25 + 의미 검색 하이브리드에서 만든 ensemble_retriever를 기반으로 동작합니다.
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
# 크로스인코더 리랭커 (로컬 실행, 첫 실행 시 모델 자동 다운로드)
model = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-v2-m3")
compressor = CrossEncoderReranker(model=model, top_n=5)
# top-20 검색 → 크로스인코더 리랭킹 → top-5 반환
compression_retriever = ContextualCompressionRetriever(
base_compressor=compressor,
base_retriever=ensemble_retriever # 앞선 예시에서 만든 하이브리드 retriever
)
reranked_results = compression_retriever.invoke("계약 해지 시 위약금 규정")크로스인코더(Cross-Encoder): 쿼리와 문서를 동시에 입력받아 관련성 점수를 계산하는 모델. 단순 벡터 유사도보다 정확하지만 문서 수만큼 추론이 필요해 속도가 느립니다. top-20을 먼저 빠르게 뽑고 크로스인코더로 top-5를 추려내는 2단계 방식이 속도와 품질을 균형 있게 가져가는 방법입니다.
리랭킹은 로컬이라 API 비용 걱정은 없지만, 추가 추론이 발생해 응답 지연이 늘어납니다. 문서 수가 많거나 실시간 응답이 중요한 환경이라면 지연 시간을 측정한 뒤 도입 여부를 결정해보시면 좋습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 완전한 데이터 프라이버시 | 모든 쿼리와 문서가 로컬에서만 처리, 법적 컴플라이언스 충족 |
| 제로 API 비용 | 초기 설치 후 운용 비용 없음, 대용량 문서 처리에 경제적 |
| 오프라인 동작 | 네트워크 의존성 없어 단절 환경(공장, 병원, 군사 시설)에 적합 |
| OpenAI 호환 API | 기존 코드 최소 변경으로 클라우드 → 로컬 마이그레이션 가능 |
| 모델 다양성 | Llama, Qwen, Mistral, DeepSeek, Gemma 등 100+ 모델 지원 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 하드웨어 요구사항 | 7B: CPU 8GB RAM 가능(1~5 token/s), 14B↑: GPU 16GB VRAM 권장 | 리소스에 맞게 모델 크기 선택, 양자화 모델 활용 |
| 동시 요청 처리 한계 | 단일 인스턴스의 병렬 처리 성능이 클라우드 대비 제한적 | 다중 Ollama 인스턴스 + 로드밸런서 구성 |
| 초기 모델 다운로드 | Llama3.2:3b(2GB)~Qwen3:32b(20GB) 최초 풀 시간 소요 | CI/CD에서 사전 풀, 로컬 레지스트리 활용 |
| 임베딩 마이그레이션 비용 | 임베딩 모델 교체 시 전체 벡터 DB 재인덱싱 필요 | 초기 모델 선택을 신중히, 모델명을 설정으로 분리 |
솔직히 "단점"에서 가장 뼈아팠던 건 임베딩 마이그레이션 비용이었습니다. 초반에 nomic-embed-text로 빠르게 시작했다가 나중에 qwen3-embedding:8b로 바꾸면서 문서 수만 건을 다시 인덱싱하느라 꽤 오랜 시간을 썼습니다. 처음부터 모델명을 환경 변수로 빼두고, 벡터 DB에 메타데이터로 기록해두는 습관이 얼마나 중요한지 그때 배웠습니다.
양자화(Quantization): 모델 가중치를 float32에서 int8, int4 등 낮은 정밀도로 줄이는 기법. VRAM 사용량과 추론 속도가 개선되지만 품질이 소폭 떨어집니다. Ollama에서는
:q4_K_M,:q8_0같은 태그로 양자화 버전을 선택할 수 있습니다.
실무에서 가장 흔한 실수
-
임베딩 모델부터 바꾸려 한다: 검색 품질이 낮을 때 반사적으로 더 큰 임베딩 모델로 교체하게 됩니다. 그런데 앞서 언급한 arXiv 연구에 따르면 청킹 전략 최적화가 임베딩 모델 교체보다 성능 향상에 더 효과적입니다. 청크 크기와 overlap부터 조정해보는 것을 권장합니다.
-
벡터 검색 하나만 믿는다: 순수 의미 검색은 "제3조", "SKU-2024-001" 같은 정확한 키워드 매칭에 취약합니다. 프로덕션이라면 BM25와 함께 쓰는 하이브리드 검색이 일관적으로 더 좋은 결과를 냅니다.
-
임베딩 모델을 하드코딩한다: 모델명을 코드 곳곳에 박아두면 나중에 교체할 때 재인덱싱과 코드 수정이 동시에 발생합니다. 모델명은 설정 파일이나 환경 변수로 빼두고, 벡터 DB에 메타데이터로 함께 저장해두는 것이 좋습니다(기본 Q&A 챗봇 구현 코드의
chunk.metadata["embedding_model"]참고).
마치며
글을 다 읽고 나면 아마 이런 생각이 드실 수 있습니다. "그래도 좋은 임베딩 모델을 쓰면 품질이 올라가겠지." 맞긴 한데, 이 글에서 제가 가장 반직관적이라고 생각하는 포인트는 청킹 전략이 임베딩 모델 교체보다 더 큰 품질 향상을 가져다준다는 겁니다. 비싼 임베딩 모델을 찾기 전에, 지금 쓰는 청크 크기가 내 문서 구조에 맞는지부터 점검해보시면 좋습니다.
Ollama 기반 로컬 RAG는 API 비용이 없고, 데이터가 외부로 나가지 않으면서, 하이브리드 검색과 리랭킹까지 갖출 수 있는 현실적인 스택입니다. 7B 이하 모델이라면 노트북 CPU로도 시작할 수 있고, GPU가 있다면 14B 이상으로 체감 품질이 크게 올라갑니다.
빠른 시작
지금 바로 시작해볼 수 있는 3단계입니다.
-
Ollama 설치 후 모델 준비: ollama.com에서 Ollama를 설치한 뒤
ollama pull qwen3-embedding:8b && ollama pull qwen3:14b로 한국어 문서에 최적화된 조합을 내려받을 수 있습니다.ollama serve로 서버를 먼저 띄운 뒤 코드를 실행합니다. -
ChromaDB로 첫 인덱싱 시도:
pip install langchain-ollama langchain-chroma langchain-community pypdf로 패키지를 설치하고, 기본 Q&A 챗봇 구현 코드를 본인의 PDF나 텍스트 파일 몇 개로 첫 인덱싱을 시도해볼 수 있습니다. ChromaDB는 별도 서버 없이 Python 패키지만으로 즉시 동작합니다. -
Ragas로 검색 품질 측정:
pip install ragas로 설치한 뒤 Faithfulness, Answer Relevancy, Context Precision 세 지표를 측정해보시면 좋습니다. 직감으로 "잘 되는 것 같다"가 아닌 숫자로 품질을 관리할 수 있어, 청킹 전략 변경이나 리랭킹 도입 효과를 명확하게 확인할 수 있습니다.
참고 자료
- Ollama Embedding Models | Ollama Blog
- Multimodal Models | Ollama Blog
- Ollama Model Library
- Local RAG Tutorial: LangChain, Ollama & ChromaDB with Ragas | Medium
- Building a Robust RAG Pipeline with LangChain, Ollama, and Chroma | Codes and Chips
- Local RAG System for Privacy with Ollama and Weaviate | Weaviate Blog
- Building a Private RAG System with Ollama | Markaicode
- Ollama Embedding Models: Benchmarks, VRAM, and Which to Use | Morph
- Advanced RAG Techniques: Hybrid Search and Re-ranking | dasroot.net
- Why Your RAG Pipeline Returns Wrong Answers: Chunk Size & Embedding | Medium
- Best RAG Frameworks 2025: LangChain vs LlamaIndex vs Haystack vs RAGFlow | LangCopilot
- How to Optimize RAG Retrieval Accuracy with Ollama: 7 Proven Techniques | Markaicode
- Rethinking Chunk Size For Long-Document Retrieval: A Multi-Dataset Analysis | arXiv
- 오프라인 RAG 시스템 구축 가이드: Ollama 및 Python 활용 | Toolify