RAG 인용 문제: 내가 프레임워크 밖에서 청크 ID를 추적하게 된 이유
The RAG citation problem: Why I ended up tracking chunk IDs outside the framework
핵심 요약
RAG 파이프라인에서 데이터 출처를 보존하기 위해 프레임워크 의존성을 줄이고 청크 ID를 직접 관리하는 전략을 제안합니다.
- 청크 ID 관리 — 데이터 수집 단계에서 결정론적 ID를 생성하고 파이프라인 전체에서 불변성을 유지함
- LLM 인용 검증 — 모델에게는 단순 정수 라벨만 제공하여 환각 인용을 즉시 탐지함
- 데이터 소스 분리 — 벡터 저장소 외에 SQLite 등을 활용해 원본 텍스트의 신뢰할 수 있는 저장소를 구축함
- 충실도 검증 — 생성된 답변의 각 주장을 원본 청크와 대조하여 지원 여부를 판단함
실무에서 RAG 시스템을 꽤 오랫동안 굴려봤는데(주로 회사에서 LangChain + Elasticsearch 조합), 계속 부딪히는 제일 골치 아픈 문제는 검색 품질이 아니더라. 데이터가 파이프라인을 타고 흐르면서 출처 정보가 완전히 박살 나는 게 문제임.
사용자한테 답변이 도달할 때쯤이면, "출처"라는 게 기껏해야 문서 단위의 뭉뚱그린 링크 하나 달랑 붙어있는 수준이지. 특정 주장이 텍스트의 어느 부분에서 근거했는지 실제로 검증할 방법이 아예 없어.
그래서 이 문제를 해결하려고 사이드 프로젝트를 하나 만들었는데, 거기서 배운 몇 가지 교훈은 LangChain을 포함해서 거의 모든 스택에 그대로 적용 가능함:
- 청크 ID는 결정론적으로 생성할 것: 데이터 넣을 때(ingestion) 딱 한 번만 만들고(
{doc_hash}:{page}:{chunk_index}같은 식으로), 파이프라인 전 과정에서 절대 안 바뀌게 고정해 놔. 출처 정보가 날아가는 건 대부분 중간에 ID를 새로 만들거나 키를 다시 매기면서 발생함. - LLM한테 진짜 ID를 절대 보여주지 마: [1], [2] 같은 작은 정수 라벨을 주고, 라벨이랑 실제 ID 매핑 정보는 요청 범위(request scope) 안에서만 관리해. 이게 진짜 좋은 게, 만약 청크 5개만 보냈는데 모델이 [7]을 인용한다고 치면, 엉뚱한 값으로 처리되는 게 아니라 바로 환각(hallucination)이라는 걸 잡아낼 수 있거든.
- 벡터 저장소는 그냥 인덱스일 뿐: 실제 청크 텍스트는 그냥 믿을만한 데이터베이스(난 SQLite 썼음)에 따로 저장해. 검증이나 리포팅 기능이 벡터 저장소 내부 구조에 의존하게 만들면 안 됨.
- 충실도(faithfulness) 검증 단계 추가: 생성된 각 주장이 인용된 청크의 원문과 일치하는지 일일이 따져봐. 난 '지원됨(supported)', '부분적(partial)', '지원 안 됨(unsupported)', '인용 없음(uncited)' 이렇게 네 가지 판정 기준을 씀. LLM이 LLM을 평가하는 게 좀 그렇긴 해도, 잘못된 주장을 걸러내는 게 아예 손 놓고 있는 것보단 백배 나음.
- 페이지 경계 지키기: 청크가 페이지 경계를 넘지 않게 하면 인용이 훨씬 쓸만해짐. 모든 주장에 정확한 페이지 번호를 붙일 수 있으니까. 검색 품질은 아주 살짝 떨어질 수 있는데, 감사(audit)가 중요한 앱이라면 무조건 이렇게 하는 게 이득임.
혹시 구현 방식 궁금한 사람 있을까 봐 오픈소스로 풀어놨음: auditrag.
미리 말해두는데, ID 불변성을 끝까지 확실하게 유지하려고 프레임워크 없이 짰음. 진짜 궁금해서 물어보는 건데, 혹시 LangChain 쓰면서 청크 단위로 출처 추적 확실하게 해본 사람 있음? 내가 할 때는 깔끔한 방법을 못 찾겠던데(커스텀 Document 메타데이터 + 콜백 쓰면 되려나?).
설계 관련해서 궁금한 거 있으면 뭐든 물어봐!

