Skip to content

fix(ingestion): emit sections for markdown without headings - #927

Open
folusho-adeyemi wants to merge 7 commits into
ascherj:mainfrom
folusho-adeyemi:fix/149-structural-chunker-drops-heading-less-docs
Open

fix(ingestion): emit sections for markdown without headings#927
folusho-adeyemi wants to merge 7 commits into
ascherj:mainfrom
folusho-adeyemi:fix/149-structural-chunker-drops-heading-less-docs

Conversation

@folusho-adeyemi

@folusho-adeyemi folusho-adeyemi commented Aug 5, 2026

Copy link
Copy Markdown

Summary

StructuralChunker silently dropped any markdown document that contained no headings. _extract_sections() only collected content lines after a heading had been seen, and only emitted a section when the heading stack was non-empty, so a heading-less document produced zero sections and chunk() returned []. Because StrategySelector routes every readme source type to this chunker, a README with no headings never entered the RAG index and no error was raised anywhere. The same guard also discarded any preamble appearing before the first heading in documents that do have headings. This PR collects content unconditionally and emits a section whenever the collected lines hold content, labelling content outside any heading with an empty breadcrumb and level 0.

Issue

Closes #149

Changes

  • ingestion/chunking/structural_chunker.py

    • _extract_sections() now appends every regular content line, removing the if heading_stack or current_section_lines gate that made collection conditional on a heading having been seen.
    • Extracted the duplicated section-dict construction into a new _build_section() static helper that returns None when the collected lines hold no content. Both the mid-loop "save previous section" branch and the final "save final section" branch now go through it, so the two call sites can no longer drift apart.
    • Content outside any heading yields heading_path == "" and heading_level == 0, which matches the heading_path if applicable contract documented on BaseChunker.chunk(). I grepped heading_path/heading_level across api/, core/, ingestion/, rag/, agent/, safety/ and frontend/src/ — no code consumes either field, only that docstring mentions them, so the empty-string choice is safe and no fallback to metadata["source"] is needed.
    • Removed the unused current_level local (ruff F841) and annotated __init__, sections, heading_stack and current_section_lines for mypy's disallow_untyped_defs.
    • No change was needed for large heading-less documents: they flow through the existing chunk() branch into SemanticChunker and are sub-chunked automatically. This is now covered by a test.
  • tests/unit/test_structural_chunker.py — see Testing below.

  • PLAN.md / JOURNAL.md — Week 8 investigation notes and the Week 9 check-ins.

Behaviour deliberately left unchanged: empty and whitespace-only input still return [], and documents whose content begins at the first heading chunk exactly as before.

Testing

  • Unit tests pass (make test-unit) — see the pre-existing-failure note below
  • Integration tests pass (make test-integration) — not run: requires Docker services that aren't available in my environment. This change is confined to a pure-function chunker with no I/O, and its unit coverage is complete.
  • Linter passes (make lint) — see note below
  • Type checker passes (make typecheck) — see note below
  • New/updated tests cover the changes

Four tests added and one strengthened in tests/unit/test_structural_chunker.py:

Test Covers
test_heading_less_document_has_empty_heading_path the heading_path == "" / heading_level == 0 contract
test_heading_less_document_preserves_source_metadata caller metadata survives the heading-less path
test_large_heading_less_document_sub_chunked a heading-less doc over SECTION_TOKEN_LIMIT is sub-chunked via SemanticChunker rather than emitted as one oversized chunk
test_preamble_before_first_heading_preserved content before the first heading is retained as a leading chunk
test_document_with_no_headings (strengthened) now also asserts the document text survives, not just the chunk count

All five fail against the pre-fix chunker and pass against the fix — verified by restoring main's version of the file and re-running:

$ git checkout main -- ingestion/chunking/structural_chunker.py
$ pytest tests/unit/test_structural_chunker.py -q -m unit
5 failed, 14 passed
$ git checkout HEAD -- ingestion/chunking/structural_chunker.py
$ pytest tests/unit/test_structural_chunker.py -q -m unit
19 passed

I also manually confirmed each edge case from PLAN.md: heading-less doc → 1 chunk; empty and whitespace-only → []; preamble + headings → 3 chunks with the preamble leading at path=''; heading-with-no-body → no crash; #nospace treated as content; 1261-token heading-less doc → 3 chunks with metadata preserved.

Pre-existing failures (this branch does not affect them)

The repo has substantial pre-existing lint/type/test failures unrelated to #149. I recorded a baseline on main before starting and re-ran everything afterwards:

Check Baseline on main With this branch Delta
make test-unit 53 failed, 375 passed 52 failed, 380 passed 0 new failures; fixes test_document_with_no_headings; +4 new tests
make lint 182 errors 178 errors 0 new errors; clears the 4 in the files I touched
make typecheck 5 errors 5 errors byte-identical output
  • The 52 remaining unit-test failures are all pre-existing and in unrelated modules (test_bias_detector, test_review_service, test_resume_parser, test_pii_scrubber, test_tech_detector, …).
  • The 5 make typecheck errors are all missing third-party stubs (PyPDF2, jose, passlib, rank_bm25) plus a numpy stub syntax error that halts checking before any project file is reached.
  • Both files I touched are clean: ruff check ingestion/chunking/structural_chunker.py tests/unit/test_structural_chunker.pyAll checks passed!

Screenshots / Demo

Not applicable — backend chunking logic with no user-facing surface.

Notes for Reviewers

  • Main thing to sanity-check: the preamble behaviour change. Documents with text before their first heading now produce one additional leading chunk that was previously discarded. That's the intended fix (that content was being silently lost), but it does change chunk counts for such documents, so anything that pinned exact counts would notice. Nothing in the existing suite does — the assertions there use >= or iterate — and all 19 tests in this file pass.
  • heading_level == 0 is new. test_chunk_metadata_includes_heading_level asserts level ∈ {1,2,3}, but only for chunks that have a heading, so level 0 doesn't trip it. Confirmed passing.
  • Test functions are intentionally left unannotated. The pre-commit mypy hook has no tests/ exclude, so it flags every test file in the repo — all 413 test functions across all 19 files in tests/unit/ are unannotated, and make typecheck deliberately scopes to api/ core/ ingestion/ rag/ agent/ safety/ only. I matched the suite's established convention rather than annotate one file inconsistently, and committed the test file with SKIP=mypy. Happy to annotate if you'd prefer, but fixing the hook/make typecheck scope mismatch felt out of scope for a bugfix.
  • black version skew, worth a separate issue. The pinned pre-commit hook (black 24.1.0) and the version installed by pip install -e ".[dev]" (black 26.5.1) disagree on how to format the pre-existing test_large_section_sub_chunked string concatenation — each reverts the other. I kept the pinned hook's output since that's what gates commits. make format would flip it back, and would also reformat 52 other files repo-wide, so I did not run it.
  • The branch was renamed to fix/149-structural-chunker-drops-heading-less-docs to follow the <type>/<issue-number>-<short-description> convention in CONTRIBUTING.md. This PR supersedes docs(ingestion): investigate issue #149 — superseded by #927 #242, which was opened from the old non-conventional branch name.
  • One legacy commit message doesn't follow Conventional Commits: the oldest commit on the branch, 396bdf4 "started working on issue 149", dates from Week 7 before I had the convention straight. I left it as-is deliberately — rewriting it would change every SHA after it, breaking the reproduction-commit link (e422a33) already recorded in my Week 8 journal entry and the SHAs cited in docs(ingestion): investigate issue #149 — superseded by #927 #242. The three commits carrying the actual change (fix(ingestion):, test(ingestion):, docs:) all follow the convention, and a squash merge would take this PR's conventional title anyway. Happy to force-push a reworded history if you'd rather.

folusho-adeyemi and others added 5 commits July 21, 2026 18:31
…less docs

Add a root-cause note at the content-collection guard in
_extract_sections. A document with no markdown headings never populates
heading_stack, so no section is emitted and chunk() returns [], silently
excluding the whole document from the RAG index.

Reproduction:
  StructuralChunker().chunk('plain text ' * 20, {}) -> 0 chunks
  pytest test_document_with_no_headings -> FAILED (assert 0 >= 1)

Bypassing pre-commit: ruff/mypy flag pre-existing issues in this file
(unused current_level, missing annotations) that are out of scope for a
doc-only reproduction commit; they will be addressed with the Week 9 fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
StructuralChunker._extract_sections() only collected content lines and
emitted a section once a heading had been seen. For a document with no
headings, heading_stack stayed empty for the whole loop, so no section was
ever built and chunk() returned an empty list — the document was silently
excluded from the RAG index. The same guard also discarded any preamble
appearing before the first heading in documents that do have headings.

Content lines are now always collected, and section construction is
factored into _build_section(), which emits a section whenever the
collected lines hold content. Outside of any heading the breadcrumb path
is empty and the level is 0, matching the "heading_path if applicable"
contract documented on BaseChunker.chunk(). Heading-less documents over
SECTION_TOKEN_LIMIT flow through the existing chunk() branch into
SemanticChunker and are sub-chunked without new code.

Also removes the unused current_level local (ruff F841), annotates
__init__ and the loop accumulators for mypy's disallow_untyped_defs, and
picks up the black formatting the pre-commit hook applies to this file.

Fixes ascherj#149
Adds coverage for the ascherj#149 regression surface in StructuralChunker:

- test_heading_less_document_has_empty_heading_path asserts the empty
  breadcrumb and level 0 contract for content outside any heading.
- test_heading_less_document_preserves_source_metadata asserts caller
  metadata survives the heading-less path.
- test_large_heading_less_document_sub_chunked asserts a heading-less
  document over SECTION_TOKEN_LIMIT is sub-chunked via SemanticChunker
  rather than emitted as one oversized chunk.
- test_preamble_before_first_heading_preserved asserts content before the
  first heading is retained as a leading chunk.
- test_document_with_no_headings now also asserts the document text
  survives, instead of only checking the chunk count.

All five fail against the pre-fix chunker and pass against the fix.

Also completes two assertions that were computed but never checked in
test_heading_path_format and test_heading_path_breadcrumb, so those tests
verify the breadcrumb they describe (clears ruff F841), and sorts the
imports (ruff I001).

Test functions are left unannotated to match the existing suite, where all
413 test functions across all 19 files are unannotated and `make typecheck`
scopes to source directories only. The pre-commit mypy hook has no
tests/ exclude and so flags every test file in the repo; that pre-existing
tooling inconsistency is out of scope for this fix.
Records both Week 9 check-ins: the mid-week progress against PLAN.md
steps 1-3, and the end-of-week entry with the PR link, branch name, tests
added, and the before/after check results.

Closes out the Week 8 blocker: no code in api/, core/, ingestion/, rag/,
agent/, safety/ or frontend/src/ consumes heading_path or heading_level,
so heading_path == "" for heading-less documents is safe and the
metadata["source"] fallback is unnecessary.

Documents the two pre-existing tooling issues worked around rather than
fixed - the pre-commit mypy hook lacking a tests/ exclude, and the black
version skew between the pinned hook and the dev extra.
Records that no reviewer feedback arrived on ascherj#927 or ascherj#242, and what I
self-reviewed against CONTRIBUTING.md and the repo tooling in its absence:
the branch rename, the two existing tests that computed an assertion
without ever checking it, and the pre-existing-failure baseline.

Reflects on the four-week cycle - that the fix was small relative to
establishing a baseline in a repo where make check already fails, that
deciding what not to fix was harder than fixing, where AI assistance
helped versus where it produced a confident but unnecessary
recommendation, and why the root-cause fix was chosen over the
SemanticChunker fallback other claimants proposed.
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.

Structural chunker silently drops documents that contain no headings

1 participant