How We Turn 500-Page Documents Into Short, Grounded AI Articles
LLM article generation from long documents: how we turn a 591-page file into grounded articles, drop repeat stories by meaning and keep IDs stable across runs.

TL;DR
- A document of several hundred pages now becomes a dozen or more short articles, each written from the few pages that back it up, instead of one general overview.
- Checking whether two story ideas are the same before writing anything means a repeated story costs one cheap comparison, not two full AI writing passes (a draft and an edit).
- The hardest rule wasn't in the prompt. It was deciding when a re-run is the same article or a new one, and keeping the published article whenever that call is unclear.
The document was 591 pages long. We had been turning documents like it into one article each, and that article never pointed to a single page. Most of the good stories were still sitting in the PDF.
You've probably been handed a document like this: a 300-page contract, a product manual, a policy pack from a new employer. You know there are three or four things in it worth knowing, and you have no idea which pages they're on. Ask a chatbot to summarize it and you get a page of safe, general sentences that could describe any document of its kind.
Multibagg AI is an AI-powered stock research platform for Indian investors. Companies preparing to list publish offer documents that run to hundreds of pages, and our readers want the handful of real stories inside them, with the page each fact came from.
Here's the two-stage pipeline we built to find those stories and write one short article per story, and why the hard part turned out to be article identity, not the prompt.
One document, one article, and no page to point at
Before this work, a finished document produced one overview article. An earlier extraction job had already pulled structured fields out of the document: financial tables, key metrics, how the money raised would be used. The article job took that row, sent it to the model as JSON, ran a second editor pass over the draft and saved the result under one ID per owner (the company the document is about).
That design had three limits. It could only say what the extraction schema had a field for, so anything outside the schema, like a lawsuit or one customer bringing in most of the revenue, never reached the article. It wrote one article, so one document produced one story. And the article's source list was saved empty. Nothing tied a sentence back to a page.
The new pipeline reads the document itself. Here's what changed, from the production database:
| Per document | Before | After |
|---|---|---|
| Articles | 1 | 12 (median) |
| Written from | extracted fields | the story's own pages, median 4 |
| Page range saved with the article | never | always |
| Words per article (median) | about 1,700 | about 1,160 |
121 overview articles before; 1,593 story articles from 126 document versions after, most of them processed under an earlier cap of 12.
Stage one: let the table of contents pick the sections
A 591-page document is too large for one model call, and most of it is boilerplate. So the first job doesn't read the whole thing at once. It reads the table of contents.
The converted text carries a marker for every page. The job matches the table of contents against 11 section names, such as the business overview, management, debt and litigation, and turns each match into a page range of at most 120 pages. A section that's still too large for one call (over 400,000 characters) is trimmed at a page boundary, and the job logs how many characters it dropped.
Then it makes one model call per section with one question: what in here is worth a standalone article? The answer is a list of candidate stories. Every candidate has a title, a line on why a reader would care, a page range and an importance score from 1 to 10. A section that fails is retried on its own, so one bad call doesn't sink the run. Nothing is saved yet. The candidates travel in the job's payload to the next step.
flowchart TD
A["Long document, converted to text with a marker per page"] --> B["Table of contents: locate 11 sections"]
B --> C["One model call per section: candidate stories with page ranges and importance"]
C --> D["Rank by importance, drop repeats by meaning, keep one story per page range"]
D --> E["Apply the importance floor, the drop rule and the cap"]
E --> F["One job per selected story"]
F --> G["Slice that story's pages, plus 3 on each side"]
G --> H["Draft, then an editor pass"]
H --> I["Identity check, save with the page range, notify search engines"]
Drop repeats by meaning before you pay for an article
The first version merged candidates by exact title. In testing, that let three separate articles through on the same disclosure, picked up from three sections under three different headlines. Long documents repeat themselves. The same fact shows up in the business overview, the risk section and the financial discussion.
So candidates are compared by meaning. Each one gets an embedding (a list of numbers that places similar text close together) built from its title plus its reason. Two candidates at a cosine similarity (a score, in practice between 0 and 1 for these embeddings, of how close two meanings are) of 0.85 or more count as the same story, and the stronger one survives: higher importance first, then the narrower page range. This runs before any article is written, so a repeated story costs one cheap embedding instead of a draft and an editor pass.
Two more filters follow. Only one story may claim a given page range in a section, because two stories built from the same pages are usually the same story told twice. Then the list is cut: nothing below an importance of 6, stop at the first drop of 2 or more points from the story before it, and stop at a cap. Here's a generic version:
interface Topic {
title: string;
reason: string;
section: string;
pageStart: number;
pageEnd: number;
importance: number;
}
const SAME_STORY = 0.85;
const IMPORTANCE_FLOOR = 6;
const IMPORTANCE_DROP = 2;
const = ;
pageCount = (: ): topic. - topic. + ;
byStrength = (: , : ):
b. - a. || (a) - (b) || a. - b.;
(): {
dot = ;
normA = ;
normB = ;
( i = ; i < a.; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
normA > && normB > ? dot / .(normA * normB) : ;
}
(): [] {
: [] = [];
( topic [...topics].(byStrength)) {
vector = vectors.(topic) ?? [];
repeat = distinct.( (vector, vectors.(kept) ?? []) >= );
(!repeat) distinct.(topic);
}
claimed = <>();
: [] = [];
( topic distinct) {
span = ;
(claimed.(span)) ;
previous = selected[selected. - ];
(selected. === || topic. < ) ;
(previous && previous. - topic. >= ) ;
claimed.(span);
selected.(topic);
}
selected;
}
The cap moved twice. It started at 5 articles per document, went to 12 a day later, and is 30 today, with up to 4 candidates per section. The floor and the drop rule do most of the cutting. Of the 17 document versions processed since the cap went to 30, the median has 20 articles, and one reached the cap.
Stage two: write each article from its own pages
Each selected story becomes its own job. The job slices out the story's page range plus 3 pages on each side, so a table that starts just before the range isn't cut in half. The model writes a draft from those pages only, a second editor pass checks it against the same rules, and the article is saved with its page range in the source list.
That source list is the grounding. Every article says which pages it came from, and the median article is written from 4 pages of a document that runs to several hundred. The writer sees only those pages and the 3-page margin around them, so anyone checking an article knows where to look.
Article identity is the hard part
Most of what I'd read about LLM article generation from long documents was about the prompt. Writing an article from four pages is a prompt problem, and it worked early. The question that took two days of review was a different one: when a job runs again, is this the same article or a new one?
Jobs run again all the time. A document gets uploaded again, the queue retries a failed job, someone re-runs a finished job by hand. Each run asks the model for stories again, and the model doesn't return the same titles twice. So each article needs an ID that a re-run can find again, and that still tells two different stories apart. When the job can't tell, it has to fail in a safe direction.
The rule changed five times before it held:
- The page range as the ID (owner, document version, section and page range). A teammate's review found that two different stories on the same pages share one ID. The one-story-per-range filter fixed that within a run, but a re-run could still pick a different story on the same pages and rewrite the published article under its old URL.
- The page range plus a hash of the title. An AI code review I ran found the flip side: model titles drift between runs, so the same story got a new hash and a second article. I took the hash back out.
- The page range alone, again, and the teammate's next review pointed straight back at the first hole.
- The page range plus a similarity band. If the range is taken, compare the new story's title with the stored one. At 0.85 or more it's already covered. Between 0.6 and 0.85 it's the same story reworded, so refresh it. Below 0.6 it's a different story, so write it under the range plus a title hash.
- The page range, a hash only when the range is taken, and no band. The teammate flagged the middle band, so I measured it with the same embedding model the job uses, on realistic pairs from the same section and the same pages. Eight of fifteen different-story pairs landed inside the band, which means the job would have overwritten them. "Customer concentration" against "supplier concentration" scored 0.730. Pairs from different sections sat between 0.14 and 0.45, which is why the tests never caught it: they modelled "a different story" as two unrelated vectors, and neighbouring stories in a real document aren't unrelated.
The band is crowded in production data too. Among stored articles from the same document version, 2,180 of 10,233 title pairs, about 21%, score between 0.6 and 0.85.
So the band went. Here, the new story's vector embeds its candidate title, and each stored vector embeds the published article's title. If the page range is free, the job creates the article unless some stored article already scores 0.85 or more. If the range is taken and the stored title scores 0.85 or more, there's nothing to do. Anything else keeps the published article and moves the new story to the range plus its own hash, where it's refreshed, created or skipped as a duplicate. A run can only rewrite a row whose ID is exactly the one it computed:
type Decision =
| { action: "skip"; reason: string }
| { action: "create"; id: string }
| { action: "update"; id: string; rowId: string };
interface StoredArticle {
rowId: string;
id: string;
title: string;
vector: number[];
}
const ALREADY_PUBLISHED = 0.85;
export function resolveIdentity(
: ,
: ,
: [],
: [],
: (a: [], b: []) => ,
): {
holder = stored.( article. === spanId);
(holder && (topicVector, holder.) >= ) {
{ : , : };
}
id = holder ? : spanId;
own = stored.( article. === id);
(own) { : , id, : own. };
duplicate = stored.(
(topicVector, article.) >= ,
);
(duplicate) { : , : };
{ : , id };
}
flowchart TD
S["New story for a page range"] --> Q1{"Is an article stored under this page range?"}
Q1 -->|"no"| Q3{"Does any stored title score 0.85 or more?"}
Q3 -->|"yes"| K["Skip: already published"]
Q3 -->|"no"| C["Create under the computed ID"]
Q1 -->|"yes"| Q2{"Does its title score 0.85 or more?"}
Q2 -->|"yes"| K2["Skip: same story, nothing to do"]
Q2 -->|"no"| H["Switch the ID to page range plus story hash"]
H --> Q4{"Is a row stored under that ID?"}
Q4 -->|"yes"| U["Refresh that row only"]
Q4 -->|"no"| Q3
The cost of a wrong call flipped. It used to be a published article silently replaced under its own URL, with no way back. Now the worst case is a redundant row, which is visible and easy to delete. I'd rather be wrong in that direction.
In production the hash path hasn't fired yet: none of the 1,593 stored story articles carries a hash in its ID, so no re-run has put a second story onto a page range that already had one. The rule is there for the run that does.
Later versions of the same document
These documents come in versions, a draft and then revised and final ones, often months apart. The first duplicate check only looked back 30 days. An AI code review I ran pointed out that a draft can sit for months before the next version lands, so the second version would publish a full second set of near-duplicates. The date bound went.
Then we tried letting a later version replace the earlier article. The teammate's review showed how that went wrong: the replace path could overwrite a sibling article from the same run, and a late retry of an old version could write old content back over a newer one. A strict "replace only an earlier version" rule fixed both, and then we decided as a team to drop replacing altogether. Each version now gets its own articles, with the version in the URL slug, and the duplicate check only looks within the current version. That knowingly allows near-duplicates across versions, and they're rare: in production, 1 of 125 owners has articles from more than one version, and 2 of 240 cross-version title pairs score 0.85 or more.
One long analysis per document: map, then write
Some readers want the whole document in one article, not a dozen stories. So, since late October, each owner whose main version (the one published just before the final version) is processed also gets one long analysis article. It's built when the main version is processed and rewritten in place when the final version arrives, keeping its ID, URL and creation date. Unlike story articles, this is the one article a later version replaces, on purpose.
It's a map step and a write step. The map step uses its own list of 14 section names and makes one notes call per section it finds: every important disclosure, with exact figures and page numbers, and no arithmetic. A section with nothing useful returns a fixed "nothing relevant" line and is dropped. The write step sends all the notes to one writer call, followed by the usual editor pass.
The coding assistant's first design had a third call to merge all the notes into one fact sheet. I asked why: if each section already gives its important facts, the writer can work from the notes directly. We dropped the step and added two lines to the writer prompt instead: use a repeated fact once, and on conflicting figures, trust the section that owns the topic.
On the 591-page test document, the job found 12 of those sections and 11 produced notes, the whole article took about 15 model calls, and it came out at 2,860 words. In production, the six analysis articles so far are each built from 11 to 14 sections, with a median of 2,736 words.
Review found two bugs in how the analysis meets the rest of the pipeline:
- A failed upload could block the analysis forever. An AI code review I ran found that the "don't overwrite a final-version article" check read the upload records, which are written before the file exists. One failed upload said "the final version is here" with no file, so every later main-version run skipped. The check now reads the version from the article's own saved sources.
- The two jobs could miss each other's page refresh. A story job checked for the analysis article before its model call, so an analysis saved in between never got its page refreshed, on pages cached for 14 days. Another AI review I ran caught it. The story job now looks the analysis up again after saving.
The same pipeline for slides, call transcripts and announcements
Two days after the analysis article shipped, I moved the pipeline beyond these documents. Slide decks, earnings call transcripts and important company announcements each used to produce one summary post. Now they go through the same topic-to-article pipeline, with per-type settings for sections, prompts, caps and IDs. They have no table of contents, so the whole document is one section. The model decides how many articles a document supports, and zero is a valid answer.
| Document type | Documents | Articles | Median per document |
|---|---|---|---|
| Slide decks | 941 | 3,198 | 3 |
| Call transcripts | 488 | 1,490 | 3 |
| Announcements | 2,003 | 3,602 | 2 |
Production data from the first ten days.
Re-runs needed a guard here too. Documents get dispatched again for ordinary reasons (reclassification, a sweep of stalled documents), and each dispatch used to rerun discovery and every draft at full cost. Now discovery skips a document that already has story articles, but only on a job's first attempt, so a retry can still queue the stories it hadn't queued yet. The trade-off we accepted: if one story's job fails both attempts, a later dispatch won't retry it. The queue already sends an alert when a job fails its last attempt, so the loss is visible.
A side finding: the embedding we sent to every browser
Every stored article carries a 1,536-number embedding of its title for the duplicate check. The article page, a React server component, loaded the row with no column list, and the data it hands to the browser is serialized into the HTML. So the embedding went to every reader: about 11.6% of the page's HTML. Selecting only the columns the page reads cut about 29 KB from every article page.
If your ORM returns whole rows by default and a column holds a vector, check what your pages actually send.
What it costs
This design isn't free, and some of its limits are deliberate.
- Model calls per article. Every story article is two model calls (draft and editor) plus two embeddings, and stage one adds one call per section. In production the median story article costs about 13 cents in model fees, and an analysis article about $1.40 with its notes calls included.
- Volume grows with versions. Keeping one article set per version means two closely related articles can sit side by side in search results. We chose that over rewriting published articles.
- Length lives in the prompt. The writer is asked for 900 to 1,150 words, and nothing in code enforces it. The median story article is 1,159 words, just over the ceiling, and the p95 is 1,330.
- Oversized sections lose their tail. The 400,000-character cap trims at a page boundary. On the 591-page test document, the business section lost 40,060 characters and the litigation section 70,149.
- Queue order, not preemption. Story jobs share a single-worker queue with the job that extracts structured fields. Extraction jobs carry a higher priority, so a document with 30 stories can't push them to the back, but an extraction job can still wait behind one running story job.
Lessons for any document-to-article pipeline
- Decide what "the same article" means, and which way you fail when you can't tell, before you tune prompts. Every retry, re-upload and manual re-run depends on that rule, so write it down first.
- Deduplicate at the cheapest point. Comparing story ideas before generation costs an embedding. Catching the same duplicate after generation costs a draft, an editor pass and a delete.
- When identity is unclear, prefer a duplicate to an overwrite. A duplicate is visible and reversible. An overwrite under a published URL is neither.
- Test thresholds on real neighbours. Random or orthogonal vectors make every threshold look safe. Pairs from the same section of the same document are where thresholds fail, so build the test set from those.
- Ask why each model call exists. One question removed a whole step from the analysis article. "Why is this a separate call?" is cheap to ask and expensive to skip.
The takeaway, and what's still open
The prompt turns four pages into a readable article. Everything around it decides whether the pipeline can run twice. With identity settled, one long document became a dozen or more short articles you can trace back to their pages, and the same pipeline now runs on slide decks, call transcripts and announcements.
What's still open:
- A final check on the written title. Six of 10,233 same-version title pairs still score 0.85 or more, because two different candidates can turn into near-identical articles. Comparing the generated title with stored titles before saving would catch them.
- Splitting oversized sections. Taking notes on a long section in chunks, instead of trimming it, would keep the end of the business and litigation sections.
- Refreshing when the source changes. A story that still matches the article stored under its page range is skipped even if its pages changed. A fingerprint of the source pages would tell a real correction from a repeat.
- Length enforced in code. Moving the word limit from the prompt into a check would bring articles back under the ceiling.






