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 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
+ ]
@@ -0,0 +1,6 @@
1
+ # MIT License
2
+ # Copyright (c) 2024 Maharshi Soni
3
+
4
+ """Version information for EmbedKit."""
5
+
6
+ __version__: str = "0.1.0"
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