쿼리 파이프라인

입찰메이트 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) → Chroma chunks top-5, doc_id 필터.
  • doc 필터 후 0청크면 빈 목록 → 전체 검색 폴백 금지(spec §12-3), NO_EVIDENCE_ANSWER.
  • format_context: 청크마다 [출처: 사업명 / 섹션] + CSV 메타(금액·마감일) 헤더 부착 — PR #16 4684beb(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.


관련