Minseong Code Lab
← Projects
Bootcamp Team ProjectRAG / LLM

RFP-AI

공공입찰 제안요청서, 원문 근거까지 추적해 답하다

공공입찰 제안요청서(RFP)를 분석해 원문 근거 기반 답변을 생성하는 근거 추적형 Hybrid RAG QA 시스템. 부트캠프 팀 프로젝트에서 Generation을 담당했습니다.

게시 2026-06-30업데이트 2026-10-22
PythonChromaDBFlagEmbeddingRAGASOpenAI API

Repository팀 공동 저장소

malit9999/AI-based-RFP-RAG-System(새 탭에서 열림)

여러 명이 함께 만든 저장소입니다. 제가 맡은 범위는 아래 Role·Scope에 있습니다.

Problem
수백 페이지 RFP에 흩어진 예산·제출조건 — 정확한 키워드·표 검색이 관건
Decision
Dense 단독의 한계 확인 → BM25 Hybrid + Reranker + Query Re-writing 채택
Evidence
faithfulness 0.913 · context_precision 0.722 — RAGAS 18문항, 구성별 최고값
Role
팀 프로젝트 Generation 파트 담당
Scope
팀 프로젝트 기록과 그 안에서 제가 맡은 범위

평가 기준 — RAGAS · 18문항 (단일 6 · 후속 8 · 필터 2 · 답변불가 2)

평가 조건·한계 자세히 보기
Configfaithfulnesscontext_precision
Dense Only0.78700.5691
BM25 Hybrid0.9133 ▲최고0.4910
Hybrid + Reranker0.86290.7222 ▲최고

주의 — faithfulness 0.9133과 context_precision 0.7222는 서로 다른 구성의 최고값으로, 동일 구성에서 동시에 달성한 결과가 아닙니다.

현재 평가의 한계

  • 부트캠프 팀 프로젝트 단계의 평가셋(18문항) 기준 수치입니다.
  • RAGAS 지표 중 faithfulness·context_precision 중심으로 검증했으며, Hit@K·MRR·nDCG 등 Retrieval 지표는 이 팀 프로젝트에서 측정하지 않았습니다.
System Flow — 전체 파이프라인
01Question
02Query Analysis
03Query Re-writing
04aBM25
04bDense
05RRF Fusion
06Reranker
07Answer + Evidence

판단 기록 — Reranker 오류 시 RRF 결과로 폴백하는 안전 장치를 유지합니다.

Contents · 목차
  1. 프로젝트 개요
  2. 시스템 구조
  3. 실험 결과
  4. 의사결정 기록
  5. 팀 작업과 제가 맡은 범위

프로젝트 개요

공공 RFP(제안요청서) 문서를 청킹하고, BM25와 Dense Retrieval을 결합한 Hybrid Retrieval 구조로 사용자의 질문에 원문 근거 기반 답변을 생성하는 RAG QA 시스템입니다.

부트캠프 팀 프로젝트로 진행했고 저는 Generation 파트를 담당했습니다. 단순 구현이 아니라 검색 전략별 성능을 수치로 검증하고, 도메인 특화 질문(금액·제출조건·후속질문)을 다루는 것이 목표였습니다. 이 페이지는 그 팀 프로젝트의 기록입니다.

문제 정의

공공 RFP 문서는 QA 시스템 입장에서 까다로운 특성이 있습니다.

  • 분량이 길고, 중요한 정보(예산, 제출 조건, 보안 요구사항)가 여러 위치에 흩어져 있습니다.
  • 예산·배점·일정 같은 핵심 정보가 표 형태로 포함되는 경우가 많습니다.
  • 사업명, 발주기관, 금액처럼 정확한 키워드 매칭이 중요해 의미 기반 검색만으로는 부족합니다.
  • 실제 사용 시나리오에서는 "그 사업 예산의 10%는?" 같은 후속 질문이 자주 발생합니다.

시스템 구조

전체 파이프라인은 상단 System Flow 도식과 같습니다. 도식에 담기지 않은 세부는 두 가지입니다 — Query Analyzer가 질문을 일반/금액/제출조건/후속 4가지 유형으로 먼저 분류하고, Reranker 이후에는 Context Builder → LLM Answer Generator → Evidence Formatter가 이어져 답변과 원문 근거를 함께 구성합니다.

핵심 기능

  • Hybrid Retrieval: 키워드 검색(BM25)과 의미 검색(Dense)을 RRF로 결합
  • Reranker: 검색 후보 중 질문 관련성이 높은 chunk를 상위 배치
  • Query Re-writing: "그 사업", "그 금액" 같은 후속 질문을 이전 대화 맥락으로 재작성
  • 근거 추적: 답변에 사용한 문서명·chunk 유형·원문 문장을 함께 표시
  • 평가 파이프라인: 18개 평가셋(단일/후속/필터/답변불가)을 RAGAS로 자동 평가

실험 결과

검색 전략별 RAGAS 평가 결과입니다.

실험faithfulnesscontext_precision
Dense Only0.78700.5691
BM25 Hybrid0.91330.4910
Hybrid + Reranker0.86290.7222

굵게 표시한 수치는 서로 다른 실험 구성에서의 최고값입니다. faithfulness 0.9133(BM25 Hybrid)과 context_precision 0.7222(Hybrid + Reranker)는 동일 구성에서 동시에 달성한 결과가 아닙니다.

핵심 인사이트:

  • RFP 도메인에서는 정확한 키워드 검색이 중요해 BM25 결합이 faithfulness를 크게 개선했습니다.
  • Reranker는 context_precision 개선에 효과가 있었습니다.
  • 후속 질문에서는 Query Re-writing이 answer_relevancy 개선에 기여했습니다.

의사결정 기록

  1. Dense Retrieval만으로는 부족했다 — 사업명·기관명·금액처럼 정확한 키워드가 중요한 질문에서 의미 기반 검색만으로는 정확도가 낮았습니다.
  2. BM25 결합 — 키워드 검색을 결합하자 faithfulness가 0.7870에서 0.9133으로 개선되었습니다.
  3. Reranker 적용 — 검색 후보의 재정렬로 context_precision이 0.7222까지 개선되었습니다.
  4. Query Re-writing — "그 사업", "그 금액" 같은 후속 질문의 모호한 지시어를 이전 대화 맥락으로 보완했습니다.

트러블슈팅

  • HWP 표 추출: 예산·배점 등 핵심 정보가 표에 있어, table chunk를 별도 관리하고 표 기반 질문에서 우선 검색하는 방향까지 정리하고 팀 프로젝트를 마쳤습니다.
  • Reranker 오류: FlagEmbedding 토크나이저 오류 발생 이력이 있어, Reranker 실패 시 RRF fallback으로 폴백하는 안전 장치를 유지합니다.
  • ChromaDB 필터: 발주기관 필터 적용 시 chunk 수보다 큰 n_results 요청으로 오류가 발생할 수 있어 safe_n 처리를 적용했습니다.

팀 작업과 제가 맡은 범위

위 저장소 카드는 팀 공동 저장소입니다. 여기까지 읽은 내용 — Hybrid 검색, RRF, Query Re-writing, RAGAS 18문항 평가 — 은 전부 그 저장소의 코드이고, 그중 제가 담당한 파트는 Generation입니다. 아래 표가 그 경계입니다.

시점어디에무엇개인 기여
2026-06 · 팀 프로젝트팀 공동 저장소Streamlit 서비스 · 하이브리드 검색 · RRF · Query Re-writing · RAGAS 평가검색 파트는 팀 공동 작업
2026-06 · 팀 프로젝트같은 저장소, 제 커밋답변 규칙 · 거부 형식 · 답변·근거·출처 3구역 출력Generation 파트 담당

Generation — 답변 규칙 (담당 파트) — 시스템 프롬프트에 "컨텍스트에 없는 문서 사실은 추측하지 않는다"를 최우선 원칙으로 두고, 정보가 없을 때의 거부 형식까지 규정했습니다.

# service/core/functions.py — SYSTEM_PROMPT_V7E 발췌
[거부 규칙]
컨텍스트에 질문이 요구하는 문서 기반 정보가 없으면:
- **답변** 첫 줄을 "제공된 문서에서 해당 정보를 찾을 수 없습니다."로 시작한다.
- 그래도 **근거**, **출처** 구역은 생략하지 않는다.
- 일반 지식, 상식, 추정, 가능성 표현으로 보완하지 않는다.

"가능성이 높습니다", "추정됩니다", "일반적으로" 같은 표현을 금지 목록으로 명시해, 근거 없는 문장이 답변에 섞이지 않도록 했습니다. 출력은 항상 답변 · 근거 · 출처 세 구역을 유지합니다.

이 페이지가 기록하는 것

이 페이지는 팀 프로젝트와 그 안에서 제가 맡은 범위만 기록합니다.

같은 도메인을 개인 프로젝트로 이어받아 검색 평가를 다시 설계하고 있지만, 측정 결과가 나오기 전까지는 이 사이트에 싣지 않습니다. 결과가 나오면 별도 항목으로 추가하고, 그 수치는 이 페이지의 RAGAS 수치와 같은 표에 두지 않습니다. 문서도 질문도 지표도 다른 신규 측정이라 비교 대상이 아니기 때문입니다.

자세한 기록 — 사용 기술 · 코드 구조 · 저장소에서 확인되는 구현 · 배운 점

사용 기술

영역기술
Vector DBChromaDB
검색BM25, Dense Retrieval, Hybrid(RRF Fusion)
RerankerFlagEmbedding (실패 시 RRF fallback)
임베딩text-embedding-3-small, bge-m3
평가RAGAS (faithfulness, answer_relevancy, context_precision)
LLMOpenAI API

코드 구조 설명

핵심 함수 4개의 시그니처를 유지하며 노트북 중심 코드를 모듈로 분리하고 있습니다.

ask(query, history) -> dict        # 질문+대화 이력 → 최종 답변
retrieve(query) -> list[dict]      # 질문 → 관련 chunk 목록
run_eval(eval_set, use_rewrite)    # 평가셋 실행
run_ragas(results, label, ...)     # RAGAS 평가 집계

저장소에서 확인되는 구현

팀 공동 저장소입니다. 저는 Generation 파트를 담당했고, 아래는 그 저장소 코드에서 직접 확인되는 구현입니다. 검색 파트는 팀 공동 작업이고, 이 페이지에서는 제 커밋과 담당 범위로 확인되는 내용만 개인 기여로 서술했습니다.

하이브리드 결합 — service/core/functions.py의 _retrieve_single()이 ChromaDB collection.query()로 Dense 후보를, bm25_index.get_scores()로 키워드 후보를 각각 뽑은 뒤 두 순위를 RRF로 합칩니다. 점수 스케일이 다른 두 검색기를 순위만으로 합치는 방식입니다.

# service/core/functions.py — 점수 대신 순위로 합친다
def reciprocal_rank_fusion(rankings: list[list[str]], k: int = 60) -> dict[str, float]:
    scores: dict[str, float] = {}

    for ranking in rankings:
        for rank, chunk_id in enumerate(ranking, start=1):
            scores[chunk_id] = scores.get(chunk_id, 0.0) + 1.0 / (k + rank)

    return scores

Reranker — BAAI/bge-reranker-base를 CrossEncoder로 올려 재정렬하는 코드는 검색 실험 노트북(notebooks/retrieval_V9.ipynb)에 있습니다. 서비스 코드에는 모델 상수만 선언돼 있고 재정렬 단계는 아직 연결돼 있지 않습니다 — 실험에서 검증한 구성과 서비스에 반영된 구성이 여기서 갈립니다.

Query Re-writing — rewrite_query()는 LLM을 먼저 부르지 않습니다. 규칙 기반 보정을 시도하고, 그것으로 충분하면 거기서 끝냅니다. 모호하거나 오타로 판단될 때만 대화 히스토리를 붙여 LLM에 재작성을 맡깁니다.

# service/core/functions.py — 규칙으로 해결되면 LLM을 부르지 않는다
def rewrite_query(query: str, history: list[dict]) -> str:
    corrected = rule_based_query_cleanup(query)

    if corrected != query and len(corrected) >= 8:
        return corrected

    if not is_ambiguous_or_typo(query):
        return query
    # ... 이후에만 히스토리를 붙여 LLM 재작성

배운 점

  • Dense Retrieval만으로는 키워드가 중요한 도메인 QA에 충분하지 않다는 것을 수치로 확인했습니다.
  • 검색 품질 개선과 답변 품질 개선을 분리해서 실험해야 원인 분석이 가능합니다.
  • 평가셋과 지표를 먼저 설계해두면 개선 작업이 "감"이 아니라 "실험"이 됩니다.

Related Notes

팀 프로젝트 코드와 README 보기malit9999/AI-based-RFP-RAG-System(새 탭에서 열림)