공공 입찰 공고를 읽지 말고 물어보세요. 답에는 항상 원문 근거가 붙습니다.
공공기관 제안요청서(RFP) 100건(hwp 96 · pdf 4)을 인덱싱해, 회사 프로필과 대조하고 질문에 문서 근거로 답하는 사내 RAG 시스템 — 코드잇 스프린트 AI 중급 2팀 프로젝트
📓 팀원별 협업일지 — Obsidian 지식베이스를 Quartz 정적 사이트로 배포
| 팀원 | 담당 | 협업일지 |
|---|---|---|
| 황인홍 | PM | 바로가기 |
| 김범진 | 데이터 처리 | 바로가기 |
| 전재완 | Retrieval | 바로가기 |
| 이찬울 | Generation | 바로가기 |
| 박종선 | 평가 | 바로가기 |
📑 보고서 · 관련 레포
| 자료 | 링크 |
|---|---|
| 최종보고서 | 한컴독스로 열기 |
| 최종보고서 (PDF) | PDF로 열기 — 검색 실험(임베딩·BM25·리랭커) 판정 포함 |
| 프론트엔드 (React + Vite + TS) | surtleo/GPTPilots_Webs |
원본 화질 영상: 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 | 놓친 것 찾기 | 입찰 준비 점검표 생성 |
![]() 1. 회사 프로필 — 이 정보로 참가자격을 대조한다 |
![]() 2. 후보 선별 → 자격 대조 진행 상황을 그대로 노출 |
![]() 3. 요건 14건 중 충족 1 · 미충족 0 · 미확인 13 — 판정마다 근거 |
![]() 4. 문장마다 출처 칩, 호버하면 어느 청크인지 표시 |
![]() 5. 선택한 공고 2건의 항목별 비교표 |
![]() 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[답변 + 근거]
| 층 | 선택 | 이유 |
|---|---|---|
| 임베딩 | 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개 기준 | /ask가 doc_id 하나만 받는다 — 활성 문서 여러 개라도 첫 번째만 근거 |
열림 |
GET /rfps의 q가 문자열 부분일치 |
"재난"은 찾지만 "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로 원문을 올리지 않는다.






