|
1 | 1 | import sqlite3 |
| 2 | +from typing import List |
2 | 3 |
|
3 | 4 | from models.chunk import Chunk |
| 5 | +from settings import Settings |
| 6 | +import math |
4 | 7 |
|
5 | 8 |
|
6 | 9 | class Chunker: |
7 | | - def __init__(self, conn: sqlite3.Connection, settings): |
| 10 | + def __init__(self, conn: sqlite3.Connection, settings: Settings): |
8 | 11 | self._conn = conn |
9 | 12 | self.settings = settings |
10 | 13 |
|
11 | 14 | def chunk(self, text: str) -> list[Chunk]: |
12 | | - # Implement chunking logic here |
13 | | - return [] |
| 15 | + """Chunk text using Recursive Character Text Splitter.""" |
| 16 | + if self._get_token_count(text) <= self.settings.chunk_size: |
| 17 | + return [Chunk(content=text)] |
| 18 | + |
| 19 | + return self._recursive_split(text) |
| 20 | + |
| 21 | + def _get_token_count(self, text: str) -> int: |
| 22 | + """Get token count using SQLite AI extension.""" |
| 23 | + if text == "": |
| 24 | + return 0 |
| 25 | + cursor = self._conn.execute("SELECT llm_token_count(?)", (text,)) |
| 26 | + return cursor.fetchone()[0] |
| 27 | + |
| 28 | + def _estimate_tokens_count(self, text: str) -> int: |
| 29 | + """Estimate token count more conservatively.""" |
| 30 | + # This is a simple heuristic; adjust as needed |
| 31 | + return (len(text) + 3) // 4 |
| 32 | + |
| 33 | + def _recursive_split(self, text: str) -> List[Chunk]: |
| 34 | + """Recursively split text into chunks with overlap.""" |
| 35 | + separators = [ |
| 36 | + "\n\n", # Double newlines (paragraphs) |
| 37 | + "\n", # Single newlines |
| 38 | + " ", # Spaces |
| 39 | + " ", |
| 40 | + ".", |
| 41 | + ",", |
| 42 | + "\u200b", # Zero-width space |
| 43 | + "\uff0c", # Fullwidth comma |
| 44 | + "\u3001", # Ideographic comma |
| 45 | + "\uff0e", # Fullwidth full stop |
| 46 | + "\u3002", # Ideographic full stop |
| 47 | + "", # Character level (fallback) |
| 48 | + ] |
| 49 | + |
| 50 | + return self._split_text_with_separators(text, separators) |
| 51 | + |
| 52 | + def _split_text_with_separators( |
| 53 | + self, text: str, separators: List[str] |
| 54 | + ) -> List[Chunk]: |
| 55 | + """Split text using hierarchical separators.""" |
| 56 | + chunks = [] |
| 57 | + |
| 58 | + if self.settings.chunk_size <= self.settings.chunk_overlap: |
| 59 | + raise ValueError("Chunk size must be greater than chunk overlap.") |
| 60 | + |
| 61 | + if not separators: |
| 62 | + # Fallback: character-level splitting |
| 63 | + return self._split_by_characters(text) |
| 64 | + |
| 65 | + separator = separators[0] |
| 66 | + remaining_separators = separators[1:] |
| 67 | + |
| 68 | + if separator == "": |
| 69 | + return self._split_by_characters(text) |
| 70 | + |
| 71 | + # Reserve space for overlap |
| 72 | + effective_chunk_size = max( |
| 73 | + 1, self.settings.chunk_size - self.settings.chunk_overlap |
| 74 | + ) |
| 75 | + |
| 76 | + splits = text.split(separator) |
| 77 | + current_chunk = "" |
| 78 | + |
| 79 | + for split in splits: |
| 80 | + test_chunk = current_chunk + (separator if current_chunk else "") + split |
| 81 | + |
| 82 | + if self._get_token_count(test_chunk) <= effective_chunk_size: |
| 83 | + current_chunk = test_chunk |
| 84 | + else: |
| 85 | + # Save current chunk if it exists |
| 86 | + if current_chunk: |
| 87 | + chunks.append(Chunk(content=current_chunk.strip())) |
| 88 | + |
| 89 | + # If single split is too large, recursively split it |
| 90 | + if self._get_token_count(split) > effective_chunk_size: |
| 91 | + sub_chunks = self._split_text_with_separators( |
| 92 | + split, remaining_separators |
| 93 | + ) |
| 94 | + chunks.extend(sub_chunks) |
| 95 | + current_chunk = "" |
| 96 | + else: |
| 97 | + current_chunk = split |
| 98 | + |
| 99 | + # Add final chunk |
| 100 | + if current_chunk: |
| 101 | + chunks.append(Chunk(content=current_chunk.strip())) |
| 102 | + |
| 103 | + return self._apply_overlap(chunks) |
| 104 | + |
| 105 | + def _split_by_characters(self, text: str) -> List[Chunk]: |
| 106 | + """Split text at character level when no separators work.""" |
| 107 | + chunks = [] |
| 108 | + |
| 109 | + # Reserve space for overlap |
| 110 | + effective_chunk_size = max( |
| 111 | + 1, self.settings.chunk_size - self.settings.chunk_overlap |
| 112 | + ) |
| 113 | + |
| 114 | + total_tokens = self._get_token_count(text) |
| 115 | + chars_per_token = ( |
| 116 | + math.ceil(len(text) / total_tokens) |
| 117 | + if total_tokens > 0 |
| 118 | + else 4 # Assume 4 chars per token if no tokens found |
| 119 | + ) |
| 120 | + |
| 121 | + # Estimate characters that fit the chunk size |
| 122 | + estimated_chunk_size_in_chars = int(effective_chunk_size * chars_per_token) |
| 123 | + |
| 124 | + start = 0 |
| 125 | + while start < len(text): |
| 126 | + # The end position of the next chunk of text |
| 127 | + end = min(start + estimated_chunk_size_in_chars, len(text)) |
| 128 | + |
| 129 | + chunk_text = text[start:end] |
| 130 | + |
| 131 | + # Verify it doesn't exceed token limit, reduce if needed |
| 132 | + while ( |
| 133 | + self._get_token_count(chunk_text) > effective_chunk_size |
| 134 | + and end > start + 1 |
| 135 | + ): |
| 136 | + end = int(end * 0.9) # Reduce by 10% |
| 137 | + chunk_text = text[start:end] |
| 138 | + |
| 139 | + if chunk_text.strip(): |
| 140 | + chunks.append(Chunk(content=chunk_text.strip())) |
| 141 | + |
| 142 | + start = end |
| 143 | + |
| 144 | + return self._apply_overlap(chunks) |
| 145 | + |
| 146 | + def _apply_overlap(self, chunks: List[Chunk]) -> List[Chunk]: |
| 147 | + """Apply overlap between consecutive chunks.""" |
| 148 | + if len(chunks) <= 1 or self.settings.chunk_overlap <= 0: |
| 149 | + return chunks |
| 150 | + |
| 151 | + overlapped_chunks = [chunks[0]] # First chunk has no overlap |
| 152 | + |
| 153 | + for i in range(1, len(chunks)): |
| 154 | + current_content = chunks[i].content |
| 155 | + prev_content = chunks[i - 1].content |
| 156 | + |
| 157 | + # Get overlap text from end of previous chunk |
| 158 | + overlap_text = self._get_overlap_text( |
| 159 | + prev_content, self.settings.chunk_overlap |
| 160 | + ) |
| 161 | + |
| 162 | + if overlap_text: |
| 163 | + combined_content = overlap_text + " " + current_content |
| 164 | + else: |
| 165 | + combined_content = current_content |
| 166 | + |
| 167 | + overlapped_chunks.append(Chunk(content=combined_content)) |
| 168 | + |
| 169 | + return overlapped_chunks |
| 170 | + |
| 171 | + def _get_overlap_text(self, text: str, max_overlap_tokens: int) -> str: |
| 172 | + """Extract overlap text from end of text, respecting token limit.""" |
| 173 | + words = text.split() |
| 174 | + if not words: |
| 175 | + return "" |
| 176 | + |
| 177 | + # Try to find the longest suffix that fits within overlap limit |
| 178 | + for i in range(len(words)): |
| 179 | + suffix = " ".join(words[i:]) |
| 180 | + if self._get_token_count(suffix) <= max_overlap_tokens: |
| 181 | + return suffix |
| 182 | + |
| 183 | + # If even single word is too large, return empty |
| 184 | + return "" |
0 commit comments