쿼리 파이프라인
입찰메이트 RFP RAG 시스템의 질의 시점 처리 — 개념(왜 그렇게 되나)과 실제 흐름(코드 레벨)을 한 문서로 정리.
진입점은 src/api/core.py:run_turn (CLI·평가·웹 POST /ask가 공유하는 순수 함수). 코드 근거는 src/query/, src/index/.
라우터 5분기 상세는 라우터 5분기 판정 흐름 캔버스가 정본, 변경 이력은 _archive/검색 개선.md, 검색 실험은 검색 실험 설계 (4모델·BM25) · 검색 실험 결과 (4모델·BM25).
1. 개념 — 검색은 “2번” 일어난다
| 질문 | 지금 방법 | BM25 자리 | |
|---|---|---|---|
| 순간 ① | 어느 문서? | fuzz 이름매칭 → 실패 시 doc_cards 검색 반문 | 여기 아님 |
| 순간 ② | 그 문서 어느 부분? | 청크 임베딩(dense) top-5 | 여기 (dense 옆에 병렬) |
- ①과 ②는 다른 순간, 다른 대상. 이걸 한 줄로 섞으면 전부 헷갈린다.
- 하이브리드 = ②에서 dense + BM25를 병렬로 돌려 RRF로 합침. “없으면 그다음” 순차 폴백이 아님.
개념 흐름:
질문
▼ ① 어느 문서? 라우터 fuzz 이름매칭(사업명·기관)
├─ 찾음 → doc_id 확정
└─ 못찾음 → doc_cards 검색으로 "이 문서 중 뭐요?" 반문
▼ ② 어느 부분? retrieve(doc_id 필터)
dense 임베딩 top-5 (+BM25면 하이브리드) → 청크 5개 (metadata 딸려옴)
▼ ③ 답변 청크 본문 + 메타데이터 → gpt-5-mini
2. 저장 구조 — 컬렉션 2개 / 레코드 3칸 / doc_card 해부
2-1. 컬렉션이 2개다 (계층 아님, 별도 저장소)
| 컬렉션 | 단위 | 개수 | 용도 |
|---|---|---|---|
chunks | 섹션청크 | 30,002개 | ②검색·답변 |
doc_cards | 문서(요약 1장/문서) | 100개 | ①문서 못 찾을 때 후보 제시 |
수치는 Phase 11 재인덱싱 후 data/chroma_v2 기준(2026-07-28 갱신). 작성 당시 구 인덱스는 28,293청크 / doc_cards 99. 문서 수는 100건이지만 추출 실패 1건이 chunks에서 빠져 본문 검색은 99문서, doc_cards는 실패 문서 포함 100. 상세 → 청킹 표 처리 수정 (Phase 11)
- “문서단위 임베딩” = doc_cards, “섹션청크 임베딩” = chunks. 둘은 따로.
2-2. Chroma 레코드는 칸이 3개 — 임베딩 vs 정형 구분
한 레코드(청크든 카드든)는:
① embedding (벡터) ← documents 텍스트로만 계산. 의미검색용
② documents (본문) ← 검색결과로 반환·LLM이 읽는 텍스트
③ metadata (정형) ← key-value. 벡터에 안 섞임. 필터·조회·직답용
금액·마감일은 ③정형 필드다. 벡터에 임베딩되지 않는다.
- 이유: 숫자·날짜는 의미검색(임베딩)에도 BM25(키워드)에도 잘 안 걸림 → 정형으로 두고 필터·직답에 씀.
- 표준 RAG 패턴: 의미 텍스트는 임베딩, 팩트(금액·날짜)는 정형 메타데이터.
embed_text vs 저장 text (중요한 함정)
- 청크의
embed_text=passage: [사업명/발주기관/섹션]\n{본문}→ 벡터 만들 때만 씀. LLM은 안 읽음. - 청크의
text(저장본) = LLM이 답할 때 읽는 것. - → 임베딩본에 아무리 태깅 잘 해도 “답변에 보이게”는 안 됨. 임베딩은 “찾기 전용”.
2-3. doc_card 해부 — “임베딩 + 정형” 한 몸
doc_card [고려대]
├─ documents (임베딩됨) "사업명 / 발주기관 / 사업요약(- 사업개요:… - 추진배경:…)"
└─ metadata (정형) 금액_num: 11270000000, 마감일_dt: 2024-08-12
- “이 문서의 뭐가 중요한지”는 시스템이 판단 안 함. 고정 템플릿 = 사업명 + 발주기관 + 사업요약.
- 그
사업요약은data_list.csv의 “사업 요약” 컬럼을 그대로 복사(사람이 미리 씀).normalize_meta.py:270. - 요약 텍스트는 임베딩되고, 금액·마감일만 정형. “임베딩 아니고 정형”은 절반만 맞음.
3. 개념 오해 교정 모음
| 오해 | 사실 |
|---|---|
| ”BM25 안 쓰고 키워드 검색” | BM25 = 키워드 검색의 채점법 그 자체. 키워드검색 = ①찾기(역색인) + ②줄세우기(BM25). BM25 빼면 순서가 엉망(긴 문서·흔한 단어가 이김) |
| “하이브리드 = 리랭커” | 다름. 하이브리드 = dense+BM25 병렬 합침(모델 없음, RRF는 수식). 리랭커 = 후보 재점수 모델(별개, 선택) |
| “BM25 = 모델 추가” | 아님. BM25는 통계 알고리즘. 추가되는 건 역색인 + 한국어 토크나이저(kiwipiepy). 모델 아님 |
| ”doc_card를 BM25로 대체” | 아님. doc_card=①문서특정, BM25=②청크검색. 다른 순간·대상 |
| ”지금 키워드로 검색” | 아님. 프로덕션 경로는 dense 임베딩(코사인)만. BM25 하이브리드는 이후 구현·측정 완료됐으나 플래그 OFF. 재측정 중 → 검색 실험 결과 (4모델·BM25) §4 |
키워드 검색(BM25) 작동 = 역색인
[색인] 청크를 단어로 쪼갬(kiwipiepy) → "단어 → 청크목록" 표
[검색] 질문 단어별로 청크 꺼냄 → TF(빈도)×IDF(희귀도) 점수 → 정렬
- 임베딩: “의미 비슷” / BM25: “같은 단어 든 것”. 정확 문자열(공고번호 2024-123)은 BM25가 강함.
- 하이브리드로 합치는 이유 = 의미(넓게) + 정확단어(못박기). 합침은 RRF.
4. 실제 흐름 — run_turn 5분기
question (+history, +active_doc_id)
│
▼
route_question() # src/query/router.py — 규칙 기반(RapidFuzz + 정규식), LLM 무호출
│ RouteDecision(route, doc_id, answer, candidates, reason)
▼
run_turn() 5분기 디스패치 # src/api/core.py
├─ retrieval → retrieve(top-k, doc_id 필터) → format_context → generate_answer(gpt-5-mini) → citations
├─ direct_meta → CSV 메타 직답 (LLM 무호출)
├─ meta_only → 청크 0개 문서 고정 응답 (LLM 무호출)
├─ clarify → 후보 나열 반문 (LLM 무호출)
└─ explore → doc_cards 검색 후보 제시 (LLM 무호출)
핵심: 라우터를 탄다 ≠ LLM을 안 쓴다. 5분기 중 LLM을 호출하는 건 retrieval 뿐.
라우터 판정 조건(문서 특정 _match, 직답 판정 _direct_field, 각 분기 조건·가드)의 상세는
라우터 5분기 판정 흐름 캔버스가 정본 (main 33973f8 기준, PR #15·#16 반영본).
5. 계층별 상세
검색 (src/query/retrieve.py)
query:prefix 임베딩(e5-small) → Chromachunkstop-5,doc_id필터.- doc 필터 후 0청크면 빈 목록 → 전체 검색 폴백 금지(spec §12-3),
NO_EVIDENCE_ANSWER. format_context: 청크마다[출처: 사업명 / 섹션]+ CSV 메타(금액·마감일) 헤더 부착 — PR #164684beb(07-27)로 완료. 검색 개선- PR 15로 LCEL retriever 전환 완료.
생성 (src/query/generate.py)
- 모델
gpt-5-mini, temperature 0.1,reasoning_effort설정. - 시스템 프롬프트 최우선: 컨텍스트에 없으면 “제공된 문서에서 확인되지 않습니다” — 환각 금지.
- 히스토리 최근 6턴. 429/5xx 백오프 3회. 세션 비용 상한 $2 →
CostLimitExceeded.
웹 계층 (src/api/server.py)
POST /ask{question, history[], doc_id?}→{answer, active_doc_id, citations, cost}.- startup 워밍업(e5 + Chroma 2컬렉션), CORS 화이트리스트 + 공유 토큰 + rate limit. 동기식(스트리밍 없음).
관측 (src/query/observability.py)
- LangSmith
hide_inputs/hide_outputs기본 ON (NDA, ADR-007).doc_id·distances·route만 화이트리스트. 텍스트 완전 차단.
섹션 태깅 품질 (해소됨 — Phase 11)
작성 당시(구 인덱스 99문서 / 28,293청크) 코퍼스 스캔에서 발견했던 이슈. 청킹 표 처리 수정 (Phase 11)에서 해소, 섹션 경로 95.2%. 현행 인덱스는 30,002청크.
- 중앙값 섹션당 청크 = 6 → 대부분 정상이었으나(“통짜 아님”), 7개 문서가 헤더 검출 실패로 섹션 뭉개짐. 워스트:
- 정읍시 체육트레이닝센터: 371청크 / 섹션 1종
- 고려대 차세대 포털: 697청크 / 섹션 4종 (본문이 죄다 “서약서” 라벨 상속)
- 파주 종량제봉투: 343청크 / 섹션 2종
- 원인: 청킹이 마크다운 헤더(
#,##)로 나누는데 추출본에서 본문 헤더가 안 잡힘. 앞쪽 목차·서약서만 인식. - 영향(당시): 검색 섹션힌트 흐려짐 + 출처 표기 틀림(유지보수 내용인데 “출처: …보안서약서”).
- ⚠️ (측정 교훈)
collection.get(limit=300)은 앞부분만 봄 → 섹션 수 과소측정. 전수는limit=100000.
6. 잔여 백로그
구 쿼리 파이프라인 개선안.md(2026-07-23)에서 아직 이행되지 않은 항목만 옮겨온 것이다.
원 문서는 절반이 이미 완료돼 현행 상태를 오도해서 _archive/로 내렸다.
| # | 항목 | 내용 | 대상 |
|---|---|---|---|
| 2 | 라우터 직답 패턴 취약성 | DIRECT_PATTERNS가 손관리 문자열(“예산은”은 되고 “예산액과”는 안 됨). 6.4에서 4건 패치함. 권장안: 트리거 확장 + 골든셋에 복합·변형 표현을 넣어 회귀 방지 | 라우터 |
| 5 | 스트리밍 부재 (웹 UX) | POST /ask 동기식이라 채팅 체감 지연. SSE /ask/stream, citations는 종료 이벤트로 | 웹 |
| 7 | 온라인 route 지표 관측 | route 분포·0청크율·평균 distance를 LangSmith 안전메타(이미 화이트리스트)로 집계. 저비용 | 관측 |
이관 시점에 완료로 확인돼 제외한 항목:
| # | 항목 | 결말 |
|---|---|---|
| 1 | 메타·본문 융합(CSV 메타 카드 주입) | PR #16 4684beb(07-27) 완료 → 검색 개선 |
| 3 | 검색 품질(리랭킹·하이브리드) “v2 보류” | 리랭커 채택·프로덕션 ON, BM25는 재측정 중 → 검색 실험 결과 (4모델·BM25) |
| B | 섹션 태깅 품질(99문서 중 7개 헤더 검출 실패) | 청킹 표 처리 수정 (Phase 11)에서 해소, 섹션 경로 95.2% (경위는 §5 섹션 태깅 품질) |
과거 개선 논의는 트랙 3개로 관리했었다 — A(메타 카드 붙이기: 렌더링, 재색인 0 → PR #16 완료) / B(섹션 태깅 보강: 인제스트 품질, 재색인 필요 → Phase 11 해소) / C(BM25 하이브리드+RRF: 검색 개선, v2 보류 → 보류 해제, 측정 완료). A의 계기는 “예산(정형) + 무상유지보수기간(본문)” 복합 질문이 retrieval로 가며 예산이 누락되던 것 — 금액·마감일은 이미 hit.metadata에 딸려오므로 답할 때 카드로 펼치기만 하면 됐다.
제약(당시 기재, 현행 유지): 신규 모델 금지(gpt-5-mini/nano만) · 팀 키 $20 한도 · NDA.
관련
- 라우터 5분기 판정 흐름 — 라우터 정본
- 검색 실험 설계 (4모델·BM25) — 무엇을 왜 어떻게 재나
- 검색 실험 결과 (4모델·BM25) — 판정과 프로덕션 반영 상태
- 청킹 표 처리 수정 (Phase 11) — 섹션 태깅·재인덱싱