로컬 LLM에 RAG 붙이기 — 내 문서로 답하는 Gemma 만들기
로컬 LLM은 인터넷 없이도 돌아가는 대신, 내 컴퓨터에 있는 문서의 내용은 전혀 모른다. 회사 매뉴얼, 개인 메모, 프로젝트 문서를 근거로 답하게 만들려면 RAG(Retrieval-Augmented Generation)를 붙여야 한다. 이전에 쓴 "로컬 LLM 제대로 활용하는 법" 글에서 RAG를 개념으로만 소개했는데, 이 글은 그 심화 실전편으로 실제 코드를 한 줄씩 따라가며 로컬 RAG 파이프라인을 완성한다. 필요한 것은 Ollama가 설치된 PC와 파이썬뿐이고, 전 과정이 내 컴퓨터 안에서만 돌기 때문에 문서가 외부로 나가지 않는다.
1. RAG 개념 — 검색해서 건네주고, 읽고 답하게 한다
RAG의 원리는 생각보다 단순하다. 모델에게 문서 전체를 통째로 외우게 하는 게 아니라, 질문이 들어올 때마다 관련 문서 조각을 검색해서 프롬프트에 끼워 넣는 것이다. 흐름은 두 단계로 나뉜다.
- 인덱싱(한 번만): 문서를 적당한 크기로 자르고(청킹), 각 조각을 숫자 벡터로 바꿔(임베딩) 벡터DB에 저장한다.
- 질의(질문할 때마다): 질문도 같은 방식으로 벡터로 바꾼 뒤, 벡터DB에서 가장 비슷한 조각을 찾아 질문과 함께 모델에 넘긴다.
2. 준비물 — 임베딩 모델과 벡터DB
생성 모델(Gemma)은 이미 있다고 치고, 추가로 두 가지가 필요하다. 첫째는 텍스트를 벡터로 바꿔 주는 임베딩 전용 모델, 둘째는 벡터를 저장하고 유사도 검색을 해 주는 벡터DB다. 임베딩 모델은 Ollama에서 바로 받을 수 있다.
ollama pull embeddinggemma
pip install ollama chromadb
임베딩 모델로 가장 널리 쓰이는 것은 nomic-embed-text지만, 한국어 문서가 많다면 다국어를 지원하는 embeddinggemma나 bge-m3가 결과가 낫다. 하나 주의할 점은 인덱싱할 때 쓴 임베딩 모델과 질문을 벡터로 바꿀 때 쓰는 모델이 반드시 같아야 한다는 것이다. 모델마다 벡터 공간이 다르기 때문에 섞어 쓰면 검색이 엉망이 된다. 벡터DB는 파이썬에서 pip 한 번으로 끝나는 ChromaDB를 쓴다. 별도 서버 설치 없이 로컬 폴더에 저장되는 방식이라 입문용으로 알맞다.
메모리 걱정은 크게 하지 않아도 된다. 임베딩 전용 모델은 파라미터가 수억 개 수준으로 생성 모델보다 훨씬 작아서, 생성용 Gemma와 함께 올려도 VRAM 부담이 크지 않다. 실행 중에 ollama ps를 열어 보면 두 모델이 어떻게 올라와 있는지 확인할 수 있다.
3. 문서 청킹과 벡터 저장
문서를 통째로 임베딩하면 검색 정밀도가 떨어지므로 몇백 자 단위로 자른다. 실전에서는 문단 경계를 존중하는 게 좋지만, 시작은 단순한 고정 길이 + 겹침(overlap) 방식이면 충분하다.
import ollama
import chromadb
def chunk_text(text, size=400, overlap=50):
chunks = []
start = 0
while start < len(text):
chunks.append(text[start:start + size])
start += size - overlap
return chunks
client = chromadb.PersistentClient(path="./ragdb")
col = client.get_or_create_collection("mydocs")
with open("my_manual.txt", encoding="utf-8") as f:
text = f.read()
for i, chunk in enumerate(chunk_text(text)):
emb = ollama.embed(model="embeddinggemma", input=chunk)["embeddings"][0]
col.add(ids=[f"chunk-{i}"], embeddings=[emb], documents=[chunk])
print("저장된 청크:", col.count())
겹침을 두는 이유는 청크 경계에서 문장이 잘려 맥락이 끊기는 걸 줄이기 위해서다. 이 스크립트는 문서를 넣을 때 한 번만 돌리면 되고, ./ragdb 폴더에 결과가 저장되므로 다음부터는 바로 질의할 수 있다.
4. 질의 — 검색해서 컨텍스트로 주입하기
이제 질문을 받아 관련 청크를 찾고, 그것을 근거로 Gemma가 답하게 만든다.
import ollama
import chromadb
client = chromadb.PersistentClient(path="./ragdb")
col = client.get_collection("mydocs")
question = "환불 규정에서 부분 환불 조건이 뭐야?"
q_emb = ollama.embed(model="embeddinggemma", input=question)["embeddings"][0]
found = col.query(query_embeddings=[q_emb], n_results=4)
context = "\n---\n".join(found["documents"][0])
prompt = f"""아래 문서 발췌만 근거로 질문에 답하라.
문서에 없는 내용은 지어내지 말고 모른다고 답하라.
[문서]
{context}
[질문]
{question}"""
res = ollama.chat(model="gemma3:4b",
messages=[{"role": "user", "content": prompt}])
print(res["message"]["content"])
핵심은 프롬프트의 지시문이다. "문서에 없으면 모른다고 답하라"라는 한 줄이 있고 없고에 따라 엉뚱한 답(환각)의 빈도가 크게 달라진다. 실제로 돌려 보면 4B급 모델도 근거가 손에 쥐어졌을 때는 꽤 정확하게 발췌 기반 답변을 한다.
5. 품질을 좌우하는 조절점
돌아가는 것과 잘 돌아가는 것은 다르다. 만들어 놓고 나면 결국 세 가지를 만지게 된다. 청킹 크기(300~800자 사이에서 문서 성격에 맞게), 검색 개수 top-k(3~5개부터 시작, 많이 넣을수록 좋아지는 게 아니라 잡음이 늘 수 있다), 그리고 프롬프트 지시문이다. 셋 다 보편적인 정답이 없어서 내 문서로 직접 바꿔 가며 답변을 비교하는 것이 가장 빠르다.
6. 한계와 개선 포인트
이 최소 구성의 한계도 분명하다. 첫째, 벡터 검색은 의미가 비슷한 것을 찾는 것이지 키워드가 정확히 일치하는 것을 찾는 게 아니라서, 제품 코드나 고유명사 검색에는 약하다. 키워드 검색을 병행하는 하이브리드 검색이 개선책이다. 둘째, PDF나 표가 많은 문서는 텍스트 추출 단계에서 품질이 갈리므로 전처리 도구를 따로 붙여야 한다. 셋째, 문서가 수만 청크 규모로 커지면 검색 결과를 재정렬하는 리랭커(reranker)를 두는 것이 일반적이다. 넷째, 문서가 수정되면 해당 청크를 다시 임베딩해서 갱신해야 한다. 처음에는 그냥 컬렉션을 지우고 전체를 다시 인덱싱하는 것이 속 편하고, 문서가 많아지면 파일별 수정 시각을 기록해 바뀐 파일만 다시 넣는 식으로 발전시키면 된다.
그래도 오늘 만든 구조가 모든 개선의 뼈대라는 점은 변하지 않는다. RAG는 붙이는 것보다 다듬는 것이 본체인 기술이다. 작게 시작해서 내 문서와 내 질문으로 병목이 보이는 곳만 하나씩 바꿔 나가면, 어느 순간 "내 자료를 아는 로컬 비서"가 손에 들어와 있을 것이다.
※ 이 글의 코드와 모델 정보는 2026년 기준이며 변경될 수 있습니다.
함께 보면 좋은 글: 로컬 LLM 제대로 활용하는 법 — 클라우드 AI와 역할 분담, RAG와 파인튜닝까지
댓글
댓글 쓰기