Skip to content

perf(citations): skip plain text between citation markers while streaming - #2451

Merged
jaluma merged 1 commit into
zylon-ai:mainfrom
b84330808:perf/citation-text-processor-skip-plain-text
Nov 11, 2026
Merged

jaluma merged 1 commit into
zylon-ai:mainfrom
b84330808:perf/citation-text-processor-skip-plain-text

Conversation

@b84330808

Copy link
Copy Markdown
Contributor

Description

With citations on (the bundled UI turns them on whenever documents are used), ExtractCitationInterceptor calls extract_citations_by_original_text on the whole accumulated answer for every streamed delta. That goes through IncrementalTextProcessor.process, which probes every rule at every character in Python and builds a ProbeResult.no_match() for each miss. So the work per answer grows with the square of its length, and long answers (or long thinking blocks, which go through the same path) spend seconds of CPU on citation parsing while streaming. A 6,000 character answer spent about 19.5 s of CPU on it in total.

This lets a rule declare triggers, the characters at which its probe can match. When every rule declares them, the processor copies the text up to the next trigger in one step instead of probing each character. DelimitedReferenceRule triggers on the first character of its start token and BacktickUnwrapRule on a backtick. Both already return NO_MATCH everywhere else, so the output doesn't change. Rules without triggers (like the ones in the existing engine tests) are still probed at every position.

The interceptor still reparses the full text per delta; that's left as is, this only removes the per-character cost.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

How Has This Been Tested?

  • Added two tests to tests/components/text_processing/test_engine.py: a rule with triggers is only probed at its trigger characters, and a rule without them is still probed everywhere. The first one fails without the change.
  • tests/engines/test_citation_*.py and tests/components/text_processing/test_engine.py: 151 passed.
  • Compared the patched engine against the original on 2,000 random strings built from brackets, backticks, separators, known/unknown ids and 【】, streamed in random chunk sizes and then finalized: all 24,779 results (text, citations, indices) were identical.
  • ruff format --check, ruff check and ty check on the touched modules pass.

Benchmark: replays the interceptor loop (extract_citations_by_original_text on the accumulated text per delta, then a final pass) for an answer with a citation every ~300 characters, streamed in 4-character deltas. Base and patched were run alternately in separate processes, 3 rounds each:

answer deltas before (median, range) after (median, range)
2,000 chars 500 1.90 s (1.89-2.02) 0.069 s (0.066-0.084)
6,000 chars 1,500 19.5 s (19.0-20.0) 0.62 s (0.60-0.65)

The streamed output was byte-identical before and after in every run.

  • Added new unit/integration tests
  • I stared at the code and made sure it makes sense

Test Configuration:

  • Hardware: Windows 11 desktop
  • Toolchain: Python 3.11, uv

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules
  • I ran make check; make test to ensure type checks and tests pass

…ming

ExtractCitationInterceptor reparses the whole answer for every streamed
delta, and IncrementalTextProcessor probed every rule at every character,
so a streamed answer cost O(length^2) Python-level probes. A 6,000
character answer spent about 15 s of CPU in citation parsing.

Rules can now declare `triggers`, the characters at which they can match.
When every rule declares them, the processor copies the text up to the next
trigger in one step. DelimitedReferenceRule triggers on its start token and
BacktickUnwrapRule on a backtick, so the output is unchanged. Rules without
triggers are still probed at every position.

@jaluma jaluma left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Thanks!

@jaluma
jaluma merged commit 6bb62ed into zylon-ai:main Nov 11, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants