RAG 응답이 어디서 왔는지 스스로 증명한다 — Claude Citations API가 citation hallucination을 구조적으로 차단하는 방식
솔직히 말하면, RAG 시스템에서 출처 인용 처리가 이렇게 골치 아플 줄 몰랐습니다. 처음에는 프롬프트에 "반드시 [문서 제목]을 인용하시오"라고 지시하면 해결될 거라 생각했습니다. 결과는 참담했습니다. 어떤 응답에는 인용이 있고, 어떤 응답에는 없었고, 심지어 멀쩡하게 실제로 없는 구절을 인용하는 경우도 생겼습니다. 그때부터 이 문제가 단순히 프롬프트 품질의 문제가 아니라는 걸 깨달았습니다.
Anthropic이 2025년 1월 공개한 Citations API는 접근 방식을 근본적으로 바꿉니다. 인용을 모델에게 부탁하는 대신, API 계층에서 인용의 유효성을 보장합니다. 반환된 모든 인용은 반드시 입력으로 제공된 문서 내 실제 위치를 가리킵니다. 존재하지 않는 구절을 만들어내는 citation hallucination이 구조적으로 차단됩니다. 여기서 한 가지 미리 짚어두면, Citations API가 없애는 것은 어디까지나 "인용 자체의 위조"이지, "검색이 잘못돼서 엉뚱한 구절이 인용되는" 문제까지는 아닙니다. 이 구분은 뒤에서 다시 다룹니다.
2026년 9월 현재 Opus 5, Sonnet 5, Haiku 4.5를 포함한 활성 Claude 모델에서 사용할 수 있습니다.
왜 프롬프트 기반 인용은 무너지는가
"답변에 출처를 명시해"라는 프롬프트는 모델에게 두 가지를 동시에 요구합니다 — 좋은 답변 생성과 인용 포맷 준수. 모델 입장에서는 둘 중 하나가 희생되기 쉽고, 특히 긴 문서를 다룰 때 인용 정확도가 떨어집니다. 인용 구절이 문서에 실제로 존재하는지 모델이 스스로 검증하지 못하기 때문입니다.
Citations API는 이 검증 책임을 API 인프라로 옮깁니다. 모델은 응답을 생성하면서 어떤 구절에 근거했는지를 내부적으로 추적하고, 시스템이 그 위치를 문자 단위 오프셋으로 확정합니다. 개발자는 추출 로직을 직접 구현할 필요 없이 검증된 인용 메타데이터를 응답 객체에서 꺼내 쓰기만 하면 됩니다.
Citations API의 응답 구조
응답 content 배열에는 텍스트 블록이 포함되고, 각 텍스트 블록에 붙어 있는 citations 속성으로 인용 정보가 들어옵니다(공식 쿡북 using_citations.ipynb 기준). 각 인용 항목의 필드는 다음과 같습니다.
| 필드 | 설명 |
|---|---|
document_index |
요청 시 제공한 문서 배열에서의 순서 (0-based) |
start_char_index |
인용 시작 문자 위치 |
end_char_index |
인용 종료 문자 위치 |
cited_text |
원문 구절, 출력 토큰으로 과금되지 않음 |
cited_text가 출력 토큰에 포함되지 않는 점은 비용 측면에서 의미가 있습니다. 원문을 응답 텍스트에 그대로 반복시키는 프롬프트 기반 방식과 비교하면 토큰 낭비가 사라집니다.
직접 붙여보기
기본 요청 구조
가장 단순한 형태부터 보겠습니다. Plain text 문서 두 개를 소스로 주고 질문하는 예시입니다. 모델 ID는 코드 실행 시점의 공식 모델 목록에서 최신 값을 확인해 쓰는 편이 안전합니다. 아래는 날짜 접미사가 붙은 스냅샷 ID를 쓰는 형태로, alias(claude-sonnet-5처럼 날짜 없이)를 쓸 수도 있습니다.
import anthropic
client = anthropic.Anthropic()
sources = [
{
"type": "document",
"source": {
"type": "text",
"media_type": "text/plain",
"data": "계약 해지는 30일 이전에 서면으로 통보해야 합니다. "
"단, 상대방의 중대한 계약 위반이 있을 경우 즉시 해지 가능합니다.",
},
"title": "서비스 이용약관 v3.2",
"citations": {"enabled": True},
},
{
"type": "document",
"source": {
"type": "text",
"media_type": "text/plain",
"data": "환불은 서비스 이용 후 7일 이내에 신청한 경우에만 가능하며, "
"디지털 콘텐츠 다운로드 완료 시점부터는 환불이 제한됩니다.",
},
"title": "환불 정책 2025",
"citations": {"enabled": True},
},
]
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
messages=[
{
"role": "user",
"content": sources + [
{"type": "text", "text": "계약을 즉시 해지할 수 있는 조건은 무엇인가요?"}
],
}
],
)
for block in response.content:
print(block)응답 content 배열에는 텍스트 블록이 들어오고, 각 텍스트 블록에 .citations 속성이 붙어 있습니다. 저도 처음에 응답 구조가 헷갈렸는데, 핵심은 모델이 서로 다른 근거를 가진 문장을 만들 때마다 텍스트 블록이 여러 개로 쪼개져 반환되고, 각각에 자기 인용이 붙는다는 점입니다.
응답을 실제로 찍어보면 대략 다음과 같은 구조입니다(개념적 예시).
TextBlock(
text='상대방의 중대한 계약 위반이 있을 경우 즉시 해지가 가능합니다.',
citations=[
Citation(
type='char_location',
cited_text='단, 상대방의 중대한 계약 위반이 있을 경우 즉시 해지 가능합니다.',
document_index=0,
document_title='서비스 이용약관 v3.2',
start_char_index=32,
end_char_index=71,
)
]
)인용 파싱 및 렌더링
실무에서는 인용을 파싱해서 UI에 하이라이팅을 걸어주는 코드가 필요합니다. 텍스트 블록 안의 citations 속성을 순회하는 형태가 됩니다.
def render_response_with_citations(response):
results = []
for block in response.content:
if getattr(block, "type", None) != "text":
continue
results.append({"kind": "text", "content": block.text})
for citation in (getattr(block, "citations", None) or []):
results.append({
"kind": "citation",
"cited_text": citation.cited_text,
"document_index": citation.document_index,
"document_title": getattr(citation, "document_title", None),
"start_char": citation.start_char_index,
"end_char": citation.end_char_index,
})
return results핵심은 인용을 독립 블록 타입으로 찾지 않고, 텍스트 블록의 속성으로 접근한다는 것입니다. 이 부분을 착각해 block.type == "citations" 같은 분기를 두면 어떤 인용도 잡히지 않고 조용히 사라지는 최악의 상황이 생깁니다. 실제 응답을 한 번 print(response.content)로 찍어보고 시작하시길 권합니다.
document_index와 오프셋을 가지고 있으면 원본 문서의 정확한 위치로 링크를 걸거나, PDF 뷰어에서 해당 구절을 하이라이트하는 것이 가능합니다. 프롬프트 기반 인용과 결정적으로 다른 지점입니다 — 문자 단위 위치 정보를 실제로 쓸 수 있습니다.
PDF 문서를 소스로 쓰는 경우
법률, 의료, 학술 도메인에서는 원본이 PDF인 경우가 많습니다. Files API로 업로드한 뒤 file_id로 참조할 수 있습니다. 정확한 파라미터 시그니처는 Anthropic Python SDK 레퍼런스와 PDF support 문서를 확인하는 편이 확실합니다.
# 개념적 예시 — 실제 시그니처는 SDK 버전에 따라 다를 수 있음
uploaded = client.beta.files.upload(
file=open("contract_v3.pdf", "rb"),
)
file_id = uploaded.id
pdf_source = {
"type": "document",
"source": {
"type": "file",
"file_id": file_id,
},
"title": "계약서 2026년 개정본",
"citations": {"enabled": True},
}Amazon Bedrock에서는 2025년 6월부터 Citations API와 PDF 지원이 함께 GA되어 동일한 패턴으로 사용할 수 있습니다.
RAG 파이프라인과 통합
현실적인 RAG 시스템에서는 문서가 하나가 아닙니다. 검색으로 뽑아온 여러 청크를 document 블록 배열로 넘기고, Citations가 각 주장을 어떤 청크에서 끌어왔는지 자동으로 연결해줍니다.
임베딩 모델은 Voyage AI 공식 모델 목록에서 자신의 도메인에 맞는 것을 고르는 편이 좋습니다. 2026년 9월 기준으로는 voyage-3 계열, 코드 특화라면 voyage-code-3 같은 옵션이 있습니다. 청크 개수(K)는 상황마다 달라서 정답이 없고, 문서 길이·모델 컨텍스트 예산·재랭커 품질에 따라 5~20 사이에서 조정하는 케이스가 많습니다. "무조건 8개"처럼 고정하지 말고 오프라인 평가로 튜닝하시길 권합니다.
def build_rag_content(query: str, retrieved_chunks: list[dict]) -> list:
content = []
for i, chunk in enumerate(retrieved_chunks):
content.append({
"type": "document",
"source": {
"type": "text",
"media_type": "text/plain",
"data": chunk["text"],
},
"title": chunk.get("title", f"문서 {i + 1}"),
"citations": {"enabled": True},
})
content.append({"type": "text", "text": query})
return content프로덕션에서는 title 필드를 반드시 채워두시길 권합니다. document_index만으로는 로그를 볼 때 어떤 문서인지 추적하기 어렵고, 디버깅 시간이 훨씬 늘어납니다.
트레이드오프와 흔한 실수
항목별 비교
| 항목 | 이점 | 함께 딸려오는 제약 |
|---|---|---|
| 인용 유효성 | API 계층에서 검증되므로 존재하지 않는 구절 인용은 발생하지 않음 | 검색 단계가 엉뚱한 청크를 올리면 인용은 유효해도 답은 틀림 |
| 비용 | cited_text는 출력 토큰 미포함으로 반복 인용 시 절감 효과 |
문서를 컨텍스트에 실어야 하므로 입력 토큰은 증가 |
| 구현 복잡도 | 인용 추출 프롬프트와 파싱 로직 불필요 | 텍스트 블록 내 citations 속성 접근 방식을 정확히 이해해야 함 |
| 위치 정보 | 문자 단위 오프셋으로 UI 하이라이팅 가능 | 이미지·도표 인용은 미지원, 텍스트 인용만 |
| 적용 범위 | 문서별로 citations.enabled를 설정하는 형태 |
검색·청킹·임베딩·재랭킹은 여전히 개발자 몫 |
실무에서 자주 보이는 실수
청크를 너무 작게 잘랐을 때
Citations는 제공된 문서 내에서만 인용 위치를 잡습니다. 청크가 한두 문장으로 짧으면 인용 범위가 거의 청크 전체가 되어 "구체적으로 어디서 왔는지"의 의미가 희석됩니다. 의미론적으로 완결된 단락 단위로 청킹하는 편이 자연스럽습니다.
title 필드를 생략했을 때
로그에서 document_index: 3이 무슨 문서인지 알 수 없게 됩니다. 단순해 보이지만 프로덕션 디버깅에서 체감 차이가 큽니다.
인용 없는 텍스트 블록을 무시했을 때
모든 응답 블록이 인용을 가지지는 않습니다. "네, 말씀하신 내용에 따르면..."처럼 일반적인 연결 문장은 인용이 비어 있을 수 있습니다. 파서가 인용 있는 블록만 남기고 나머지 텍스트를 버리면 응답이 뚝뚝 끊겨 보입니다. 텍스트는 항상 이어붙이고, 인용은 그 위에 메타 정보로 덧대는 방식이 안전합니다.
독립 블록 타입으로 인용을 찾는 실수
앞서 언급한 대로, 인용은 텍스트 블록의 .citations 속성입니다. 별도 블록 타입으로 착각해 분기 조건을 잘못 세우면 모든 인용이 무시되면서도 코드는 조용히 동작해 문제가 오래도록 발견되지 않습니다.
"인용 유효성 = 답변 정확성"으로 착각할 때
가장 자주 보는 오해입니다. Citations API는 모델이 만들어낸 인용이 실제 문서 안에 있다는 것을 보장할 뿐, 그 인용이 사용자 질문에 대한 옳은 근거라는 것까지 보장하지는 않습니다. 검색이 잘못돼 무관한 청크가 올라오면, 모델은 그 청크 안에서 존재하는 구절을 인용해가며 자신 있게 틀린 답을 냅니다.
이미지 인용 미지원, 어떻게 다뤘나
프로젝트를 진행하면서 가장 오래 붙잡았던 문제는 이미지 인용이었습니다. 계약서 PDF에는 표와 서명란, 별첨 이미지가 섞여 있고, 사용자는 "3페이지 표에 명시된 요율"처럼 시각적 요소를 근거로 답을 받길 원했습니다. Citations API는 텍스트 인용만 지원하므로 그대로는 커버되지 않았습니다.
결국 택한 절충안은 두 단계였습니다. 첫째, 전처리 단계에서 표와 도표를 캡션·마크다운 표·OCR 텍스트로 변환해 문서 텍스트에 인라인으로 삽입했습니다. 이렇게 하면 Citations는 그 변환된 텍스트를 인용할 수 있습니다. 둘째, 원본 페이지 이미지와 문자 오프셋을 별도 인덱스로 매핑해두었다가, 인용이 등장하면 UI에서 해당 페이지 썸네일과 함께 표시했습니다. 완벽하지는 않지만, 사용자 입장에서는 "이 답이 원본의 어느 부분을 보고 있는지"가 시각적으로 이어지는 경험을 만들 수 있었습니다.
Citations API 자체가 이 문제를 언제쯤 정면으로 풀어줄지는 저도 잘 모릅니다. 다만 지금 도입을 미룰 이유는 아니라고 봅니다. 텍스트 인용만으로도 실제로 걸러지는 리스크가 상당하고, 이미지 부분은 인접 시스템에서 얼마든지 보완할 수 있습니다.
참고 자료
- Introducing Citations on the Anthropic API — Anthropic 공식 블로그
- Citations — Claude Platform Docs
- claude-cookbooks/misc/using_citations.ipynb — GitHub
- PDF support — Claude Platform Docs
- Files API — Claude Platform Docs
- About Claude models — 모델 ID와 alias 규칙
- Voyage AI Embeddings 모델 목록
- Anthropic's new Citations API — Simon Willison
- Citations API and PDF support for Claude models in Amazon Bedrock — AWS 발표