검색과 생성을 붙여 놓고 보니 화면이 제법 그럴듯했다. 답변 아래 출처가 붙고 문서 이름을 누르면 원문으로 넘어갔다. 되돌린 뒤로 한 달 넘게 붙들고 있던 것이 한 화면에 다 들어와 있어서, 개발팀은 이제 됐다고 생각했다.
그런데 겉모습만으로는 알 수 없는 것이 있다. 교육비 문서를 인용하면서 출장 숙박비 한도를 답하는 시스템도 화면은 똑같이 생겼다. 개발팀은 화면이 정말 그 출처대로 답하는지 확인하기로 하고 검색·문맥 구성·생성·인용 검증을 한 요청 경로로 연결해 실제 로컬 모델로 돌려 봤다.
요청 경로
직원이 물으면 검색기가 사내 문서에서 관련된 것을 찾고, 구성기가 그중 그 직원이 읽어도 되는 것만 골라 질문 뒤에 붙이고, 모델이 붙은 자료만 보고 답을 쓴다.
RAG는 검색한 외부 자료를 생성에 넣는 접근이다. 모델 가중치 안의 지식만 쓰는 대신 요청에 필요한 자료를 찾아 입력으로 준다. 모델을 고치는 것이 아니라 모델이 보는 입력을 고치는 일이다. 그래서 정책이 바뀌면 문서 한 건만 갈아 끼우면 되고 누가 그 문서를 읽을 수 있는지도 검색 단계에서 사람마다 다르게 정할 수 있다. 가중치 학습으로는 둘 다 어렵다.
바뀌지 않는 것도 있다. 모델은 여전히 다음 토큰을 예측할 뿐이라, 입력에 든 문장을 그대로 옮겨 적을 의무가 없다. 근거를 넣어 줬다고 모델이 그 근거대로 답하지는 않는다. 원 논문은 신경 검색기와 생성 모델을 함께 학습하는 방법을 다루지만, 이 책의 구현은 사전 학습된 임베딩·생성 모델을 검색과 문맥 전달로 연결하는 애플리케이션 파이프라인이다. RAG 원 논문
| 역할 | 하는 일 |
|---|---|
| 검색기 | 사용자가 볼 수 있고 기준일에 유효한 후보를 찾는다 |
| 문맥 구성기 | 후보 중 생성 입력에 들어갈 자료를 고른다 |
| 생성기 | 질문과 자료를 읽어 답변이나 유보 상태를 만든다 |
| 검증기 | 출력 구조와 실제 전달한 근거의 인용을 확인한다 |
로컬 생성 모델은 문서가 입력에 있어도 답을 못 찾았고, 검색기는 답을 담지 않은 문서에도 높은 점수를 줬고, 저장소는 특정 시점의 권한과 리비전을 검사했지만 요청 도중의 변경은 고정하지 못했다. 통합은 이런 단계를 없애는 일이 아니라, 각 단계의 결과와 실패를 다음 단계가 제대로 받도록 만드는 일이다. 오래된 규정과 새 규정을 함께 넣으면 모델이 둘을 섞으니, 원본·시행일·권한 규칙을 먼저 정하고 그 위에서 검색과 생성 품질을 잰다.
검색 기준일은 호출자가 정한다. 시스템의 오늘 날짜를 쓸지 사용자가 요청한 과거 날짜를 쓸지는 제품 규칙이다. 후보 수에는 상한을 둔다. 실제로 모델에 가는 근거 수는 이보다 적을 수 있다. 문맥 구성기가 전체 입력 예산을 보고 들어갈 후보를 고르기 때문이다. 검색 점수를 정수 우선순위로 반올림하지 않고 점수순으로 정렬된 결과에 서로 다른 우선순위를 붙여 순서를 지킨다.
후보가 하나도 없으면 생성기를 호출하지 않고 insufficient_evidence를 돌려준다. 현재 사용자와 기준일로 쓸 근거가 없다는 뜻이고 문서가 시스템 어딘가에 있는지는 말하지 않는다. 후보는 있었지만 예산 안에 하나도 못 들어갔으면 context_failed다. 둘을 합치면 운영자는 문맥 예산 오류를 못 찾고 사용자는 문서가 없는 줄 안다.
세는 쪽은 완성된 입력의 토큰 수를 세고, 보내는 쪽은 그 입력을 그대로 생성기에 넘긴다. 셀 때는 시스템 지시문·출력 스키마·고정 작업 문장까지 넣은 실제 입력으로 센다. 개발팀은 실제로 돌려 보면서 생성 결과의 입력 토큰 수가 직전에 센 값과 같은지 확인했다. 로컬에서 세면 외부 전송이 없지만 외부 세기 API로 바꾸면 세는 일 자체가 자료를 보내는 일이라 그 단계의 권한 시점도 따로 정한다.
별칭과 출처 연결
저장소의 청크 식별자는 긴 해시라서 모델에는 s1, s2처럼 짧은 별칭을 준다. 응답의 sources에는 별칭과 함께 실제 chunk_id, doc_id, revision, start, end, 인용 구절을 돌려준다. URL은 서버가 만든다. 문서 열기 기능을 붙인다면 서버가 문서 ID를 신뢰하는 경로로 바꾸고 현재 권한을 확인한다.
검증에 쓰는 근거 집합은 검색 후보 전체가 아니라 구성기가 실제로 고른 문서다. 검사는 앞에서 만든 검증기를 그대로 쓴다. 필수 필드, 타입, 상태와 본문의 일관성, 실제 별칭, 원문의 연속 구절을 보고, 중복 JSON 키·추가 필드·없는 구절은 거부한다. 실패하면 원본 생성문은 응답에 남기지 않고 오류 코드와 사용량만 돌려준다.
“교육비 한도는 분기당 30만 원이다”라는 구절은 원문에 있다. 이 구절을 붙여 “숙박비 한도는 30만 원이다”라고 답하면 문자열 검사는 통과한다. 주장의 대상·조건·수치가 근거와 맞는지까지는 문자열 검사로 알 수 없다. 이것을 따지는 일을 의미 평가라고 부른다. 인용 품질을 다룬 ALCE 연구도 유창성, 답변 정확성, 인용 품질을 나눠 평가한다. 이 서비스의 answered는 출력 상태와 인용 검사가 통과했다는 뜻이고 모든 주장이 사실이라는 인증이 아니다. 개발팀은 잘못된 300만 원 답변이 실제 30만 원 구절을 인용하면 구조 검사를 통과하는 사례를 테스트에도 일부러 남겼다. 나쁜 답을 허용하려는 것이 아니라 검증기가 무엇을 보장하지 않는지 분명히 하기 위해서다. 인용을 포함하는 언어 모델 응답의 평가 연구
근거 부족과 상충 문서
| 상황 | 처리 | 누가 정하나 |
|---|---|---|
| 후보가 없다 | 생성 없이 insufficient_evidence | 서버 |
| 후보는 있지만 답이 없다 | 모델이 insufficient_evidence로 유보해야 한다 | 모델 (지시만으로는 보장 안 됨) |
| 질문에 조건이 빠졌다 | needs_clarification · 답변과 인용을 비우고 되묻는다 | 모델 |
| 문서 사이에 충돌이 있다 | 결론을 정할 수 없으면 유보 · 권위 규칙은 메타데이터로 | 지시는 있지만 검출기는 없다 |
“얼마까지 지원되나요?”만으로는 교육비인지 출장비인지 알 수 없다. 근거는 충분한데 질문 대상이 불명확한 상황을 자료 부족과 섞지 않는다. 같은 문서의 시행일 충돌은 수집에서 막았지만 서로 다른 문서 사이의 충돌은 남는다. 규정과 공지가 같은 날짜에 다른 금액을 말할 때 점수가 높은 쪽이 권위가 높은 것이 아니다. 규정 우선, 명시적 개정 공지 우선 같은 규칙이 있으면 메타데이터와 선택 로직으로 표현한다. 이번 구현은 문서 사이의 권위를 자동으로 판정하지 않는다.
저장소는 고른 청크의 현재 권한·시행일·원문과 출처 메타데이터를 다시 조회한다. 검색 뒤, 생성 직전, 생성 뒤 세 번이다. 그 사이 삭제·갱신·권한 회수가 있으면 evidence_changed로 답변과 출처를 버린다. 질문의 기준일은 요청 내내 유지한다. 그 사이 문서가 바뀌면 바뀐 쪽을 읽는다. 원문 오류가 정정돼 청크가 바뀌었으면 같은 과거 날짜의 질문이라도 진행 중인 답변을 다시 본다. 긴 모델 호출 동안 DB 쓰기 잠금을 잡는 방식은 비용이 커서 쓰지 않는다.
실패 상태
| 결과 상태 | 뜻 | 다음에 볼 것 |
|---|---|---|
| retrieval_failed | 질문 임베딩 또는 저장소 검색 실패 | 입력 한도, 모델·저장소 상태 |
| context_failed | 입력 구성·세기 실패 또는 모든 근거의 예산 초과 | 실제 지시문·스키마·문맥 예산 |
| generation_failed | 생성 예외 또는 완료되지 않은 출력 | 모델 상태, 출력 한도, 종료 정보 |
| contract_failed | JSON·필드·상태·인용 검사 실패 | 원본 실험 출력과 오류 분류 |
| evidence_changed | 선택 근거를 현재 권한·판으로 쓸 수 없음 | 삭제·개정·권한 변경 |
| insufficient_evidence | 쓸 근거가 없거나 모델이 유보 | 원본 자료와 질문 범위 |
사용자는 답을 못 받았다는 한 가지 현상을 보지만 운영자는 실패 지점을 나눠야 한다. 임베딩 생성이 실패했는데 프롬프트를 바꾸거나, JSON 필드가 빠졌는데 검색 개수를 늘리면 원인은 그대로다. 운영 로그에는 원문을 무조건 남기지는 않는다. 질문·문서·생성문은 모두 민감할 수 있어서 결과 객체는 실패 원문을 지운다. 이번 실험은 가상 자료라서 원본을 남겨 뒀지만 그 방식을 사용자 서비스의 로그 정책으로 옮길 수는 없다. 정상 상태에서도 정답 근거가 후보에 있었는가, 예산을 거쳐 실제로 전달됐는가, 생성기가 그 근거를 제대로 썼는가, 인용이 주장을 뒷받침하는가를 나눠 본다.
첫 통합 실험
개발팀은 E5 임베딩과 Qwen3-0.6B 생성 모델을 캐시에서 올리고 임시 저장소에 가상 교육비 문서를 넣었다. 외부 API 계정은 없다. CPU float32 4스레드, 샘플링과 사고 모드 없음, 출력 상한 512토큰이다. 로컬에서는 출력 스키마를 지시문에 적어 보낼 뿐 제약 디코딩은 하지 않는다. 구조를 요청한 뒤 검증기로 검사한다.
| 사례 | 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의 잘못된 숙박비 답도 다음 단계로 넘긴다. 출력 복구가 필요하면 허용하는 변환을 명시하고 변환 전후를 검증한다. 실패한 실험을 통과시키려고 필수 필드를 선택 필드로 바꾸거나 의미 기준을 낮추지 않았다. 생성 모델 교체, 제약 생성 기능, 작업에 맞는 프롬프트가 다음 후보고, 그때도 숙박비 오답과 충돌 사례는 회귀 집합에 남는다.
이제 됐다고 생각했던 그 화면은 R01부터 R04까지를 그대로 보여 줬다. 출처가 붙었다고 그 출처로 답한 것은 아니라는 사실을 개발팀은 실행 기록에서 처음 눈으로 봤다.