From 8fe8b6d291b0c03e1f5302e0488aa860c225048e Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 22 Jun 2026 13:37:22 -0400 Subject: [PATCH 1/6] Thread chunker.chunk() off the asyncio event loop Extracts the CPU-bound HybridChunker/HierarchicalChunker work into a sync helper and wraps it with asyncio.to_thread so large documents don't block the event loop during chunking. Fixes #455. --- .../haiku/rag/chunkers/docling_local.py | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/chunkers/docling_local.py b/haiku_rag_slim/haiku/rag/chunkers/docling_local.py index fc07bb22..42bfaffe 100644 --- a/haiku_rag_slim/haiku/rag/chunkers/docling_local.py +++ b/haiku_rag_slim/haiku/rag/chunkers/docling_local.py @@ -1,3 +1,4 @@ +import asyncio from functools import cache from typing import TYPE_CHECKING, cast @@ -105,24 +106,12 @@ class DoclingLocalChunker(DocumentChunker): "Must be 'hybrid' or 'hierarchical'." ) - async def chunk(self, document: "DoclingDocument") -> list[Chunk]: - """Split the document into chunks with metadata. + def _chunk_sync(self, document: "DoclingDocument") -> list[Chunk]: + """Synchronous chunking helper (CPU-bound, no I/O). - Extracts structured metadata from each DocChunk including: - - doc_item_refs: JSON pointer references to DocItems (e.g., "#/texts/5") - - headings: Section heading hierarchy - - labels: Semantic labels for each doc_item (e.g., "paragraph", "table") - - page_numbers: Page numbers where content appears - - Args: - document: The DoclingDocument to be split into chunks. - - Returns: - List of Chunk containing content and structured metadata. + Runs the underlying HybridChunker/HierarchicalChunker and extracts + structured metadata from each DocChunk. """ - if document is None: - return [] - raw_chunks = list(self.chunker.chunk(document)) result: list[Chunk] = [] @@ -172,3 +161,23 @@ class DoclingLocalChunker(DocumentChunker): ) return result + + async def chunk(self, document: "DoclingDocument") -> list[Chunk]: + """Split the document into chunks with metadata. + + Extracts structured metadata from each DocChunk including: + - doc_item_refs: JSON pointer references to DocItems (e.g., "#/texts/5") + - headings: Section heading hierarchy + - labels: Semantic labels for each doc_item (e.g., "paragraph", "table") + - page_numbers: Page numbers where content appears + + Args: + document: The DoclingDocument to be split into chunks. + + Returns: + List of Chunk containing content and structured metadata. + """ + if document is None: + return [] + + return await asyncio.to_thread(self._chunk_sync, document) From 256fd66b46eeb849936ccbace1662fc63f917b35 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 22 Jun 2026 13:58:37 -0400 Subject: [PATCH 2/6] Add test for chunker.chunk(None) to cover guard clause --- tests/test_chunker.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_chunker.py b/tests/test_chunker.py index e59681a0..39003d0a 100644 --- a/tests/test_chunker.py +++ b/tests/test_chunker.py @@ -55,6 +55,13 @@ async def test_local_chunker(qa_corpus: list[dict[str, str]]): assert abs(total_tokens - original_tokens) <= original_tokens * 0.1 +@pytest.mark.asyncio +async def test_local_chunker_none_document(): + """Test DoclingLocalChunker returns empty list for None document.""" + chunker = DoclingLocalChunker() + assert await chunker.chunk(None) == [] + + @pytest.mark.asyncio async def test_local_chunker_custom_config(): """Test DoclingLocalChunker with custom configuration.""" From 25b0a155b0f563d46e5d85b30f1abb3e21eaed3a Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 22 Jun 2026 14:04:58 -0400 Subject: [PATCH 3/6] =?UTF-8?q?Remove=20None=20guard=20and=20test=20?= =?UTF-8?q?=E2=80=94=20type=20system=20guarantees=20document=20is=20not=20?= =?UTF-8?q?None?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- haiku_rag_slim/haiku/rag/chunkers/docling_local.py | 3 --- tests/test_chunker.py | 7 ------- 2 files changed, 10 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/chunkers/docling_local.py b/haiku_rag_slim/haiku/rag/chunkers/docling_local.py index 42bfaffe..9ab36b88 100644 --- a/haiku_rag_slim/haiku/rag/chunkers/docling_local.py +++ b/haiku_rag_slim/haiku/rag/chunkers/docling_local.py @@ -177,7 +177,4 @@ class DoclingLocalChunker(DocumentChunker): Returns: List of Chunk containing content and structured metadata. """ - if document is None: - return [] - return await asyncio.to_thread(self._chunk_sync, document) diff --git a/tests/test_chunker.py b/tests/test_chunker.py index 39003d0a..e59681a0 100644 --- a/tests/test_chunker.py +++ b/tests/test_chunker.py @@ -55,13 +55,6 @@ async def test_local_chunker(qa_corpus: list[dict[str, str]]): assert abs(total_tokens - original_tokens) <= original_tokens * 0.1 -@pytest.mark.asyncio -async def test_local_chunker_none_document(): - """Test DoclingLocalChunker returns empty list for None document.""" - chunker = DoclingLocalChunker() - assert await chunker.chunk(None) == [] - - @pytest.mark.asyncio async def test_local_chunker_custom_config(): """Test DoclingLocalChunker with custom configuration.""" From 99a200c9d2faf6794d974d7360537c4eeb99f96a Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 22 Jun 2026 14:05:56 -0400 Subject: [PATCH 4/6] Restore None guard, add thread-safety test for _chunk_sync --- .../haiku/rag/chunkers/docling_local.py | 3 ++ tests/test_chunker.py | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/haiku_rag_slim/haiku/rag/chunkers/docling_local.py b/haiku_rag_slim/haiku/rag/chunkers/docling_local.py index 9ab36b88..42bfaffe 100644 --- a/haiku_rag_slim/haiku/rag/chunkers/docling_local.py +++ b/haiku_rag_slim/haiku/rag/chunkers/docling_local.py @@ -177,4 +177,7 @@ class DoclingLocalChunker(DocumentChunker): Returns: List of Chunk containing content and structured metadata. """ + if document is None: + return [] + return await asyncio.to_thread(self._chunk_sync, document) diff --git a/tests/test_chunker.py b/tests/test_chunker.py index e59681a0..3debf339 100644 --- a/tests/test_chunker.py +++ b/tests/test_chunker.py @@ -55,6 +55,34 @@ async def test_local_chunker(qa_corpus: list[dict[str, str]]): assert abs(total_tokens - original_tokens) <= original_tokens * 0.1 +@pytest.mark.asyncio +async def test_local_chunker_runs_off_event_loop_thread(): + """Chunking is CPU-bound; verify it runs in a worker thread.""" + import threading + + chunker = DoclingLocalChunker() + event_loop_thread = threading.current_thread() + called_from: list[threading.Thread] = [] + + original = chunker._chunk_sync + + def recording_chunk_sync(document): + called_from.append(threading.current_thread()) + return original(document) + + chunker._chunk_sync = recording_chunk_sync + + converter = get_converter(Config) + doc = await converter.convert_text("# Hello\n\nWorld", name="test.md") + await chunker.chunk(doc) + + assert called_from, "_chunk_sync was never called" + assert called_from[0] is not event_loop_thread, ( + "_chunk_sync ran on the event-loop thread; " + "it must be dispatched via asyncio.to_thread" + ) + + @pytest.mark.asyncio async def test_local_chunker_custom_config(): """Test DoclingLocalChunker with custom configuration.""" From 21a8f52893ca9f99a81f6c31f74bcc5ef64d16c7 Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 22 Jun 2026 14:09:01 -0400 Subject: [PATCH 5/6] Use patch.object to satisfy type checker --- tests/test_chunker.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_chunker.py b/tests/test_chunker.py index 3debf339..192bc0f9 100644 --- a/tests/test_chunker.py +++ b/tests/test_chunker.py @@ -59,6 +59,7 @@ async def test_local_chunker(qa_corpus: list[dict[str, str]]): async def test_local_chunker_runs_off_event_loop_thread(): """Chunking is CPU-bound; verify it runs in a worker thread.""" import threading + from unittest.mock import patch chunker = DoclingLocalChunker() event_loop_thread = threading.current_thread() @@ -66,15 +67,15 @@ async def test_local_chunker_runs_off_event_loop_thread(): original = chunker._chunk_sync - def recording_chunk_sync(document): + def recording_chunk_sync(self, document): called_from.append(threading.current_thread()) return original(document) - chunker._chunk_sync = recording_chunk_sync - converter = get_converter(Config) doc = await converter.convert_text("# Hello\n\nWorld", name="test.md") - await chunker.chunk(doc) + + with patch.object(DoclingLocalChunker, "_chunk_sync", recording_chunk_sync): + await chunker.chunk(doc) assert called_from, "_chunk_sync was never called" assert called_from[0] is not event_loop_thread, ( From 633517bf048392cd2eae05e50ce477d84231380d Mon Sep 17 00:00:00 2001 From: Chris McDonough Date: Mon, 22 Jun 2026 14:24:12 -0400 Subject: [PATCH 6/6] Widen chunk() type to accept None, add test for None guard --- haiku_rag_slim/haiku/rag/chunkers/base.py | 2 +- haiku_rag_slim/haiku/rag/chunkers/docling_local.py | 2 +- haiku_rag_slim/haiku/rag/chunkers/docling_serve.py | 2 +- tests/test_chunker.py | 7 +++++++ 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/chunkers/base.py b/haiku_rag_slim/haiku/rag/chunkers/base.py index e1daf93b..96c9fc9e 100644 --- a/haiku_rag_slim/haiku/rag/chunkers/base.py +++ b/haiku_rag_slim/haiku/rag/chunkers/base.py @@ -15,7 +15,7 @@ class DocumentChunker(ABC): """ @abstractmethod - async def chunk(self, document: "DoclingDocument") -> list["Chunk"]: + async def chunk(self, document: "DoclingDocument | None") -> list["Chunk"]: """Split a document into chunks with metadata. Args: diff --git a/haiku_rag_slim/haiku/rag/chunkers/docling_local.py b/haiku_rag_slim/haiku/rag/chunkers/docling_local.py index 42bfaffe..85b7f690 100644 --- a/haiku_rag_slim/haiku/rag/chunkers/docling_local.py +++ b/haiku_rag_slim/haiku/rag/chunkers/docling_local.py @@ -162,7 +162,7 @@ class DoclingLocalChunker(DocumentChunker): return result - async def chunk(self, document: "DoclingDocument") -> list[Chunk]: + async def chunk(self, document: "DoclingDocument | None") -> list[Chunk]: """Split the document into chunks with metadata. Extracts structured metadata from each DocChunk including: diff --git a/haiku_rag_slim/haiku/rag/chunkers/docling_serve.py b/haiku_rag_slim/haiku/rag/chunkers/docling_serve.py index c0380134..1bfb7648 100644 --- a/haiku_rag_slim/haiku/rag/chunkers/docling_serve.py +++ b/haiku_rag_slim/haiku/rag/chunkers/docling_serve.py @@ -129,7 +129,7 @@ class DoclingServeChunker(DocumentChunker): return result.get("chunks", []) - async def chunk(self, document: "DoclingDocument") -> list[Chunk]: + async def chunk(self, document: "DoclingDocument | None") -> list[Chunk]: """Split the document into chunks with metadata via docling-serve. Extracts structured metadata from the API response including: diff --git a/tests/test_chunker.py b/tests/test_chunker.py index 192bc0f9..c1f1a833 100644 --- a/tests/test_chunker.py +++ b/tests/test_chunker.py @@ -55,6 +55,13 @@ async def test_local_chunker(qa_corpus: list[dict[str, str]]): assert abs(total_tokens - original_tokens) <= original_tokens * 0.1 +@pytest.mark.asyncio +async def test_local_chunker_none_document(): + """Test DoclingLocalChunker returns empty list for None document.""" + chunker = DoclingLocalChunker() + assert await chunker.chunk(None) == [] + + @pytest.mark.asyncio async def test_local_chunker_runs_off_event_loop_thread(): """Chunking is CPU-bound; verify it runs in a worker thread."""