Merge pull request #27 from ggozad/feat/docling-chunking
Use docling for chunking
This commit is contained in:
commit
2cdee6608d
8 changed files with 68 additions and 63 deletions
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ class FileReader:
|
|||
".jpeg",
|
||||
".jpg",
|
||||
".md",
|
||||
".pdf.png",
|
||||
".pdf",
|
||||
".png",
|
||||
".pptx",
|
||||
".tiff",
|
||||
".xlsx",
|
||||
|
|
|
|||
|
|
@ -63,7 +63,6 @@ class SettingsRepository:
|
|||
"EMBEDDINGS_MODEL",
|
||||
"EMBEDDINGS_VECTOR_DIM",
|
||||
"CHUNK_SIZE",
|
||||
"CHUNK_OVERLAP",
|
||||
]
|
||||
|
||||
errors = []
|
||||
|
|
|
|||
|
|
@ -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
22
tests/test_reader.py
Normal 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
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue