AI 인용 위치가 어긋날 때: offset 단위와 원문 판본 맞추기

이 카테고리 글 목록

전체글 보기

AI 답변이 제시한 인용 위치를 다시 열었는데 엉뚱한 문장이 나오면, 먼저 그 위치가 어떤 단위이고 어느 판본의 텍스트를 기준으로 했는지 확인해야 한다. 숫자가 범위 안에 들어간다는 사실만으로 같은 구절을 가리키지는 않는다. 이모지 하나, 줄바꿈 변환 하나도 위치를 바꿀 수 있다.

이 글은 문서 AI의 인용 구간을 저장하거나 검수 화면에서 다시 찾는 담당자를 위한 설명이다. 문자열과 인용 정보는 합성 자료이며 실제 고객 문서나 제품 API 응답이 아니다. 로컬 Node.js v24.15.0, Unicode 17.0, ICU 78.2에서 확인했다. 어떤 공급자의 인용 API도 실제 호출하지 않았고, 그 API의 offset 단위를 추정하지 않는다.

offset은 단위와 기준 원문이 있어야 위치가 된다

가상의 원문이 A😀BC이고 찾을 구절이 BC라고 하자. 시작을 0으로 세고 끝을 포함하지 않는 [start, end) 구간으로 나타내도, 무엇을 세느냐에 따라 숫자는 다르다.

세는 단위 BC의 구간 원문에서 다시 읽는 방법
코드 포인트 [2, 4) 코드 포인트 배열의 2 이상 4 미만
UTF-16 코드 단위 [3, 5) JavaScript 문자열의 slice(3, 5)
UTF-8 바이트 [5, 7) 이 원문을 UTF-8로 인코딩한 바이트의 5 이상 7 미만

JavaScript 문자열의 위치가 UTF-16 코드 단위인 것은 ECMAScript의 String 계약이다. UTF-8 바이트 수는 인코딩에 따른 바이트 길이와 관련된다. 이 언어의 관례가 모든 문서 AI 서비스의 문자 위치 계약이라는 뜻은 아니다.

복사한 코드에서도 이모지 문자열이 유지되도록 Unicode escape 표기를 사용했으며, 실행하면 같은 이모지 문자열이 된다.

const text = "A\u{1F600}BC";
console.log(JSON.stringify({
  codePointRange: [...text].slice(2, 4).join(""),
  utf16Range: text.slice(3, 5),
  utf8Range: Buffer.from(text, "utf8").subarray(5, 7).toString("utf8"),
  mixedUnits: text.slice(2, 4),
  mixedWellFormed: text.slice(2, 4).isWellFormed()
}));

앞의 세 결과는 모두 BC였다. 코드 포인트 구간을 그대로 문자열 slice에 넣은 결과는 JSON에서 "\ude00B", well-formed 여부는 false였다. 이모지의 뒤 서로게이트 코드 단위부터 읽었기 때문이다. 다른 입력에서는 잘못된 범위도 멀쩡한 문자열이 될 수 있어, 깨진 문자가 없다는 것만으로 위치가 맞다고 판단해서도 안 된다.

slice의 시작과 끝은 언어 계약의 일부다. API가 끝을 포함하는지, 시작과 길이를 주는지, 페이지 안의 위치인지 전체 추출문의 위치인지도 확인해야 한다. 단위와 구간 형식이 미확인이라면 임의로 1을 더하거나 빼는 보정부터 하지 않는다.

정규화와 줄바꿈 변환 뒤에는 같은 숫자가 다른 구절을 가리킨다

검색하기 편하게 문자열을 정리한 뒤 예전 위치를 그대로 쓰는 실수가 생길 수 있다. Unicode 정규화는 문자 표현을 바꾸는 처리이며, 변환 전후 위치가 같다는 계약은 아니다. 아래 예는 UTF-16 코드 단위 기준이다.

const beforeNfc = "\u1100\u1161:목표";
const afterNfc = beforeNfc.normalize("NFC");
const nfcStart = beforeNfc.indexOf("목표");
console.log(JSON.stringify({
  original: beforeNfc,
  converted: afterNfc,
  oldRange: [nfcStart, nfcStart + 2],
  oldQuote: beforeNfc.slice(nfcStart, nfcStart + 2),
  oldRangeOnNewText: afterNfc.slice(nfcStart, nfcStart + 2),
  newStart: afterNfc.indexOf("목표")
}));

const beforeLf = "제목\r\n목표";
const afterLf = beforeLf.replaceAll("\r\n", "\n");
const lfStart = beforeLf.indexOf("목표");
console.log(JSON.stringify({
  oldRange: [lfStart, lfStart + 2],
  oldQuote: beforeLf.slice(lfStart, lfStart + 2),
  oldRangeOnNewText: afterLf.slice(lfStart, lfStart + 2),
  newStart: afterLf.indexOf("목표")
}));
합성 변환 변환 전 목표 구간 옛 구간을 새 텍스트에 적용 새 시작 위치
결합 자모를 NFC로 정규화 [3, 5), “목표” “표” 2
CRLF를 LF로 변환 [4, 6), “목표” “표” 3

표는 위 코드의 실제 로컬 출력이다. 머리말 추가, 선두 BOM 처리, OCR 추출기 교체도 원문의 표현이나 판본을 바꾸는 경계가 될 수 있다. 모든 변환에서 offset이 반드시 달라진다는 뜻은 아니지만, 바뀌지 않는다고 가정해서는 안 된다. 특히 원 PDF의 위치와 OCR이 만든 문자열 위치는 같은 좌표 체계라고 단정할 수 없다.

원본 카드와 두 결과 목록의 대응을 돋보기로 확인하는 삽화
기준 원문과 처리 결과의 대응을 확인하는 맥락으로 사용한 기존 AI 생성 개념도. 실제 인용 위치·페이지 좌표·문자열 offset이나 프로그램 화면을 표시한 그림은 아니다.

판본·단위·예상 구절을 함께 대조한다

작성자는 원문 또는 접근 가능한 원문 참조, 추출문 판본, 위치 단위, 구간 형식, 최소 인용문을 함께 보존할 것을 제안한다. 원문 전체를 일반 로그에 복사하라는 뜻은 아니다. 민감한 문서라면 필요한 최소 구절과 접근 통제를 유지한다.

다음은 UTF-16 반열린 구간만 받기로 정한 작은 로컬 대조 예다. 해시는 이 추출 문자열을 UTF-8로 인코딩한 바이트의 SHA-256이다. PDF 파일 해시나 출처의 진위 인증으로 이름을 바꿔 쓰지 않는다.

import { createHash } from "node:crypto";
const textHash = text => createHash("sha256")
  .update(text, "utf8").digest("hex");

function quoteAt(document, ref) {
  if (document.version !== ref.version || textHash(document.text) !== ref.sha256) {
    throw new Error("source version mismatch");
  }
  if (ref.unit !== "utf16-code-unit" || ref.interval !== "[start,end)") {
    throw new Error("unsupported range contract");
  }
  if (!Number.isSafeInteger(ref.start) || !Number.isSafeInteger(ref.end) ||
      ref.start < 0 || ref.end < ref.start || ref.end > document.text.length) {
    throw new Error("invalid range");
  }
  const quote = document.text.slice(ref.start, ref.end);
  if (!quote.isWellFormed() || quote !== ref.quote) {
    throw new Error("quote mismatch");
  }
  return quote;
}
const document = { version: "extraction-v1", text: "A\u{1F600}BC" };
const ref = {
  version: document.version, sha256: textHash(document.text),
  unit: "utf16-code-unit", interval: "[start,end)",
  start: 3, end: 5, quote: "BC"
};
console.log(quoteAt(document, ref));
try {
  quoteAt({ version: "extraction-v2", text: "머리말A\u{1F600}BC" }, ref);
} catch (error) {
  console.log(error.message);
}

실제 출력은 BC와 source version mismatch였다.

이 예는 단위·판본·범위·인용 문자열을 검사하는 후보일 뿐 범용 인용 검증기나 변환 위치 매핑 구현은 아니다. 반복된 같은 문장은 텍스트 검색만으로 어느 출현 위치인지 구별할 수 없다. 또한 입력과 함께 해시도 바꿔 버리면 기준 자체가 달라지므로, 신뢰할 기준 판본과 지문은 별도로 관리해야 한다. 해시 일치는 값의 동일성을 대조하는 근거이지 내용의 사실성·접근 권한·답변의 타당성을 보증하지 않는다.

변환한 텍스트가 필요하면 위치 대응을 따로 다룬다

가능하면 인용 위치는 기준 추출문에 그대로 적용하고, 화면 표시용·검색용 변환본과 구분한다. 변환본에 표시해야 한다면 변환 규칙과 위치 대응을 명시적으로 관리하거나 그 판본에서 인용 구간을 다시 산출해야 한다. 정규화는 여러 코드 포인트를 합칠 수 있으므로 원 위치에 상수 하나를 더하는 보정이 언제나 충분하지는 않다.

다시 찾은 구절은 문서·판본·주변 문맥과 함께 확인한다. 위치가 맞은 다음에는 그 구절이 답변을 실제로 지지하는지 AI 인용 근거 확인법으로 검토할 수 있다. 문서끼리 결론이 다르면 적용 범위와 문서 버전도 판단해야 한다. 정확한 위치와 올바른 해석은 서로 다른 확인이다.

자주 묻는 질문

시작·끝 숫자가 텍스트 길이 안에 있으면 맞는 인용인가요?

범위를 벗어나지 않았다는 확인만 된다. 단위·기준 판본이 다르거나 다른 출현 위치일 수 있다. 실제 구절과 주변 문맥까지 다시 읽는다.

같은 인용문을 검색해 위치를 다시 잡으면 되나요?

유일하게 나타나는지, 같은 판본인지 확인해야 한다. 반복 문구·표의 같은 값·정규화된 검색 키는 여러 위치를 가리킬 수 있다. 원 인용 정보의 대체가 필요하다면 새로 산출한 위치라는 사실을 구분한다.

공급자 문서가 “문자 위치”라고만 하면 JavaScript 길이를 쓰면 되나요?

그 표현만으로 UTF-16 코드 단위라고 확정할 수 없다. 해당 API의 실제 공식 계약과 지원 예시에서 단위를 확인하고, 모르면 미확인으로 남긴다. 한 언어에서 성공한 slice를 다른 제품의 위치 계약으로 일반화하지 않는다.

이 카테고리의 다른 글

전체글 보기

댓글 남기기