제 9 장

근거를 제시하는 RAG

문서를 검색했고 답변에 출처도 붙었다. 이제 믿을 수 있는 서비스일까. 교육비 문서를 인용하면서 출장 숙박비 한도를 답하는 시스템도 겉모습은 같다. 인용한 문자열이 원문에 있다는 것과 그 문자열이 답변의 주장을 뒷받침한다는 것은 다른 문제다. 검색·문맥 구성·생성·인용 검증을 한 요청 경로로 연결하고 실제 로컬 모델로 돌린 결과를 그대로 둔다. 채택 기준을 넘지 못한 결과도 포함해서다.

요청 경로

RAG는 검색한 외부 자료를 생성에 넣는 접근이다. 모델 가중치 안의 지식만 쓰는 대신 요청에 필요한 자료를 찾아 입력으로 준다. 원 논문은 신경 검색기와 생성 모델을 함께 학습하는 방법을 다루지만, 이 책의 구현은 사전 학습된 임베딩·생성 모델을 검색과 문맥 전달로 연결하는 애플리케이션 파이프라인이다. RAG 원 논문

respond의 요청 경로질문 검사질문 임베딩허용·유효문서 검색청크 현재상태 확인문맥 예산선택 근거재확인생성구조·인용검사근거 현재상태 재확인상태·출처반환굵은 칸은 근거의 현재 상태를 다시 보는 자리다. 이 경로에는 문서 수정도 티켓 등록도 없다
역할하는 일
검색기사용자가 볼 수 있고 기준일에 유효한 후보를 찾는다
문맥 구성기후보 중 생성 입력에 들어갈 자료를 고른다
생성기질문과 자료를 읽어 답변이나 유보 상태를 만든다
검증기출력 구조와 실제 전달한 근거의 인용을 확인한다

로컬 생성 모델은 문서가 입력에 있어도 답을 못 찾았고, 검색기는 답을 담지 않은 문서에도 높은 점수를 줬고, 저장소는 특정 시점의 권한과 판을 검사했지만 요청 도중의 변경은 고정하지 못했다. 통합은 이런 단계를 없애는 일이 아니라, 각 단계의 결과와 실패를 다음 단계가 제대로 받도록 만드는 일이다. 오래된 규정과 새 규정을 함께 넣으면 모델이 둘을 섞으니, 원본·시행일·권한 규칙을 먼저 정하고 그 위에서 검색과 생성 품질을 잰다.

result = respond(
    store, embedder, backend, principal,
    "교육비 한도와 신청 순서는?",
    as_of="2026-09-08",
    k=3,
    window=4096,
    output_reserve=512,
)

진입점은 books/ai-engineering/examples/rag_service.pyrespond다. 검색 기준일은 호출자가 정한다. 시스템의 오늘 날짜를 쓸지 사용자가 요청한 과거 날짜를 쓸지는 제품 규칙이다. k는 검색 후보의 최대 수고 실제 전송 근거 수와 다를 수 있다. 문맥 구성기가 전체 입력 예산을 보고 들어갈 후보를 고르기 때문이다. 검색 점수를 정수 우선순위로 반올림하지 않고, 점수순으로 정렬된 결과에 서로 다른 우선순위를 붙여 순서를 지킨다.

후보가 하나도 없으면 생성기를 부르지 않고 insufficient_evidence를 돌려준다. 현재 사용자와 기준일로 쓸 근거가 없다는 뜻이고, 문서가 시스템 어딘가에 있는지는 말하지 않는다. 후보는 있었지만 예산 안에 하나도 못 들어갔으면 context_failed다. 둘을 합치면 운영자는 문맥 예산 오류를 못 찾고 사용자는 문서가 없는 줄 안다.

class Backend(Protocol):
    def count(self, input_text: str) -> int: ...
    def generate(
        self, input_text: str, output_limit: int
    ) -> GenerationResult: ...

백엔드의 count는 완성된 입력의 토큰 수를, generate는 같은 입력을 생성기에 보낸다. rag_local_backend.py는 시스템 지시문·출력 스키마·고정 작업 문장까지 넣은 실제 채팅 입력을 계산하고, 시연에서 생성 결과의 입력 토큰 수가 직전 계수값과 같은지 검사했다. 로컬 계수는 외부 전송이 없지만, 외부 계수 API로 바꾸면 계수 자체가 자료를 보내는 일이라 그 단계의 권한 시점도 따로 정한다.

별칭과 출처 연결

별칭에서 원문 구간으로모델 입력의 별칭s1이번 요청의 대응표서버가 보관한다실제 청크 ID3f9a…문서 · 리비전 · 구간edu · v2 · 120–184s2 · 예산 때문에 빠진 후보unknown_source · 이번 생성에 준 근거가 아니다별칭은 요청마다 새로 만든다. 다른 요청의 s1은 다른 문서다. 영구 출처 ID로 저장하지 않는다

저장소의 청크 식별자는 긴 해시라서 모델에는 s1, s2처럼 짧은 별칭을 준다. 응답의 sources에는 별칭과 함께 실제 chunk_id, doc_id, revision, start, end, 인용 구절을 돌려준다. URL은 서버가 만든다. 문서 열기 기능을 붙인다면 서버가 문서 ID를 신뢰하는 경로로 바꾸고 현재 권한을 확인한다.

검증에 쓰는 근거 집합은 검색 후보 전체가 아니라 plan.selected에 들어간 문서다. 검사는 validate_answer를 그대로 쓴다. 필수 필드, 타입, 상태와 본문의 일관성, 실제 별칭, 원문의 연속 구절을 보고, 중복 JSON 키·추가 필드·없는 구절은 거부한다. 실패하면 원본 생성문은 응답에 남기지 않고 오류 코드와 사용량만 돌려준다.

“교육비 한도는 분기당 30만 원이다”라는 구절은 원문에 있다. 이 구절을 붙여 “숙박비 한도는 30만 원이다”라고 답하면 문자열 검사는 통과한다. 주장의 대상·조건·수치가 근거와 맞는지는 의미 평가다. 인용 품질을 다룬 ALCE 연구도 유창성, 답변 정확성, 인용 품질을 나눠 평가한다. 이 서비스의 answered는 출력 상태와 인용 검사가 통과했다는 뜻이고 모든 주장이 사실이라는 인증이 아니다. 테스트에도 잘못된 300만 원 답변이 실제 30만 원 구절을 인용하면 구조 검사를 통과하는 사례를 남겼다. 나쁜 답을 허용하려는 것이 아니라 검증기가 무엇을 보장하지 않는지 분명히 하기 위해서다. 인용을 포함하는 언어 모델 응답의 평가 연구

근거 부족과 상충 문서

상황처리누가 정하나
후보가 없다생성 없이 insufficient_evidence서버
후보는 있지만 답이 없다모델이 insufficient_evidence로 유보해야 한다모델 (지시만으로는 보장 안 됨)
질문에 조건이 빠졌다needs_clarification · 답변과 인용을 비우고 되묻는다모델
문서 사이에 충돌이 있다결론을 정할 수 없으면 유보 · 권위 규칙은 메타데이터로지시는 있지만 검출기는 없다

“얼마까지 지원되나요?”만으로는 교육비인지 출장비인지 알 수 없다. 근거는 충분한데 질문 대상이 불명확한 상황을 자료 부족과 섞지 않는다. 같은 문서의 시행일 충돌은 수집에서 막았지만 서로 다른 문서 사이의 충돌은 남는다. 규정과 공지가 같은 날짜에 다른 금액을 말할 때 점수가 높은 쪽이 권위가 높은 것이 아니다. 규정 우선, 명시적 개정 공지 우선 같은 규칙이 있으면 메타데이터와 선택 로직으로 표현한다. 이번 구현은 문서 간 권위를 자동 판정하지 않고, 실제 모델은 유보 지시도 따르지 않았다.

한 요청 안의 근거 재확인 세 번검색문맥 구성생성 (오래 걸린다)검증반환① 청크 현재 상태② 선택 근거 재확인③ 근거 재확인권한 회수 · 문서 개정③이 잡는다 → evidence_changed③ 직후의 변경은 잡지 못하고, 이미 보낸 자료는 되돌릴 수 없다. 허용할 변경 시점과 취소·재시도 규칙은 따로 정한다

Store.resolve는 선택된 청크의 현재 권한·시행일·원문과 출처 메타데이터를 다시 조회한다. 검색 뒤, 생성 직전, 생성 뒤 세 번이다. 그 사이 삭제·갱신·권한 회수가 있으면 evidence_changed로 답변과 출처를 버린다. 질문의 기준일은 요청 내내 유지하지만 문서 스냅샷을 고정하는 것과는 다르다. 원문 오류가 정정돼 청크가 바뀌었으면 같은 과거 날짜의 질문이라도 진행 중인 답변을 다시 본다. 긴 모델 호출 동안 DB 쓰기 잠금을 잡는 방식은 비용이 커서 쓰지 않는다.

실패 상태 여섯 가지

결과 상태다음에 볼 것
retrieval_failed질문 임베딩 또는 저장소 검색 실패입력 한도, 모델·저장소 상태
context_failed입력 구성·계수 실패 또는 모든 근거의 예산 초과실제 지시문·스키마·문맥 예산
generation_failed생성 예외 또는 완료되지 않은 출력모델 상태, 출력 한도, 종료 정보
contract_failedJSON·필드·상태·인용 검사 실패원본 실험 출력과 오류 분류
evidence_changed선택 근거를 현재 권한·판으로 쓸 수 없음삭제·개정·권한 변경
insufficient_evidence쓸 근거가 없거나 모델이 유보원본 자료와 질문 범위

사용자는 답을 못 받았다는 한 가지 현상을 보지만 운영자는 실패 지점을 나눠야 한다. 임베딩 생성이 실패했는데 프롬프트를 바꾸거나, JSON 필드가 빠졌는데 검색 개수를 늘리면 원인은 그대로다. 운영 로그에는 원문을 무조건 남기지 않는다. 질문·문서·생성문은 모두 민감할 수 있어서 결과 객체는 실패 원문을 지운다. 이 장의 실험 기록만 가상 자료라서 원본을 보존했고, 그 방식을 사용자 서비스의 로그 정책으로 옮길 수는 없다. 정상 상태에서도 정답 근거가 후보에 있었는가, 예산을 거쳐 실제로 전달됐는가, 생성기가 그 근거를 제대로 썼는가, 인용이 주장을 뒷받침하는가를 나눠 본다.

첫 통합 실험

source books/ai-engineering/examples/.venv/bin/activate
python books/ai-engineering/examples/rag_demo.py > /tmp/rag-v1-new.json
python books/ai-engineering/examples/rag_demo.py \
  --prompt-version rag-local-v2 > /tmp/rag-v2-new.json

rag_demo.py는 E5 임베딩과 Qwen3-0.6B 생성 모델을 캐시에서 올리고, 임시 SQLite 저장소에 가상 교육비 문서를 넣는다. 외부 API 계정은 없다. 리비전은 각각 614241f…c1899de…, CPU float32 4스레드, 샘플링과 사고 모드 없음, 출력 상한 512토큰이다. 로컬 백엔드는 JSON Schema를 지시문에 넣지만 제약 디코딩은 하지 않는다. 구조를 요청한 뒤 검증기로 검사한다. 기록은 experiments/rag-local.jsonrag-local-v2.json이다.

첫 통합 실험 · Qwen3-0.6B · 사례당 1회프롬프트 v1v2 (예시 추가)사례내용형식내용형식서비스 결과R01교육비 한도와 신청 순서contract_failedR02해외 출장 숙박비 (근거 없음)contract_failedR03상충하는 두 문서contract_failedR04권한 회수 뒤 질문insufficient_evidence△ = 내용 일부 누락 (승인 절차) · – = 생성을 부르지 않음. 입력 토큰은 v1 574·577·598 → v2 784·787·808 (+210)v2는 코드 블록만 없앴다. 형식을 고쳐도 R02·R03의 내용은 그대로 틀렸다
사례v1의 관찰v2의 관찰
R01 교육비분기당 30만 원·팀장 승인·포털 신청을 답했지만 clarification 필드가 없고 코드 블록을 붙임코드 블록은 사라졌지만 필드 누락, 승인 절차도 빠짐
R02 숙박비후보에 교육비 문서만 있는데 숙박비 한도를 30만 원이라고 답하고 교육비 원문을 인용근거에 없는 150,000원을 새로 만듦
R03 상충 문서같은 기준일에 30만 원·50만 원인 두 문서에서 유보 없이 50만 원 선택50만 원 선택
R04 권한 없음두 문서의 권한을 회수한 뒤 질문. 후보가 없어 생성 호출 없음같음

R01의 실패는 형식이고 R02·R03의 실패는 내용이다. 형식을 고친다고 내용까지 맞아지지 않는다. R04는 모델의 유보 능력이 아니라 서버의 권한·빈 후보 경로가 만든 결과다. v2에는 모든 필드를 가진 답변 예시와 근거 부족 예시를 추가했고(소재는 가상의 도서 대출), 모델·문서·질문·출력 상한·검증 규칙은 그대로 뒀다. 그런데도 clarification 누락은 남았고 R02는 금액을 재사용하는 대신 없는 금액을 만들었다. 프롬프트를 길게 했다고 개선이라고 할 수 없다. 생성 함수 시간은 v1 약 6.91·5.50·4.35초, v2 약 5.33·4.88·3.38초였지만 v2의 출력이 더 짧고 통제된 반복 실험도 아니어서 속도 개선으로 읽을 수 없다.

사례당 한 번 돌린 개발 실험이고, 예시는 첫 결과를 보고 추가했으므로 독립적인 최종 평가 집합과도 다르다. 이 결과가 말하는 것은 이 모델·설정이 출력 검사와 근거 사용의 채택 기준을 넘지 못했다는 사실이다.

코드 블록을 지우고 빠진 필드를 자동으로 채우면 R01은 형식 검사를 통과할 수 있다. 그런데 같은 복구가 R02의 잘못된 숙박비 답도 다음 단계로 넘긴다. 출력 복구가 필요하면 허용하는 변환을 명시하고 변환 전후를 검증한다. 실패한 실험을 통과시키려고 필수 필드를 선택 필드로 바꾸거나 의미 기준을 낮추지 않았다. 생성 모델 교체, 제약 생성 기능, 작업에 맞는 프롬프트가 다음 후보고, 그때도 숙박비 오답과 충돌 사례는 회귀 집합에 남는다.

실습: 실패를 단계별로 나눠 기록하기

python -m unittest discover -s books/ai-engineering/examples -p 'test_*.py' -q

test_rag_service.py는 실제 임시 SQLite 저장소와 대역 임베딩·생성기를 쓴다. 정상 출력이 실제 문서·리비전·청크로 연결되는지, 예산에서 빠진 출처가 거부되는지, 생성 전 권한 회수 때 호출을 멈추는지, 생성 중 권한 회수나 문서 개정이 있으면 최종 답변을 버리는지 본다. 대역 생성기의 정상 JSON은 모델 능력의 증거가 아니라 애플리케이션이 정상·비정상 입력을 제대로 처리하는지의 증거다.

  1. rag_demo.py를 두 프롬프트로 돌리고 R01~R04를 실패 상태 표의 여섯 상태에 대응시킨다. 같은 contract_failed라도 R01과 R02의 고칠 자리가 다르다는 것을 써 둔다.
  2. 빠진 필드를 자동으로 채우는 복구를 넣었다고 가정하고 네 사례가 어디까지 흘러가는지 따라간다. R02가 answered까지 가면 그 복구는 쓸 수 없다.
  3. 문서 권한을 생성 직전과 생성 직후에 각각 회수해 어느 확인이 잡는지 본다.

통합의 결과물은 성공·유보·실패를 구분하고 출처를 돌려주는 Python 서비스다. 현재 로컬 모델의 응답 품질은 아직 채택 기준 아래에 있다.

문서를 지식으로 연결하기9 / 23