4개의 AI API, 4개의 완전히 다른 인용 형식. 데이터를 추출하는 것이 분석보다 더 어려웠습니다.
요약
OpenAI, Anthropic, Gemini, Perplexity 등 4개 AI API의 서로 다른 인용(Citation) 데이터 구조를 비교하고, 이를 정규화하여 추출하는 기술적 방법을 다룹니다.
핵심 포인트
- 각 AI API마다 인용 정보의 데이터 구조와 용어가 상이함
- API별 데이터 추출을 위한 어댑터 패턴 사용 권장
- Gemini의 경우 리디렉션 URL이 아닌 실제 발행사 URI 추출 필요
- 데이터 정규화를 통해 `{ engine, question, urls[] }` 형태로 관리할 것
저는 소규모 월간 연구를 진행해 왔습니다. 한 산업군에서 구매 의도가 담긴 질문 10개를 선정하여, 4개의 AI 어시스턴트에게 동일한 질문을 던지고, 각 어시스턴트가 실제로 읽은 모든 출처를 기록하는 방식입니다.
분석은 쉬운 부분입니다. 중복을 설정하고, 도메인을 세면 끝납니다.
비교 가능한 형태로 4개의 서로 다른 API에서 인용(Citation) 정보를 추출하는 것 — 그 작업 때문에 주말을 통째로 날렸습니다. 제가 필요할 때 어디에서도 이 정보를 찾을 수 없었기에 직접 글로 남겨봅니다.
문제점
4개의 제공업체 모두 어떤 URL을 사용했는지는 알려줍니다. 하지만 그 정보를 어디에 배치할지, 무엇이라 부를지, 심지어 무엇을 "URL"이라고 정의할지에 대해서는 어느 곳도 일치하지 않습니다.
각 API가 제공하는 대략적인 형태는 다음과 같습니다. (2026년 중반 기준 형태이며, 수시로 변경되므로 복사하기 전에 문서를 확인하세요.)
OpenAI, 웹 검색 도구가 포함된 Responses API. 인용은 텍스트 출력의 주석(Annotation) 형태로 따라옵니다:
const urls = [];
for (const item of resp.output ?? []) {
for (const block of item.content ?? []) {
...
Anthropic, 웹 검색 도구가 포함된 Messages API. 검색 결과는 텍스트와 분리된 별도의 콘텐츠 블록 유형(Content block type)으로 반환됩니다:
const urls = [];
for (const block of msg.content ?? []) {
if (block.type === "web_search_tool_result") {
...
Gemini, Google Search 그라운딩 (Grounding) 적용 시. 후보(Candidate) 내의 그라운딩 메타데이터 (Grounding metadata) 아래에 위치합니다:
const urls = (resp.candidates?.[0]?.groundingMetadata?.groundingChunks ?? [])
.map(c => c.web?.uri)
.filter(Boolean);
Perplexity는 친절한 편입니다 — 응답에 평탄한 배열(Flat array) 형태로 제공됩니다:
const urls = resp.citations ?? resp.search_results?.map(r => r.url) ?? [];
네 가지의 서로 다른 중첩 깊이(Nesting depth), 동일한 개념에 대한 세 가지의 서로 다른 용어 ("annotation", "search result", "grounding chunk"), 그리고 그중 하나는 URL을 아예 제공하지도 않습니다. 이에 대해서는 잠시 후에 더 자세히 다루겠습니다.
따라서, 제공업체별로 어댑터를 하나씩 사용하고, { engine, question, urls[] } 형태로 정규화하며, 제공업체의 형태를 한 번이라도 그 경계를 넘어서는 데이터를 절대 허용해서는 안 됩니다. 사후적으로 보면 당연한 일입니다. 저는 이것을 처음부터 하지 않아 후회했습니다.
문제점 1: Gemini의 근거(grounding) URI가 출처 자체가 아니다
이것은 제 수치를 조용히 망칠 뻔했던 부분입니다.
Gemini의 groundingChunks[].web.uri는 발행사(publisher)의 URL이 아닙니다. 그것은 Google 자체 근거 엔드포인트를 거친 리디렉션(redirect)입니다. 이것을 액면 그대로 받아들이면, 모든 Gemini 인용문이 동일한 도메인에서 온 것처럼 보이고, 사용자가 분석하는 'Gemini가 읽는 사이트' 목록은 정확히 하나의 사이트로만 돌아옵니다.
따라서 이를 해결해야 합니다:
async function resolveFinal(url) {
try {
const r = await fetch(url, { method: "HEAD", redirect: "follow" });
...
이 작업을 대량으로 수행하면서 얻은 두 가지 참고 사항이 있습니다. GET보다 HEAD가 충분하고 훨씬 저렴합니다. 최종 URL만 원하기 때문입니다. 그리고 캐싱을 공격적으로(aggressively) 해야 합니다. 같은 리디렉션이 반복해서 나타날 것이고, 어떤 것도 과부하를 일으키고 싶지 않기 때문입니다.
저는 Gemini의 수치가 첫 실행에서 터무니없어 보이는 것을 보고 이를 알아차렸습니다. 하나의 엔진 도메인 다양성이 거의 0에 가까워지는 경우(즉, 붕괴되는 경우) 경고하는 검증 시스템을 구축할 가치가 있습니다. 이는 발견된 내용이 아니라 거의 항상 추출 버그입니다.
문제점 2: 다섯 가지 모습으로 나타나는 같은 페이지
실제 URL을 확보했더라도 여전히 서로 일치하지 않을 것입니다. 이 모든 것은 같은 페이지입니다:
...
만약 세트를 비교한다면, 한 페이지의 다섯 가지 철자는 다섯 개의 '다른' 출처를 의미하며, 오버랩(overlap) 숫자가 실제보다 낮게 나옵니다. 만약 전체 발견 내용이 '놀라울 정도로 적게 겹친다'는 것이라면, 이는 원치 않는 오류 방향을 가리키는 것입니다.
제가 결정한 정규화 함수(canonicaliser)입니다:
const TRACKING = /^(utm_|fbclid|gclid|msclkid|ref|source$)/i;
function canonical(raw) {
...
경로(path)를 의도적으로 소문자로 변환하지 않았습니다. 경로가 많은 서버에서 대소문자를 구분하기 때문에, 저는 두 개의 실제로 다른 페이지를 병합하는 것보다 과도하게 카운트하는 것을 선호합니다.
저를 웃게 만든 점은, 여러 어시스턴트(assistants)가 자신들이 반환하는 URL 끝에 ?utm_source=chatgpt.com(또는 그에 상응하는 자체 태그)을 추가한다는 것이었습니다. 도구들이 스스로의 추천 트래픽(referral traffic)을 태깅하고 있는 셈입니다. 이를 제거하지 않으면, 동일한 페이지에 대해 엔진별로 중복된 데이터가 생성될 것입니다.
그다음은 정말 지루한 분석 단계입니다
엔진 간 쌍별(pairwise) 질문당 집합 중첩(Set overlap):
const jaccard = (a, b) => {
const A = new Set(a), B = new Set(b);
const inter = [...A].filter(x => B.has(x)).length;
...
여기서 자카드(Jaccard) 지수는 실질적인 결함이 있습니다. 한 번만 읽힌 소스를 모든 엔진이 모든 질문에서 읽은 소스와 완전히 동일하게 취급한다는 점입니다. 가중치를 부여하는 문제에 대해 고민해 보았지만, 어느 쪽도 확신이 서지 않았습니다. 만약 "이 두 시스템이 동일한 근거를 참조했는가"를 측정할 더 나은 지표가 있다면 진심으로 듣고 싶습니다.
수치 결과는 어떠했는가
이번 달 실행 대상은 미용 및 성형 클리닉이었습니다. 10개의 질문, 9개의 도시, 4개의 엔진을 대상으로 했습니다.
- 단 10개의 질문을 통해 읽어들인 서로 다른 웹사이트는 214개
- 그중 **75%**는 단 하나의 엔진에 의해서만 읽혔고 다른 엔진은 읽지 않음
- 동일한 질문에 대한 엔진 간의 중첩(Overlap): 7%
- 10개의 질문 중 정확히 1개의 질문에서만 4개 엔진이 모두 읽은 웹사이트가 생성됨
- 읽어들인 내용의 약 **13%**는 디렉토리(directory)였으며, 나머지는 기업 자체 사이트였음
마지막 항목은 산업군에 따라 결과가 바뀌었는데, 이는 예상치 못한 결과였습니다. 지난달 진행한 법률 사무소 연구에서는 디렉토리 도메인이 목록 상단을 차지했었습니다.
제가 즉각적으로 제기한 이의(objection), 그리고 여러분도 아마 제기할 만한 의문은 이것입니다: LLM(대규모 언어 모델)은 비결정론적(non-deterministic)이므로, 두 번의 호출 결과가 다른 것은 당연하며 이것이 노이즈(noise)일 수 있다는 점입니다.
그래서 저는 엔진당 하위 집합을 세 번씩 다시 실행하여, 각 엔진을 _자기 자신_과 비교해 보았습니다:
- 동일한 엔진에 다시 질문했을 때: 51% 중첩
- 서로 다른 엔진, 동일한 실행 시: 7%
경쟁사들과 비교했을 때 자기 자신과는 약 7.5배 더 일관적입니다. 실제 실행 간의 변동성 (run-to-run variance)이 존재하지만, 엔진 간의 격차보다는 훨씬 작습니다. 이러한 차이는 샘플링 (sampling)의 문제가 아니라 구조적인 문제입니다.
반대 방향에서 이 문제를 파헤쳐 보고 싶다면, 단일 어시스턴트가 특정 사이트를 개별적으로 볼 수 있는지 여부에 대해 작성한 글을 참고하세요. 동일한 문제이며, 한 번에 하나의 엔진씩 다루었습니다.
제가 주장할 수 없는 부분
한 분야에서 10개의 질문을 던지는 것은 전수 조사 (census)가 아닌 표본 (sample)입니다. 변동성 제어 (variance control)는 전체 세트가 아니라, 두 개의 질문을 세 번씩 다시 실행한 결과였습니다. "읽었다"는 것은 해당 응답의 인용 (citations)에 URL이 나타났음을 의미할 뿐입니다. 가중치 (weighting)나 페이지의 어느 부분이 실제로 중요했는지는 알 수 없습니다. 또한, 이 모든 것은 2026년 7월의 스냅샷 (snapshot)입니다. 이러한 시스템들은 여러분이 지켜보는 와중에도 계속 변합니다.
이를 기반으로 무언가를 구축하는 분들을 위한 시사점
만약 제공업체 간의 검색 (retrieval)을 비교하는 작업을 작성한다면, 분석 (analysis)이 아니라 추출 (extraction)과 정규화 (normalisation)에 대부분의 시간을 할당하십시오. 저의 대략적인 비율은 지루한 작업 쪽으로 80/20으로 기울었습니다.
그리고 초기 단계부터 무결성 검사 (sanity checks)를 구축하십시오. 제가 겪은 두 가지 실제 버그 — 리다이렉트 (redirect) 문제와 트래킹 파라미터 (tracking-param) 문제 — 모두 출력값에서는 보이지 않았습니다. 코드는 잘 작동했습니다. 단지 숫자가 틀렸을 뿐인데, 공교롭게도 제 가설을 뒷받침하는 방향으로 틀려 있었습니다. 그것이 바로 편집증적으로 경계해야 할 종류의 버그입니다.
한 가지만 기억해야 한다면, 더 넓은 관점에서의 핵심은 다음과 같습니다: "AI 가시성"을 단일 채널로 취급하는 것은 실제 데이터와 대조했을 때 성립하지 않습니다. 네 개의 엔진은 동일한 질문으로부터 거의 완전히 분리된 읽기 목록을 만들어냈으므로, 이는 하나의 문제가 아니라 네 개의 문제입니다.
질문별 세부 분석을 포함한 이번 달 실행에 대한 전체 보고서는 여기에서 확인할 수 있습니다. 제 계산 수치를 확인하고 싶으시다면 원본 JSONL 파일을 기꺼이 공유하겠습니다. 저는 인용되기보다는 교정받는 쪽을 택하겠습니다.
(공개 사항: 저는 이 분야의 도구를 제작하고 있습니다. 이 연구는 유료 결제 장벽이 없으며 별도의 가입 절차도 필요하지 않습니다.)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기