Skip to content

Repository files navigation

BidMate · 입찰메이트 RFP RAG

공공 입찰 공고를 읽지 말고 물어보세요. 답에는 항상 원문 근거가 붙습니다.

공공기관 제안요청서(RFP) 100건(hwp 96 · pdf 4)을 인덱싱해, 회사 프로필과 대조하고 질문에 문서 근거로 답하는 사내 RAG 시스템 — 코드잇 스프린트 AI 중급 2팀 프로젝트

📓 팀원별 협업일지 — Obsidian 지식베이스를 Quartz 정적 사이트로 배포

팀원 담당 협업일지
황인홍 PM 바로가기
김범진 데이터 처리 바로가기
전재완 Retrieval 바로가기
이찬울 Generation 바로가기
박종선 평가 바로가기

📑 보고서 · 관련 레포

자료 링크
최종보고서 한컴독스로 열기
최종보고서 (PDF) PDF로 열기 — 검색 실험(임베딩·BM25·리랭커) 판정 포함
프론트엔드 (React + Vite + TS) surtleo/GPTPilots_Webs

데모 (25초)

BidMate 데모 — 프로필 작성부터 맞춤 공고·근거 답변·비교표·준비 점검까지

원본 화질 영상: assets/bidmate-demo.mp4 (2.1MB · 음성 없음)

핵심 기능

기능 하는 일 구현
참가자격 자가진단 회사 프로필 vs 공고 참가자격 요건을 항목별 대조 → 적격 / 확인필요 / 미달 POST /eligibility/{doc_id}
맞춤 공고 추천 프로필 임베딩으로 후보를 좁힌 뒤, 그 후보만 자격 대조 POST /recommendations
근거 붙은 Q&A 문장마다 출처 청크 표시 · 클릭하면 원문 해당 위치로 스크롤·하이라이트 POST /ask
공고 비교표 공고 2건 이상 선택 → 항목별 비교 문서 생성 (값이 갈리면 "차이" 표시) 대화에서 생성
입찰 준비 점검 그 공고에서 무엇을 놓쳤는지 체크 문서로 산출 대화에서 생성
환각 금지 계약 근거 없으면 "확인되지 않습니다" 고정 응답. 문서 필터 해제 폴백 금지 src/query/generate.py

적합도(임베딩 유사도)와 참가자격(요건 충족)은 2축으로 분리한다. 근거 없는 "적합도 85점" 대신, 요건별 충족·미충족·불명을 판정하고 유사도는 정렬에만 쓴다.

서비스 흐름

Step 화면 하는 일
1 내 회사 프로필 분야·지역·실적·보유 자격을 입력 (판정의 기준이 된다)
2 맞춤 공고 찾기 프로필로 후보 선별 → 후보별 참가자격 대조
3 공고 들여다보기 원문 + 요건별 충족/미확인 판정을 나란히
4 궁금한 것 묻기 답변 문장마다 출처 → 클릭하면 원문 위치로 이동
5 골라서 판단하기 공고 2건 비교표 생성
6 놓친 것 찾기 입찰 준비 점검표 생성
Step 1 — 회사 프로필
1. 회사 프로필 — 이 정보로 참가자격을 대조한다
Step 2 — 맞춤 공고 대조
2. 후보 선별 → 자격 대조 진행 상황을 그대로 노출
Step 3 — 참가자격 판정
3. 요건 14건 중 충족 1 · 미충족 0 · 미확인 13 — 판정마다 근거
Step 4 — 출처 근거
4. 문장마다 출처 칩, 호버하면 어느 청크인지 표시
Step 5 — 비교표
5. 선택한 공고 2건의 항목별 비교표
Step 6 — 준비 점검
6. 입찰 준비 점검 — 무엇을 놓쳤는지 문서로

시스템 아키텍처

flowchart LR
  subgraph OFF["오프라인 배치 (1회)"]
    A["RFP 100건<br/>hwp · pdf"] --> B[extract<br/>→ markdown]
    B --> C[clean · PII 마스킹]
    C --> D["chunk<br/>의사-헤더 + 표 정규화"]
    D --> E[embed<br/>gpl3 · 384D]
    E --> F[("Chroma<br/>chunks · doc_cards")]
  end
  subgraph ON["온라인 질의"]
    Q[질문 + 히스토리] --> R{router}
    R -->|retrieval| S[dense 검색<br/>doc_id 필터]
    R -->|direct_meta| M[CSV 직답<br/>LLM 무호출]
    R -->|clarify · explore| K[후보 반문<br/>LLM 무호출]
    S --> RR[리랭커<br/>bge-reranker-v2-m3]
    RR --> G["gpt-5-mini<br/>출처 표기 강제"]
  end
  F -.-> S
  G --> ANS[답변 + 근거]
Loading
선택 이유
임베딩 gpl3 (LoRA-GPL 머지 e5-small · 118M · 384D) 실험에서 e5-small 대비 MRR@10 +0.0437, CI 하한 양수
리랭커 bge-reranker-v2-m3 (GPU 필수) ON이 OFF 대비 MRR@10 +0.0651. 없으면 dense 단독 폴백
Vector DB Chroma persistent · chunks + doc_cards 2컬렉션 청크 검색과 문서 라우팅의 입도가 다르다
생성 gpt-5-mini (judge는 gpt-5-nano) 팀 키 허용 모델. temperature 0.1, 세션 상한 $2
관측 LangSmith 트레이스에서 doc_id·거리·route는 보이고 원문은 마스킹 (NDA)

질문 하나가 통과하는 경로

질문 "한영대학교 정보시스템 구축사업의 사업기간은?"
  → 라우터가 문서 100건 → 1건으로 특정
  → 그 문서의 청크 약 300개 중 dense로 20개 후보
  → 리랭커가 20개 재정렬 → 상위 5개만 LLM에 전달
  → 답변 + 출처 표기

파싱·청킹 — 무엇이 문제였고 어떻게 풀었나

hwp는 "markdown으로 잘 나왔다"가 곧 "검색이 잘 된다"가 아니었다. 실측으로 드러난 것들:

# 문제 실측 해결
1 hwp 추출물에 markdown 헤더가 없다 99건 중 93건이 헤더 0개 → 구조 청킹 불가 의사-헤더 승격제N장·Ⅰ.·1.1· 패턴을 헤더로 승격. 승격 후 전건 헤더 생성(중앙값 75개), 진짜 Recursive 폴백 0건
2 스플리터가 인접 표를 융합 MarkdownHeaderTextSplitter가 빈 줄을 삭제 → 표 블록 11,830 → 6,139 (약 1,837개 손실) 표 블록 경계를 청킹 전에 보존
3 "행별 열 수 일치" 표 게이트 병합셀 때문에 hwp 표에서 성립 불가 → 표 행 77.1%가 평문 폴백, 전체 청크의 40.3%가 표 한복판에서 절단, 그중 29.2%는 열 이름 없이 값만 임베딩 게이트 폐기 → 빈칸 패딩 정규화(내용 손실 0). 레이아웃성 블록(빈칸 ≥80% · 열 >12)만 평문 폴백 유지
4 검증 리포트가 무력화 flagged 97건 중 92건이 허위 사유 단독(원본 헤더로 판정) — 항상 켜진 신호 판정을 승격 후 기준으로 교정 + header_count_effective 추가 → flagged 97 → 35, 잔여는 전부 실제 신호
5 validate가 langchain을 끌어옴 src.index.chunk import에만 15.4초 (CI마다 지불) 승격 로직을 순수 정규식 src/ingest/headers.py로 분리 → 15.4초 → 0.19초, ingest→index 역방향 의존 소멸
6 대형 PDF가 파이프라인을 멈춤 7.3MB 문서 layout 파싱 886초 (기본 timeout 120초) 3단 폴백 layout → classic → plain. 실데이터 폴백 발동 0건, 강등 시 validate가 플래그
7 추출 실패 1건이 문서 집합을 깸 1건 extract_failed → chunks에 없음 doc_cards에는 100건 유지 + 라우터 "본문 미인덱싱 — 메타만 제공" 고정 응답. 종료 게이트는 diff를 실패 목록과 정확 일치 검사
8 CSV↔파일명이 조용히 어긋남 macOS NFD/NFC 자소 분리 양쪽 NFC + strip + 확장자 소문자화, 미매칭 1건이라도 있으면 파이프라인 하드 중단

검색 실험 — 임베딩을 바꿀 값어치가 있는가

한 문장 요약: 정답이 적힌 문단을 몇 등으로 찾아오는지 재고, 베이스 e5-small을 교체할 값어치가 있는지 판정한다.

통제 변인

고정한 것
청크 경계 e5 토크나이저 (CHUNK_TOKENIZER_MODEL) 경계가 밀리면 정답지가 전부 다른 청크를 가리킨다
정답지 골든셋 v7 · 300문항 준비 → 채점 성립 251 · 정답 490 모델마다 라벨이 다르면 비교가 아니다
검색 범위 대상 문서 안 (doc_id 필터) 라우팅 후 청크 순위만 재는 실험
top-k 후보 20(구성별 정의값) → 리랭크 후 10 후보 예산을 명시해야 델타 원인을 읽는다
지표 MRR@10(판정) · nDCG@10 · Recall@5 · Recall@20 MRR 하나면 반대 방향 손해가 안 보인다
통계 paired bootstrap 95% CI · 1만 회 (문서 클러스터) 문항 단위 재표집은 CI를 실제보다 좁게 만든다

조작 변인

코드
임베딩 M0 / M1 / M2 / M3 e5-small(118M·384D) · bge-m3(568M·1024D) · KURE-v1(0.6B·1024D) · gpl3(LoRA-GPL 머지 e5-small · 118M·384D)
후보 구성 A / C / E / F / G dense만(리랭커 X) · dense 20+리랭커 · dense∪BM25 상위 20 · 상위 30 · 전부
리랭커 R1 / R2 bge-reranker-v2-m3 · dragonkue/bge-reranker-v2-m3-ko

지표를 나눠 보는 이유

지표 무엇을 보나 역할
MRR@10 (리랭커 ON) 첫 정답이 몇 등인가 판정 ★
nDCG@10 상위권 전체의 질 회귀 감시
Recall@5 LLM에 실제로 넘어가는 5개의 질 회귀 감시
Recall@20 정답이 후보 안에 들어왔나 진단 — 임베딩 고유 실력, 리랭커가 손댈 수 없는 상한

Recall@20 ↑ · MRR ↑ = 더 데려왔고 성적으로 이어졌다 / Recall@20 ↑ · MRR → = 리랭커 문제 / 둘 다 → = 키워도 못 찾는 문항, 청킹·질의 문제.

결과

맞댄 것 결정 MRR@10 Δ 95% CI
임베딩 e5-small ↔ bge-m3 · KURE · gpl3 gpl3 채택 +0.0437 [+0.0097, +0.0807]
후보 구성 dense만 ↔ dense+BM25 BM25 미채택 +0.0007 [−0.0163, +0.0202]
리랭커 ON/OFF 없음 ↔ bge-reranker R1 ON 유지 +0.0651 [+0.0203, +0.1108]
리랭커 종류 bge-reranker ↔ 한국어판(ko) R1 유지 전 모델 음수 상한까지 0 미만

임베딩 = gpl3 · 하이브리드 = BM25 미채택 · 리랭커 = bge-reranker-v2-m3 BM25는 CI가 0을 걸쳐 "차이 없음" — 성능이 아니라 복잡도만 늘어 기각했다.

청크 크기(300/500/800) × e5-small/base ablation과 라우터 개선 전후 비교는 EXPERIMENTS.md.

폴더 구조

src/
├── ingest/       추출·정제 — extract(hwp/pdf) · clean · mask_pii · headers · validate
├── index/        청킹·임베딩 — chunk · encoder · build_index
├── query/        질의 — router · retrieve · hybrid · generate · observability
├── eval/         평가 — goldenset · ir_metrics · metrics · run_eval
├── api/          FastAPI — server · cards · eligibility · core
├── eligibility.py  참가자격 요건 추출·대조
├── pipeline.py   오프라인 배치 오케스트레이션
└── config.py     실험 파라미터 정본 (하드코딩 금지)

scripts/          파이프라인 실행 · 실험 · 진단 도구
tests/            pytest (50+ 모듈)
assets/           README용 스크린샷·데모 영상
data/             원본·인덱스·평가 리포트 (전부 gitignored — NDA)

빠른 시작

uv sync                                              # ① 의존성 (Python 3.12.13)
cp .env.example .env                                 # ② OPENAI_API_KEY 기입
#   ③ NDA 데이터 배치: data/raw/ 에 CSV + 원문 100건
PYTHONPATH=. uv run python scripts/run_pipeline.py   # ④ 인덱싱: raw → Chroma
PYTHONPATH=. uv run python -m src.app                # ⑤ Q&A CLI

웹 API 서버·VM 검증·평가 실행·API 레퍼런스는 OPERATIONS.md.

한계점

한계 내용 상태
본문 미인덱싱 1건 100건 중 1건이 추출 타임아웃 → 메타만 응답 라우터 고정 응답으로 처리
이미지·도표 제외 v1은 텍스트만. 도표 안의 정보는 검색되지 않는다 v2에서 외부 추출 텍스트 병합 예정
대화가 문서 1개 기준 /askdoc_id 하나만 받는다 — 활성 문서 여러 개라도 첫 번째만 근거 열림
GET /rfpsq가 문자열 부분일치 "재난"은 찾지만 "CCTV 영상분석 관련 사업"은 0건 열림 (TODO(의미검색))
치명적 요건의 즉시 탈락 미구분 verdict가 미충족 개수로만 갈린다(0 / 1~3 / 4+) — 대기업 참여 제한처럼 그 자체로 탈락인 요건이 섞여 있다 열림
리랭커에 GPU 필요 L4 fp16 전제. GPU 없으면 dense 단독 폴백(품질 저하, 에러 아님) 설계된 폴백
추천 레이턴시 ~1분 20초 LLM 배치 호출 4~5회. 요건 캐시가 있으면 단축 열림
골든셋 표본 한계 채점 성립 251문항 — 유형별로 쪼개면 문항 수가 적다 열림

문서

문서 내용
OPERATIONS.md 셋업 · 인덱싱 · 서버 기동 · VM 검증 6단계 · API 레퍼런스
EXPERIMENTS.md 청크·임베딩 ablation · PDF 파싱 엔진 실측 · 라우터 개선 전후
ELIGIBILITY_MATCH_PLAN.md 참가자격 매칭 설계·데이터 갱신 이력
spec.md 구현 명세 정본 (개인 문서)

NDA: 원본 RFP·data_list.csv·골든셋은 레포에 없다. data/ 전체가 gitignore 대상이며, LangSmith 트레이스도 hide_inputs/hide_outputs로 원문을 올리지 않는다.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages