POST /retrieve → [1] 쿼리 라우팅 → 검색 → 리랭킹 → 응답
POST /answer → [1] 쿼리 라우팅 → [2] CRAG 루프 → [3] 프롬프트 분기 → LLM → [4] Self-RAG → 응답
/retrieve는 1단계만 적용, /answer는 1~4단계 전체 적용.
같은 /answer 호출이라도 각 단계의 실행 주체가 다르다. 가장 혼동되기 쉬운 부분 — LLM이 호출되는 곳과 아닌 곳을 한 표로:
| 단계 | 실행 주체 | LLM? |
|---|---|---|
| [1-A] 쿼리 유형 분류 (5-type) | 정규식 | ❌ 0ms |
| [1-B] Dense/BM25 배수 선택 | 분류 결과 기반 dict lookup | ❌ |
| Dense 검색 | BGE-M3 + Qdrant ANN | 임베딩 GPU |
| BM25 검색 | Qdrant sparse | ❌ |
| RRF 융합 | Qdrant 내부 | ❌ |
| CrossEncoder 리랭킹 | BGE-reranker-v2-m3 | 리랭커 GPU |
| [2] CRAG 점수 평가 | 숫자 비교 (>= 0.3 통과 / < 0.1 즉시 거절) |
❌ |
| [2] CRAG 쿼리 재작성 (0.1~0.3 구간만) | vLLM (Qwen3) | ✓ ~2s |
| [3] 프롬프트 템플릿 선택 | dict lookup (PROMPTS[query_type]) |
❌ |
| [3] 답변 생성 | vLLM (Qwen3) | ✓ ~3s |
| [4] Self-RAG 검증 (조항·숫자 구조) | 정규식 + 집합 비교 | ❌ |
| [4'] Critic failure type 분류 | 정규식 + 집합 비교 + (선택) NLI/LLM judge | ❌ (judge 미주입 시) |
| [4'] Hint-guided regenerate (조건부) | vLLM, generation_error/unit_error에만 1회 | ✓ ~3s (조건부) |
LLM이 호출되는 지점은 3곳 — CRAG 재작성 · 답변 생성 · Critic regenerate(조건부). 분류·선택·검증은 전부 결정론적 (정규식 or dict lookup or 숫자 비교). 그래서 라우팅·프롬프트 선택·검증·failure type 분류는 매 요청 0ms로 끝난다.
쿼리를 읽고 → 성격 분류 → 성격에 맞춘 검색 전략 실행 의 3단계 세트로 작동. 3개가 맞물려 하나의 Adaptive 레이어를 이룬다. 이 단계는 1-C의 2차 fallback을 제외하면 전부 정규식 — LLM 호출 없음.
| 유형 | 감지 패턴 | 예시 |
|---|---|---|
STRUCTURED_LOOKUP |
제N조 / 별표N / 부칙 / 서식N (의미 질문 없이) | "제43조 내용 알려줘" |
INTERPRETATION |
해석 · 의미 · 적용 · 되나요? · 가능한가요? | "무면허운전 시 보장되나요?" |
PROCEDURE |
어떻게 · 방법 · 절차 · 신청 · 청구 | "보험금 청구 방법은?" |
COMPARISON |
비교 · 차이 · 다른 점 · vs | "1종과 2종의 차이가 뭔가요?" |
SIMPLE_FACT |
위 4개 다 해당 안 될 때 (기본값) | "보험금 지급 기준" |
분류 결과로 (1) 하이브리드 검색 배수와 (2) 프롬프트 템플릿이 결정됨.
검색 구조는 항상 하이브리드 (Dense BGE-M3 + BM25 → RRF 융합). 다만 각 검색이 prefetch하는 후보 수 배수를 쿼리 유형에 맞춰 조절한다.
Prefetch(query=dense_vec, using="dense", limit=top_k * dense_factor)
Prefetch(query=bm25_text, using="content-bm25", limit=top_k * bm25_factor)
# → RRF 융합| 전략 | dense | bm25 | 적용 유형 | 이유 |
|---|---|---|---|---|
| BM25_HEAVY | ×3 | ×8 | STRUCTURED_LOOKUP |
"제43조"는 의미보다 키워드 정확 매칭이 결정적. BM25가 토큰 일치로 정확. Dense는 보조 |
| DENSE_HEAVY | ×8 | ×3 | INTERPRETATION · PROCEDURE · COMPARISON |
"무면허운전 시 보장되나요?"는 의미 이해가 중요. Dense 벡터가 유리. BM25는 보조 |
| HYBRID | ×6 | ×6 | SIMPLE_FACT · 조항+의미 혼합 |
판단 불가 → 균등 |
즉 같은 top_k=10 요청이라도:
- STRUCTURED_LOOKUP: BM25에서 80개, Dense에서 30개 prefetch → 융합 후 BM25 비중 높음
- INTERPRETATION: Dense에서 80개, BM25에서 30개 → Dense 비중 높음
검색 방식을 바꾸는 게 아니라 후보 풀의 구성을 바꾸는 방식. Cormack et al. RRF (SIGIR 2009)의 원리 — 어느 쪽 리스트에서 더 많은 후보가 들어오면 fusion 결과도 그쪽 비중이 커짐.
COMPARISON은 별도 분해 단계 없이 하이브리드 검색(DENSE_HEAVY)으로 넓게 두 대상의 근거를 함께 모으고, 비교 프롬프트로 LLM이 문맥에서 표로 비교한다. (1-A 분류 → 1-B factor → 표준 search_and_rerank.)
왜 분해를 뺐나 (SOTA + 실측) — 비교 질의에서 분해가 돕는 건 LLM의 추론력이 아니라 coverage(두 대상 근거를 다 검색했나)인데, "1종 vs 2종"은 보통 근거가 같은 약관에 나란히 있어 wide-retrieve로 충분하다. 예전 규칙 분해(
_PAIR_PATTERN)는 서브쿼리를 문자열 수술로 만들다 "1종 가 뭔가요" 같은 깨진 쿼리를 생성해 comparison 4건 중 2건 refusal을 냈다(회고 §1). 2024–2026 컨센서스도 닫힌 코퍼스에선 분해 대신 wide-retrieve가 기본(ARAGOG "advanced ≠ better"). coverage gap이 측정되면 entity-aware 병렬검색을 조건부 도입.
| 책임 | 파일 : 함수/상수 |
|---|---|
| 5-type 분류 (regex) + factor 결정 | src/v1/rag/classifier.py: classify_query(), RouteResult, QueryType, SearchStrategy, _STRUCTURED_REF_PATTERN / _PROCEDURE_PATTERN / _COMPARISON_PATTERN / _INTERPRETATION_PATTERN (BASE + DOMAIN extension 구조) |
| 하이브리드 검색 (Dense + BM25 + RRF) | src/v1/rag/search.py: search_rrf_only() |
| 리랭킹 포함 검색 | src/v1/rag/search.py: search_and_rerank() |
| prefetch 배수 상수 | src/v1/config/settings.py: SEARCH_PREFETCH_MULTIPLIER |
관측 메모:
- 쿼리 유형 분포(INTERPRETATION / SIMPLE_FACT / COMPARISON 등)는 평가셋 vs 운영 데이터 간 차이가 클 수 있으므로
trace_summary.pySection 1으로 주기 확인
CrossEncoder 리랭킹 후 상위 문서의 rerank score로 검색 품질을 판단한다. 판단 자체는 숫자 비교, LLM 호출 없음. 판단 결과 "부족"일 때만 쿼리 재작성을 위해 LLM을 부른다.
점수를 하나가 아니라 두 개의 경계로 나누는 게 핵심이다. CRAG 재작성이 실제로 고치는 건 어휘갭이지(구어체 "다시 살리다" → 약관 용어 "부활(효력회복)", 평가 §9 실측 7위→2위) 주제 부재가 아니다. 코퍼스에 없는 주제는 어떻게 재작성해도 못 찾으므로, 그 구간에서 재작성을 돌리면 LLM 2회를 확실히 헛되이 쓰고 끝에는 어차피 저신뢰 컨텍스트로 답을 지어낸다 — 조용한 실패.
검색 → 리랭킹 → top-1 점수
│
├─ >= 0.3 ─────────────────────────→ 다음 단계 (통과)
│
├─ 0.1 ~ 0.3 어휘갭 후보
│ └→ LLM 쿼리 재작성 → 라우팅 재수행 → 재검색 (최대 2회)
│ └→ 그래도 < 0.3 → 거절
│
└─ < 0.1 주제 부재 → **재작성 건너뛰고 즉시 거절** (LLM 0회)
거절 = "관련 내용을 찾지 못했습니다." + trace `answer.is_refusal=true` · `refusal_reason`
- 통과 기준:
CRAG_SCORE_THRESHOLD = 0.3 - 조기중단 하한:
CRAG_ABORT_THRESHOLD = 0.1— 이 아래면 재작성 없이 거절 - 재작성: vLLM에 "검색에 더 적합한 형태로 재작성하라" 프롬프트
- 재작성 쿼리도 라우팅 재수행: 원래 쿼리는 STRUCTURED_LOOKUP이었는데 재작성 후 INTERPRETATION이 될 수 있음
- 최대 재시도:
CRAG_MAX_RETRIES = 2
0.1이라는 값의 근거 (실측 분포, 2026-09-04):
| 질의 | top-1 | 성격 |
|---|---|---|
| 피자 배달 지연 보상 | 0.0009 | 주제 부재 |
| 반려동물진단비 담보 | 0.0041 | 주제 부재 |
| 자동차 정비 보증 | 0.1225 | 경계 (보증·조항 어휘 겹침) |
| 긍정 골든 (n=26) | p10 0.83 · p50 0.96 | 정상 |
0.12 ↔ 0.83 이 통째로 비어 있어 두 경계(0.3 통과 · 0.1 조기중단)가 여유 있게 앉는다.
0.3은 "도메인 안이냐"가 아니라 "관련 조가 있느냐"의 선이다. "우주여행 상해로 입원하면 하루 얼마?"를 부정으로 라벨했다가 실측 0.41이 나와 긍정으로 교정했다 — 코퍼스에 상해입원일당 조가 실제로 있어 검색은 정답이었고, 도메인 밖인 건 '우주여행' 수식어뿐이다. 이런 질의는 거절할 게 아니라 면책 준용 계층이 "위험활동 면책 확인 필요"로 받아야 한다. 게이트에 도메인 판정을 기대하면 안 된다는 뜻.
부정 점수는 코퍼스가 커지면 올라간다 — 같은 세션 안에서 자동차 정비가 0.1045 → 0.1225로 밀렸다(청크가 늘수록 우연히 맞는 후보가 생김). 색인을 확장할 때마다 재측정 대상이고, golden_retrieval의 부정 행이 그 감시자다.
실측 검증 (vLLM 없이도 확인됨 — 거절이 LLM 앞에 있으므로):
| 질의 | top-1 | 동작 | LLM 호출 |
|---|---|---|---|
| 피자 배달 지연 보상 | 0.0009 | 즉시 거절 (HTTP 200) | 0회 |
| 반려동물진단비 담보 | 0.0041 | 즉시 거절 (HTTP 200) | 0회 |
| 자동차 정비 보증 | 0.1225 | 재작성 시도 → 못 넘으면 거절 | 재작성 호출 |
| 중환자실의 정의 | ≥ 0.3 | 게이트 통과 | 답변 생성 |
| 책임 | 파일 : 함수/상수 |
|---|---|
| 점수 게이트 판정 (숫자 비교) | src/v1/rag/grader.py: evaluate_retrieval() |
| 조기중단 판정 | src/v1/router.py: answer() 내부 _abort (CRAG 루프 앞) |
| LLM 쿼리 재작성 | src/v1/rag/search.py: rewrite_query() + src/v1/rag/prompts.py: REWRITE_PROMPT |
| 재시도 루프 orchestration | src/v1/router.py: answer() 내부 while (not _abort) and not evaluate_retrieval(...) 블록 |
| threshold 상수 | src/v1/config/settings.py: CRAG_SCORE_THRESHOLD, CRAG_ABORT_THRESHOLD, CRAG_MAX_RETRIES |
| 거절률 집계 | scripts/trace_summary.py: _aggregate_answerability (거절% · refusal_reason 분포) |
알려진 한계:
- 최악 케이스에서 LLM 호출 3~4회(재작성 2 + 답변 1 + Critic 조건부), latency 8초+
- CRAG on/off A/B 비교 데이터 미확보. 관측 지표:
crag_retry_rate,crag_on_off_ab_test - 0.1~0.3 구간은 여전히 재작성 2회를 쓴다 — 이 구간의 재작성 성공률은 아직 미측정(vLLM 필요)
쿼리 유형은 1단계 정규식 분류에서 이미 결정됨. 여기는 단순 PROMPTS[query_type] dict lookup으로 템플릿만 꺼내 오는 단계 — LLM 호출 없음. 실제 LLM 호출은 선택된 템플릿에 context·query를 채워 넣은 뒤 답변 생성 시점에 1회 발생.
| 유형 | 프롬프트 전략 |
|---|---|
| STRUCTURED_LOOKUP | 해당 참조(조항·섹션·표 등)의 원문 정확 인용 + 구체적 위치(장·절·챕터) 명시 |
| INTERPRETATION | IRAC 구조 (쟁점→규정→적용→결론). 근거 조항 명시 |
| PROCEDURE | 절차를 단계별 설명. 각 단계의 근거 조항 명시 |
| COMPARISON | 비교 항목을 표 형식으로 정리. 근거 조항 명시 |
| SIMPLE_FACT | 간결 답변 + 근거 조항 명시 |
공통: 모든 프롬프트에 "아래 컨텍스트만 참고하여" 제약 포함.
| 책임 | 파일 : 함수/상수 |
|---|---|
| 프롬프트 템플릿 5종 | src/v1/rag/prompts.py: PROMPTS dict (QueryType enum을 키로) |
| 공통 규칙 상수 | src/v1/rag/prompts.py: _COMMON_RULES |
| 템플릿 선택 + LLM 호출 | src/v1/router.py: answer() 내 prompt = PROMPTS[route.query_type] → llm.invoke(prompt.format_messages(...)) |
| LLM 클라이언트 | src/v1/rag/clients.py: llm = ChatOpenAI(...) 싱글톤 + src/v1/config/ LLM_CONFIG (vLLM base_url + Qwen3 모델 경로) |
| 컨텍스트 토큰 예산 | src/v1/rag/tokens.py: calc_context_budget(), truncate_context(), count_tokens() |
LLM 답변에서 구조적 참조(조항·별표·숫자)를 추출해 context와 대조하고 위험 등급을 부여한다. 전 과정이 정규식 + 집합 비교(set difference) + 규칙 기반 policy gate — LLM 호출 없음. 검증 자체는 결정론적이라 같은 (답변, context) 쌍은 항상 동일 risk_level을 리턴.
이름에 "Self-RAG"가 들어가 있지만 원논문(Asai et al., ICLR 2024)의 reflection token 기반 self-critique와는 다르다. Self-RAG의 [ISSUP] 토큰이 잡으려던 "의미 일치" 검증(예: 답변 "보장된다" vs context의 "보장하지 아니한다")은 현재 미구현 — 구조적 참조 존재·부재만 본다. 의미 검증은 NLI/HHEM 어댑터 확장 지점 (semantic_judge 슬롯).
동작 결정 — flag-only.
risk_level을warnings로 노출만 하고 답변은 그대로 반환한다. 전문가 검토 툴이라 자동 교정보다 "근거와 함께 플래그" 가 맞다. 자동 교정(Critic)은 기본 꺼짐 (아래). 개선은 재생성이 아니라 측정(RAGAS/Recall@k) → 검증기 정밀화로 간다 — design-retrospective §1.5.
| 유형 | 예시 | 처리 |
|---|---|---|
| 조항 참조 | "제12조 제3항 제1호" | 계층형 파싱 (ArticleRef) — 조항만 맞고 항이 틀린 케이스도 분리 |
| 별표/부칙/서식 | "별표1", "부칙 제2조", "서식3" | AppendixRef |
| 금액·기간·비율·나이 | "1,000만원", "90일", "10%" | 단위 정규화 (1,000만원 == 10,000,000원) |
| 날짜 | "2026.04.20" | 구분자 정규화 후 비교 |
답변을 claim 단위(한국어 종결 어미 기준 단순 분할)로 쪼개 각 claim의 구조적 fact를 추출한다. Chunk-level provenance — 어느 chunk가 그 claim의 근거인지 — 는 trace JSONL에만 기록하고 응답에는 노출하지 않는다. UI citation이 필요해지면 확장 지점으로 사용.
verify_answer가 매기는 severity 축(risk_level). root cause(failure_type)와 control flow(action_taken)는 별개 축으로, 다음 섹션에서 다룸.
severity (risk_level) |
조건 | 권장 처리 |
|---|---|---|
hard_fail |
답변이 context에 없는 조항/별표 인용 | warnings 라벨 + 전문가 검토 (자동 교정 안 함) |
soft_fail |
금액·기간·퍼센트 mismatch (크리티컬 단위) | "근거 문서에 확인되지 않음" 주석 |
warn |
나이·횟수 등 사소한 숫자 차이 (비크리티컬 단위) | 답변 반환 + 로그 |
pass |
모두 일치 | 답변 반환 |
hard_fail이어도 기본은 플래그만 하고 답을 그대로 반환한다. 과거엔 failure type(generation_error / retrieval_gap / unit_error / semantic_mismatch / minor)을 분류해 일부를 hint-guided regenerate 했지만 — 실측상 hard_fail 6/6이 오탐(문서엔 있는데 top-3에 안 담긴 retrieval_gap·계층 불일치)이라 정답을 환각으로 오판하고 재생성하는 꼴이었다. 그래서 CRITIC_DISPATCH_ENABLED 기본 false. 근거: design-retrospective §1.5.
- 켰을 때만: generation_error/unit_error → 1회 hint-guided regenerate, retrieval_gap/semantic_mismatch → regenerate 금지 +
escalation_required(Huang et al. ICLR 2024 자기교정 함정 회피). - 올바른 개선은 재생성이 아니라 검증기 정밀화 — 계층 매칭 조(條) 단위 + 장(章) 파싱 + retrieval recall↑. 측정(RAGAS/Recall@k)이 병목을 가리키면 roadmap에서.
failure type·3축 좌표·hint 생성 상세(켰을 때 경로): grader.py classify_failure()/build_hint(), 집계 trace_summary.py Section 10.
{
"risk_level": "hard_fail",
"groundedness": 0.50,
"warnings": ["인용 조항이 검색 근거에 없음(검색 격차 가능): 제99조"],
"escalation_required": true
}groundedness는 0~1 스칼라 (supported / verifiable) — RAGAS faithfulness · Azure AI Foundry Groundedness 패턴. 검증 가능한 claim(조항/별표/숫자가 추출된)만 분모에 둠 — 평문 claim ("이 경우 보험금이 지급됩니다")은 구조적으로 supported_by_chunks가 강제 [] 라 분모에 넣으면 절차/해석형 답변이 부당하게 0점으로 깔리는 분모 결함이 생김. verifiable claim이 0 (순수 평문 답변)이면 키 자체 생략 — 측정 불가 ⇒ 평균 왜곡 방지. risk_level의 4단계 라벨이 못 보여주는 추세·A/B 비교·SLA 임계 박는 데 사용.
escalation_required는 retrieval_gap / semantic_mismatch에서만 노출 (regenerate 안 함을 명시). 클라이언트가 재질문 유도·refusal UI 등으로 활용 가능.
응답에는 추가로 citations[] 배열이 들어감 (claim별 supported_by_chunks 매핑이 있을 때만). 형태: {"claim", "refs": ["제43조"], "supported_by_chunks": ["chunk_id..."]}. 클라이언트가 sources[].chunk_id와 lookup해서 inline [1][3] UI 구성 가능 — Anthropic Citations API · Perplexity 패턴.
claim 단위 상세(claims[]·missing_refs·numeric_mismatches·supported_by_chunks)는 내부 계산 후 data/eval/trace/<YYYYMMDD>/traces.jsonl에 기록. critic 동작 (critic.failure_type, critic.regenerate_improved 등)도 같은 trace에 함께 저장.
| 한계 | 예시 | 확장 후보 |
|---|---|---|
| 의미 반전 미감지 | context "보장하지 아니한다" + 답변 "보장된다" → pass | NLI classifier (HHEM-2.1) / LLM judge |
| Parametric 지식 주입 | LLM이 일반 상식으로 보강한 문장 | 동일 |
| Temporal mismatch | 개정 전 조항 참조 | 개정 이력 대조 레이어 |
| Claim 과분할 | 한국어 종결어미 기준 단순 split — 복문 부정확 | LLM 기반 분해 (FActScore 스타일) |
| 책임 | 파일 : 함수/상수 |
|---|---|
| 오케스트레이션 (답변·context 대조 + 위험 판정) | src/v1/rag/grader.py: verify_answer() |
| 조항·별표·숫자·날짜 추출 (정규식) | src/v1/rag/grader.py: extract_article_refs(), extract_appendix_refs(), extract_numeric_facts() + _ARTICLE_HIERARCHY_RE, _APPENDIX_RE, _NUMBER_SPAN_RE, _DATE_RE, _NUMERIC_UNIT_MAP |
| 구조적 참조 dataclass | src/v1/rag/grader.py: ArticleRef, AppendixRef, NumericFact, Chunk |
| Claim 분해 (한국어 종결어미 기준) | src/v1/rag/grader.py: decompose_claims(), _CLAIM_SPLIT_RE |
| Claim-chunk 근거 매핑 | src/v1/rag/grader.py: _provenance_map(), _build_claim_record() |
| 위험 등급 정책 게이트 | src/v1/rag/grader.py: _decide_risk(), _BUSINESS_CRITICAL_UNITS |
| 사람이 읽는 경고 메시지 빌드 | src/v1/rag/grader.py: _build_warnings() |
| 호출 + trace 기록 + 응답 projection | src/v1/router.py: answer() 내 verify_answer() 호출 → rec.verification slim count → warning 로그 → result["verification"] 2키 projection |
Self-RAG (Asai et al., ICLR 2024) · FActScore (Min et al., EMNLP 2023) · AIS (Rashkin et al., 2023) · Anthropic Citations API · Azure Groundedness Detection · RAGAS FaithfulnessWithHHEM
{
"trace_id": "abc-123-...",
"query": "제43조 보험금 지급 기준이 뭐야",
"answer": "...",
"elapsed_ms": 2340,
"sources": [
{"chunk_id": "121", "page_range": [15, 15], "content": "...", "chunk_type": "text", "rrf_score": 0.0312, "rerank_score": 0.8721},
{"chunk_id": "122", "page_range": [15, 15], "content": "...", "chunk_type": "image", "rrf_score": 0.0280, "rerank_score": 0.7510, "image_paths": ["약관_images/img8.png"]}
],
"citations": [
{"claim": "제43조에 따라 보험금이 지급된다", "refs": ["제43조"], "supported_by_chunks": ["121"]}
],
"route": {"strategy": "hybrid", "query_type": "interpretation"},
"verification": {"risk_level": "warn", "groundedness": 0.83, "warnings": ["..."]},
"crag_retries": 1
}| 필드 | 조건 | 설명 |
|---|---|---|
route |
항상 | 라우팅 결과 (검색 전략 + 쿼리 유형) |
sources[].chunk_id |
항상 | Qdrant point ID — citations[].supported_by_chunks 매핑 키 |
citations |
claim에 ref 매핑된 게 있을 때만 | claim별 인용 매핑 (Anthropic Citations API 패턴) |
verification |
warnings 있을 때만 | Self-RAG + Critic 결과 (risk_level, groundedness 0~1, warnings, optional escalation_required) |
crag_retries |
재검색 발생 시만 | CRAG 재시도 횟수 |
| 파라미터 | 위치 | 현재값 | 조절 방향 |
|---|---|---|---|
| CRAG threshold | config/settings.py | 0.3 | 올리면 재검색 빈번, 내리면 저품질 허용 |
| CRAG abort threshold | config/settings.py | 0.1 | 이 아래는 재작성 건너뛰고 즉시 거절. 올리면 거절↑·헛LLM↓, 내리면 그 반대 |
| CRAG max retries | config/settings.py | 2 | 올리면 latency↑ 품질↑. 1회당 +2~3초 (vLLM 재작성 + 재검색 + 리랭킹) |
| SIBLING_WINDOW | config/settings.py | 2 | hit 기준 ±N개 sibling 복원 |
| SEARCH_PREFETCH_MULTIPLIER | config/settings.py | 3 | RRF prefetch top_k의 N배 |
| dense/bm25 factor | rag/router.py | 3~8 | 검색 전략별 prefetch 배수 |
| 라우팅 정규식 | rag/router.py | 현재 패턴 | 도메인 확장 시 패턴 추가 |
| 프롬프트 템플릿 | rag/prompts.py | 현재 5종 | 답변 품질 보고 조절 |