Skip to content

Commit b399168

Browse files
authored
feat: add chunking module, remove chonkie (#84)
* retry * simple version * refactor * move chunk to chunk * remove language detection * remove group * add tests, rename * rename function raw -> inner * comment desired chunk length, move to chunkboundary
1 parent ad1a7dc commit b399168

9 files changed

Lines changed: 258 additions & 391 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ dependencies = [
3030
"vicinity>=0.4.4",
3131
"numpy>=1.24.0",
3232
"bm25s>=0.2.0",
33-
"chonkie[code]!=1.6.3",
3433
"pathspec>=0.12",
34+
"tree-sitter>=0.25",
35+
"tree-sitter-language-pack!=1.6.3"
3536
]
3637

3738
[project.optional-dependencies]

src/semble/chunking/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from semble.chunking.chunking import chunk_source
2+
3+
__all__ = ["chunk_source"]

src/semble/chunking/chunking.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import logging
2+
3+
from semble.chunking.core import chunk, chunk_lines, is_supported_language
4+
from semble.types import Chunk
5+
6+
logger = logging.getLogger(__name__)
7+
8+
# The desired length of chunks in chars.
9+
# TODO: makes this configurable
10+
_DESIRED_CHUNK_LENGTH_CHARS = 1500
11+
12+
13+
def chunk_source(source: str, file_path: str, language: str | None) -> list[Chunk]:
14+
"""Chunk pre-read source text."""
15+
if not source.strip():
16+
return []
17+
if language is not None and is_supported_language(language):
18+
chunk_boundaries = chunk(source, language, _DESIRED_CHUNK_LENGTH_CHARS)
19+
else:
20+
chunk_boundaries = chunk_lines(source, _DESIRED_CHUNK_LENGTH_CHARS)
21+
22+
chunks: list[Chunk] = []
23+
for boundary in chunk_boundaries:
24+
# Clamp to start_index so zero-length chunks don't produce an off-by-one.
25+
end_index = max(boundary.end - 1, boundary.start)
26+
text = source[boundary.start : end_index + 1]
27+
chunks.append(
28+
Chunk(
29+
content=text,
30+
file_path=file_path,
31+
start_line=source[: boundary.start].count("\n") + 1,
32+
end_line=source[:end_index].count("\n") + 1,
33+
language=language,
34+
)
35+
)
36+
return chunks

src/semble/chunking/core.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from functools import cache
5+
from logging import getLogger
6+
7+
from tree_sitter import Node, Parser
8+
from tree_sitter_language_pack import SupportedLanguage, get_parser, manifest_languages
9+
10+
logger = getLogger(__name__)
11+
12+
13+
_TREE_SITTER_LANGUAGES: frozenset[str] = frozenset(manifest_languages())
14+
15+
16+
def is_supported_language(language: str) -> bool:
17+
"""Check if the language is supported by tree-sitter."""
18+
return language in _TREE_SITTER_LANGUAGES
19+
20+
21+
@dataclass
22+
class ChunkBoundary:
23+
"""The output of the internal chunking algorithm."""
24+
25+
start: int
26+
end: int
27+
28+
29+
@cache
30+
def _cached_get_parser(language: SupportedLanguage) -> Parser:
31+
"""Gets a parser from tree_sitter."""
32+
return get_parser(language)
33+
34+
35+
def _merge_adjacent_chunks(
36+
chunks: list[ChunkBoundary],
37+
desired_length: int,
38+
) -> list[ChunkBoundary]:
39+
"""Merge adjacent chunks up to the desired length."""
40+
merged = []
41+
42+
current_start = chunks[0].start
43+
current_end = chunks[0].end
44+
current_length = current_end - current_start
45+
46+
for group in chunks[1:]:
47+
start, end = group.start, group.end
48+
length = end - start
49+
50+
if current_length + length > desired_length:
51+
merged.append(ChunkBoundary(start=current_start, end=current_end))
52+
current_start = start
53+
current_end = end
54+
current_length = length
55+
continue
56+
57+
current_end = end
58+
current_length += length
59+
60+
merged.append(ChunkBoundary(start=current_start, end=current_end))
61+
62+
return merged
63+
64+
65+
def _merge_node_inner(node: Node, desired_length: int) -> list[ChunkBoundary]:
66+
"""Recursively merge and split nodes."""
67+
# If there are no child nodes, the only thing we can do is return the current node.
68+
if not node.children:
69+
return [ChunkBoundary(node.start_byte, node.end_byte)]
70+
71+
groups: list[ChunkBoundary] = []
72+
children = node.children
73+
index = 0
74+
75+
while index < len(children):
76+
child = children[index]
77+
start = child.start_byte
78+
end = child.end_byte
79+
length = child.end_byte - child.start_byte
80+
81+
# Increment the pointer, as we accessed a child node.
82+
index += 1
83+
# If this single chunk is longer than the desired length
84+
# we try to split it again.
85+
if length > desired_length:
86+
groups.extend(_merge_node_inner(child, desired_length))
87+
continue
88+
89+
while index < len(children):
90+
# Extend the current group with or more children, if they fit.
91+
child = children[index]
92+
child_length = child.end_byte - child.start_byte
93+
94+
if length + child_length > desired_length:
95+
break
96+
97+
end = child.end_byte
98+
length += child_length
99+
index += 1
100+
101+
groups.append(ChunkBoundary(start, end))
102+
103+
return groups
104+
105+
106+
def _merge_node(node: Node, desired_length: int) -> list[ChunkBoundary]:
107+
"""Recursively turn nodes into chunks, then merge adjacent chunks."""
108+
raw_chunks = _merge_node_inner(node, desired_length)
109+
return _merge_adjacent_chunks(raw_chunks, desired_length)
110+
111+
112+
def chunk_lines(text: str, desired_length: int) -> list[ChunkBoundary]:
113+
"""Chunk source code by line."""
114+
if not text.strip():
115+
return []
116+
lines_as_groups = []
117+
index = 0
118+
for line in text.splitlines(keepends=True):
119+
lines_as_groups.append(ChunkBoundary(start=index, end=index + len(line)))
120+
index += len(line)
121+
122+
return _merge_adjacent_chunks(lines_as_groups, desired_length)
123+
124+
125+
def chunk(text: str, language: str, desired_length: int) -> list[ChunkBoundary]:
126+
"""Chunk source code."""
127+
if not text.strip():
128+
return []
129+
130+
as_bytes = text.encode("utf-8")
131+
parser = _cached_get_parser(language)
132+
root = parser.parse(as_bytes).root_node
133+
134+
chunks = []
135+
for chunk_boundary in _merge_node(root, desired_length):
136+
start_char = len(as_bytes[: chunk_boundary.start].decode("utf-8"))
137+
end_char = len(as_bytes[: chunk_boundary.end].decode("utf-8"))
138+
chunks.append(ChunkBoundary(start=start_char, end=end_char))
139+
140+
return chunks

src/semble/index/chunker.py

Lines changed: 0 additions & 91 deletions
This file was deleted.

src/semble/index/create.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import bm25s
55
from vicinity.backends.basic import BasicArgs
66

7-
from semble.index.chunker import chunk_source
7+
from semble.chunking import chunk_source
88
from semble.index.dense import SelectableBasicBackend, embed_chunks
99
from semble.index.file_walker import filter_extensions, language_for_path, walk_files
1010
from semble.index.sparse import enrich_for_bm25

0 commit comments

Comments
 (0)