출처는 생산 지점에서, 원본도 같은 정책으로: RAG 출처 표시 파이프라인 재정비
답변 출처가 검색 모드에 따라 사라지거나, 검색 노드 없이도 붙거나, 원본 열람이 마스킹 정책을 우회하고, 큰 CSV 출처가 브라우저를 멈추던 문제를 차례로 추적했습니다. 출처는 백엔드 생산 지점에서 결정하고, 원본도 청크와 같은 정책을 거치며, 뷰어는 보이는 만큼만 그린다는 원칙으로 정리했습니다.
Key Results
- 63케이스 불일치 0
- 마스킹 공용 코어 차분 검증기존 인라인 구현 대조, 정상·빈 패턴·잘못된 정규식·중복 매치·빈 문자열 등
- 22,278 → 385
- CSV 출처 뷰어 DOM 노드2,473행 CSV, 가상 스크롤 적용 전후(-98.3%)
- 92,457 ms → 0 ms
- 시트 하이라이트 처리 시간700행×12열 시트, 시트 경로에서 텍스트 하이라이터 분리
RAG 답변을 믿을 수 있게 만드는 것은 답변 아래의 출처 칩과, 칩을 눌렀을 때 열리는 원본 뷰어입니다. XGEN에서는 2026년 5월에 이 기능을 처음 만든 뒤 9월까지 같은 경로를 여러 번 다시 손봤습니다. 출처가 검색 모드에 따라 나왔다 안 나왔다 했고, 검색 노드가 없는 워크플로우에도 칩이 표시됐고, 출처 표시 토글을 끄면 오히려 칩이 25개로 늘었습니다. 원본 뷰어는 마스킹 정책을 건 컬렉션에서도 원본 파일을 그대로 내려보내고 있었고, 큰 CSV 출처를 누르면 브라우저가 수십 초 멈췄습니다.
하나씩 보면 별개의 버그였지만, 원인을 따라가면 공통점이 있었습니다. 출처를 만들 자격을 판단하는 지점이 없거나 여러 곳에 흩어져 있었고, 같은 규칙(마스킹, 토글 의미)이 경로마다 다르게 적용되고 있었습니다. 이 글은 증상별로 원인을 좁힌 과정과, 판단을 어디로 모았는지, 결과로 무엇이 달라졌는지를 정리합니다.
출처 한 건이 사용자에게 닿기까지
출처는 백엔드에서 만들어져 세 갈래로 퍼집니다.
[생산] 정보검색 노드의 사전 검색 결과 / 에이전트의 검색 도구 출력
→ 인용 파싱 → DB 저장 + SSE 이벤트(tool_result, citations)
[소비] 프론트엔드: 답변 본문의 [DOC_n] 마커 → 칩, 답변 아래 칩 목록
[재진입] 대화를 다시 열 때 인터랙션 단위로 저장된 출처 조회
[원본] 칩 클릭 → 원본 열람 API → 저장소 객체 → 포맷별 뷰어 + 하이라이트마스킹은 원래 업로드 파이프라인에만 있었습니다. 문서를 올릴 때 관리자가 고른 PII·금칙어 정책으로 청크를 가린 뒤 벡터 DB에 넣습니다. 원본 열람 경로는 이 단계와 접점이 없었습니다.
출처가 사라지거나 엉뚱하게 표시되던 경로들
첫 번째 문제는 5월에 드러났습니다. 인용 SSE 이벤트 발행이 검색 모드 값에 묶여 있어, 답변 전에 미리 검색하는 모드에서는 출처 카드가 뜨지 않았습니다. 검색 모드와 무관하게 인용을 발행하도록 바꾸고, 본문의 [DOC_x], 소괄호 형식, (문서 N) 같은 마커를 칩으로 변환했습니다. 칩을 누르면 PDF, HWP, DOCX, TXT 뷰어로 이동해 해당 위치를 강조하고, HWP는 HTML로 변환해 보여 줍니다. 이 기능의 첫 구현은 빌드가 여러 번의 핫픽스에도 안정되지 않아 통째로 되돌렸고, 다른 저장소에 있던 사내 PDF 하이라이팅 구현을 옮겨 오는 방식으로 다시 만들었습니다.
8월에는 반대 방향의 제보가 들어왔습니다. 고객 환경의 한 워크플로우에 정보검색 노드가 없는데도 답변에 출처 칩과 원본 뷰가 산발적으로 떴습니다. 의도는 "정보검색 노드가 연결돼 있을 때만 출처 제공"이었습니다. 조사해 보니 게이트가 생산과 소비 두 층 모두 풀려 있었습니다.
[생산] 도구 콜백 핸들러 5곳
도구 종료 시(모든 도구) → 인용 파싱 ← 도구 종류와 무관
→ 즉시 DB 저장 ← 답변이 인용했는지와 무관
→ SSE tool_result{tool_name: 검색 도구 이름} ← 이름까지 위장
[소비] 프론트엔드: 인용 배열이 있으면 칩으로 표시
[재진입] 저장된 출처 조회 → 답변이 쓰지 않은 저장분까지 반환프론트엔드는 원래 도구 이름이 정확히 일치할 때만 칩을 그렸는데, 사용자 검색 도구 이름에 고유 접미사가 붙으면서 정확 일치에서 탈락하는 문제가 있어 "인용 배열이 있는가"로 완화된 상태였습니다. 백엔드 쪽 답변 사용 필터는 실제로 인용된 출처를 추가로 저장할 뿐 쓰이지 않은 행을 지우지 않았습니다. 그래서 검색이 아닌 도구의 출력도 인용 형식에 걸리면 출처로 파싱돼 저장될 수 있었고, 대화를 다시 열면 답변이 쓰지 않은 저장분까지 칩으로 돌아왔습니다.
출처 게이트를 어디에 둘 것인가
선택지는 세 가지였습니다.
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 프론트엔드에서 도구 이름으로 거르기 | 변경이 작음 | 접미사가 포함된 검색 도구의 출처가 다시 사라짐, 저장 잔재는 그대로 | 기각 |
| 백엔드에서 이름 규칙으로만 판별 | 생산 지점에서 차단 | 검색 도구 이름은 사용자가 편집 가능한 파라미터라 개명하면 출처가 사라짐 | 폴백으로만 |
| 백엔드 단일 판별 함수 + 정보검색 노드가 공급한 도구 이름 목록(allowlist) 우선 | 개명한 도구도 통과, 비검색 도구는 파싱 자체를 건너뜀 | 목록 수집·주입 배선 필요 | 채택 |
판별 함수 하나를 만들어 인용 파싱 5곳에 모두 걸었습니다. 통과하지 못하면 파싱을 건너뛰므로 저장과 SSE가 함께 사라집니다. 허용 목록은 에이전트가 정보검색 노드의 내장 도구에서 이름을 모아 핸들러에 넘기고, 목록이 주입되지 않는 레거시 경로에서는 검색 도구 이름 규칙으로 최소 동작을 유지합니다. 스트리밍 경로 하나는 도구 종료 이벤트에서 이름을 읽다가 "unknown"으로 떨어질 수 있어, 도구 시작 이벤트에서 실행 ID와 도구 이름을 기록해 두고 종료 때 조회하게 했습니다. 판별 함수는 접미사가 포함된 기본 검색 도구, 반복 검색 도구, 온톨로지 검색은 통과, 사용자가 개명한 검색 도구는 목록이 없으면 차단되고 목록에 있으면 통과, 문서 편집 도구·첨부 읽기·빈 이름은 차단되는 9가지 경우로 검증했습니다.
9월에는 토글이 문제였습니다. 외부망 데모에서 주문 데이터 CSV를 물었더니 답변 본문에 인용 표기가 하나도 없는데 칩이 25개 표시됐습니다. 에이전트 노드의 출처 표시 토글은 꺼져 있었습니다.
| 경로 | 출처 표시 OFF | 출처 표시 ON |
|---|---|---|
| 사전 검색(정보검색 노드) | 인용 규칙을 프롬프트에 넣지 않음 → 모델이 태그를 안 붙임 → 출처 필터가 "판별 근거 없음"으로 검색 결과 전부 유지 | 답변이 인용한 태그만 |
| 검색 도구 모드 | 도구 종료 시 파싱된 인용을 전부 저장(토글 무관) | 전부 저장 후 인용된 것만 다시 저장 |
끄면 오히려 더 많이 보여 주는 구조였습니다. 프론트엔드는 본문 [DOC_3]과 칩 번호를 맞추려고 일부러 (파일, 페이지)로 칩을 합치지 않는데, 태그가 없는 답변에서는 그 전제가 없어 청크 단위 칩이 그대로 남았습니다. 그래서 토글을 하나의 스위치로 정의했습니다. 끄면 근거 표기, 출처 칩, 저장이 모두 없고, 켜면 기존과 같습니다. 인용 규칙 주입은 토글만 보게 했고(정보검색 노드가 둘이면 꺼져 있어도 주입하던 조건 제거), 사전 검색 출처 확정은 꺼지면 빈 목록을 돌려주며, 도구 핸들러와 스트리밍 경로도 꺼지면 인용을 버립니다. 배포 API가 "false" 문자열을 주입해도 켜진 것으로 오판하지 않도록 값도 불리언으로 정규화했습니다. 백엔드 4개 파일에 14줄을 더하고 8줄을 지웠고, 프론트엔드는 바꾸지 않았습니다. 기본값이 꺼짐이라 토글 없이 출처가 뜨던 워크플로우는 토글을 켜야 다시 뜬다는 점은 배포 공지 항목으로 남겼습니다.
원본 열람도 같은 마스킹 정책을 거치게
7월에는 보안 경로를 정리했습니다. 원본 열람 API는 저장소 객체를 그대로 스트리밍할 뿐이라, 청크는 가려져도 원본은 저장소 원본 그대로 내려갔습니다. 마스킹 정책을 건 컬렉션에서도 출처 열람이 정책 우회 경로였던 셈입니다. 원본 서빙에 마스킹을 적용하려다 보니 마스킹 로직이 업로드 3곳, 사전 스캔 2곳에 복사돼 있었습니다. 호출부마다 다른 것은 "어떤 정책을 고르는가"뿐이고 치환 규약은 같았기 때문에, 정책 로딩(업로드용 선택∩활성, 사전 스캔용 활성 전체, 서빙용 적용 이력)과 치환, 좌표 반환을 공용 코어 한 벌로 합쳤습니다. 합치는 과정에서 결함 네 건이 나왔고, 그중 하나는 기존 코드에 숨어 있던 가장 위험한 결함이었습니다.
업로드 마스킹 (수정 전)
pattern.findall(text) 캡처 그룹이 2개 이상이면 문자열이 아니라 튜플 목록
→ 치환 단계에서 TypeError
→ 안쪽 except re.error 는 못 잡고 바깥 except Exception 이 삼킴
→ "PII processing failed, continuing without masking" 경고 한 줄
→ 그 업로드의 나머지 정책 전부 건너뜀, 문서가 평문으로 색인
수정 후 finditer + group(0) 으로 통일관리자가 주민번호 정책을 (\d{6})-([1-4]\d{6})처럼 쓰기만 해도 재현되는 결함이었습니다. 개발 서버의 활성 정책은 캡처 그룹이 없어 노출되지 않았지만, 다른 배포 환경의 정책 정규식은 따로 확인하도록 후속 항목으로 남겼습니다.
나머지 세 건은 새 코드에서 나왔습니다. Word는 맞춤법과 서식 경계마다 텍스트 run을 쪼개기 때문에 이메일 주소 하나가 세 노드에 나뉘어 매치되지 않았습니다. 문단 단위로 노드 텍스트를 연결해 매치를 찾고 원래 노드에 되돌려 쓰되, 마스크는 매치가 시작된 노드에만 넣어 매치 밖 서식을 보존했습니다. 마스킹 응답에 max-age=3600 헤더가 설정돼 있어 정책을 켜기 전 받아 간 원본이 브라우저 캐시에 남는 우회도 있었습니다. no-cache와 ETag로 바꾸고, 지문은 테이블의 버전 컬럼이 아니라 실제 패턴과 마스크를 해싱해 버전 없이 고친 정책도 잡게 했습니다. 재검증 응답은 0.13초로 전체 응답 0.70초보다 빨랐습니다. 감사 이력의 문서명이 항상 비어 있던 기존 버그도 함께 고쳤습니다.
어떤 정책을 원본에 적용할지는 설계 판단이 필요했습니다. 처음에는 "그 문서가 업로드 때 받은 정책만" 적용했는데, 실측해 보니 정책을 새로 켜도 기존 문서 원본에는 영원히 걸리지 않았습니다. 그래서 업로드 때 받은 정책과 현재 활성 정책의 합집합으로 바꿨습니다. 이력분은 활성 여부로 거르지 않습니다. 청크가 이미 가려졌는데 원본만 풀리면 그것이 유출이기 때문입니다. 활성분은 플래그를 존중해 새로 켠 정책이 기존 원본에도 바로 적용됩니다. 마스킹 이력이 있는 문서에서 마스킹이 실패하면 원본을 막고 청크 표시로 대체하며, 이력이 없는 문서는 청크도 평문이라 원본만 막을 실익이 없어 그대로 둡니다.
포맷별로는 PDF는 페이지 텍스트에서 찾은 매치를 좌표로 역추적해 바이트에서 지우고, DOCX·PPTX·XLSX는 압축 내부 XML을 문단 단위로 치환하고, HTML·HWP·HWPX는 텍스트 노드만 바꾸고, 시트와 슬라이드는 HTML로 변환한 뒤 같은 마스킹을 거칩니다. 이 작업에서 출처 뷰어가 XLSX, XLSM, XLS, CSV, TSV를 새로 지원하게 됐고 PPTX도 되살렸습니다. 업로드 때마다 지수 백오프로 6번 재시도하다 실패하던 PPT→PDF 변환은 어느 배포에서도 성공할 수 없는 구조(팩토리가 해당 메서드가 없는 클래스만 반환)였기에 걷어내고 기존 의존성으로 직접 변환했습니다.
시트 출처가 브라우저를 멈춘 이유
7월 말, CSV 출처를 누르면 브라우저가 응답하지 않고 CPU 사용률이 치솟는다는 제보가 왔습니다. 원인은 DOCX용 텍스트 하이라이터가 시트 뷰에도 적용돼 있던 것이었습니다. 이 컴포넌트는 문단·셀 요소를 전부 모으고, 요소 텍스트를 연결해 매칭 후보를 계산한 뒤, 요소 수와 매칭 수의 이중 루프를 실행하고, 그 계산을 모두 마친 뒤 실제로는 한 군데만 칠합니다. 셀 하나가 요소인 시트에서는 곱이 수억 회가 됩니다. 700행×12열 시트에서 92,457 ms, DOCX 150문단에서 250,432 ms가 걸렸습니다.
이 비용은 그날 만든 것이 아니었습니다. 시트 뷰어를 넣을 때부터 있었지만, 파일 유형 초기값이 "unknown"이라 CSV가 청크 뷰로 열리면서 시트 자체가 렌더되지 않았던 것입니다. 그 버그를 고쳐 시트가 실제로 뜨자 숨어 있던 비용이 처음 실행됐습니다. 시트에서는 하이라이터를 떼고, 하이라이터가 클래스 이름으로 전역 탐색하므로 시트 컨테이너의 클래스 이름도 분리했습니다. 큰 DOCX·HWP 대비로 매칭을 일치 문자열 기준으로 접어 34,394건을 30건으로(26,546 ms → 22 ms) 줄였고, 칠할 후보 33,000개는 같다는 것을 세 가지 문서 형태에서 확인했습니다. DOCX 150문단은 608 ms가 됐습니다.
하이라이터를 떼자 같은 파일의 다른 출처를 눌러도 뷰가 바뀌지 않았습니다. 인용 구간을 직접 표시해야 했는데, 첫 판은 표시가 0개였습니다. 인용 청크가 마크다운 표 조각이라 컬럼명을 모두 담고 있어 헤더 행이 항상 최고점이었고, 표시 직후 innerHTML이 교체돼 표시한 행이 버려졌고, 행 번호 접근(rowIndex)이 브라우저가 표 전체를 훑는 O(n²) 연산이었습니다(5만 행에서 53,857 ms, 문서 순서 인덱스로는 163 ms). 셀 값을 집합으로 만들어 정확 일치시키고 행별 일치 비율 0.9 이상을 인용 구간으로 잡자, 부분 문자열 방식의 44행이나 점수 임계 방식의 1,930행이 아니라 정확히 16행이 선택됐습니다.
그래도 탭이 꺼지는 일이 남았습니다. 힙 스냅샷에서 셀 노드가 GC 루트에 도달하지 않아 누수는 아니었고, 원인은 크기였습니다. 표 HTML을 통째로 넣어 2,473행 CSV 하나가 DOM 노드 22,278개가 됐습니다. 보이는 행만 그리는 가상 스크롤로 바꿨고, 라이브러리는 이미 저장소의 다른 모듈이 쓰는 것과 같은 버전을 썼습니다. 가상화하면 안 그려진 행은 DOM에 없으므로, 인용 행 판정을 DOM 스캔에서 파싱된 셀 값 배열로 옮기고 찾은 행으로 스크롤 이동하게 했습니다. 노드는 385개(-98.3%)가 됐습니다.
같은 작업에서 두 가지를 더 고쳤습니다. 변환 HTML의 body { font-size: 13px; ... } 스타일이 앱 전체 body에 새어 원문과 청크를 오갈 때 페이지 글자 크기와 여백이 바뀌어, 규칙의 body를 문서 루트 클래스로 치환해 가뒀습니다. 메모리를 아끼려고 청크 뷰로 갈 때 원본 데이터를 해제했더니, POST 응답은 브라우저가 캐시하지 않아 토글할 때마다 서버가 문서 전체를 다시 변환하고 다시 마스킹하고 있었습니다. 패널이 열린 동안은 데이터를 유지하도록 되돌렸습니다.
측정 방법에서도 배운 것이 있습니다. 처음에는 100 ms 이상 작업을 잡는 longtask 관찰과 프레임 타이밍으로 재고 "유휴"라고 판단했는데, 100 ms 미만 작업이 반복되는 렌더 루프는 이 방법에 잡히지 않았습니다. CDP Profiler의 유휴 비율로 다시 재자 같은 시기 목록 페이지가 유휴 2.1%, 즉 CPU를 97.9% 쓰고 있던 것이 드러났습니다. 이후 프론트엔드 성능은 프로파일러 유휴 비율로, 누수는 힙 스냅샷의 GC 루트 도달 여부로 판정합니다.
결과와 남은 과제
| 지표 | 이전 | 이후 | 측정 조건 |
|---|---|---|---|
| 마스킹 공용 코어 동작 차이 | 5곳에 복사된 구현 | 63케이스 불일치 0 | 기존 인라인 루프를 떼어 같은 입력으로 대조 |
| 원본 재검증 응답 | 0.70 s(전체 응답) | 0.13 s | 정책 지문 ETag, 변경 없을 때 |
| 출처 게이트 판별 | 모든 도구 출력 파싱 | 9케이스 기대대로 | 개명 검색 도구·문서 편집·첨부 읽기·빈 이름 포함 |
| 시트 하이라이트 처리 | 92,457 ms | 0 ms | 700행×12열, 시트 경로에서 하이라이터 미실행 |
| CSV 출처 뷰어 DOM 노드 | 22,278 | 385 | 2,473행 CSV, 가상 스크롤 |
| CSV 출처 클릭 | 수십 초 멈춤 | CPU 6.7%, 오류 0건 | 개발 서버 배포 후 같은 파일 |
마스킹은 PDF, DOCX, XLSX, XLS, CSV, PPTX, HWPX 원본에서 렌더까지 확인했고, 같은 파일을 정책 조건만 바꿔 6가지로 올려 청크 결과가 기대와 맞는지도 봤습니다. 토글은 사전 검색과 검색 도구 경로 각각에서 켜고 끈 경우를 실제 런타임으로 import해 확인했습니다.
돌아보면 출처 기능의 결함은 대부분 "판단이 소비 쪽에 있거나 여러 곳에 복사돼 있다"는 형태였습니다. 출처 자격은 생산 지점의 판별 함수 하나가, 마스킹은 공용 코어 한 벌이, 토글은 하나의 의미가 결정하게 모으자 이후 수정이 한 곳에서 끝났습니다. 뷰어 쪽에서는 임의의 상한을 두는 대신 이미 저장소에 있는 패턴(같은 형제 컴포넌트의 지연 로딩, 같은 버전의 가상 스크롤 라이브러리)을 따르는 쪽이 결과가 좋았습니다.
남은 것도 있습니다. 스캔 이미지 PDF는 텍스트 레이어가 없어 OCR 좌표 기반 마스킹이 필요하고, .hwp 실파일은 샘플이 없어 회귀를 확인하지 못했습니다. PDF는 줄바꿈을 넘는 매치를 잡지 못합니다. 원본은 정책 변경이 즉시 반영되지만 청크는 업로드 시점 상태 그대로라 기존 컬렉션 재색인 방안이 필요합니다. 매우 큰 HWP·DOCX에는 매칭 비용이 남아 있어 220KB 문서 기준 12.9초가 걸립니다. 토글을 켰을 때 사전 검색은 인용된 것만, 검색 도구 모드는 검색 결과 전부를 보여 주는 차이도 아직 맞추지 않았습니다.