Skip to content

Feature/102 context aware chunking via llm#131

Merged
DaniloTatti merged 6 commits into
devfrom
feature/102-context-aware-chunking-via-llm
Jul 7, 2026
Merged

Feature/102 context aware chunking via llm#131
DaniloTatti merged 6 commits into
devfrom
feature/102-context-aware-chunking-via-llm

Conversation

@DaniloTatti

@DaniloTatti DaniloTatti commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Context-Aware Chunking Feature

Overview

This PR introduces a new context-aware chunking strategy for text and PDF content ingestion in the SprintStart AI service. This strategy leverages a Large Language Model (LLM) to produce semantically coherent chunk boundaries and selectively prepend short situating context blocks to the resulting chunks. It serves as an opt-in, additive alternative to the existing paragraph-based character-limit chunking.


Key Changes

  • Chunk Metadata Enhancements
    to_parsed_chunk now supports additional metadata flags:

    • has_context_block: whether a short context block was prepended
    • context_block_range: character range of the context block within chunk content
    • has_overlap: whether a chunk includes overlap content from adjacent chunks
    • overlap_range: character range of the overlap within chunk content
  • New Context-Aware Chunking Implementation
    Implemented in src/ingestion/context_aware_chunker.py, including:

    • LLM prompt construction and parsing logic that uses paragraph indices for boundary decisions
    • Character-length size checks to avoid LLM overrun, with a fallback to the classic chunker
    • Robust fallback to chunk_text when the LLM is unreachable or produces invalid responses
    • Hard-splitting of overly large chunks (including context blocks) with duplicated context
  • Parsing Path Updates

    • parse_text and parse_pdf now accept optional LLM client and flags for semantic boundaries and contextualization, defaulting to enabled
    • Dispatcher parse() forwards these parameters to text- and PDF-specific parsers
    • Code and image parsers remain unaffected and use the legacy chunking
  • API Changes

    • IngestRequest schema extended with two new boolean flags: semantic_boundaries and contextualize (both default true) to control this feature per request
    • /api/v1/ingest endpoint updated to forward these flags and LLM client to the parser
    • /api/v1/ingest/sync (batch ingest) intentionally left unchanged to avoid backend contract changes; it continues using legacy chunking
  • Documentation Updates

    • README updated with environment variable CONTEXT_AWARE_CHUNKING_MAX_CHARS and detailed description of the new chunking behavior
    • .env.example updated to include this new environment variable
    • Design caveats explained, including context-scoping for PDF pages and performance considerations
  • Tests

    • Comprehensive unit tests for the new chunking logic
    • Integration tests verifying API behavior and flag handling
    • All existing tests remain passing except for a known unrelated Ollama Vision Client test

Important Notes / Considerations

  • Scope:
    This feature currently applies only to the single artifact ingest API (POST /api/v1/ingest). The batch ingest API /api/v1/ingest/sync continues using the legacy chunking strategy to avoid changes in wire contracts with the backend. Enabling this behavior in batch ingest would require further backend coordination and is out of scope for this PR.

  • Chunk Inspector CLI:
    The existing offline scripts/chunk_inspector_cli.py does NOT yet support the new context-aware chunking. Extending it to utilize the LLM-based chunker with toggles for flags and LLM client configuration would be a larger undertaking and is deferred. Manual API testing can be done instead.

  • LLM Client Handling:
    The chunking code uses the injected LLMClient interface to remain agnostic to backend providers (Ollama, OpenAI, Anthropic). The fallback logic is designed to be robust to network or parsing failures.

  • Performance:
    The LLM-based chunker performs an additional generate call per ingested text or PDF artifact (once per PDF page). The environment variable CONTEXT_AWARE_CHUNKING_MAX_CHARS caps input size to mitigate latency and failure risks.


Suggested Review Focus Areas

  • Review the core logic in src/ingestion/context_aware_chunker.py, particularly prompt formulation, JSON parsing, and chunk splitting behavior
  • Confirm appropriate defaulting and fallback behavior in text and PDF parsers
  • Verify API schema additions and downstream flag passing
  • Evaluate test coverage and quality
  • Validate README and .env.example documentation for clarity and completeness

How to Test Manually

  1. Run the service locally with a valid LLM backend configured (e.g., Ollama).
  2. Use the /api/v1/ingest endpoint with text or PDF files, examining chunk counts and metadata to verify context blocks and semantic chunking.
  3. Observe fallback logic in case of large input or simulated LLM failures by toggling flags or oversizing documents.
  4. Review logs for fallback warnings.

This PR delivers a substantial quality and relevance improvement to the chunking stage of ingestion with minimal disruption to existing workflows and optional feature activation per request.


Type of PR — pick one:

  • Functional — adds or changes user-visible behavior
  • Non-functional — improves a measurable property (perf, security, a11y …)
  • Mixed — both
  • Internal — refactor / docs / tests only

1 Process baseline — every box must be ticked, on every PR

  • All acceptance criteria in the linked issue are met
  • 1 review approval from a non-author
  • CI green: lint, type-check, unit tests, build, secret-scan
  • No secrets / tokens / credentials in the diff
  • No new TODO / FIXME without a follow-up issue
  • Docs updated if behavior, API, config, or architecture changed
  • No regression of other NFR baselines (a11y / perf / security)

2 Outcome proof — fill the bullets that match your PR type

Functional / Mixed PR:

  • ≥ 1 black-box test that exercises the acceptance criteria
    e.g. upload a markdown file, then assert the chat answer cites it

Non-functional / Mixed PR:

  • Measurement that proves the acceptance criteria, attached to the PR
    e.g. benchmark output for chat p95 < 2 s, axe audit for a11y ≥ 90, scan report for 0 critical CVEs

3 Cross-cutting impact — tick the areas this PR touches

For each area below, ask: "does my PR touch this?"

-> If yes → tick the area and complete its sub-checks (they become mandatory).
-> If no → skip it.
-> If nothing applies → tick "None of the above".

  • UI touched

    • Responsive on desktop and mobile
    • Contrast + visible focus state, no color-only signals
      e.g. new button has a focus ring and is readable on a 360 px screen
  • Backend endpoint added or changed

    • Behind authentication — not accidentally public
    • Input validation (size / type / allowed values)
    • OpenAPI spec updated
      e.g. /upload rejects files > 10 MB with a clear error
  • Stores user data, ingested content, or LLM output

    • Logged with the standard fields (request id, source, latency)
    • No PII / credentials in log output
  • New env var or config

    • Added to .env.example and mentioned in README.md
    • No real value committed
      e.g. LLM_API_KEY=... — only the key name lives in .env.example
  • Deployment / build changed

    • Deployable via the standard pipeline (no undocumented manual steps)
    • Local dev setup still works
  • None of the above — purely internal change


-based on AGENT.md with some additional refactoring
- Extend to_parsed_chunk with has_context_block/has_overlap flags and
  their character ranges, defaulting to false/empty so chunk_text and
  chunk_code behavior is unchanged.
- Add ingestion/context_aware_chunker.py with the prompt builder and
  LLM call that will back the new opt-in LLM-based chunking strategy
  (semantic boundary detection + selective contextualization).
…nd contextualization

- Add new chunk_text_context_aware function with LLM integration and robust fallback
- Enhance parse_text and parse_pdf to optionally use context-aware chunking
- Update parse dispatcher to forward chunking flags and LLM client
- Extend IngestRequest schema and ingest endpoint with chunking toggle flags
- Update README and .env.example with new CONTEXT_AWARE_CHUNKING_MAX_CHARS setting and detailed context-aware chunking docs
- Add comprehensive unit and integration tests covering all new features and edge cases

Note: Batch ingest (/ingest/sync) not modified, uses legacy chunk_text only.
@DaniloTatti
DaniloTatti requested review from Afif-del and LinseCed July 6, 2026 05:11
Comment thread src/api/schemas.py
Comment on lines +49 to +71
semantic_boundaries: bool = Field(
default=True,
description=(
"Only affects text and PDF content. When true, an LLM chooses "
"chunk boundaries based on semantic coherence (topic shifts, "
"section boundaries) instead of the default character-length "
"accumulation. Falls back to the default chunker if the "
"content is too large for the LLM or the LLM output is "
"invalid. Independently toggleable from 'contextualize'."
),
)
contextualize: bool = Field(
default=True,
description=(
"Only affects text and PDF content. When true, an LLM flags "
"which chunks would benefit from a short situating context "
"block (Anthropic-style Contextual Retrieval) and prepends it "
"to their content; self-contained chunks are left untouched. "
"Falls back to the default chunker if the content is too "
"large for the LLM or the LLM output is invalid. "
"Independently toggleable from 'semantic_boundaries'."
),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: these flags should default to false.

The issue describes context-aware chunking as opt-in/additive and explicitly says existing ingestion behavior should remain unchanged when the feature is not enabled. With both flags defaulting to true, normal /ingest requests now invoke the LLM-based chunker by default, which changes chunk boundaries, latency, and fallback behavior for existing callers.

Suggestion:

semantic_boundaries: bool = Field(default=False, ...)
contextualize: bool = Field(default=False, ...)

Then callers can opt in per request when they want LLM-based semantic chunking and/or contextualization.

Comment thread src/ingestion/parser.py
Comment on lines +21 to +27
def parse(
filename: str,
content: bytes,
llm: LLMClient | None = None,
semantic_boundaries: bool = True,
contextualize: bool = True,
) -> list[ParsedChunk]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please make the parser-level defaults false as well.

Even if the API schema defaults are changed, the lower-level parser functions should also default to legacy behavior. Otherwise, internal callers/tests can accidentally enable LLM chunking just by passing an LLM client.

Suggested default:

semantic_boundaries: bool = False
contextualize: bool = False

Comment on lines +50 to +56
return chunk_text_context_aware(
filename,
text,
llm,
semantic_boundaries=semantic_boundaries,
contextualize=contextualize,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same opt-in concern here: parse_text should default to legacy chunking.

The context-aware path should only run when semantic_boundaries or contextualize is true and an LLM client is available. With both flags false, this should call the existing chunk_text path exactly as before.

Comment thread src/ingestion/pdf_parser.py Outdated
Comment on lines +107 to +115
if llm is None
else chunk_text_context_aware(
filename,
text,
llm,
semantic_boundaries=semantic_boundaries,
contextualize=contextualize,
)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same opt-in concern here: parse_pdf should default to legacy chunking.

The context-aware path should only run when semantic_boundaries or contextualize is true and an LLM client is available. With both flags false, this should call the existing chunk_text path exactly as before.

Comment thread src/api/routes/ingest.py
Comment on lines +151 to +157
parsed_chunks = parse(
body.filename,
content_bytes,
llm=llm,
semantic_boundaries=body.semantic_boundaries,
contextualize=body.contextualize,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Consider only passing the LLM into parse() when one of the context-aware features is enabled.

This makes it much harder for default ingest to accidentally call the LLM and makes the “existing behavior unchanged” guarantee easier to test.

Suggested change
parsed_chunks = parse(
body.filename,
content_bytes,
llm=llm,
semantic_boundaries=body.semantic_boundaries,
contextualize=body.contextualize,
)
#Suggested pattern:
use_context_aware_chunking = body.semantic_boundaries or body.contextualize
parsed_chunks = parse(
body.filename,
content_bytes,
llm=llm if use_context_aware_chunking else None,
semantic_boundaries=body.semantic_boundaries,
contextualize=body.contextualize,
)

@@ -0,0 +1,445 @@
"""LLM-based context-aware chunking strategy for text content.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please hard-cap context block length before prepending it.

The acceptance criteria say chunk size constraints must still be respected and the prepended context block counts toward the budget. If the LLM returns a long context block, duplicating it during hard-splitting can exceed CHUNK_SIZE or leave very little room for source text.

Suggested rule:

  • cap context blocks to a small maximum, e.g. min(300, chunk_size // 4)
  • subtract the context length from the available source-text budget
  • assert/fallback if the final chunk would exceed chunk_size
Suggested change
def _trim_context_block(context_block: str, chunk_size: int) -> str:
max_context_chars = min(300, max(0, chunk_size // 4))
return context_block.strip()[:max_context_chars].rstrip()

return results


def chunk_text_context_aware(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please validate LLM-provided split points strictly before using them.

The LLM can return duplicate, unsorted, missing, or out-of-range paragraph indices. Those should not produce empty chunks or dropped text. If validation fails, we should fall back to chunk_text.

Suggested validation:

  • boundaries are integers
  • sorted ascending
  • unique
  • first boundary is 0
  • final boundary reaches the end, or the implementation appends the end safely
  • all boundaries are within paragraph range
  • no empty chunk ranges

@LinseCed LinseCed 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.

Verification

Checked the branch out locally: full suite passes (266 passed; the 3 failures in tests/llm/test_ollama_client.py are pre-existing Ollama-connectivity tests untouched by this PR), ruff check + ruff format --check clean, pyright src/ reports 0 errors.

What's good

  • The fallback design is solid: size ceiling, unreachable LLM, and malformed/invalid output all degrade to chunk_text — no request can fail because of this feature, and each fallback is logged.
  • Prompting over small segment indices instead of character offsets is the right call, and the docstring explains why.
  • The chunk_text refactor into split_into_paragraphs / group_paragraphs_into_chunks is clean and behavior-preserving.
  • Boundary validation (sorted, deduped, in-range, starts at 0) is thorough; good test coverage including the fallback paths, hard-split, and API flag handling; README and .env.example updated.

Should fix before merge

  1. Unvalidated context-block length can cause a chunk explosion. _Payload.context_blocks values are never length-checked. In _hard_split_with_context, if len(context_block) >= chunk_size (default 512), body_budget clamps to 1 and body_overlap to 0 — the loop then emits one sub-chunk per character of body, each of which still exceeds chunk_size. A verbose (or prompt-injected — the document content is attacker-influenceable input to this LLM call) response with a long context block on a ~24k-char text produces tens of thousands of chunks, each individually embedded and stored. Validate context block length in _request_chunking_plan (e.g. reject blocks over some fraction of chunk_sizeContextAwareChunkingError → fallback), or truncate.

  2. Empty boundaries in semantic mode silently degrades below the fallback. The prompt requires boundaries to start at 0, but an empty list passes validation, and boundaries = payload.boundaries or [0] then turns the whole document into a single chunk — which, if oversized, gets hard-split at raw character offsets (mid-word, no paragraph awareness). That's strictly worse than the chunk_text fallback the module promises. When semantic_boundaries=True, treat empty boundaries as invalid output and fall back.

Worth considering

  1. "Opt-in" vs default-on. The PR text and module docstring call this opt-in, but both flags default to true — every existing /api/v1/ingest caller silently gains one LLM generate call per text artifact and one per PDF page (a 100-page PDF = 100 sequential LLM calls in a sync route). With a paid backend (LLM_BACKEND=anthropic/openai) this is also a silent cost change. Worth an explicit team decision: either default false, or keep true and drop the "opt-in" wording.

  2. Oversized semantic chunks are hard-split at character level. When the LLM groups paragraphs into a chunk larger than chunk_size, it goes straight to character-window splitting. Re-running group_paragraphs_into_chunks on just that chunk's paragraphs would preserve paragraph boundaries and reuse existing logic.

  3. Metadata inconsistencies (minor): the legacy path's paragraph-overlap chunks still report has_overlap=false (only _hard_split_with_context sets it), and context_block_range includes the trailing \n\n separator. Both fine if intentional — worth a doc note.

  4. Scope creep: AGENTS.md (94 lines of agent-tooling docs) and the opencode.json gitignore entry are unrelated to this feature. Fine content, but they'd be cleaner as a separate chore PR.

Verdict

Solid, well-tested feature with genuinely robust fallback behavior. Items 1 and 2 are cheap validation additions in _request_chunking_plan that close real degradation paths; item 3 needs a deliberate decision before merge since it changes default behavior for existing clients.

@DaniloTatti
DaniloTatti merged commit 318fb7c into dev Jul 7, 2026
4 checks passed
@LinseCed
LinseCed deleted the feature/102-context-aware-chunking-via-llm branch July 14, 2026 00:00
@LinseCed
LinseCed restored the feature/102-context-aware-chunking-via-llm branch July 14, 2026 00:00
@LinseCed
LinseCed deleted the feature/102-context-aware-chunking-via-llm branch July 14, 2026 00:01
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.

3 participants