Scoring Documents Against a Content Model Without an LLM A developer building a Google Docs to Contentful import pipeline replaced the naive approach of sending entire documents to an LLM with a deterministic pre-filtering stage that scores document scopes against content types using Jaccard similarity. The method narrows forty content types to a shortlist of candidates before any LLM call, reducing token costs and improving accuracy. The naive way to map a Google Doc into a CMS is to send the whole document to an LLM and ask it to figure out which sections go where. That works at small scale, with a small content model. It stops working when your content model has forty content types, each with a dozen fields, and each document has a different structure. You end up burning a large context window on mostly-irrelevant content types, paying for tokens that don't help, and getting worse results because the model is reasoning about the full space of possibilities instead of a curated shortlist. The pipeline I was working on a Google Docs → Contentful import addressed this with a deterministic pre-filtering stage that runs before any LLM call. Its job is not to do the mapping. Its job is to narrow the problem: segment the document into contiguous regions, score each region against every content type, and hand the LLM a small set of scope, candidate content types pairs instead of a full Cartesian product. This post is about how that scoring works and why the design choices look the way they do. The document is treated as a sequence of blocks. Any block that is a tab opener, an H1, or an H2 starts a new scope. Every block between two openers belongs to the scope opened by the first one. The result is a flat array of DocumentScope objects: export type ScopeReasonCode = 'tab-label' | 'heading-match' | 'body-term-match' export type ContentTypeMatch = { contentTypeId: string score: number reasons: ScopeReasonCode } export type DocumentScope = { openerText: string | null openerKind: 'tab' | 'heading' | 'document-root' blocks: NormalizedBlock matches: ContentTypeMatch topMatch: ContentTypeMatch | null needsReview: boolean } openerText is the raw text of the tab label or heading that opened the scope. blocks is the contiguous slice of the document. matches is every content type scored against this scope in descending score order. topMatch is a convenience pointer to the first match - null if there were no matches above the noise floor. needsReview is a flag, not a gate. The function signature is straightforward: export function segmentDocumentScopes doc: NormalizedDocument, slice: ContentModelOntologySlice, : DocumentScope The ContentModelOntologySlice is a pre-built graph of content type nodes, field nodes, and the intentTerms sets derived from each; more on those below. The score for a scope, content type pair is Jaccard similarity over tokenized text: function jaccardSimilarity a: Set