LLM 없이 콘텐츠 모델에 맞춰 문서 점수 매기기
요약
대규모 콘텐츠 모델을 가진 Google Docs를 CMS로 매핑할 때 LLM의 비용과 비효율성을 줄이기 위한 사전 필터링 기법을 소개합니다. 문서를 세그먼트로 분할하고 자카드 유사도를 활용해 후보 콘텐츠 타입을 좁힘으로써 LLM의 추론 범위를 최적화합니다.
핵심 포인트
- LLM에 전체 문서를 보내는 대신 결정론적 사전 필터링 단계 도입
- 문서를 헤딩 기반의 연속적인 영역(Scope)으로 분할하여 처리
- 벡터 코사인 유사도 대신 집합 기반의 자카드 유사도(Jaccard similarity) 사용
- LLM의 컨텍스트 윈도우 낭비 방지 및 토큰 비용 절감
Google Doc을 CMS로 매핑하는 순진한(naive) 방법은 문서 전체를 LLM에 보내고 어떤 섹션이 어디로 가야 하는지 파악하도록 요청하는 것입니다. 이는 규모가 작고 콘텐츠 모델이 단순할 때는 작동합니다. 하지만 콘텐츠 모델에 40개의 콘텐츠 타입(content types)이 있고, 각 타입마다 12개의 필드가 있으며, 각 문서의 구조가 모두 다를 때는 더 이상 작동하지 않습니다. 결국 대부분 관련 없는 콘텐츠 타입에 대해 거대한 컨텍스트 윈도우(context window)를 낭비하고, 도움이 되지 않는 토큰 비용을 지불하게 되며, 모델이 선별된 짧은 목록 대신 모든 가능성의 전체 공간에 대해 추론하게 함으로써 더 나쁜 결과를 얻게 됩니다.
제가 작업하던 파이프라인(Google Docs → Contentful 임포트)은 LLM 호출 전에 실행되는 결정론적(deterministic) 사전 필터링 단계를 통해 이 문제를 해결했습니다. 이 단계의 역할은 매핑을 수행하는 것이 아닙니다. 문제의 범위를 좁히는 것이 목적입니다. 즉, 문서를 연속적인 영역(contiguous regions)으로 분할하고, 각 영역을 모든 콘텐츠 타입과 비교하여 점수를 매긴 뒤, LLM에 전체 데카르트 곱(Cartesian product) 대신 소수의 (범위, 후보 콘텐츠 타입) 쌍을 전달하는 것입니다. 이 포스트는 그 점수 매기기(scoring)가 어떻게 작동하는지, 그리고 왜 그러한 설계 선택을 했는지에 관한 것입니다.
세분화 모델 (The segmentation model)
문서는 블록의 시퀀스로 취급됩니다. 탭 오프너(tab opener), H1, 또는 H2인 모든 블록은 새로운 범위(scope)를 시작합니다. 두 오프너 사이의 모든 블록은 첫 번째 오프너에 의해 열린 범위에 속합니다. 그 결과는 DocumentScope 객체들의 평탄한 배열(flat array)이 됩니다:
export type ScopeReasonCode = 'tab-label' | 'heading-match' | 'body-term-match'
export type ContentTypeMatch = {
...
openerText는 범위를 연 탭 레이블(tab label) 또는 헤딩(heading)의 원문 텍스트입니다. blocks는 문서의 연속적인 슬라이스(slice)입니다. matches는 이 범위에 대해 점수가 높은 순서대로 매겨진 모든 콘텐츠 타입입니다. topMatch는 첫 번째 매치에 대한 편의용 포인터이며, 노이즈 플로어(noise floor) 이상의 매치가 없다면 null입니다. needsReview는 게이트(gate)가 아닌 플래그(flag)입니다.
함수 시그니처(function signature)는 간단합니다:
export function segmentDocumentScopes(
doc: NormalizedDocument,
slice: ContentModelOntologySlice,
...
ContentModelOntologySlice는 콘텐츠 타입 노드(content type nodes), 필드 노드(field nodes), 그리고 각 노드에서 파생된 intentTerms 집합으로 구성된 사전 구축된 그래프(pre-built graph)입니다. 이에 대한 자세한 내용은 아래에서 다룹니다.
벡터에 대한 코사인 유사도가 아닌, 집합에 대한 자카드 유사도
(scope, content type) 쌍에 대한 점수는 토큰화된 텍스트(tokenized text)에 대한 자카드 유사도(Jaccard similarity)입니다:
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
if (a.size === 0 && b.size === 0) return 0
let intersection = 0
...
|A ∩ B| / |A ∪ B|입니다. 이것이 전체 기본 점수입니다. TF-IDF 가중치도, 벡터 임베딩(vector embeddings)도, 미리 계산된 코퍼스 통계(corpus statistics)도 사용하지 않습니다. { "product", "info", "title" }를 가진 scope와 의도 용어(intent terms)가 { "product", "title", "description" }인 content type 사이의 점수는 2 / 4 = 0.5가 됩니다.
자카드를 사용하는 이유는 이것이 TF-IDF 벡터에 대한 코사인 유사도(cosine similarity)보다 더 나은 점수를 생성하기 때문이 아닙니다. 거의 확실히 그렇지 않을 것입니다. 이유는 이 함수가 모든 요청(request)에서 LLM 호출 이전에 동기식(synchronously)으로 실행되기 때문입니다. 임베딩(Embeddings)은 모델 호출을 필요로 합니다. TF-IDF에 대한 코사인 유사도는 전체 문서 집합으로부터 코퍼스 통계를 미리 계산해야 하는데, 이는 요청 시점에는 사용할 수 없습니다. 단순한 토큰화된 집합에 대한 자카드는 O(|A| + |B|)의 시간 복잡도를 가지며, 결정론적(deterministic)이고, 재현 가능하며(reproducible), 외부 의존성이 없습니다. 완벽할 필요는 없습니다. 40개의 콘텐츠 타입을 3개로 좁힐 수 있을 만큼 충분히 좋고, 측정 가능한 지연 시간(latency)을 추가하지 않을 만큼 충분히 빠르며, 오류가 발생했을 때 코드를 읽고 정확히 왜 그런지 이해할 수 있을 만큼 충분히 감사 가능(auditable)하면 됩니다.
구조적 보너스: 모든 용어의 위치가 동일하지는 않다
"Product Info"라고 적힌 탭 레이블은 "product"를 한 번 언급하는 본문 단락보다 범주적으로 더 강력한 신호입니다. 기본 자카드 점수는 용어가 어디에 나타나든 모든 용어를 동일하게 취급합니다. 보너스 레이어(bonus layer)는 이 문제를 해결합니다:
function computeScore(
scope: RawScope,
ctNode: ContentTypeNode,
...
보너스는 고정된 상수가 아니라 구조적 위치 (structural position)의 중첩 (overlap)에 대한 승수 (multiplier)입니다. 콘텐츠 타입의 의도 용어 (intent terms)와 용어의 절반을 공유하는 탭 레이블 (tab label)은 점수에 0.2 × 0.5 = 0.1을 기여합니다. 완전히 일치하는 탭 레이블은 0.2를 기여합니다. 승수 (multiplier)를 사용한다는 것은 보너스가 단순히 일치 여부뿐만 아니라 구조적 위치가 얼마나 강력하게 일치했는지에 따라 규모가 달라짐을 의미합니다.
이는 많은 프로덕션 콘텐츠 모델(production content models)이 의미론적 정보(semantic information)를 담고 있는 프로그래밍 방식의 ID를 사용하기 때문에 중요합니다. 표시 이름(display name)은 "Page: Blog Entry"이지만 API 이름은 pgBlogEntry인 콘텐츠 타입의 경우, 두 이름 모두에 대해 용어(terms)를 생성할 것입니다. 검증 관계(linkContentType, allowedContentTypes)는 참조된 콘텐츠 타입 ID를 가져오므로, 지나가는 과정에서 "블로그 포스트"를 참조하는 스코프(scope)는 컨테이너 자체의 이름에 해당 내용이 언급되지 않더라도 BlogPost에 연결된 컨테이너 콘텐츠 타입에 대해 점수를 매길 수 있습니다.
일급 객체 출력으로서의 needsReview
점수 산정 후, 다음 세 가지 조건이 스코프에 needsReview: true를 설정합니다:
function computeNeedsReview(scope: RawScope, matches: ContentTypeMatch[]): boolean {
// 콘텐츠 블록 없음 — 점수를 매길 대상이 없음
if (scope.blocks.length === 0) return true
...
이 조건들 중 어느 것도 에러를 발생시키거나 파이프라인(pipeline)을 중단시키지 않습니다. 파이프라인은 플래그가 지정되었든 아니든 모든 스코프와 함께 계속 진행됩니다. needsReview 플래그는 게이트(gate)가 아니라, 하류(downstream)의 LLM(및 모니터링)으로 전달되는 신호입니다.
이러한 설계의 이유는 낮은 점수에 대해 하드 페일(hard-failing)을 적용할 경우 정당한 예외 케이스(edge cases)를 차단하게 되기 때문입니다. 예를 들어, 헤딩(heading)이 없는 문서는 하나의 커다란 스코프를 생성하여 여러 콘텐츠 타입에 걸쳐 분산된 점수를 받게 되며, 의도 용어(intent terms)가 희소한 콘텐츠 타입은 명백히 관련 있는 문서에 대해서도 낮은 점수를 생성합니다. 이것들은 파이프라인 에러가 아니라, 결정론적 스코어러(deterministic scorer)가 신호(signal)를 모두 소진한 경우입니다. 올바른 동작 방식은 임포트(import)를 중단하고 수동 재시도를 강제하는 것이 아니라, 프로세스를 계속 진행하여 LLM이 스코어러의 확신이 낮았다는 전체 맥락을 파악한 상태에서 해당 스코프들을 처리하도록 하는 것입니다.
Top-2 격차 확인(gap check)은 별도의 설명이 필요합니다. 0.15 대 0.14라는 점수는 결정(decision)이 아니라, 단순한 집합 중첩 함수(set-overlap function)에서의 노이즈입니다. 거의 비등한 점수를 확신을 가진 1위 할당으로 통과시키는 대신, 격차 확인(gap check)을 통해 이를 명시적으로 표시합니다. LLM은 거의 동일한 두 후보를 보게 되고 needsReview: true를 확인하게 되는데, 이는 정확한 판단입니다. 즉, 스코어러(scorer)가 승자를 선택할 만큼 충분한 신호(signal)를 가지고 있지 않으므로, LLM은 1위로 랭크된 후보가 정답이라고 가정하기보다 두 후보 모두를 진지하게 다루어야 합니다.
이로부터 일반화할 수 있는 점
여기서 사용된 구체적인 구현 방식인 자카드 유사도(Jaccard similarity), 구조적 보너스(structural bonuses), needsReview 플래그 등은 이 파이프라인에 종속되어 있습니다. 하지만 그 밑바탕이 되는 결정 사항들은 그렇지 않습니다.
-
사전 필터링 단계에서는 "빠르고 충분히 좋은 것"이 "느리고 최적인 것"보다 낫습니다. 40개의 콘텐츠 유형을 1밀리초 미만으로 3개의 후보로 좁히는 결정론적 스코어러(deterministic scorer)는, LLM 호출이 시작되기도 전에 2초의 지연 시간(latency)을 추가하는 완벽한 스코어러보다 더 가치 있습니다.
-
구조적 위치는 단순히 용어의 존재 여부뿐만 아니라 가중치 승수(weight multipliers)를 가져야 합니다. 제목(heading)에 나타나는 용어는 본문(body text)에 나타나는 동일한 용어보다 범주적으로 더 강력한 증거입니다. 모든 위치를 동일하게 취급하는 것은 문서 구조에 이미 존재하는 실제 신호(signal)를 버리는 것과 같습니다.
-
needsReview를 일급 객체(first-class) 출력으로 사용하는 것이 시스템 오류를 일으키거나 낮은 확신의 결정을 조용히 통과시키는 것보다 낫습니다. 파이프라인은 계속 진행되고, 플래그는 LLM에 정확히 필요한 맥락을 제공하며, 모니터링을 통해 성공적인 임포트(import)를 차단하지 않으면서도 낮은 확신의 세그먼트(segments)를 추적할 수 있습니다. -
표시 텍스트(display text)뿐만 아니라 메타데이터(필드 이름, 유효성 검사 규칙, ID 등)도 토큰화(Tokenize)해야 합니다. 프로덕션 콘텐츠 모델은 표시 이름에는 절대 나타나지 않는 프로그래밍 방식의 ID와 유효성 검사 제약 조건에 의미론적 정보(semantic information)를 인코딩하는 경우가 많습니다. 이러한 어휘(vocabulary)를 무시하면 스코어러는 이미 접근 가능한 어휘에 대해 눈이 먼 상태가 됩니다.
-
1위와 2위 사이의 격차는 최고 점수만큼이나 중요합니다. 1위 점수가 0.15일 때, 2위 점수가 0.14인 경우와 0.05인 경우는 매우 다른 의미를 갖습니다. 승자뿐만 아니라 이 두 가지 정보 모두 출력 결과에 포함되어야 합니다.
이 파이프라인(pipeline)에서 LLM은 전체 문서-콘텐츠 모델 행렬 (document-by-content-model matrix) 대신 범위가 제한된 문제를 받게 됩니다. 그것이 이 단계의 유일한 역할입니다. 이를 결정론적(deterministically)으로 정확하게 수행한다는 것은 파이프라인에서 비용이 많이 들고 느리며 창의적인 부분, 즉 LLM이 실제로 내려야 하는 결정에 컨텍스트(context)를 집중할 수 있음을 의미합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기