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
|
```bash
|
||||||
# Chunk size for document processing
|
# Chunk size for document processing
|
||||||
CHUNK_SIZE=256
|
CHUNK_SIZE=256
|
||||||
|
|
||||||
# Chunk overlap for better context
|
|
||||||
CHUNK_OVERLAP=32
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
|
from io import BytesIO
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
import tiktoken
|
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
|
from haiku.rag.config import Config
|
||||||
|
|
||||||
|
|
@ -8,9 +13,11 @@ from haiku.rag.config import Config
|
||||||
class Chunker:
|
class Chunker:
|
||||||
"""A class that chunks text into smaller pieces for embedding and retrieval.
|
"""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:
|
Args:
|
||||||
chunk_size: The maximum size of a chunk in tokens.
|
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")
|
encoder: ClassVar[tiktoken.Encoding] = tiktoken.encoding_for_model("gpt-4o")
|
||||||
|
|
@ -18,50 +25,36 @@ class Chunker:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
chunk_size: int = Config.CHUNK_SIZE,
|
chunk_size: int = Config.CHUNK_SIZE,
|
||||||
chunk_overlap: int = Config.CHUNK_OVERLAP,
|
|
||||||
):
|
):
|
||||||
self.chunk_size = chunk_size
|
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]:
|
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:
|
Args:
|
||||||
text: The text to be split into chunks.
|
text: The text to be split into chunks.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A list of text chunks with token-based boundaries and overlap.
|
A list of text chunks with semantic boundaries.
|
||||||
"""
|
"""
|
||||||
if not text:
|
if not text:
|
||||||
return []
|
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):
|
# Chunk using docling's hybrid chunker
|
||||||
return [text]
|
chunks = list(self.chunker.chunk(doc))
|
||||||
|
return [self.chunker.contextualize(chunk) for chunk in chunks]
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
chunker = Chunker()
|
chunker = Chunker()
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ class AppConfig(BaseModel):
|
||||||
QA_MODEL: str = "qwen3"
|
QA_MODEL: str = "qwen3"
|
||||||
|
|
||||||
CHUNK_SIZE: int = 256
|
CHUNK_SIZE: int = 256
|
||||||
CHUNK_OVERLAP: int = 32
|
|
||||||
|
|
||||||
OLLAMA_BASE_URL: str = "http://localhost:11434"
|
OLLAMA_BASE_URL: str = "http://localhost:11434"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ class FileReader:
|
||||||
".jpeg",
|
".jpeg",
|
||||||
".jpg",
|
".jpg",
|
||||||
".md",
|
".md",
|
||||||
".pdf.png",
|
".pdf",
|
||||||
|
".png",
|
||||||
".pptx",
|
".pptx",
|
||||||
".tiff",
|
".tiff",
|
||||||
".xlsx",
|
".xlsx",
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,6 @@ class SettingsRepository:
|
||||||
"EMBEDDINGS_MODEL",
|
"EMBEDDINGS_MODEL",
|
||||||
"EMBEDDINGS_VECTOR_DIM",
|
"EMBEDDINGS_VECTOR_DIM",
|
||||||
"CHUNK_SIZE",
|
"CHUNK_SIZE",
|
||||||
"CHUNK_OVERLAP",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
|
|
|
||||||
|
|
@ -13,32 +13,22 @@ async def test_chunker(qa_corpus: Dataset):
|
||||||
# Ensure that the text is split into multiple chunks
|
# Ensure that the text is split into multiple chunks
|
||||||
assert len(chunks) > 1
|
assert len(chunks) > 1
|
||||||
|
|
||||||
# Ensure that each chunk corresponds to roughly Config.CHUNK_SIZE tokens
|
# Ensure that chunks are reasonably sized (allowing more flexibility for structure-aware chunking)
|
||||||
for chunk in chunks[:-1]:
|
total_tokens = 0
|
||||||
|
for chunk in chunks:
|
||||||
encoded_tokens = Chunker.encoder.encode(chunk, disallowed_special=())
|
encoded_tokens = Chunker.encoder.encode(chunk, disallowed_special=())
|
||||||
assert len(encoded_tokens) <= Chunker().chunk_size
|
token_count = len(encoded_tokens)
|
||||||
assert len(encoded_tokens) > Chunker().chunk_size * 0.9
|
total_tokens += token_count
|
||||||
|
|
||||||
# Ensure that the last chunk is less than Config.CHUNK_SIZE tokens
|
# Each chunk should be reasonably sized (allowing more flexibility than the old strict limits)
|
||||||
assert (
|
assert (
|
||||||
len(Chunker.encoder.encode(chunks[-1], disallowed_special=()))
|
token_count <= chunker.chunk_size * 1.2
|
||||||
< Chunker().chunk_size
|
) # Allow some flexibility for semantic boundaries
|
||||||
)
|
assert token_count > 5 # Ensure chunks aren't too small
|
||||||
|
|
||||||
# Test overlap between consecutive chunks
|
# Ensure that all chunks together contain roughly the same content as original
|
||||||
for i in range(len(chunks) - 1):
|
original_tokens = len(Chunker.encoder.encode(doc, disallowed_special=()))
|
||||||
current_chunk = chunks[i]
|
|
||||||
next_chunk = chunks[i + 1]
|
|
||||||
|
|
||||||
current_tokens = Chunker.encoder.encode(current_chunk, disallowed_special=())
|
# Due to structure-aware chunking, we might have some variation in token count
|
||||||
next_tokens = Chunker.encoder.encode(next_chunk, disallowed_special=())
|
# but it should be reasonable
|
||||||
|
assert abs(total_tokens - original_tokens) <= original_tokens * 0.1
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
|
||||||
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)
|
created_document = await doc_repo.create(document)
|
||||||
documents.append((created_document, doc_data))
|
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]
|
target_document, doc_data = documents[i]
|
||||||
question = doc_data["question"]
|
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}
|
target_document_ids = {chunk.document_id for chunk, _ in fts_results}
|
||||||
assert target_document.id in target_document_ids
|
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
|
# Test hybrid search
|
||||||
hybrid_results = await chunk_repo.search_chunks_hybrid(question, limit=5)
|
hybrid_results = await chunk_repo.search_chunks_hybrid(question, limit=5)
|
||||||
target_document_ids = {chunk.document_id for chunk, _ in hybrid_results}
|
target_document_ids = {chunk.document_id for chunk, _ in hybrid_results}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue