RFP-AI
공공입찰 제안요청서, 원문 근거까지 추적해 답하다
공공입찰 제안요청서(RFP)를 분석해 원문 근거 기반 답변을 생성하는 근거 추적형 Hybrid RAG QA 시스템. 부트캠프 팀 프로젝트에서 Generation을 담당했습니다.
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)
평가 조건·한계 자세히 보기
| Config | faithfulness | context_precision |
|---|---|---|
| Dense Only | 0.7870 | 0.5691 |
| BM25 Hybrid | 0.9133 ▲최고 | 0.4910 |
| Hybrid + Reranker | 0.8629 | 0.7222 ▲최고 |
주의 — faithfulness 0.9133과 context_precision 0.7222는 서로 다른 구성의 최고값으로, 동일 구성에서 동시에 달성한 결과가 아닙니다.
현재 평가의 한계
- 부트캠프 팀 프로젝트 단계의 평가셋(18문항) 기준 수치입니다.
- RAGAS 지표 중 faithfulness·context_precision 중심으로 검증했으며, Hit@K·MRR·nDCG 등 Retrieval 지표는 이 팀 프로젝트에서 측정하지 않았습니다.
System Flow — 전체 파이프라인
판단 기록 — Reranker 오류 시 RRF 결과로 폴백하는 안전 장치를 유지합니다.
Contents · 목차
프로젝트 개요
공공 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 평가 결과입니다.
| 실험 | faithfulness | context_precision |
|---|---|---|
| Dense Only | 0.7870 | 0.5691 |
| BM25 Hybrid | 0.9133 | 0.4910 |
| Hybrid + Reranker | 0.8629 | 0.7222 |
굵게 표시한 수치는 서로 다른 실험 구성에서의 최고값입니다. faithfulness 0.9133(BM25 Hybrid)과 context_precision 0.7222(Hybrid + Reranker)는 동일 구성에서 동시에 달성한 결과가 아닙니다.
핵심 인사이트:
- RFP 도메인에서는 정확한 키워드 검색이 중요해 BM25 결합이 faithfulness를 크게 개선했습니다.
- Reranker는 context_precision 개선에 효과가 있었습니다.
- 후속 질문에서는 Query Re-writing이 answer_relevancy 개선에 기여했습니다.
의사결정 기록
- Dense Retrieval만으로는 부족했다 — 사업명·기관명·금액처럼 정확한 키워드가 중요한 질문에서 의미 기반 검색만으로는 정확도가 낮았습니다.
- BM25 결합 — 키워드 검색을 결합하자 faithfulness가 0.7870에서 0.9133으로 개선되었습니다.
- Reranker 적용 — 검색 후보의 재정렬로 context_precision이 0.7222까지 개선되었습니다.
- 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 DB | ChromaDB |
| 검색 | BM25, Dense Retrieval, Hybrid(RRF Fusion) |
| Reranker | FlagEmbedding (실패 시 RRF fallback) |
| 임베딩 | text-embedding-3-small, bge-m3 |
| 평가 | RAGAS (faithfulness, answer_relevancy, context_precision) |
| LLM | OpenAI 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에 충분하지 않다는 것을 수치로 확인했습니다.
- 검색 품질 개선과 답변 품질 개선을 분리해서 실험해야 원인 분석이 가능합니다.
- 평가셋과 지표를 먼저 설계해두면 개선 작업이 "감"이 아니라 "실험"이 됩니다.