Repository navigation
The chunks of one document are extracted one at a time #588
Description
One document is one job, and inside the job the chunks are extracted one after another (extraction.rs, for chunk in chunks.iter()). A model call takes about 55 seconds on DeepSeek-V3 with the schema.org candidates, so a document's extraction time is chunks × 55 s no matter what the deployment's worker_concurrency or the model's max_concurrent say: those only let more documents run at once.
Measured on the #582 runs, three documents uploaded together: anthropic.txt (27 chunks) 25 min, removal-of-sam-altman-from-openai.txt (23) 21 min, openai.txt (73) 68 min. The two small ones finish and the base then waits three quarters of an hour on one document with two idle workers, while the model gate (10 concurrent by default) sits at one call.
What should happen
The chunks of one document are extracted concurrently, bounded by the model's concurrency gate (model_limits, the same one every other call goes through), and the results are applied in chunk order so that entity resolution and the ledger behave exactly as they do today: resolution is order-sensitive (a later chunk's namesake logic reads what earlier chunks bound, handled_by_name, response_claims), so the model calls are the parallel part and the resolution stays sequential over the replies in sequence order.
Expected effect on the same three documents: bounded by the gate, not by the longest document. With a gate of 10, openai.txt becomes roughly 73 / 10 × 55 s ≈ 7 min of calls plus resolution.
Not this
- Not a bigger
worker_concurrency: it changes nothing for a single document. - Not parallel resolution: the order-dependent parts stay in order.
- Not a change to what is extracted: the same prompt, the same replies, the same ledger; a run before and after on the same documents should differ only in wall-clock time (and the run-to-run variance the model already has).
Acceptance
- Three documents uploaded together finish in about the time of the longest document's
chunks / gatecalls, notchunkscalls. - A failed chunk is reported the same way it is today (
unextracted, the per-document summary), and a rate-limited call still backs off through the gate rather than retrying in a tight loop. - The extraction ledger and the fact/entity counts on a re-extraction are within the run-to-run variance already measured (A fact quotes the words that name each side #583's table).
Related: #526 (a worker held while the ontology index is built, the other reason a document waits), #583 (the runs that measured this).
Activity
jeremycapps commentedon Sep 15, 2026
Hey @WaylandYang, I saw here that re-extraction is being reworked. Does that cover single-document chunk concurrency, or is this separate? If it's separate, I'd like to help plan it. Here's how I'd scope the first step.
Scope
First PR is a refactor only: make
knowna settable input to the extraction call. Concurrency and the variance measurement build on it and come after.The refactor
Split the per-chunk loop into a call/parse step and an apply step, along the boundary that already sits at the model call. No behavior change on its own: same order, same prompts, same ledger, existing extraction tests green.
knownbuild, messages, and the model call: L1108-L1159.knownbecomes an argument to it, currentdoc_entitiesby default or&[]behind a flag: L1126-L1134.doc_entitiesappend, andmark_chunk_extracted: L1272-L2803.knowncontains, not how it renders: lib.rs L507-L541.Why this first
The open question is how parallel one document's chunks can go, and it hinges on one number: does dropping
knownmove extraction output beyond the run-to-run variance in #583's table. The refactor is cheap and low-risk; the concurrency shape after it depends on that answer. Measure, then decide, so we don't build ordering machinery the number might not justify.Dependency
The variance run needs a stable baseline. #714 changes doc-date handling on the same recall-bench corpus #583 measured, so take the
knowncomparison before #714 lands or on a subset it doesn't touch, or the two deltas confound.rollroyces commentedon Sep 20, 2026
Picking this up. Jeremycapps's comment in this thread already scoped cut 1 as the refactor only: make
knowna settable input to the extraction call, with the chunk loop split into a call/parse helper and an apply helper. No behavior change. Both #714 and #583 are now CLOSED and MERGED respectively, so the variance-run dependency is gone — the refactor can land.The minimum-cut I have in mind:
call_and_parse_one_chunk(...) -> ChunkOutcomethat takes the same arguments the current loop body builds each iteration (known: &[KnownEntity], messages context, the chunk, opening chunk override, etc.) and returnsParsed { extraction, cut_by_ceiling }orSkipped(reason)orStolen(epoch was bumped). The model call, the rate-limit retry, the parse, and the two audit-drop paths (CHUNK_UNEXTRACTEDfor model failure and parse failure) all move into this helper.apply_open_extraction(...) -> ()that takes the parsed reply plus&mutreferences into the order-sensitive HashMaps (doc_entities,known_by_name,described,handled_by_name,ambiguous_bare_cache,touched_names,statement_count,needs_adjudication,human_reviews_found) and runs the existing apply half: entity resolution, the place phase for deferred entities, fact writes, evidence writes, the truncated/malformed drop signals.knownfromdoc_entities, call helper, match on outcome, apply orunextracted.push, thenmark_chunk_extracted. The order-sensitive state still mutates sequentially per chunk — concurrency is a separate cut.Two questions before I write the patch:
Parameter shape on
call_and_parse_one_chunk. I see three defensible shapes:(state, settings, client, pool, kb_id, doc_id, my_epoch, chunk, opening, known, proposer, bound_types, attested_at)— explicit, no hidden state.CallContextstruct built once before the loop holds the run-level fields; the helper takes(ctx, &mut chunk, opening, known)— smaller per-call surface, but adds one allocation.&mut selfon a struct that owns the LLM client and a small cache — only worth it if we want to keep call state between chunks.My read: option (a) is the smallest cut. Option (b) is more readable if the function grows (the next cut is concurrency + per-call cache, where the struct pays off). I'd ship (a) today and refactor to (b) when the concurrency cut lands.
Tests. The current
extraction_open.rstest module is pure-function tests (span_in_quote,locate_time); no integration test of the chunk loop. Should this PR add a test? The cleanest would be a doc-backed test that exercisesrun_openend-to-end with a scripted HTTP model (Maya-Kid did this for An ontology edit during type alignment can be hidden by the response timestamp #795, with a fixture postgres), but that's a fixture setup of its own. Two options:My read: (a). Adding the test expands scope by another 100-200 lines and a fixture setup; the refactor itself is provably behavior-preserving by reading the diff. Maintainer's test-the-test approach (run on a real base, eyeball the diff) is the right gate for this cut.
I'll wait on (1) before writing code. (2) is a heads-up — if you want a test, say so and I'll add it; if you don't, I'll ship without.
What I would NOT do in this PR
for chunk in chunks.iter()stays sequential.knowncontains, not how it renders. Thebuild_open_messagesfunction is untouched.WaylandYang commentedon Sep 25, 2026
Cut 1 as you scope it is right: a refactor with no behaviour change,
knownas an explicit input, the loop body split into a call-and-parse helper and an apply helper. One update since the thread was written: the loop moved. Extraction now writes the open graph, and the chunk loop lives incrates/utopia-server/src/extraction_open.rs(run_open, thefor chunk in chunks.iter()there), not inextraction.rs; it also has apushedbranch that parses the chunk instead of calling the model (#884). The helper should take that branch with it.For cut 2, when chunks of one document run at once, the per-model concurrency gate in
llm_utilstays the only limiter, andknownkeeps its meaning: an entity listed by an earlier chunk is known to a later one, so the apply step has to run in chunk order even when the calls do not.jeremycapps commentedon Sep 25, 2026
Thanks @WaylandYang, and thanks @rollroyces for picking up cut 1.
Once cut 1 lands, I'd like to take the measurement it unblocks, and then cut 2 if the numbers support it.
Measurement
Take the #583 corpus and model config and run each document with known as today (earlier chunks' doc_entities) and with known empty. Do several runs of each so the comparison has its own variance and doesn't lean on #583's alone. Report fact and entity counts, the ledger diff, and namesake/handled_by_name resolutions per document, next to #583's table. The question is the one from before: does an empty known move the output beyond the run-to-run variance? The answer decides how much of cut 2 can go in parallel.
Cut 2
As you described it: the calls run at once under the llm_util gate, rate-limited calls back off through the gate, and the apply step consumes replies in chunk order. I'd measure wall-clock time on the same three documents from the issue before and after, so the acceptance check (about the time of chunks / gate calls) is a measured number.
Two questions before I start:
Is the #583 corpus and model config still the right baseline now that #714 has merged, or should I rerun a fresh baseline first?
Should the measurement go in a comment here, or as a script/bench in the repo so it can be rerun later?
WaylandYang commentedon Sep 25, 2026
Answering the four open questions in order.
@rollroyces, on the refactor:
state,settings,client,kb, document, epoch,bound_types,attested_at,pushed) are the ones that would go in the context struct; keep them as plain arguments now so the diff is the split and nothing else.errata_tests.rsandphrase_alignment_tests.rsalready drive a real database with a scripted HTTP model (Fx::new(vec![script]), replies in order); a test that ingests a two-chunk document, scripts two open-contract replies where the second chunk names a thing the first listed, runsextract_document, and asserts the second chunk resolved to the first chunk's entity is about eighty lines on that pattern. It is the test that cut 2 needs anyway: it pins thatknownfrom an earlier chunk reaches a later one, which is the property concurrency must keep. Without it, "behaviour-preserving by reading the diff" is the only gate, and the diff is a thousand lines moving.@jeremycapps, on the measurement:
scripts/bench/recall.mjson its corpora, several runs, and that table is the variance theknowncomparison is read against.--known emptyknob onrecall.mjs(it needs a server-side switch, which is the flag the refactor introduces) and the numbers inscripts/bench/README.mdnext to the other measured runs, with the summary posted here. A comment cannot be rerun after the next change to extraction, and there will be one.Sequence: rollroyces lands cut 1 with the switch and the test; jeremycapps runs the baseline and the comparison on it; cut 2 goes in only if an empty
knownmoves the output beyond the run-to-run variance by less than the wall-clock gain is worth, or with the apply step kept in chunk order if it does not.refactor(extraction): split run_open loop into call/parse + apply hel…
refactor(extraction): split run_open loop into call/parse + apply hel…
Pin that known reaches a later chunk, and let a run leave it out of t…
refactor(extraction): split run_open loop into call/parse + apply hel…
Each chunk reports the dates its passage states, and a time word is m…
The recall bench labels a run by whether the prompt carried known, re…
jeremycapps commentedon Sep 28, 2026
Cut 1 is in (#980), thanks @rollroyces. The next step is the
knownmeasurement, and I'd like to split it: I've built the bench changes, but I don't have a budget for model calls, so I'm asking whether someone with the bench setup can run the rounds.What's ready:
recall.mjschanges (#1002)--known shown|empty: a label for the run. The switch itself isUTOPIA_EXTRACT_KNOWN_IN_PROMPT, set when the server starts, so each group runs against a server started to match.--out <dir>: one JSON record per run. Per document it records chunks, facts, entities, entities that appear in facts from two or more chunks,name_vector|pairs proposed to the adjudicator (Recall a mention by its name vector so a short form or another script meets its entity #877), drops by reason (includingchunk_unextracted), and seconds from queueing to the last chunk. It also records the knowledge-base-wide pair total, split into pending / merged / kept.--table <dir>: groups runs by label and adds the fact-set overlap within each group (the run-to-run variance) and across the two groups (the effect ofknown).I counted the
name_vector|pairs because since #877 a variant name becomes a pair for the adjudicator rather than a silent merge. So an emptyknownmay show up as more pairs, with the recall score barely moving. The score alone could read as "no difference."Not yet run against a database. I don't have a model endpoint, so the queries in
stats()andnearPairs()have only been reviewed, not run.--tableis tested on made-up records.The ask
Could someone who has the bench running do three rounds with
knownshown and three with it empty, on current dev (after #991/#995/#1000, since all three changed the prompt)?Any model is fine as long as all six rounds use the same one. I'd guess gemini-3.5-flash with
reasoning_effortminimal, as in the recent runs. If the first round shows a problem with the queries, I'll fix it the same day. I'm happy to write up the table inscripts/bench/README.mdand post the summary here, so all anyone needs to share is theruns/588records. Or, if there's a project key contributors can use, I can run it myself.For cut 2, whichever way the numbers go
a_thing_listed_by_an_earlier_chunk_reaches_a_later_one_via_knownasserts that chunk 2's prompt contains chunk 1's entity, and fully concurrent calls break that by design. If the numbers support full concurrency, I'd change that test to assert only the apply-side property (the second test's), and say why in the PR. If they don't, cut 2 extracts the first chunk(s) before the rest, and the test stays as it is.The recall bench labels a run by whether the prompt carried known, re…
WaylandYang commentedon Sep 29, 2026
Results of the
knownmeasurement, @jeremycapps. Thank you for the bench (#1002, #1005); it ran as written.Three rounds per group (edited 2026-09-30: the first version of this comment had two per group; the two rounds that were void on the first day were re-run). Rounds alternate shown / empty with a server restart before each.
Setup
hexBinaryimport and the CI file, not extraction), one base with the schema.org pack (915 classes, 1559 properties), governance on.reasoning_effortminimal) and bge-m3. Rounds 1–4 through one gateway, rounds 5–6 through OpenRouter after the first ran out of credit; same models, and the bge-m3 vectors were checked against the ones already in the base (cosine 0.9999).Rounds
Pending pairs are governance calls the gateway refused (rate limits on the second day, an outage on the first); the pair totals are counted at proposal and are not affected, the merged counts are.
Fact-set overlap (Jaccard, averaged over documents)
All nine across-group pairs sit below all six within-group pairs.
What it says
cfo-title,er-coreweave,er-nebius,er-oci) plus one that varies.knownproposed more (this comment said so); round 5, a shown round, proposed 372, the most of any round. Shown: 228, 208, 372. Empty: 267, 309, 244. The count follows how much the model extracted from the earnings release in that run (366 of round 5's 372 pairs are that document's), notknown.For cut 2
Running the calls of one document at once is safe for recall. Without
known, a third of what a run writes is different from what it would have written with it, and the adjudication count does not show a cost. So the decision is about which output we want, not about recall or adjudication load. Keeping part ofknownunder concurrency (the first chunk alone, the rest at once with what it listed) is still on the table; I would like your view before deciding.jeremycapps commentedon Sep 29, 2026
Thanks for running these, @WaylandYang.
My view: keep part of
known, and the simplest part covers most of it. Extract chunk 1 alone, then the rest at once with chunk 1'sknown, applying in chunk order as today.Why chunk 1 carries most of it
knownonly helps an entity that appears in more than one chunk, and in my round that's a small, lopsided set. The earnings release (32 chunks, 165 entities,knownshown):The one entity that runs through the document is its subject, and chunk 1 defines it (
NVIDIA Corporation ("NVIDIA" or the "Company")). Seeding from chunk 1 keeps that spelling stable everywhere. What it gives up is the handful of 2–3-chunk names first introduced later (Vera Rubin, the non-GAAP measures).What it costs
One extra round. The same document took 228 s sequentially (gemini-3.5-flash, minimal, about 7 s a chunk). With chunk 1 first and the gate at 10 it would be about 7 + 28 ≈ 35 s. Apply stays in chunk order and the list is still built from resolved entities, so the invariant from the issue holds, and
a_thing_listed_by_an_earlier_chunk_reaches_a_later_one_via_knownstill holds for chunk 1 onward.jeremycapps commentedon Sep 29, 2026
A follow-up to my comment above, @WaylandYang: the alternatives I looked at before landing on the chunk-1 seed, and why I'd still start with it. Collapsed, since it's long.
Alternatives to the chunk-1 seed, and why start with it
Terms
default_model_concurrency, 10 here). t: one call, about 7 s on gemini-3.5-flash at minimal effort (includes apply; the two are not split).known.Alternatives
knownknownempty; the gate runs G at a timeceil(n / G)* Best in its column; tied options are all marked. The seed is the only option near the best in both time and blind chunks.
Why start with the chunk-1 seed
knowndoes. In the earnings release, 1 of 165 entities is in 20 chunks, 5 are in 3, 10 are in 2, and 149 are in one. The one that runs through the document is its subject, and chunk 1 defines it (NVIDIA Corporation ("NVIDIA" or the "Company")).known, and it is the smallest change from today's code: one call before the rest, and the existing apply loop.What would make a wave scheme worth it
Each of these would need an answer first:
NVIDIA,NVIDIA Corporation) both enter it, as do names the apply step would reject. From applied replies it is today's list, but applying by wave breaks chunk-order apply. It is the same number of applies, grouped differently; the apply time per chunk is unmeasured.ceil(n / G)anyway.n, a trademark symbol or a legal suffix, an all-caps form, a product-family prefix). Rarity is weak: recurring names are rare (Vera Rubin) and generic (GAAP, AI) alike. The list's 1,200-character cap already drops recurring names that first appear late (GAAP operating expenses, chunk 26).Open questions
WaylandYang commentedon Oct 1, 2026
Thanks @jeremycapps, for the measurement of where
knownactually matters and for the alternatives table. Decision: go with the chunk-1 seed. Chunk 1 alone, then the rest at once under the model gate with chunk 1's list, applied in chunk order as today.What settled it: the 165-entity count shows
knownis about the document's subject, and chunk 1 defines it. The 14-point drop in fact-set overlap that the measurement found is the cost of losing that; the handful of 2–3-chunk names introduced later is what we accept losing. One extra round for a 6× wall-clock gain on a 32-chunk document is a good trade.Two things to keep as you build it:
knownthe parallel calls receive.knownwas.Please take it; you have the context. Open the PR against
devwhen it is ready, and say in it which of the alternatives you ruled out and why, in a sentence each, so the record has it.rollroyces commentedon Oct 15, 2026
动手前想先把并行化的边界定下来。
crates/utopia-server/src/extraction_open.rs:985的循环有跨 chunk 共享 mut 状态:unextracted: Vec<(i32, String)>— 第 N 块在解析时消费前 N 块留下的 unextractedtime_entries: Vec<utopia_extract::time::TimeEntry>— 每块报上来的日期往里 pushknown: HashMap<String, Vec<Uuid>>/response_claims/handled_by_name/ambiguous_bare_cache— 名字消歧的"前面认下的实体"缓存extracted_chunks: Vec<Uuid>— 顺序extract_known_in_prompt: bool这个旋钮(crates/utopia-server/src/state.rs:35)是 maintainer 留的"开/关 提示词里出现 known"接口,说明并行化这事早就在动念。但known/unextracted这两路就算旋钮关掉也仍要写:第 N 块消歧时仍可能问"我之前这块认下的 k1",而顺序无关假设这块跳过 known 是可以并发跑得稳的。想确认三件事再下刀:
futures::stream::iter().for_each_concurrent(N, ...)还是tokio::task::JoinSet?这一处是 extraction.rs 唯一的 for 循环,影响范围有限。state.worker_concurrency是 job 级的,不适合拿来当 per-chunk 的并发上限。LlmSettings::max_concurrent(如果存在) 还是按 chunk 数自适应?#588没写明。for chunk现在遇到Err(e)直接?整文档失败;并发下某一块失败:整文档重 fail?部分 chunks 写库、坏的那几块写last_error?issue 没讲。A 路径:
for_each_concurrent+extract_known_in_prompt = true时回退到串行(逻辑等同现状),= false时并行(known路径在关掉时本来就不读前块)。代码改动:for chunk改for_each_concurrent、结果收集改成Vec<Result<_>>一次性 apply。前提:你们的extract_known_in_prompt = true默认值今天是不是要改?默认是true(crates/utopia-core/src/config.rs:58),所以默认行为没变,只有显式关掉的部署才看到加速。B 路径:
for_each_concurrent统一生效,known这一支改成"按 chunk_seq 索引回放"——每块写自己的 known,后块读到前块的那段 known 时 block 等待。改动面更大,跨 chunk 的 wait 也加复杂度。我倾向 A(默认行为不变,显式 opt-in 才加速),但 maintainer 如果觉得 B 才是你们想要的(可能默认就想并行,只是旋钮还没想清楚),我就走 B。请给一句。