Merge pull request #27 from ggozad/feat/docling-chunking

Use docling for chunking
This commit is contained in:
Yiorgis Gozadinos 2025-08-01 17:22:02 +02:00 committed by GitHub
commit 2cdee6608d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 68 additions and 63 deletions

View file

@ -150,7 +150,4 @@ DEFAULT_DATA_DIR="/path/to/data"
```bash
# Chunk size for document processing
CHUNK_SIZE=256
# Chunk overlap for better context
CHUNK_OVERLAP=32
```

View file

@ -1,6 +1,11 @@
from io import BytesIO
from typing import ClassVar
import tiktoken
from docling.chunking import HybridChunker # type: ignore
from docling.document_converter import DocumentConverter
from docling_core.transforms.chunker.tokenizer.openai import OpenAITokenizer
from docling_core.types.io import DocumentStream
from haiku.rag.config import Config
@ -8,9 +13,11 @@ from haiku.rag.config import Config
class Chunker:
"""A class that chunks text into smaller pieces for embedding and retrieval.
Uses docling's structure-aware chunking to create semantically meaningful chunks
that respect document boundaries.
Args:
chunk_size: The maximum size of a chunk in tokens.
chunk_overlap: The number of tokens of overlap between chunks.
"""
encoder: ClassVar[tiktoken.Encoding] = tiktoken.encoding_for_model("gpt-4o")
@ -18,50 +25,36 @@ class Chunker:
def __init__(
self,
chunk_size: int = Config.CHUNK_SIZE,
chunk_overlap: int = Config.CHUNK_OVERLAP,
):
self.chunk_size = chunk_size
self.chunk_overlap = chunk_overlap
tokenizer = OpenAITokenizer(
tokenizer=tiktoken.encoding_for_model("gpt-4o"), max_tokens=chunk_size
)
self.chunker = HybridChunker(tokenizer=tokenizer) # type: ignore
async def chunk(self, text: str) -> list[str]:
"""Split the text into chunks based on token boundaries.
"""Split the text into chunks using docling's structure-aware chunking.
Args:
text: The text to be split into chunks.
Returns:
A list of text chunks with token-based boundaries and overlap.
A list of text chunks with semantic boundaries.
"""
if not text:
return []
encoded_tokens = self.encoder.encode(text, disallowed_special=())
# Convert to docling document
bytes_io = BytesIO(text.encode("utf-8"))
doc_stream = DocumentStream(name="text.md", stream=bytes_io)
converter = DocumentConverter()
result = converter.convert(doc_stream)
doc = result.document
if self.chunk_size > len(encoded_tokens):
return [text]
chunks = []
i = 0
split_id_counter = 0
while i < len(encoded_tokens):
# Overlap
start_i = i
end_i = min(i + self.chunk_size, len(encoded_tokens))
chunk_tokens = encoded_tokens[start_i:end_i]
chunk_text = self.encoder.decode(chunk_tokens)
chunks.append(chunk_text)
split_id_counter += 1
# Exit loop if this was the last possible chunk
if end_i == len(encoded_tokens):
break
i += (
self.chunk_size - self.chunk_overlap
) # Step forward, considering overlap
return chunks
# Chunk using docling's hybrid chunker
chunks = list(self.chunker.chunk(doc))
return [self.chunker.contextualize(chunk) for chunk in chunks]
chunker = Chunker()

View file

@ -27,7 +27,6 @@ class AppConfig(BaseModel):
QA_MODEL: str = "qwen3"
CHUNK_SIZE: int = 256
CHUNK_OVERLAP: int = 32
OLLAMA_BASE_URL: str = "http://localhost:11434"

View file

@ -16,7 +16,8 @@ class FileReader:
".jpeg",
".jpg",
".md",
".pdf.png",
".pdf",
".png",
".pptx",
".tiff",
".xlsx",

View file

@ -63,7 +63,6 @@ class SettingsRepository:
"EMBEDDINGS_MODEL",
"EMBEDDINGS_VECTOR_DIM",
"CHUNK_SIZE",
"CHUNK_OVERLAP",
]
errors = []

View file

@ -13,32 +13,22 @@ async def test_chunker(qa_corpus: Dataset):
# Ensure that the text is split into multiple chunks
assert len(chunks) > 1
# Ensure that each chunk corresponds to roughly Config.CHUNK_SIZE tokens
for chunk in chunks[:-1]:
# Ensure that chunks are reasonably sized (allowing more flexibility for structure-aware chunking)
total_tokens = 0
for chunk in chunks:
encoded_tokens = Chunker.encoder.encode(chunk, disallowed_special=())
assert len(encoded_tokens) <= Chunker().chunk_size
assert len(encoded_tokens) > Chunker().chunk_size * 0.9
token_count = len(encoded_tokens)
total_tokens += token_count
# Ensure that the last chunk is less than Config.CHUNK_SIZE tokens
assert (
len(Chunker.encoder.encode(chunks[-1], disallowed_special=()))
< Chunker().chunk_size
)
# Each chunk should be reasonably sized (allowing more flexibility than the old strict limits)
assert (
token_count <= chunker.chunk_size * 1.2
) # Allow some flexibility for semantic boundaries
assert token_count > 5 # Ensure chunks aren't too small
# Test overlap between consecutive chunks
for i in range(len(chunks) - 1):
current_chunk = chunks[i]
next_chunk = chunks[i + 1]
# Ensure that all chunks together contain roughly the same content as original
original_tokens = len(Chunker.encoder.encode(doc, disallowed_special=()))
current_tokens = Chunker.encoder.encode(current_chunk, disallowed_special=())
next_tokens = Chunker.encoder.encode(next_chunk, disallowed_special=())
overlap_size = min(chunker.chunk_overlap, len(current_tokens))
current_overlap_tokens = current_tokens[-overlap_size:]
next_overlap_tokens = next_tokens[:overlap_size]
# The overlapping tokens should be identical
assert current_overlap_tokens == next_overlap_tokens
assert len(current_overlap_tokens) == min(
chunker.chunk_overlap, len(current_tokens)
)
# Due to structure-aware chunking, we might have some variation in token count
# but it should be reasonable
assert abs(total_tokens - original_tokens) <= original_tokens * 0.1

22
tests/test_reader.py Normal file
View file

@ -0,0 +1,22 @@
import tempfile
from pathlib import Path
from haiku.rag.reader import FileReader
def test_code_file_wrapped_in_code_block():
"""Test that code files are wrapped in markdown code blocks."""
python_code = '''def hello_world():
print("Hello, World!")
return "success"'''
with tempfile.NamedTemporaryFile(mode="w", suffix=".py") as f:
f.write(python_code)
f.flush()
temp_path = Path(f.name)
result = FileReader.parse_file(temp_path)
assert result.startswith("```python\n")
assert result.endswith("\n```")
assert "def hello_world():" in result

View file

@ -36,7 +36,7 @@ async def test_search_qa_corpus(qa_corpus: Dataset):
created_document = await doc_repo.create(document)
documents.append((created_document, doc_data))
for i in range(num_documents): # Test with first few documents
for i in range(5): # Test with first few documents
target_document, doc_data = documents[i]
question = doc_data["question"]
@ -50,6 +50,10 @@ async def test_search_qa_corpus(qa_corpus: Dataset):
target_document_ids = {chunk.document_id for chunk, _ in fts_results}
assert target_document.id in target_document_ids
for i in range(num_documents): # Test with first few documents
target_document, doc_data = documents[i]
question = doc_data["question"]
# Test hybrid search
hybrid_results = await chunk_repo.search_chunks_hybrid(question, limit=5)
target_document_ids = {chunk.document_id for chunk, _ in hybrid_results}