로컬 RAG 고급: LLM 리랭킹·근거 요약으로 품질↑

하이브리드 RAG(=BM25+벡터 검색)까지 만들었는데도 “답이 어딘가 애매하다”면, 이제는 (1) LLM에 넣는 컨텍스트를 더 똑똑하게 고르고(LLM 리랭킹), (2) 답변을 근거 중심으로 요약(Summarization) 하는 단계로 넘어갈 타이밍입니다. 이 두 가지만 추가해도 “그럴듯한 말”이 “근거 있는 답”으로 확 바뀝니다.
이번 주제는 뭐예요?
이번 편의 주제는 “로컬 RAG의 품질을 한 단계 올리는 마지막 퍼즐” 두 가지입니다.
- LLM 기반 재순위(Re-rank)
- 1차 검색(BM25/벡터)으로 뽑힌 문서 후보(top-k)를 더 정밀한 모델이 다시 점수화해서 순서를 재정렬합니다.
- 특히 Cross-Encoder(리랭커)*는 “질의+문서”를 한 번에 입력으로 넣고 직접 관련 점수를 뽑기 때문에, 상위 후보를 정렬하는 데 강합니다. (Pinecone)
- 답변 요약(Summarization) / 근거 정리
- LLM이 최종 답변을 만들고 나서, 근거 문단만 다시 요약해 “핵심 3줄 + 근거 bullet + 불확실성” 형태로 정리합니다.
- 결과적으로 사용자가 “이 답이 어디서 나왔는지” 빠르게 확인할 수 있어 신뢰도가 올라갑니다.
왜 지금 이게 뜨는가
- 로컬 LLM(Ollama)은 설치/운영이 쉬워졌고, 임베딩 전용 모델도 공식적으로 지원되면서 로컬 RAG가 현실이 됐습니다.
- 하지만 “검색은 잘했는데 답이 애매한 문제”는 흔합니다. 이유는 간단해요.
- top-k에 노이즈 문단이 섞이거나
- 핵심 문단이 순위에서 밀리거나
- LLM이 긴 컨텍스트를 대충 읽고 그럴듯하게 말해버리기 때문이죠.
- 그래서 요즘 RAG 파이프라인은 “검색 1단 + 재순위 2단”을 정석(two-stage retrieval)으로 많이 씁니다.
전체 구조(그림으로 이해)

로컬(맥 + SQLite + Ollama) 기준으로 이렇게 가면 됩니다.
- 1단 검색
- BM25(FTS5)로 “키워드에 강한 후보” 뽑기
- 벡터 검색(sqlite-vec)으로 “표현이 달라도 의미가 비슷한 후보” 뽑기
- (선택) RRF로 두 리스트를 합쳐 “넓게” 후보군 만들기 (Microsoft Learn)
- 2단 재순위(Re-rank)
- Cross-Encoder 리랭커가 질의+문단 쌍을 스코어링해 top-n만 남기기 (Hugging Face)
- 생성(Answer)
- top-n 문단만 붙여 Ollama
/api/chat또는 OpenAI 호환 API로 답변 생성 (Ollama)
- top-n 문단만 붙여 Ollama
- 요약(Summarize)
- 방금 사용한 근거 문단을 “핵심 요약 + 근거 목록 + 불확실성”으로 재정리(짧게!)
✅ 핵심: “검색을 잘하는 것”보다 “컨텍스트를 잘 고르는 것”이 답변 품질을 좌우합니다.
설치/준비물
이번 편은 “이전 편(하이브리드 RAG)” 환경을 그대로 씁니다.
- macOS
- Python 3.10+
- SQLite(FTS5)
- sqlite-vec(벡터 검색)
- 참고: vec0 KNN 쿼리는 환경에 따라
LIMIT또는k = ?같은 제약을 요구하는 경우가 있어요.
- 참고: vec0 KNN 쿼리는 환경에 따라
- Ollama
- 임베딩:
/api/embed(Ollama Docs) - 채팅:
/api/chat - (선택) OpenAI 호환 API 사용 가능
- 임베딩:
추가(LLM 리랭킹용, 로컬)
pip install sentence-transformers(CrossEncoder 사용)- 모델 예:
BAAI/bge-reranker-large같은 리랭커는 “top-k를 재정렬하는 용도”로 많이 소개됩니다.
- 모델 예:
실전 사용법 (Step-by-step)
Step 1) “후보군”을 넓게 뽑기 (BM25 + 벡터)
- BM25(FTS5): 정확한 키워드/고유명사/코드 심볼에 강함
- 벡터 검색: 표현이 달라도 의미가 비슷하면 잡아줌
- 두 결과를 합쳐서 candidate set을 만듭니다(top 40~200 정도).
Step 2) RRF로 “1차 통합 순위” 만들기 (선택이지만 추천)
BM25와 벡터는 점수 스케일이 달라서 그대로 더하면 이상해질 수 있어요.
그럴 때 RRF는 순위(rank)만 써서 섞어줍니다.
- 점수 = 1 / (rank + k)
- k는 보통 60 같은 작은 상수로 두는 경우가 많이 언급됩니다.
Step 3) LLM 기반 Re-rank로 “최종 근거 top-n” 고르기
여기서 리랭커(Cross-Encoder)가 일을 합니다.
- 입력: (질의, 문단) 쌍
- 출력: 관련도 점수
- 후보 50개를 넣고 top 6~12개만 뽑는 느낌이 가장 실전적입니다.
Step 4) Ollama로 답변 생성 + “근거 기반 요약” 만들기
- 답변 프롬프트는 근거 문단을 먼저 주고, 그 다음 질문
- 생성 뒤 요약은 “같은 근거 문단”만 대상으로 다시 짧게 정리(핵심 3줄 + bullet)

(작동 예시 코드) 하이브리드 후보 → 리랭크 → 답변 → 요약
아래 코드는 “전체 파이프라인 감”을 잡기 위한 짧은 동작 예시입니다.
import sqlite3
import requests
from sentence_transformers import CrossEncoder
DB_PATH = "rag.db"
OLLAMA = "http://localhost:11434"
EMBED_MODEL = "embeddinggemma"
CHAT_MODEL = "llama3"
# 1) 로컬 리랭커(크로스 인코더) 로드
reranker = CrossEncoder("BAAI/bge-reranker-large")
def ollama_embed(text: str) -> list[float]:
r = requests.post(f"{OLLAMA}/api/embed", json={
"model": EMBED_MODEL,
"input": text
})
r.raise_for_status()
return r.json()["embeddings"][0]
def bm25_search(conn, query: str, k: int = 30) -> list[dict]:
rows = conn.execute(
"SELECT rowid, content FROM docs WHERE docs MATCH ? LIMIT ?",
(query, k)
).fetchall()
return [{"id": rid, "text": txt, "src": "bm25"} for rid, txt in rows]
def vec_search(conn, query: str, k: int = 30) -> list[dict]:
# sqlite-vec 쿼리 형태는 구현/버전에 따라 다를 수 있습니다.
# 핵심은 'query 임베딩'을 넣고 top-k 문단 rowid를 가져오는 구조입니다.
qv = ollama_embed(query)
rows = conn.execute(
"""
SELECT doc_id, distance
FROM vec_docs
WHERE embedding MATCH ? AND k = ?
ORDER BY distance ASC
""",
(bytes(bytearray()), k) # ← 예시 자리: 실제로는 qv를 vec 형식으로 전달
).fetchall()
# NOTE: 위 vec 전달 형식은 환경별로 다릅니다. (여기서는 파이프라인 예시가 목적)
# 실제 구현에서는 sqlite-vec 문서/바인딩 형식에 맞춰 qv를 전달하세요.
# doc_id로 본문 조회
out = []
for doc_id, _dist in rows:
txt = conn.execute("SELECT content FROM docs WHERE rowid = ?", (doc_id,)).fetchone()
if txt:
out.append({"id": doc_id, "text": txt[0], "src": "vec"})
return out
def rerank(query: str, candidates: list[dict], top_n: int = 8) -> list[dict]:
pairs = [(query, c["text"]) for c in candidates]
scores = reranker.predict(pairs)
ranked = sorted(
[{"score": float(s), **c} for s, c in zip(scores, candidates)],
key=lambda x: x["score"],
reverse=True
)
return ranked[:top_n]
def ollama_chat(prompt: str) -> str:
r = requests.post(f"{OLLAMA}/api/chat", json={
"model": CHAT_MODEL,
"messages": [{"role": "user", "content": prompt}],
"stream": False
})
r.raise_for_status()
return r.json()["message"]["content"]
def build_grounded_prompt(query: str, passages: list[dict]) -> str:
context = "\n\n".join([f"[근거 {i+1}] {p['text']}" for i, p in enumerate(passages)])
return f"""아래 근거만 사용해서 답하세요. 근거에 없는 내용은 '추정'이라고 표시하세요.
{context}
질문: {query}
출력 형식:
1) 한 줄 답
2) 근거 요약(불릿 3개)
3) 불확실한 점(있으면)
"""
def summarize_evidence(passages: list[dict]) -> str:
context = "\n\n".join([p["text"] for p in passages])
prompt = f"""다음 근거 문단만으로 요약해줘.
- 핵심 3줄
- 중요한 숫자/고유명사만 따로 5개
- 한 줄 결론
근거:
{context}
"""
return ollama_chat(prompt)
def answer(query: str):
conn = sqlite3.connect(DB_PATH)
bm = bm25_search(conn, query, k=30)
# vec = vec_search(conn, query, k=30) # 실제 환경에서 sqlite-vec 바인딩 맞추면 활성화
vec = []
# 후보 통합(중복 제거)
merged = {c["id"]: c for c in (bm + vec)}
candidates = list(merged.values())
top_passages = rerank(query, candidates, top_n=8) # LLM 기반 재순위
final_prompt = build_grounded_prompt(query, top_passages)
final_answer = ollama_chat(final_prompt)
evidence_summary = summarize_evidence(top_passages)
return final_answer, evidence_summary
if __name__ == "__main__":
q = "벡터 검색 예시를 들어서 하이브리드 RAG 장점을 설명해줘"
a, s = answer(q)
print("\n=== 답변 ===\n", a)
print("\n=== 근거 요약 ===\n", s)코드 해설
CrossEncoder(...): 리랭커 모델 로드. 질의+문단을 같이 넣어 점수를 뽑습니다.ollama_embed(): Ollama/api/embed로 임베딩을 뽑습니다(임베딩은 벡터 검색/RAG의 출발점).bm25_search(): SQLite FTS5에서 키워드 기반으로 후보를 뽑습니다.vec_search(): sqlite-vec로 벡터 후보를 뽑는 자리(실제 바인딩 형식은 환경에 맞춰 구현). 참고로 vec0 쿼리는 LIMIT 또는k = ?제약이 필요한 경우가 있습니다.rerank(): 후보 문단을 리랭커로 점수화하고 top_n만 남깁니다(두 단계 검색의 핵심).build_grounded_prompt(): “근거 밖 내용은 추정 표시” 같은 규칙을 넣어 환각을 줄입니다.summarize_evidence(): 답변 후 근거만 다시 요약해서 사용자 검증 시간을 줄입니다.ollama_chat(): Ollama/api/chat로 답변 생성(스트리밍 끄려면stream: false).
⚠️ 참고: 예시 코드는 “구조 이해용”입니다. 특히 sqlite-vec에 임베딩을 바인딩하는 방식은 확장/언어 바인딩에 따라 달라질 수 있어, 여러분 환경의 sqlite-vec 문서/예제에 맞춰
vec_search()부분을 연결해 주세요.
바로 써먹는 팁 3가지
✅ 팁 1 — 리랭크는 ‘후보 50개 → 최종 8개’ 정도가 가장 가성비
- 리랭커(Cross-Encoder)는 정확하지만 비용이 큽니다.
- 그래서 “대충 넓게 뽑고, 좁게 정렬”이 정석이에요.
✅ 팁 2 — 답변 프롬프트에 ‘근거 밖이면 추정’ 규칙을 박아라 🧷
- “근거에 없는 내용은 추정이라고 표시”만 해도 환각이 확 줄어듭니다.
- 그리고 요약 단계에서 “숫자/고유명사만 따로”를 꼭 뽑아두면 검증이 쉬워요.
✅ 팁 3 — 요약은 ‘답변 요약’이 아니라 ‘근거 요약’으로
- 답변을 요약하면 또 “그럴듯한 요약”이 나올 수 있습니다.
- 근거 문단만 요약하면 검증 가능성이 올라갑니다(실무에서 이게 체감 큼).
온라인 반응/후기 요약
- 리랭커를 “RAG의 성능을 올리는 2단계 구성(two-stage retrieval)의 핵심”으로 소개하는 자료가 많습니다. (질의-문서 쌍을 직접 점수화하는 cross-encoder 방식)
- Hugging Face의 bge-reranker 계열도 “간단한 리트리버가 뽑은 top-k를 리랭킹하는 용도”로 널리 설명됩니다.
- 로컬 관점에서는 Ollama가 임베딩 모델과
/api/embed를 공식 지원해 RAG 파이프라인에 쓰기 쉬워졌다는 점이 반복적으로 강조됩니다. - OpenAI 호환 API 지원도 로컬 도구/프레임워크 연결성을 높였다는 평가가 많습니다(단, 지원 범위는 문서 기준으로 확인 권장).
리스크 & 주의사항
1) TOS/API 사용 리스크
- 로컬 Ollama는 기본적으로 로컬 호출이지만, OpenAI 호환 API를 쓸 때도 “어떤 엔드포인트/버전”을 쓰는지 문서 기준으로 확인하세요.
2) 보안 & 프라이버시
- ✅ 장점: 문서가 로컬에서만 처리되면 유출 위험이 크게 줄어듭니다.
- ⚠️ 주의: 로컬 서버(11434 포트)를 외부에 노출하지 마세요(공유기 포트포워딩 금지).
3) 데이터 처리 주의(문서 청크/로그)
- 문서 청크에 개인정보/민감정보가 섞이면, 요약 결과에 그대로 튀어나올 수 있어요.
- 로깅(프롬프트/근거 저장)을 켜두면 “편하지만 위험”합니다. 운영 모드에서는 최소화하세요.
4) 비용/사용량 스파이크(시간/자원)
- 리랭커는 후보 수에 비례해서 느려집니다(특히 CPU).
- 요약까지 붙이면 LLM 호출이 2번이 되므로, 응답 시간이 2배 가까이 늘 수 있습니다.
5) 안전 검증 체크리스트 (How to verify safely) ✅
- 근거 문단을 같이 출력했는가? (답변만 내보내면 위험)
- “근거 밖 내용 = 추정” 규칙을 적용했는가?
- top-n 근거를 사람이 10초라도 훑어보면 “말이 되는지” 판단 가능한가?
- 로컬 포트가 외부에 열려 있지 않은가?
- 로그/캐시에 민감정보가 남지 않는가?
FAQ
Q1. Re-rank는 꼭 LLM으로 해야 하나요?
A. 꼭은 아닙니다. 하지만 Cross-Encoder 리랭커는 “질의+문서”를 함께 보고 점수화해서 top-k 재정렬에 강한 방식으로 자주 소개됩니다.
Q2. RRF는 왜 쓰나요?
A. BM25와 벡터는 점수 스케일이 달라서 단순 합산이 어렵습니다. RRF는 순위만으로 1/(rank+k) 점수를 만들어 섞어주는 방식으로 널리 설명됩니다.
Q3. Ollama로 임베딩도 만들 수 있나요?
A. 네. Ollama는 임베딩 모델을 지원하고 /api/embed로 임베딩을 생성합니다.
Q4. 요약은 왜 필요한가요? 답만 잘하면 되지 않나요?
A. 실무에서는 “검증 가능성”이 중요합니다. 근거 요약을 함께 주면 사용자가 빠르게 맞는지 확인할 수 있어요.
Q5. 벡터 검색 예시는 어떤 게 좋아요?
A. “같은 의미인데 표현이 다른 질문”이 좋습니다. 예: ‘권한 분리’ vs ‘유저별 접근제어’, ‘장애 원인’ vs ‘근본 원인 분석’. 벡터는 이런 표현 차이를 완화해 줍니다.
Q6. sqlite-vec에서 KNN 쿼리가 안 돼요.
A. 환경에 따라 LIMIT 또는 k = ? 같은 제약이 필요한 이슈가 보고된 적이 있습니다. (SQLite 버전 영향 가능)
Q7. OpenAI 호환 API로 붙이는 게 더 좋은가요?
A. 도구 호환성(기존 SDK 재사용) 측면에서 장점이 있지만, 지원 범위는 버전에 따라 달라질 수 있으니 공식 문서 기준으로 확인하세요.
Q8. 리랭커 모델은 뭐부터 쓰면 돼요?
A. bge-reranker 계열처럼 “top-k 재정렬”을 전제로 설명되는 리랭커부터 시작하는 편이 무난합니다.
마무리
이번 편의 포인트는 딱 하나입니다: “검색 결과를 더 똑똑하게 고르고, 답변을 근거 중심으로 정리하라.”
하이브리드 RAG는 “찾는 능력”을 올리고, 리랭킹+근거 요약은 “설득력(검증 가능성)”을 올립니다.




