Skip to content

Commit eae709d

Browse files
author
Daniele Briggi
committed
feat(add): add and store a document with chunks
- store on database document and chunks - create chunks with Recursive Character Text Splitter
1 parent 91deea6 commit eae709d

11 files changed

Lines changed: 546 additions & 23 deletions

src/chunker.py

Lines changed: 174 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,184 @@
11
import sqlite3
2+
from typing import List
23

34
from models.chunk import Chunk
5+
from settings import Settings
6+
import math
47

58

69
class Chunker:
7-
def __init__(self, conn: sqlite3.Connection, settings):
10+
def __init__(self, conn: sqlite3.Connection, settings: Settings):
811
self._conn = conn
912
self.settings = settings
1013

1114
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 ""

src/engine.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ def __init__(self, conn: sqlite3.Connection, settings: Settings):
1313
def load_model(self):
1414
"""Load the model model from the specified path
1515
or download it from Hugging Face if not found."""
16-
model_path = self.settings.model_path_or_name
16+
pass
17+
# model_path = self.settings.model_path_or_name
1718
# if not Path(self.settings.model_path_or_name).exists():
1819
# # check if exists locally or try to download it from Hugging Face
1920
# model_path = hf_hub_download(
@@ -22,6 +23,8 @@ def load_model(self):
2223
# cache_dir="./models"
2324
# )
2425

25-
self.repository.load_model(model_path)
26+
self._conn.execute(
27+
f"SELECT llm_model_load('{self.settings.model_path_or_name}', '{self.settings.model_config}');"
28+
)
2629

2730

src/repository.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import sqlite3
44
from uuid import uuid4
55

6-
from database import Database
76
from models.document import Document
87
from settings import Settings
98

src/settings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,6 @@ def __init__(self, model_path_or_name: str, db_path: str = "sqliterag.db"):
77
self.vector_type = "FLOAT32" # Default vector type for sqlite-vector
88

99
self.model_config = "n_ctx=0" # See: https://github.com/sqliteai/sqlite-ai/blob/e172b9c9b9d76435be635d1e02c1e88b3681cc6e/src/sqlite-ai.c#L51-L57
10+
11+
self.chunk_size = 1000 # Maximum tokens per chunk
12+
self.chunk_overlap = 200 # Token overlap between chunks

src/sqliterag.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def add(self, path: str):
4343
if not Path(path).exists():
4444
raise FileNotFoundError(f"File {path} does not exist.")
4545

46+
# TODO: check the file extension
4647
content = FileReader.parse_file(Path(path))
4748
# TODO: include metadata extraction and mdx options (see our docsearch)
4849
document = Document(content=content, uri=path)

test_chunker.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#!/usr/bin/env python3
2+
3+
import sqlite3
4+
import sys
5+
import os
6+
7+
# Add src to path
8+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
9+
10+
from chunker import Chunker
11+
from settings import Settings
12+
from database import Database
13+
14+
def test_chunker():
15+
# Create in-memory database
16+
conn = sqlite3.connect(":memory:")
17+
18+
# Initialize with small chunk sizes for testing
19+
settings = Settings("test-model")
20+
settings.chunk_size = 50 # Small for testing
21+
settings.chunk_overlap = 10 # Small overlap
22+
23+
# Initialize database with extensions (this might fail without extensions)
24+
try:
25+
conn = Database.initialize(conn, settings)
26+
except RuntimeError as e:
27+
print(f"Warning: Could not load extensions: {e}")
28+
print("Testing without extensions - token counting will not work")
29+
return
30+
31+
# Create chunker
32+
chunker = Chunker(conn, settings)
33+
34+
# Test with sample text
35+
test_text = """# Deep Learning Neural Networks
36+
37+
Deep learning utilizes artificial neural networks with multiple layers to process and learn from vast amounts of data. These networks automatically discover intricate patterns and representations without manual feature engineering.
38+
39+
Convolutional neural networks excel at image recognition tasks, while recurrent neural networks handle sequential data like text and speech. Popular frameworks include TensorFlow, PyTorch, and Keras.
40+
41+
Deep learning has revolutionized computer vision, natural language processing, and speech recognition applications."""
42+
43+
print("Original text:")
44+
print(test_text)
45+
print(f"\nText length: {len(test_text)} characters")
46+
47+
# Test token counting
48+
try:
49+
token_count = chunker._get_token_count(test_text)
50+
print(f"Token count: {token_count}")
51+
except Exception as e:
52+
print(f"Token counting failed: {e}")
53+
return
54+
55+
# Test chunking
56+
chunks = chunker.chunk(test_text)
57+
58+
print(f"\nGenerated {len(chunks)} chunks:")
59+
for i, chunk in enumerate(chunks):
60+
print(f"\n--- Chunk {i+1} ---")
61+
print(f"Content: {chunk.content}")
62+
print(f"Length: {len(chunk.content)} characters")
63+
try:
64+
tokens = chunker._get_token_count(chunk.content)
65+
print(f"Tokens: {tokens}")
66+
except Exception as e:
67+
print(f"Token counting failed: {e}")
68+
69+
if __name__ == "__main__":
70+
test_chunker()

tests/conftest.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,10 @@ def db_conn():
1818
yield conn, settings
1919

2020
conn.close()
21+
22+
23+
@pytest.fixture
24+
def db_settings() -> Settings:
25+
with tempfile.NamedTemporaryFile(suffix=".db") as tmp_db:
26+
settings = Settings(model_path_or_name="all-MiniLM-L6-v2", db_path=tmp_db.name)
27+
return settings

0 commit comments

Comments
 (0)