문서를 검색했고 답변에 출처도 붙었다. 이제 믿을 수 있는 서비스일까. 교육비 문서를 인용하면서 출장 숙박비 한도를 답하는 시스템도 겉모습은 같다. 인용한 문자열이 원문에 있다는 것과 그 문자열이 답변의 주장을 뒷받침한다는 것은 다른 문제다. 검색·문맥 구성·생성·인용 검증을 한 요청 경로로 연결하고 실제 로컬 모델로 돌린 결과를 그대로 둔다. 채택 기준을 넘지 못한 결과도 포함해서다.
요청 경로
RAG는 검색한 외부 자료를 생성에 넣는 접근이다. 모델 가중치 안의 지식만 쓰는 대신 요청에 필요한 자료를 찾아 입력으로 준다. 원 논문은 신경 검색기와 생성 모델을 함께 학습하는 방법을 다루지만, 이 책의 구현은 사전 학습된 임베딩·생성 모델을 검색과 문맥 전달로 연결하는 애플리케이션 파이프라인이다. RAG 원 논문
| 역할 | 하는 일 |
|---|---|
| 검색기 | 사용자가 볼 수 있고 기준일에 유효한 후보를 찾는다 |
| 문맥 구성기 | 후보 중 생성 입력에 들어갈 자료를 고른다 |
| 생성기 | 질문과 자료를 읽어 답변이나 유보 상태를 만든다 |
| 검증기 | 출력 구조와 실제 전달한 근거의 인용을 확인한다 |
로컬 생성 모델은 문서가 입력에 있어도 답을 못 찾았고, 검색기는 답을 담지 않은 문서에도 높은 점수를 줬고, 저장소는 특정 시점의 권한과 판을 검사했지만 요청 도중의 변경은 고정하지 못했다. 통합은 이런 단계를 없애는 일이 아니라, 각 단계의 결과와 실패를 다음 단계가 제대로 받도록 만드는 일이다. 오래된 규정과 새 규정을 함께 넣으면 모델이 둘을 섞으니, 원본·시행일·권한 규칙을 먼저 정하고 그 위에서 검색과 생성 품질을 잰다.
result = respond(
store, embedder, backend, principal,
"교육비 한도와 신청 순서는?",
as_of="2026-09-08",
k=3,
window=4096,
output_reserve=512,
)
진입점은 books/ai-engineering/examples/rag_service.py의 respond다. 검색 기준일은 호출자가 정한다. 시스템의 오늘 날짜를 쓸지 사용자가 요청한 과거 날짜를 쓸지는 제품 규칙이다. 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, 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 · 답변과 인용을 비우고 되묻는다 | 모델 |
| 문서 사이에 충돌이 있다 | 결론을 정할 수 없으면 유보 · 권위 규칙은 메타데이터로 | 지시는 있지만 검출기는 없다 |
“얼마까지 지원되나요?”만으로는 교육비인지 출장비인지 알 수 없다. 근거는 충분한데 질문 대상이 불명확한 상황을 자료 부족과 섞지 않는다. 같은 문서의 시행일 충돌은 수집에서 막았지만 서로 다른 문서 사이의 충돌은 남는다. 규정과 공지가 같은 날짜에 다른 금액을 말할 때 점수가 높은 쪽이 권위가 높은 것이 아니다. 규정 우선, 명시적 개정 공지 우선 같은 규칙이 있으면 메타데이터와 선택 로직으로 표현한다. 이번 구현은 문서 간 권위를 자동 판정하지 않고, 실제 모델은 유보 지시도 따르지 않았다.
Store.resolve는 선택된 청크의 현재 권한·시행일·원문과 출처 메타데이터를 다시 조회한다. 검색 뒤, 생성 직전, 생성 뒤 세 번이다. 그 사이 삭제·갱신·권한 회수가 있으면 evidence_changed로 답변과 출처를 버린다. 질문의 기준일은 요청 내내 유지하지만 문서 스냅샷을 고정하는 것과는 다르다. 원문 오류가 정정돼 청크가 바뀌었으면 같은 과거 날짜의 질문이라도 진행 중인 답변을 다시 본다. 긴 모델 호출 동안 DB 쓰기 잠금을 잡는 방식은 비용이 커서 쓰지 않는다.
실패 상태 여섯 가지
| 결과 상태 | 뜻 | 다음에 볼 것 |
|---|---|---|
| retrieval_failed | 질문 임베딩 또는 저장소 검색 실패 | 입력 한도, 모델·저장소 상태 |
| context_failed | 입력 구성·계수 실패 또는 모든 근거의 예산 초과 | 실제 지시문·스키마·문맥 예산 |
| generation_failed | 생성 예외 또는 완료되지 않은 출력 | 모델 상태, 출력 한도, 종료 정보 |
| contract_failed | JSON·필드·상태·인용 검사 실패 | 원본 실험 출력과 오류 분류 |
| 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.json과 rag-local-v2.json이다.
| 사례 | 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은 모델 능력의 증거가 아니라 애플리케이션이 정상·비정상 입력을 제대로 처리하는지의 증거다.
rag_demo.py를 두 프롬프트로 돌리고 R01~R04를 실패 상태 표의 여섯 상태에 대응시킨다. 같은contract_failed라도 R01과 R02의 고칠 자리가 다르다는 것을 써 둔다.- 빠진 필드를 자동으로 채우는 복구를 넣었다고 가정하고 네 사례가 어디까지 흘러가는지 따라간다. R02가
answered까지 가면 그 복구는 쓸 수 없다. - 문서 권한을 생성 직전과 생성 직후에 각각 회수해 어느 확인이 잡는지 본다.
통합의 결과물은 성공·유보·실패를 구분하고 출처를 돌려주는 Python 서비스다. 현재 로컬 모델의 응답 품질은 아직 채택 기준 아래에 있다.