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

LLM 리랭킹

하이브리드 RAG(=BM25+벡터 검색)까지 만들었는데도 “답이 어딘가 애매하다”면, 이제는 (1) LLM에 넣는 컨텍스트를 더 똑똑하게 고르고(LLM 리랭킹)(2) 답변을 근거 중심으로 요약(Summarization) 하는 단계로 넘어갈 타이밍입니다. 이 두 가지만 추가해도 “그럴듯한 말”이 “근거 있는 답”으로 확 바뀝니다.

이번 주제는 뭐예요?

이번 편의 주제는 “로컬 RAG의 품질을 한 단계 올리는 마지막 퍼즐” 두 가지입니다.

  1. LLM 기반 재순위(Re-rank)
  • 1차 검색(BM25/벡터)으로 뽑힌 문서 후보(top-k)를 더 정밀한 모델이 다시 점수화해서 순서를 재정렬합니다.
  • 특히 Cross-Encoder(리랭커)*는 “질의+문서”를 한 번에 입력으로 넣고 직접 관련 점수를 뽑기 때문에, 상위 후보를 정렬하는 데 강합니다. (Pinecone)
  1. 답변 요약(Summarization) / 근거 정리
  • LLM이 최종 답변을 만들고 나서, 근거 문단만 다시 요약해 “핵심 3줄 + 근거 bullet + 불확실성” 형태로 정리합니다.
  • 결과적으로 사용자가 “이 답이 어디서 나왔는지” 빠르게 확인할 수 있어 신뢰도가 올라갑니다.

왜 지금 이게 뜨는가

  • 로컬 LLM(Ollama)은 설치/운영이 쉬워졌고, 임베딩 전용 모델도 공식적으로 지원되면서 로컬 RAG가 현실이 됐습니다.
  • 하지만 “검색은 잘했는데 답이 애매한 문제”는 흔합니다. 이유는 간단해요.
    • top-k에 노이즈 문단이 섞이거나
    • 핵심 문단이 순위에서 밀리거나
    • LLM이 긴 컨텍스트를 대충 읽고 그럴듯하게 말해버리기 때문이죠.
  • 그래서 요즘 RAG 파이프라인은 “검색 1단 + 재순위 2단”을 정석(two-stage retrieval)으로 많이 씁니다.

전체 구조(그림으로 이해)

2단 RAG 파이프라인 전체 구조

로컬(맥 + 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)
  • 요약(Summarize)
    • 방금 사용한 근거 문단을 “핵심 요약 + 근거 목록 + 불확실성”으로 재정리(짧게!)

✅ 핵심: “검색을 잘하는 것”보다 “컨텍스트를 잘 고르는 것”이 답변 품질을 좌우합니다.

설치/준비물

이번 편은 “이전 편(하이브리드 RAG)” 환경을 그대로 씁니다.

  • macOS
  • Python 3.10+
  • SQLite(FTS5)
  • sqlite-vec(벡터 검색)
    • 참고: vec0 KNN 쿼리는 환경에 따라 LIMIT 또는 k = ? 같은 제약을 요구하는 경우가 있어요.
  • 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는 “찾는 능력”을 올리고, 리랭킹+근거 요약은 “설득력(검증 가능성)”을 올립니다.

유사한 게시물

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다