embedkit-py 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- embedkit/__init__.py +47 -0
- embedkit/__version__.py +6 -0
- embedkit/cache.py +150 -0
- embedkit/chunkers.py +358 -0
- embedkit/cli.py +274 -0
- embedkit/embedder.py +127 -0
- embedkit/hybrid.py +217 -0
- embedkit/pipeline.py +253 -0
- embedkit/py.typed +0 -0
- embedkit/stores/__init__.py +10 -0
- embedkit/stores/base.py +99 -0
- embedkit/stores/faiss_store.py +160 -0
- embedkit/stores/memory_store.py +159 -0
- embedkit_py-0.1.0.dist-info/METADATA +297 -0
- embedkit_py-0.1.0.dist-info/RECORD +19 -0
- embedkit_py-0.1.0.dist-info/WHEEL +5 -0
- embedkit_py-0.1.0.dist-info/entry_points.txt +2 -0
- embedkit_py-0.1.0.dist-info/licenses/LICENSE +21 -0
- embedkit_py-0.1.0.dist-info/top_level.txt +1 -0
embedkit/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
# Copyright (c) 2024 Maharshi Soni
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
EmbedKit - Production Embedding Pipeline Toolkit.
|
|
6
|
+
|
|
7
|
+
A pip-installable toolkit for building embedding pipelines with multiple
|
|
8
|
+
chunking strategies, cached embedding generation, and a unified vector
|
|
9
|
+
store interface supporting FAISS and in-memory backends.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from embedkit.__version__ import __version__
|
|
13
|
+
from embedkit.chunkers import (
|
|
14
|
+
ChunkingStrategy,
|
|
15
|
+
FixedSizeChunker,
|
|
16
|
+
SentenceChunker,
|
|
17
|
+
RecursiveChunker,
|
|
18
|
+
)
|
|
19
|
+
from embedkit.embedder import Embedder
|
|
20
|
+
from embedkit.cache import EmbeddingCache
|
|
21
|
+
from embedkit.stores.base import VectorStore, SearchResult
|
|
22
|
+
from embedkit.stores.faiss_store import FAISSStore
|
|
23
|
+
from embedkit.stores.memory_store import InMemoryStore
|
|
24
|
+
from embedkit.hybrid import HybridSearcher
|
|
25
|
+
from embedkit.pipeline import EmbedPipeline
|
|
26
|
+
|
|
27
|
+
__all__: list[str] = [
|
|
28
|
+
"__version__",
|
|
29
|
+
# Chunkers
|
|
30
|
+
"ChunkingStrategy",
|
|
31
|
+
"FixedSizeChunker",
|
|
32
|
+
"SentenceChunker",
|
|
33
|
+
"RecursiveChunker",
|
|
34
|
+
# Embedder
|
|
35
|
+
"Embedder",
|
|
36
|
+
# Cache
|
|
37
|
+
"EmbeddingCache",
|
|
38
|
+
# Stores
|
|
39
|
+
"VectorStore",
|
|
40
|
+
"SearchResult",
|
|
41
|
+
"FAISSStore",
|
|
42
|
+
"InMemoryStore",
|
|
43
|
+
# Hybrid Search
|
|
44
|
+
"HybridSearcher",
|
|
45
|
+
# Pipeline
|
|
46
|
+
"EmbedPipeline",
|
|
47
|
+
]
|
embedkit/__version__.py
ADDED
embedkit/cache.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
# Copyright (c) 2024 Maharshi Soni
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
Local disk cache for embedding vectors.
|
|
6
|
+
|
|
7
|
+
Stores embeddings keyed by a hash of the input text + model name,
|
|
8
|
+
avoiding redundant computation on repeated indexing runs.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
from numpy.typing import NDArray
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EmbeddingCache:
|
|
24
|
+
"""Persistent on-disk cache for embedding vectors.
|
|
25
|
+
|
|
26
|
+
Embeddings are stored as .npy files in a directory, keyed by a SHA-256
|
|
27
|
+
hash of (model_name, text). A manifest JSON tracks metadata.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
cache_dir: Directory to store cached embeddings.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, cache_dir: str | Path = ".embedkit_cache") -> None:
|
|
34
|
+
self.cache_dir = Path(cache_dir)
|
|
35
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
self._manifest_path = self.cache_dir / "manifest.json"
|
|
37
|
+
self._manifest: dict[str, dict[str, Any]] = self._load_manifest()
|
|
38
|
+
|
|
39
|
+
def _load_manifest(self) -> dict[str, dict[str, Any]]:
|
|
40
|
+
"""Load the manifest from disk.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
A dict mapping cache keys to metadata entries.
|
|
44
|
+
"""
|
|
45
|
+
if self._manifest_path.exists():
|
|
46
|
+
try:
|
|
47
|
+
with open(self._manifest_path, "r", encoding="utf-8") as f:
|
|
48
|
+
return json.load(f)
|
|
49
|
+
except (json.JSONDecodeError, OSError):
|
|
50
|
+
return {}
|
|
51
|
+
return {}
|
|
52
|
+
|
|
53
|
+
def _save_manifest(self) -> None:
|
|
54
|
+
"""Persist the manifest to disk."""
|
|
55
|
+
with open(self._manifest_path, "w", encoding="utf-8") as f:
|
|
56
|
+
json.dump(self._manifest, f, indent=2)
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _compute_key(text: str, model_name: str) -> str:
|
|
60
|
+
"""Compute a cache key from text and model name.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
text: The input text.
|
|
64
|
+
model_name: Name of the embedding model.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
A hex digest string used as the cache key.
|
|
68
|
+
"""
|
|
69
|
+
content = f"{model_name}::{text}"
|
|
70
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
71
|
+
|
|
72
|
+
def get(self, text: str, model_name: str) -> NDArray[np.float32] | None:
|
|
73
|
+
"""Retrieve a cached embedding if available.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
text: The original text.
|
|
77
|
+
model_name: The model used to generate the embedding.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The cached embedding array, or None if not found.
|
|
81
|
+
"""
|
|
82
|
+
key = self._compute_key(text, model_name)
|
|
83
|
+
if key not in self._manifest:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
npy_path = self.cache_dir / f"{key}.npy"
|
|
87
|
+
if not npy_path.exists():
|
|
88
|
+
# Stale manifest entry
|
|
89
|
+
del self._manifest[key]
|
|
90
|
+
self._save_manifest()
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
return np.load(str(npy_path)).astype(np.float32)
|
|
94
|
+
|
|
95
|
+
def put(self, text: str, model_name: str, embedding: NDArray[np.float32]) -> None:
|
|
96
|
+
"""Store an embedding in the cache.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
text: The original text.
|
|
100
|
+
model_name: The model used.
|
|
101
|
+
embedding: The embedding vector to cache.
|
|
102
|
+
"""
|
|
103
|
+
key = self._compute_key(text, model_name)
|
|
104
|
+
npy_path = self.cache_dir / f"{key}.npy"
|
|
105
|
+
np.save(str(npy_path), embedding)
|
|
106
|
+
|
|
107
|
+
self._manifest[key] = {
|
|
108
|
+
"model": model_name,
|
|
109
|
+
"text_length": len(text),
|
|
110
|
+
"dim": embedding.shape[0],
|
|
111
|
+
}
|
|
112
|
+
self._save_manifest()
|
|
113
|
+
|
|
114
|
+
def contains(self, text: str, model_name: str) -> bool:
|
|
115
|
+
"""Check whether an embedding is cached.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
text: The original text.
|
|
119
|
+
model_name: The model name.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
True if a valid cache entry exists.
|
|
123
|
+
"""
|
|
124
|
+
return self.get(text, model_name) is not None
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def size(self) -> int:
|
|
128
|
+
"""Return the number of cached embeddings."""
|
|
129
|
+
return len(self._manifest)
|
|
130
|
+
|
|
131
|
+
def clear(self) -> None:
|
|
132
|
+
"""Remove all cached embeddings and reset the manifest."""
|
|
133
|
+
for key in list(self._manifest.keys()):
|
|
134
|
+
npy_path = self.cache_dir / f"{key}.npy"
|
|
135
|
+
if npy_path.exists():
|
|
136
|
+
os.remove(str(npy_path))
|
|
137
|
+
self._manifest.clear()
|
|
138
|
+
self._save_manifest()
|
|
139
|
+
|
|
140
|
+
def disk_usage_bytes(self) -> int:
|
|
141
|
+
"""Calculate total disk usage of the cache in bytes.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Total size in bytes.
|
|
145
|
+
"""
|
|
146
|
+
total = 0
|
|
147
|
+
for path in self.cache_dir.iterdir():
|
|
148
|
+
if path.is_file():
|
|
149
|
+
total += path.stat().st_size
|
|
150
|
+
return total
|
embedkit/chunkers.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
# Copyright (c) 2024 Maharshi Soni
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
Chunking strategies for splitting documents into embeddable segments.
|
|
6
|
+
|
|
7
|
+
Provides three strategies:
|
|
8
|
+
- FixedSizeChunker: splits text into chunks of a fixed token/character count
|
|
9
|
+
- SentenceChunker: splits on sentence boundaries
|
|
10
|
+
- RecursiveChunker: hierarchical splitting using multiple separators
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import Sequence
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ChunkingStrategy(str, Enum):
|
|
23
|
+
"""Available chunking strategies."""
|
|
24
|
+
|
|
25
|
+
FIXED = "fixed"
|
|
26
|
+
SENTENCE = "sentence"
|
|
27
|
+
RECURSIVE = "recursive"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Chunk:
|
|
32
|
+
"""A single chunk of text with metadata."""
|
|
33
|
+
|
|
34
|
+
text: str
|
|
35
|
+
index: int
|
|
36
|
+
start_char: int
|
|
37
|
+
end_char: int
|
|
38
|
+
metadata: dict[str, str | int | float] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def length(self) -> int:
|
|
42
|
+
"""Return the character length of this chunk."""
|
|
43
|
+
return len(self.text)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BaseChunker(ABC):
|
|
47
|
+
"""Abstract base class for all chunking strategies."""
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def chunk(self, text: str, metadata: dict[str, str | int | float] | None = None) -> list[Chunk]:
|
|
51
|
+
"""Split text into chunks.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
text: The input text to chunk.
|
|
55
|
+
metadata: Optional metadata to attach to each chunk.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
A list of Chunk objects.
|
|
59
|
+
"""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
def chunk_many(
|
|
63
|
+
self,
|
|
64
|
+
texts: Sequence[str],
|
|
65
|
+
metadata_list: Sequence[dict[str, str | int | float]] | None = None,
|
|
66
|
+
) -> list[list[Chunk]]:
|
|
67
|
+
"""Chunk multiple texts.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
texts: Sequence of texts to chunk.
|
|
71
|
+
metadata_list: Optional per-text metadata.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
A list of chunk lists, one per input text.
|
|
75
|
+
"""
|
|
76
|
+
results: list[list[Chunk]] = []
|
|
77
|
+
for i, text in enumerate(texts):
|
|
78
|
+
meta = metadata_list[i] if metadata_list else None
|
|
79
|
+
results.append(self.chunk(text, metadata=meta))
|
|
80
|
+
return results
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FixedSizeChunker(BaseChunker):
|
|
84
|
+
"""Splits text into fixed-size character chunks with optional overlap.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
chunk_size: Maximum number of characters per chunk.
|
|
88
|
+
overlap: Number of overlapping characters between consecutive chunks.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, chunk_size: int = 512, overlap: int = 64) -> None:
|
|
92
|
+
if chunk_size <= 0:
|
|
93
|
+
raise ValueError("chunk_size must be positive")
|
|
94
|
+
if overlap < 0:
|
|
95
|
+
raise ValueError("overlap must be non-negative")
|
|
96
|
+
if overlap >= chunk_size:
|
|
97
|
+
raise ValueError("overlap must be less than chunk_size")
|
|
98
|
+
self.chunk_size = chunk_size
|
|
99
|
+
self.overlap = overlap
|
|
100
|
+
|
|
101
|
+
def chunk(self, text: str, metadata: dict[str, str | int | float] | None = None) -> list[Chunk]:
|
|
102
|
+
"""Split text into fixed-size chunks.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
text: The input text.
|
|
106
|
+
metadata: Optional metadata for each chunk.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
List of Chunk objects.
|
|
110
|
+
"""
|
|
111
|
+
if not text.strip():
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
chunks: list[Chunk] = []
|
|
115
|
+
step = self.chunk_size - self.overlap
|
|
116
|
+
idx = 0
|
|
117
|
+
pos = 0
|
|
118
|
+
|
|
119
|
+
while pos < len(text):
|
|
120
|
+
end = min(pos + self.chunk_size, len(text))
|
|
121
|
+
chunk_text = text[pos:end]
|
|
122
|
+
|
|
123
|
+
if chunk_text.strip():
|
|
124
|
+
chunks.append(
|
|
125
|
+
Chunk(
|
|
126
|
+
text=chunk_text,
|
|
127
|
+
index=idx,
|
|
128
|
+
start_char=pos,
|
|
129
|
+
end_char=end,
|
|
130
|
+
metadata=metadata or {},
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
idx += 1
|
|
134
|
+
|
|
135
|
+
if end >= len(text):
|
|
136
|
+
break
|
|
137
|
+
pos += step
|
|
138
|
+
|
|
139
|
+
return chunks
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class SentenceChunker(BaseChunker):
|
|
143
|
+
"""Splits text on sentence boundaries, grouping sentences up to a max size.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
max_chunk_size: Maximum character count per chunk.
|
|
147
|
+
min_chunk_size: Minimum character count; shorter chunks merge with neighbors.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
# Regex to split on sentence-ending punctuation followed by whitespace
|
|
151
|
+
_SENTENCE_RE = re.compile(r"(?<=[.!?])\s+")
|
|
152
|
+
|
|
153
|
+
def __init__(self, max_chunk_size: int = 1024, min_chunk_size: int = 100) -> None:
|
|
154
|
+
if max_chunk_size <= 0:
|
|
155
|
+
raise ValueError("max_chunk_size must be positive")
|
|
156
|
+
if min_chunk_size < 0:
|
|
157
|
+
raise ValueError("min_chunk_size must be non-negative")
|
|
158
|
+
self.max_chunk_size = max_chunk_size
|
|
159
|
+
self.min_chunk_size = min_chunk_size
|
|
160
|
+
|
|
161
|
+
def chunk(self, text: str, metadata: dict[str, str | int | float] | None = None) -> list[Chunk]:
|
|
162
|
+
"""Split text into sentence-boundary-aware chunks.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
text: The input text.
|
|
166
|
+
metadata: Optional metadata for each chunk.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
List of Chunk objects.
|
|
170
|
+
"""
|
|
171
|
+
if not text.strip():
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
sentences = self._SENTENCE_RE.split(text.strip())
|
|
175
|
+
sentences = [s.strip() for s in sentences if s.strip()]
|
|
176
|
+
|
|
177
|
+
chunks: list[Chunk] = []
|
|
178
|
+
current_sentences: list[str] = []
|
|
179
|
+
current_len = 0
|
|
180
|
+
idx = 0
|
|
181
|
+
char_offset = 0
|
|
182
|
+
|
|
183
|
+
for sentence in sentences:
|
|
184
|
+
sentence_len = len(sentence)
|
|
185
|
+
|
|
186
|
+
if current_len + sentence_len + 1 > self.max_chunk_size and current_sentences:
|
|
187
|
+
chunk_text = " ".join(current_sentences)
|
|
188
|
+
start = text.find(current_sentences[0], char_offset)
|
|
189
|
+
if start == -1:
|
|
190
|
+
start = char_offset
|
|
191
|
+
end = start + len(chunk_text)
|
|
192
|
+
|
|
193
|
+
chunks.append(
|
|
194
|
+
Chunk(
|
|
195
|
+
text=chunk_text,
|
|
196
|
+
index=idx,
|
|
197
|
+
start_char=start,
|
|
198
|
+
end_char=end,
|
|
199
|
+
metadata=metadata or {},
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
idx += 1
|
|
203
|
+
char_offset = end
|
|
204
|
+
current_sentences = []
|
|
205
|
+
current_len = 0
|
|
206
|
+
|
|
207
|
+
current_sentences.append(sentence)
|
|
208
|
+
current_len += sentence_len + 1
|
|
209
|
+
|
|
210
|
+
# Flush remaining
|
|
211
|
+
if current_sentences:
|
|
212
|
+
chunk_text = " ".join(current_sentences)
|
|
213
|
+
start = text.find(current_sentences[0], char_offset)
|
|
214
|
+
if start == -1:
|
|
215
|
+
start = char_offset
|
|
216
|
+
end = start + len(chunk_text)
|
|
217
|
+
|
|
218
|
+
# Merge with previous chunk if too small
|
|
219
|
+
if len(chunk_text) < self.min_chunk_size and chunks:
|
|
220
|
+
prev = chunks[-1]
|
|
221
|
+
merged_text = prev.text + " " + chunk_text
|
|
222
|
+
chunks[-1] = Chunk(
|
|
223
|
+
text=merged_text,
|
|
224
|
+
index=prev.index,
|
|
225
|
+
start_char=prev.start_char,
|
|
226
|
+
end_char=end,
|
|
227
|
+
metadata=metadata or {},
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
chunks.append(
|
|
231
|
+
Chunk(
|
|
232
|
+
text=chunk_text,
|
|
233
|
+
index=idx,
|
|
234
|
+
start_char=start,
|
|
235
|
+
end_char=end,
|
|
236
|
+
metadata=metadata or {},
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
return chunks
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class RecursiveChunker(BaseChunker):
|
|
244
|
+
"""Recursively splits text using a hierarchy of separators.
|
|
245
|
+
|
|
246
|
+
Tries the first separator; if any resulting piece exceeds the max size,
|
|
247
|
+
splits that piece with the next separator, and so on.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
chunk_size: Maximum character count per chunk.
|
|
251
|
+
overlap: Overlap between chunks at the leaf level.
|
|
252
|
+
separators: Ordered list of separators from coarsest to finest.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
DEFAULT_SEPARATORS: list[str] = ["\n\n", "\n", ". ", " ", ""]
|
|
256
|
+
|
|
257
|
+
def __init__(
|
|
258
|
+
self,
|
|
259
|
+
chunk_size: int = 512,
|
|
260
|
+
overlap: int = 64,
|
|
261
|
+
separators: list[str] | None = None,
|
|
262
|
+
) -> None:
|
|
263
|
+
if chunk_size <= 0:
|
|
264
|
+
raise ValueError("chunk_size must be positive")
|
|
265
|
+
if overlap < 0:
|
|
266
|
+
raise ValueError("overlap must be non-negative")
|
|
267
|
+
self.chunk_size = chunk_size
|
|
268
|
+
self.overlap = overlap
|
|
269
|
+
self.separators = separators or self.DEFAULT_SEPARATORS
|
|
270
|
+
|
|
271
|
+
def _split_text(self, text: str, separators: list[str]) -> list[str]:
|
|
272
|
+
"""Recursively split text using the separator hierarchy.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
text: Text to split.
|
|
276
|
+
separators: Remaining separators to try.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
List of text fragments each within chunk_size.
|
|
280
|
+
"""
|
|
281
|
+
if len(text) <= self.chunk_size:
|
|
282
|
+
return [text] if text.strip() else []
|
|
283
|
+
|
|
284
|
+
if not separators:
|
|
285
|
+
# Fallback: hard split
|
|
286
|
+
return [text[i : i + self.chunk_size] for i in range(0, len(text), self.chunk_size)]
|
|
287
|
+
|
|
288
|
+
sep = separators[0]
|
|
289
|
+
remaining_seps = separators[1:]
|
|
290
|
+
|
|
291
|
+
if sep == "":
|
|
292
|
+
pieces = list(text)
|
|
293
|
+
else:
|
|
294
|
+
pieces = text.split(sep)
|
|
295
|
+
|
|
296
|
+
result: list[str] = []
|
|
297
|
+
current: list[str] = []
|
|
298
|
+
current_len = 0
|
|
299
|
+
|
|
300
|
+
for piece in pieces:
|
|
301
|
+
piece_len = len(piece) + (len(sep) if current else 0)
|
|
302
|
+
|
|
303
|
+
if current_len + piece_len > self.chunk_size and current:
|
|
304
|
+
merged = sep.join(current)
|
|
305
|
+
if len(merged) > self.chunk_size:
|
|
306
|
+
result.extend(self._split_text(merged, remaining_seps))
|
|
307
|
+
else:
|
|
308
|
+
result.append(merged)
|
|
309
|
+
current = []
|
|
310
|
+
current_len = 0
|
|
311
|
+
|
|
312
|
+
current.append(piece)
|
|
313
|
+
current_len += piece_len
|
|
314
|
+
|
|
315
|
+
if current:
|
|
316
|
+
merged = sep.join(current)
|
|
317
|
+
if len(merged) > self.chunk_size:
|
|
318
|
+
result.extend(self._split_text(merged, remaining_seps))
|
|
319
|
+
else:
|
|
320
|
+
result.append(merged)
|
|
321
|
+
|
|
322
|
+
return [r for r in result if r.strip()]
|
|
323
|
+
|
|
324
|
+
def chunk(self, text: str, metadata: dict[str, str | int | float] | None = None) -> list[Chunk]:
|
|
325
|
+
"""Split text recursively using separator hierarchy.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
text: The input text.
|
|
329
|
+
metadata: Optional metadata for each chunk.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
List of Chunk objects.
|
|
333
|
+
"""
|
|
334
|
+
if not text.strip():
|
|
335
|
+
return []
|
|
336
|
+
|
|
337
|
+
fragments = self._split_text(text, self.separators)
|
|
338
|
+
chunks: list[Chunk] = []
|
|
339
|
+
search_start = 0
|
|
340
|
+
|
|
341
|
+
for idx, fragment in enumerate(fragments):
|
|
342
|
+
start = text.find(fragment, search_start)
|
|
343
|
+
if start == -1:
|
|
344
|
+
start = search_start
|
|
345
|
+
end = start + len(fragment)
|
|
346
|
+
search_start = start + 1
|
|
347
|
+
|
|
348
|
+
chunks.append(
|
|
349
|
+
Chunk(
|
|
350
|
+
text=fragment,
|
|
351
|
+
index=idx,
|
|
352
|
+
start_char=start,
|
|
353
|
+
end_char=end,
|
|
354
|
+
metadata=metadata or {},
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
return chunks
|