baked-vector 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.
- baked/__init__.py +25 -0
- baked/chunking.py +281 -0
- baked/embed.py +166 -0
- baked/evals/__init__.py +42 -0
- baked/evals/harness.py +138 -0
- baked/evals/synthetic.py +148 -0
- baked/store.py +194 -0
- baked_vector-0.1.0.dist-info/METADATA +225 -0
- baked_vector-0.1.0.dist-info/RECORD +11 -0
- baked_vector-0.1.0.dist-info/WHEEL +5 -0
- baked_vector-0.1.0.dist-info/top_level.txt +1 -0
baked/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .store import VectorStore
|
|
2
|
+
from .embed import (
|
|
3
|
+
EmbeddingStore,
|
|
4
|
+
sentence_transformer_embedder,
|
|
5
|
+
openai_embedder,
|
|
6
|
+
azure_openai_embedder,
|
|
7
|
+
bedrock_embedder,
|
|
8
|
+
google_embedder,
|
|
9
|
+
)
|
|
10
|
+
from .chunking import chunk_fixed, chunk_sentences, chunk_paragraphs, chunk_recursive
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"VectorStore",
|
|
14
|
+
"EmbeddingStore",
|
|
15
|
+
"sentence_transformer_embedder",
|
|
16
|
+
"openai_embedder",
|
|
17
|
+
"azure_openai_embedder",
|
|
18
|
+
"bedrock_embedder",
|
|
19
|
+
"google_embedder",
|
|
20
|
+
"chunk_fixed",
|
|
21
|
+
"chunk_sentences",
|
|
22
|
+
"chunk_paragraphs",
|
|
23
|
+
"chunk_recursive",
|
|
24
|
+
]
|
|
25
|
+
__version__ = "0.1.0"
|
baked/chunking.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Text chunking utilities — stdlib only (re, math). No external dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def chunk_fixed(text: str, size: int = 512, overlap: int = 64) -> list[str]:
|
|
9
|
+
"""Split text into fixed-size character chunks with overlap.
|
|
10
|
+
|
|
11
|
+
Slides a window of *size* chars over *text* with a step of ``size - overlap``.
|
|
12
|
+
Each chunk is stripped; empty chunks are dropped.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
text: Input text to split.
|
|
16
|
+
size: Maximum characters per chunk.
|
|
17
|
+
overlap: Number of characters repeated at the start of the next chunk.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
List of non-empty stripped chunks.
|
|
21
|
+
"""
|
|
22
|
+
if not text or not text.strip():
|
|
23
|
+
return []
|
|
24
|
+
|
|
25
|
+
step = size - overlap
|
|
26
|
+
if step <= 0:
|
|
27
|
+
raise ValueError("overlap must be less than size")
|
|
28
|
+
|
|
29
|
+
chunks: list[str] = []
|
|
30
|
+
start = 0
|
|
31
|
+
while start < len(text):
|
|
32
|
+
chunk = text[start : start + size].strip()
|
|
33
|
+
if chunk:
|
|
34
|
+
chunks.append(chunk)
|
|
35
|
+
start += step
|
|
36
|
+
|
|
37
|
+
return chunks
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Sentence-ending punctuation followed by whitespace or end-of-string.
|
|
41
|
+
_SENT_RE = re.compile(r"(?<=[.!?])(?:\s+|$)")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def chunk_sentences(text: str, max_size: int = 512, overlap: int = 1) -> list[str]:
|
|
45
|
+
"""Split on sentence boundaries (.!?).
|
|
46
|
+
|
|
47
|
+
Sentences are detected by ``.``, ``!``, or ``?`` followed by whitespace or
|
|
48
|
+
end-of-string. ``overlap`` is the number of sentences from the end of the
|
|
49
|
+
previous chunk to repeat at the start of the next chunk (not characters).
|
|
50
|
+
|
|
51
|
+
Short sentences are merged into a single chunk until *max_size* would be
|
|
52
|
+
exceeded, then the current chunk is flushed.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
text: Input text to split.
|
|
56
|
+
max_size: Soft maximum characters per chunk. A single sentence longer
|
|
57
|
+
than *max_size* is kept as its own chunk.
|
|
58
|
+
overlap: Number of trailing sentences carried into the next chunk.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
List of non-empty stripped chunks.
|
|
62
|
+
"""
|
|
63
|
+
if not text or not text.strip():
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
# Split and keep the punctuation attached to the preceding sentence.
|
|
67
|
+
raw = _SENT_RE.split(text)
|
|
68
|
+
sentences = [s.strip() for s in raw if s.strip()]
|
|
69
|
+
|
|
70
|
+
if not sentences:
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
chunks: list[str] = []
|
|
74
|
+
current: list[str] = []
|
|
75
|
+
current_len = 0
|
|
76
|
+
|
|
77
|
+
def _flush() -> None:
|
|
78
|
+
if current:
|
|
79
|
+
joined = " ".join(current)
|
|
80
|
+
if joined.strip():
|
|
81
|
+
chunks.append(joined.strip())
|
|
82
|
+
|
|
83
|
+
for sent in sentences:
|
|
84
|
+
# +1 for the space separator
|
|
85
|
+
added_len = len(sent) + (1 if current else 0)
|
|
86
|
+
if current and current_len + added_len > max_size:
|
|
87
|
+
_flush()
|
|
88
|
+
# carry overlap sentences into next chunk
|
|
89
|
+
tail = current[-overlap:] if overlap else []
|
|
90
|
+
current = list(tail)
|
|
91
|
+
current_len = sum(len(s) for s in current) + max(0, len(current) - 1)
|
|
92
|
+
|
|
93
|
+
current.append(sent)
|
|
94
|
+
current_len += len(sent) + (1 if len(current) > 1 else 0)
|
|
95
|
+
|
|
96
|
+
_flush()
|
|
97
|
+
return chunks
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def chunk_paragraphs(text: str, max_size: int = 1024, overlap: int = 0) -> list[str]:
|
|
101
|
+
"""Split on blank lines, merging short paragraphs up to *max_size*.
|
|
102
|
+
|
|
103
|
+
Paragraphs are separated by one or more blank lines (``\\n\\n+``). Consecutive
|
|
104
|
+
short paragraphs are merged (joined with ``\\n\\n``) until adding the next one
|
|
105
|
+
would exceed *max_size*, then the current buffer is flushed. ``overlap=0``
|
|
106
|
+
means no paragraph is repeated; ``overlap=N`` repeats the last N paragraphs
|
|
107
|
+
of each chunk at the start of the next.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
text: Input text to split.
|
|
111
|
+
max_size: Soft maximum characters per chunk.
|
|
112
|
+
overlap: Number of trailing paragraphs carried into the next chunk.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
List of non-empty stripped chunks.
|
|
116
|
+
"""
|
|
117
|
+
if not text or not text.strip():
|
|
118
|
+
return []
|
|
119
|
+
|
|
120
|
+
raw = re.split(r"\n\n+", text)
|
|
121
|
+
paragraphs = [p.strip() for p in raw if p.strip()]
|
|
122
|
+
|
|
123
|
+
if not paragraphs:
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
chunks: list[str] = []
|
|
127
|
+
current: list[str] = []
|
|
128
|
+
current_len = 0
|
|
129
|
+
sep = "\n\n"
|
|
130
|
+
sep_len = len(sep)
|
|
131
|
+
|
|
132
|
+
def _flush() -> None:
|
|
133
|
+
if current:
|
|
134
|
+
joined = sep.join(current)
|
|
135
|
+
if joined.strip():
|
|
136
|
+
chunks.append(joined.strip())
|
|
137
|
+
|
|
138
|
+
for para in paragraphs:
|
|
139
|
+
added_len = len(para) + (sep_len if current else 0)
|
|
140
|
+
if current and current_len + added_len > max_size:
|
|
141
|
+
_flush()
|
|
142
|
+
tail = current[-overlap:] if overlap else []
|
|
143
|
+
current = list(tail)
|
|
144
|
+
current_len = (
|
|
145
|
+
sum(len(p) for p in current) + sep_len * max(0, len(current) - 1)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
current.append(para)
|
|
149
|
+
current_len += len(para) + (sep_len if len(current) > 1 else 0)
|
|
150
|
+
|
|
151
|
+
_flush()
|
|
152
|
+
return chunks
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# Ordered list of separators tried by chunk_recursive.
|
|
156
|
+
_RECURSIVE_SEPS = ["\n\n", "\n", ". ", ""]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def chunk_recursive(text: str, size: int = 512, overlap: int = 64) -> list[str]:
|
|
160
|
+
"""LangChain-style recursive text splitter.
|
|
161
|
+
|
|
162
|
+
Tries splitting on ``\\n\\n``, then ``\\n``, then ``". "``, then falls back
|
|
163
|
+
to fixed character splits. Each piece that is still larger than *size* is
|
|
164
|
+
recursively split with the remaining separators.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
text: Input text to split.
|
|
168
|
+
size: Maximum characters per chunk.
|
|
169
|
+
overlap: Characters of overlap between consecutive chunks on hard cuts.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
List of non-empty stripped chunks.
|
|
173
|
+
"""
|
|
174
|
+
if not text or not text.strip():
|
|
175
|
+
return []
|
|
176
|
+
return _recursive_split(text, size, overlap, list(_RECURSIVE_SEPS))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _recursive_split(
|
|
180
|
+
text: str, size: int, overlap: int, seps: list[str]
|
|
181
|
+
) -> list[str]:
|
|
182
|
+
text = text.strip()
|
|
183
|
+
if not text:
|
|
184
|
+
return []
|
|
185
|
+
if len(text) <= size:
|
|
186
|
+
return [text]
|
|
187
|
+
|
|
188
|
+
# Try each separator in order until one produces more than one piece.
|
|
189
|
+
chosen_sep: str | None = None
|
|
190
|
+
splits: list[str] = []
|
|
191
|
+
remaining_seps = list(seps)
|
|
192
|
+
while remaining_seps:
|
|
193
|
+
sep = remaining_seps.pop(0)
|
|
194
|
+
if sep == "":
|
|
195
|
+
# Hard character split — handled after the loop.
|
|
196
|
+
break
|
|
197
|
+
parts = text.split(sep)
|
|
198
|
+
if len(parts) > 1:
|
|
199
|
+
chosen_sep = sep
|
|
200
|
+
splits = [p for p in parts if p.strip()]
|
|
201
|
+
break
|
|
202
|
+
else:
|
|
203
|
+
chosen_sep = None
|
|
204
|
+
|
|
205
|
+
if chosen_sep is None:
|
|
206
|
+
# Fall back to fixed character split with overlap.
|
|
207
|
+
return _fixed_split(text, size, overlap)
|
|
208
|
+
|
|
209
|
+
# Recursively handle pieces that are still too long.
|
|
210
|
+
next_seps = [s for s in _RECURSIVE_SEPS if s != chosen_sep]
|
|
211
|
+
good: list[str] = []
|
|
212
|
+
for piece in splits:
|
|
213
|
+
piece = piece.strip()
|
|
214
|
+
if not piece:
|
|
215
|
+
continue
|
|
216
|
+
if len(piece) <= size:
|
|
217
|
+
good.append(piece)
|
|
218
|
+
else:
|
|
219
|
+
good.extend(_recursive_split(piece, size, overlap, list(next_seps)))
|
|
220
|
+
|
|
221
|
+
# Merge good pieces back up to size.
|
|
222
|
+
return _merge(good, chosen_sep, size, overlap)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _merge(
|
|
226
|
+
splits: list[str], sep: str, size: int, overlap: int
|
|
227
|
+
) -> list[str]:
|
|
228
|
+
"""Merge split pieces into chunks up to *size* with *overlap* char carry-over."""
|
|
229
|
+
chunks: list[str] = []
|
|
230
|
+
current: list[str] = []
|
|
231
|
+
current_len = 0
|
|
232
|
+
sep_len = len(sep)
|
|
233
|
+
|
|
234
|
+
for piece in splits:
|
|
235
|
+
piece = piece.strip()
|
|
236
|
+
if not piece:
|
|
237
|
+
continue
|
|
238
|
+
piece_len = len(piece)
|
|
239
|
+
join_len = current_len + sep_len + piece_len if current else piece_len
|
|
240
|
+
|
|
241
|
+
if join_len > size and current:
|
|
242
|
+
chunk_text = sep.join(current).strip()
|
|
243
|
+
if chunk_text:
|
|
244
|
+
chunks.append(chunk_text)
|
|
245
|
+
# Build overlap from the tail of current.
|
|
246
|
+
ov: list[str] = []
|
|
247
|
+
ov_len = 0
|
|
248
|
+
for part in reversed(current):
|
|
249
|
+
part_len = len(part)
|
|
250
|
+
candidate = ov_len + (sep_len if ov else 0) + part_len
|
|
251
|
+
if candidate > overlap and ov:
|
|
252
|
+
break
|
|
253
|
+
ov.insert(0, part)
|
|
254
|
+
ov_len = candidate
|
|
255
|
+
current = ov
|
|
256
|
+
current_len = ov_len
|
|
257
|
+
|
|
258
|
+
current.append(piece)
|
|
259
|
+
current_len = (
|
|
260
|
+
sum(len(p) for p in current) + sep_len * max(0, len(current) - 1)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if current:
|
|
264
|
+
chunk_text = sep.join(current).strip()
|
|
265
|
+
if chunk_text:
|
|
266
|
+
chunks.append(chunk_text)
|
|
267
|
+
|
|
268
|
+
return chunks
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _fixed_split(text: str, size: int, overlap: int) -> list[str]:
|
|
272
|
+
"""Hard character split with overlap — last resort."""
|
|
273
|
+
step = max(1, size - overlap)
|
|
274
|
+
chunks: list[str] = []
|
|
275
|
+
start = 0
|
|
276
|
+
while start < len(text):
|
|
277
|
+
chunk = text[start : start + size].strip()
|
|
278
|
+
if chunk:
|
|
279
|
+
chunks.append(chunk)
|
|
280
|
+
start += step
|
|
281
|
+
return chunks
|
baked/embed.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
from .store import VectorStore
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
Embedder = Callable[[str], list[float]]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EmbeddingStore:
|
|
12
|
+
"""VectorStore wrapper that auto-embeds text via a provided embedder callable."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, path: str, embedder: Embedder):
|
|
15
|
+
self._store = VectorStore(path)
|
|
16
|
+
self._embed = embedder
|
|
17
|
+
|
|
18
|
+
def add(self, text: str, metadata: dict[str, Any] | None = None) -> int:
|
|
19
|
+
vec = self._embed(text)
|
|
20
|
+
meta = {"text": text, **(metadata or {})}
|
|
21
|
+
return self._store.add(vec, meta)
|
|
22
|
+
|
|
23
|
+
def add_many(self, texts: list[str], metadatas: list[dict[str, Any]] | None = None) -> list[int]:
|
|
24
|
+
"""Embed and add multiple texts. Returns list of assigned ids."""
|
|
25
|
+
if metadatas is None:
|
|
26
|
+
metadatas = [{} for _ in texts]
|
|
27
|
+
if len(texts) != len(metadatas):
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"Length mismatch: {len(texts)} texts but {len(metadatas)} metadatas."
|
|
30
|
+
)
|
|
31
|
+
return [self.add(text, meta) for text, meta in zip(texts, metadatas)]
|
|
32
|
+
|
|
33
|
+
def search(
|
|
34
|
+
self,
|
|
35
|
+
query: str,
|
|
36
|
+
top_k: int = 5,
|
|
37
|
+
radius: float | None = None,
|
|
38
|
+
filter: dict[str, Any] | None = None,
|
|
39
|
+
) -> list[tuple[dict[str, Any], float]]:
|
|
40
|
+
vec = self._embed(query)
|
|
41
|
+
return self._store.search(vec, top_k=top_k, radius=radius, filter=filter)
|
|
42
|
+
|
|
43
|
+
def remove(self, id: int) -> None:
|
|
44
|
+
self._store.remove(id)
|
|
45
|
+
|
|
46
|
+
def update(
|
|
47
|
+
self,
|
|
48
|
+
id: int,
|
|
49
|
+
text: str | None = None,
|
|
50
|
+
metadata: dict[str, Any] | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Re-embed and/or update metadata for an existing entry."""
|
|
53
|
+
if text is not None:
|
|
54
|
+
vec = self._embed(text)
|
|
55
|
+
merged_meta = {"text": text, **(metadata or {})}
|
|
56
|
+
self._store.update(id, embedding=vec, metadata=merged_meta)
|
|
57
|
+
else:
|
|
58
|
+
self._store.update(id, metadata=metadata)
|
|
59
|
+
|
|
60
|
+
def save(self) -> None:
|
|
61
|
+
self._store.save()
|
|
62
|
+
|
|
63
|
+
def __len__(self) -> int:
|
|
64
|
+
return len(self._store)
|
|
65
|
+
|
|
66
|
+
def __repr__(self) -> str:
|
|
67
|
+
return f"EmbeddingStore(store={self._store!r})"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Provider adapters
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def sentence_transformer_embedder(model_name: str = "all-MiniLM-L6-v2") -> Embedder:
|
|
75
|
+
"""Returns an embedder using a sentence-transformers model (install baked[st])."""
|
|
76
|
+
try:
|
|
77
|
+
from sentence_transformers import SentenceTransformer
|
|
78
|
+
except ImportError:
|
|
79
|
+
raise ImportError("Install sentence-transformers: pip install baked-vector[st]")
|
|
80
|
+
|
|
81
|
+
model = SentenceTransformer(model_name)
|
|
82
|
+
|
|
83
|
+
def embed(text: str) -> list[float]:
|
|
84
|
+
return model.encode(text, normalize_embeddings=False).tolist()
|
|
85
|
+
|
|
86
|
+
return embed
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def openai_embedder(model: str = "text-embedding-3-small", api_key: str | None = None) -> Embedder:
|
|
90
|
+
"""Returns an embedder using OpenAI's API (install baked[openai])."""
|
|
91
|
+
try:
|
|
92
|
+
from openai import OpenAI
|
|
93
|
+
except ImportError:
|
|
94
|
+
raise ImportError("Install openai: pip install baked-vector[openai]")
|
|
95
|
+
|
|
96
|
+
client = OpenAI(api_key=api_key)
|
|
97
|
+
|
|
98
|
+
def embed(text: str) -> list[float]:
|
|
99
|
+
response = client.embeddings.create(input=text, model=model)
|
|
100
|
+
return response.data[0].embedding
|
|
101
|
+
|
|
102
|
+
return embed
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def azure_openai_embedder(
|
|
106
|
+
deployment: str,
|
|
107
|
+
endpoint: str,
|
|
108
|
+
api_key: str,
|
|
109
|
+
api_version: str = "2024-02-01",
|
|
110
|
+
) -> Embedder:
|
|
111
|
+
"""Returns an embedder using Azure OpenAI (install baked[openai])."""
|
|
112
|
+
try:
|
|
113
|
+
from openai import AzureOpenAI
|
|
114
|
+
except ImportError:
|
|
115
|
+
raise ImportError("Install openai: pip install baked-vector[openai]")
|
|
116
|
+
|
|
117
|
+
client = AzureOpenAI(azure_endpoint=endpoint, api_key=api_key, api_version=api_version)
|
|
118
|
+
|
|
119
|
+
def embed(text: str) -> list[float]:
|
|
120
|
+
response = client.embeddings.create(input=text, model=deployment)
|
|
121
|
+
return response.data[0].embedding
|
|
122
|
+
|
|
123
|
+
return embed
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def bedrock_embedder(
|
|
127
|
+
model_id: str = "amazon.titan-embed-text-v2:0",
|
|
128
|
+
region_name: str = "us-east-1",
|
|
129
|
+
) -> Embedder:
|
|
130
|
+
"""Returns an embedder using AWS Bedrock (install baked[bedrock])."""
|
|
131
|
+
try:
|
|
132
|
+
import boto3
|
|
133
|
+
except ImportError:
|
|
134
|
+
raise ImportError("Install boto3: pip install baked-vector[bedrock]")
|
|
135
|
+
|
|
136
|
+
import json
|
|
137
|
+
|
|
138
|
+
client = boto3.client("bedrock-runtime", region_name=region_name)
|
|
139
|
+
|
|
140
|
+
def embed(text: str) -> list[float]:
|
|
141
|
+
body = json.dumps({"inputText": text})
|
|
142
|
+
response = client.invoke_model(modelId=model_id, body=body)
|
|
143
|
+
result = json.loads(response["body"].read())
|
|
144
|
+
return result["embedding"]
|
|
145
|
+
|
|
146
|
+
return embed
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def google_embedder(
|
|
150
|
+
model: str = "models/text-embedding-004",
|
|
151
|
+
api_key: str | None = None,
|
|
152
|
+
) -> Embedder:
|
|
153
|
+
"""Returns an embedder using Google Generative AI (install baked[google])."""
|
|
154
|
+
try:
|
|
155
|
+
import google.generativeai as genai
|
|
156
|
+
except ImportError:
|
|
157
|
+
raise ImportError("Install google-generativeai: pip install baked-vector[google]")
|
|
158
|
+
|
|
159
|
+
if api_key:
|
|
160
|
+
genai.configure(api_key=api_key)
|
|
161
|
+
|
|
162
|
+
def embed(text: str) -> list[float]:
|
|
163
|
+
result = genai.embed_content(model=model, content=text)
|
|
164
|
+
return result["embedding"]
|
|
165
|
+
|
|
166
|
+
return embed
|
baked/evals/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
baked.evals — retrieval evaluation toolkit
|
|
3
|
+
|
|
4
|
+
Typical workflow:
|
|
5
|
+
|
|
6
|
+
1. Generate labeled pairs (query → expected doc) once and save them:
|
|
7
|
+
|
|
8
|
+
from baked.evals.synthetic import generate_pairs, openai_llm
|
|
9
|
+
from baked.evals import save_pairs
|
|
10
|
+
|
|
11
|
+
pairs = generate_pairs(docs, llm=openai_llm(), questions_per_doc=5)
|
|
12
|
+
save_pairs(pairs, "evals/my_pairs.json")
|
|
13
|
+
|
|
14
|
+
2. Run eval against your store whenever embeddings or search params change:
|
|
15
|
+
|
|
16
|
+
from baked.evals import load_pairs, run_eval, print_report
|
|
17
|
+
|
|
18
|
+
pairs = load_pairs("evals/my_pairs.json")
|
|
19
|
+
result = run_eval(store, pairs, top_k=5)
|
|
20
|
+
print_report(result, verbose=True)
|
|
21
|
+
|
|
22
|
+
Metrics:
|
|
23
|
+
Recall@k — fraction of queries where the correct doc appears in top-k results.
|
|
24
|
+
MRR — mean reciprocal rank; rewards finding the correct doc earlier.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from .harness import EvalPair, EvalResult, QueryResult, run_eval, print_report, save_pairs, load_pairs
|
|
28
|
+
from .synthetic import generate_pairs, groq_llm, openai_llm, anthropic_llm
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"EvalPair",
|
|
32
|
+
"EvalResult",
|
|
33
|
+
"QueryResult",
|
|
34
|
+
"run_eval",
|
|
35
|
+
"print_report",
|
|
36
|
+
"save_pairs",
|
|
37
|
+
"load_pairs",
|
|
38
|
+
"generate_pairs",
|
|
39
|
+
"groq_llm",
|
|
40
|
+
"openai_llm",
|
|
41
|
+
"anthropic_llm",
|
|
42
|
+
]
|
baked/evals/harness.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class EvalPair:
|
|
10
|
+
"""A single labeled evaluation example.
|
|
11
|
+
|
|
12
|
+
query: the question or search string to issue against the store.
|
|
13
|
+
expected: a metadata subset that the correct result must contain.
|
|
14
|
+
Partial match — {"title": "X"} matches any result whose metadata
|
|
15
|
+
has at least title="X", regardless of other keys.
|
|
16
|
+
label: optional human-readable name shown in print_report output.
|
|
17
|
+
"""
|
|
18
|
+
query: str
|
|
19
|
+
expected: dict[str, Any]
|
|
20
|
+
label: str = ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class QueryResult:
|
|
25
|
+
"""Outcome of a single query during an eval run."""
|
|
26
|
+
pair: EvalPair
|
|
27
|
+
rank: int | None # 1-indexed rank of first match; None = not found in top_k
|
|
28
|
+
hits: list[dict[str, Any]] # metadata of each top_k result
|
|
29
|
+
scores: list[float] # cosine scores of each top_k result
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class EvalResult:
|
|
34
|
+
"""Aggregate metrics from run_eval().
|
|
35
|
+
|
|
36
|
+
recall_at_k: fraction of queries where the correct doc appeared in top-k.
|
|
37
|
+
mrr: mean reciprocal rank across all queries.
|
|
38
|
+
top_k: the k used during this eval run.
|
|
39
|
+
n: total number of queries evaluated.
|
|
40
|
+
query_results: per-query breakdown for verbose inspection.
|
|
41
|
+
"""
|
|
42
|
+
recall_at_k: float
|
|
43
|
+
mrr: float
|
|
44
|
+
top_k: int
|
|
45
|
+
n: int
|
|
46
|
+
query_results: list[QueryResult]
|
|
47
|
+
|
|
48
|
+
def __str__(self) -> str:
|
|
49
|
+
return (
|
|
50
|
+
f"Recall@{self.top_k}: {self.recall_at_k:.3f} "
|
|
51
|
+
f"MRR: {self.mrr:.3f} "
|
|
52
|
+
f"(n={self.n})"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _matches(metadata: dict[str, Any], expected: dict[str, Any]) -> bool:
|
|
57
|
+
return all(metadata.get(k) == v for k, v in expected.items())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run_eval(
|
|
61
|
+
store: Any,
|
|
62
|
+
pairs: list[EvalPair],
|
|
63
|
+
top_k: int = 5,
|
|
64
|
+
) -> EvalResult:
|
|
65
|
+
"""Evaluate retrieval quality against labeled query-document pairs.
|
|
66
|
+
|
|
67
|
+
Works with EmbeddingStore (pass str queries in EvalPair.query) and
|
|
68
|
+
VectorStore (pass vector queries). Any object with a compatible
|
|
69
|
+
.search(query, top_k) interface works.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
store: the store to evaluate. Must implement .search(query, top_k).
|
|
73
|
+
pairs: labeled examples — see EvalPair.
|
|
74
|
+
top_k: number of results to retrieve per query.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
EvalResult with Recall@k, MRR, and per-query breakdown.
|
|
78
|
+
"""
|
|
79
|
+
query_results: list[QueryResult] = []
|
|
80
|
+
|
|
81
|
+
for pair in pairs:
|
|
82
|
+
results = store.search(pair.query, top_k=top_k)
|
|
83
|
+
hits = [meta for meta, _ in results]
|
|
84
|
+
scores = [score for _, score in results]
|
|
85
|
+
rank = next(
|
|
86
|
+
(i for i, meta in enumerate(hits, 1) if _matches(meta, pair.expected)),
|
|
87
|
+
None,
|
|
88
|
+
)
|
|
89
|
+
query_results.append(
|
|
90
|
+
QueryResult(pair=pair, rank=rank, hits=hits, scores=scores)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
n = len(pairs)
|
|
94
|
+
recall = sum(1 for r in query_results if r.rank is not None) / n if n else 0.0
|
|
95
|
+
mrr = sum(1.0 / r.rank for r in query_results if r.rank is not None) / n if n else 0.0
|
|
96
|
+
|
|
97
|
+
return EvalResult(
|
|
98
|
+
recall_at_k=recall,
|
|
99
|
+
mrr=mrr,
|
|
100
|
+
top_k=top_k,
|
|
101
|
+
n=n,
|
|
102
|
+
query_results=query_results,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def print_report(result: EvalResult, verbose: bool = False) -> None:
|
|
107
|
+
"""Print a human-readable summary of an EvalResult.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
result: output from run_eval().
|
|
111
|
+
verbose: if True, prints rank and label for every query.
|
|
112
|
+
Missed queries also show the top-scoring result for debugging.
|
|
113
|
+
"""
|
|
114
|
+
print(str(result))
|
|
115
|
+
if verbose:
|
|
116
|
+
for qr in result.query_results:
|
|
117
|
+
status = f"rank {qr.rank}" if qr.rank is not None else "MISS"
|
|
118
|
+
label = qr.pair.label or qr.pair.query[:70]
|
|
119
|
+
print(f" [{status:>6}] {label!r}")
|
|
120
|
+
if qr.rank is None and qr.hits:
|
|
121
|
+
print(f" top hit: {qr.hits[0]}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def save_pairs(pairs: list[EvalPair], path: str) -> None:
|
|
125
|
+
"""Save eval pairs to a JSON file so they can be reused across runs.
|
|
126
|
+
|
|
127
|
+
Generate pairs once (with generate_pairs or by hand), save them,
|
|
128
|
+
then load and re-run whenever you change embeddings or search params.
|
|
129
|
+
"""
|
|
130
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
131
|
+
json.dump([asdict(p) for p in pairs], f, indent=2, ensure_ascii=False)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def load_pairs(path: str) -> list[EvalPair]:
|
|
135
|
+
"""Load eval pairs previously saved with save_pairs()."""
|
|
136
|
+
with open(path, encoding="utf-8") as f:
|
|
137
|
+
data = json.load(f)
|
|
138
|
+
return [EvalPair(**d) for d in data]
|
baked/evals/synthetic.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
baked.evals.synthetic — LLM-powered eval pair generation
|
|
3
|
+
|
|
4
|
+
Use this when you have documents but no labeled query-answer pairs.
|
|
5
|
+
An LLM reads each document and generates realistic questions whose answers
|
|
6
|
+
are in that document, producing EvalPairs ready for run_eval().
|
|
7
|
+
|
|
8
|
+
Typical use:
|
|
9
|
+
|
|
10
|
+
from baked.evals.synthetic import generate_pairs, openai_llm
|
|
11
|
+
from baked.evals import save_pairs
|
|
12
|
+
|
|
13
|
+
docs = [
|
|
14
|
+
("Wealth is not money...", {"title": "How to Make Wealth", "author": "PG"}),
|
|
15
|
+
("A startup is a company designed to grow fast...", {"title": "Startup = Growth"}),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
pairs = generate_pairs(docs, llm=openai_llm(), questions_per_doc=5)
|
|
19
|
+
save_pairs(pairs, "evals/pairs.json")
|
|
20
|
+
|
|
21
|
+
The LLM is only called during generation — not during eval. Save the pairs
|
|
22
|
+
and reuse them to compare embedding models or search configurations cheaply.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
from typing import Any, Callable
|
|
29
|
+
|
|
30
|
+
from .harness import EvalPair
|
|
31
|
+
|
|
32
|
+
# Any callable that takes a prompt string and returns the LLM's reply.
|
|
33
|
+
LLMFn = Callable[[str], str]
|
|
34
|
+
|
|
35
|
+
_PROMPT = """\
|
|
36
|
+
Read the following text and generate {n} specific, self-contained questions whose \
|
|
37
|
+
answers are found within it. Each question should test a different aspect of the content. \
|
|
38
|
+
Do not ask vague or generic questions.
|
|
39
|
+
|
|
40
|
+
Return ONLY a JSON array of strings. No explanation, no markdown fences, no extra text.
|
|
41
|
+
|
|
42
|
+
Text:
|
|
43
|
+
{text}"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def generate_pairs(
|
|
47
|
+
docs: list[tuple[str, dict[str, Any]]],
|
|
48
|
+
llm: LLMFn,
|
|
49
|
+
questions_per_doc: int = 5,
|
|
50
|
+
text_preview_chars: int = 3000,
|
|
51
|
+
) -> list[EvalPair]:
|
|
52
|
+
"""
|
|
53
|
+
Generate EvalPairs by prompting an LLM to produce questions from each document.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
docs: List of (text, metadata) tuples. metadata becomes EvalPair.expected.
|
|
57
|
+
llm: Callable (str) -> str — use openai_llm() or anthropic_llm() as factories.
|
|
58
|
+
questions_per_doc: Number of questions to generate per document.
|
|
59
|
+
text_preview_chars: How many chars of each doc to send to the LLM.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
List of EvalPairs ready to pass to run_eval().
|
|
63
|
+
"""
|
|
64
|
+
pairs: list[EvalPair] = []
|
|
65
|
+
for text, metadata in docs:
|
|
66
|
+
prompt = _PROMPT.format(n=questions_per_doc, text=text[:text_preview_chars])
|
|
67
|
+
response = llm(prompt)
|
|
68
|
+
questions = _extract_json_list(response)
|
|
69
|
+
for q in questions:
|
|
70
|
+
if isinstance(q, str) and q.strip():
|
|
71
|
+
pairs.append(EvalPair(query=q.strip(), expected=metadata))
|
|
72
|
+
return pairs
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _extract_json_list(text: str) -> list:
|
|
76
|
+
match = re.search(r"\[.*?\]", text, re.DOTALL)
|
|
77
|
+
if match:
|
|
78
|
+
try:
|
|
79
|
+
return json.loads(match.group())
|
|
80
|
+
except json.JSONDecodeError:
|
|
81
|
+
pass
|
|
82
|
+
try:
|
|
83
|
+
return json.loads(text.strip())
|
|
84
|
+
except json.JSONDecodeError:
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def openai_llm(model: str = "gpt-4o-mini", api_key: str | None = None) -> LLMFn:
|
|
89
|
+
"""LLM factory using OpenAI Chat Completions (install baked-vector[openai])."""
|
|
90
|
+
try:
|
|
91
|
+
from openai import OpenAI
|
|
92
|
+
except ImportError:
|
|
93
|
+
raise ImportError("pip install baked-vector[openai]")
|
|
94
|
+
|
|
95
|
+
client = OpenAI(api_key=api_key)
|
|
96
|
+
|
|
97
|
+
def llm(prompt: str) -> str:
|
|
98
|
+
resp = client.chat.completions.create(
|
|
99
|
+
model=model,
|
|
100
|
+
messages=[{"role": "user", "content": prompt}],
|
|
101
|
+
temperature=0.3,
|
|
102
|
+
)
|
|
103
|
+
return resp.choices[0].message.content
|
|
104
|
+
|
|
105
|
+
return llm
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def groq_llm(model: str = "openai/gpt-oss-120b", api_key: str | None = None) -> LLMFn:
|
|
109
|
+
"""LLM factory using Groq's OpenAI-compatible API (install baked-vector[openai])."""
|
|
110
|
+
try:
|
|
111
|
+
from openai import OpenAI
|
|
112
|
+
except ImportError:
|
|
113
|
+
raise ImportError("pip install baked-vector[openai]")
|
|
114
|
+
|
|
115
|
+
client = OpenAI(
|
|
116
|
+
api_key=api_key,
|
|
117
|
+
base_url="https://api.groq.com/openai/v1",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def llm(prompt: str) -> str:
|
|
121
|
+
resp = client.chat.completions.create(
|
|
122
|
+
model=model,
|
|
123
|
+
messages=[{"role": "user", "content": prompt}],
|
|
124
|
+
temperature=0.3,
|
|
125
|
+
)
|
|
126
|
+
return resp.choices[0].message.content
|
|
127
|
+
|
|
128
|
+
return llm
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def anthropic_llm(model: str = "claude-haiku-5-5", api_key: str | None = None) -> LLMFn:
|
|
132
|
+
"""LLM factory using Anthropic Messages API (install baked-vector[anthropic])."""
|
|
133
|
+
try:
|
|
134
|
+
import anthropic
|
|
135
|
+
except ImportError:
|
|
136
|
+
raise ImportError("pip install baked-vector[anthropic]")
|
|
137
|
+
|
|
138
|
+
client = anthropic.Anthropic(api_key=api_key)
|
|
139
|
+
|
|
140
|
+
def llm(prompt: str) -> str:
|
|
141
|
+
msg = client.messages.create(
|
|
142
|
+
model=model,
|
|
143
|
+
max_tokens=1024,
|
|
144
|
+
messages=[{"role": "user", "content": prompt}],
|
|
145
|
+
)
|
|
146
|
+
return msg.content[0].text
|
|
147
|
+
|
|
148
|
+
return llm
|
baked/store.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import math
|
|
3
|
+
import struct
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
_HEADER_FMT = "II" # dim, count — both unsigned int
|
|
8
|
+
_HEADER_SIZE = struct.calcsize(_HEADER_FMT)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _normalize(vec: list[float]) -> list[float]:
|
|
12
|
+
magnitude = math.sqrt(sum(x * x for x in vec))
|
|
13
|
+
if magnitude == 0.0:
|
|
14
|
+
raise ValueError("Cannot normalize a zero vector.")
|
|
15
|
+
return [x / magnitude for x in vec]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _dot(a: list[float], b: list[float]) -> float:
|
|
19
|
+
return sum(x * y for x, y in zip(a, b))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class VectorStore:
|
|
23
|
+
def __init__(self, path: str):
|
|
24
|
+
self._path = path
|
|
25
|
+
self._vecs_path = path + ".vecs"
|
|
26
|
+
self._meta_path = path + ".json"
|
|
27
|
+
self._dim: int | None = None
|
|
28
|
+
self._vectors: list[list[float]] = []
|
|
29
|
+
self._metadata: list[dict[str, Any]] = []
|
|
30
|
+
self._deleted: set[int] = set()
|
|
31
|
+
|
|
32
|
+
if os.path.exists(self._vecs_path) and os.path.exists(self._meta_path):
|
|
33
|
+
self._load()
|
|
34
|
+
|
|
35
|
+
# ------------------------------------------------------------------
|
|
36
|
+
# Write API
|
|
37
|
+
# ------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def add(self, embedding: list[float], metadata: dict[str, Any] | None = None) -> int:
|
|
40
|
+
"""Add a vector with optional metadata. Returns its id."""
|
|
41
|
+
if self._dim is None:
|
|
42
|
+
self._dim = len(embedding)
|
|
43
|
+
elif len(embedding) != self._dim:
|
|
44
|
+
raise ValueError(f"Dimension mismatch: expected {self._dim}, got {len(embedding)}.")
|
|
45
|
+
|
|
46
|
+
normalized = _normalize(embedding)
|
|
47
|
+
idx = len(self._vectors)
|
|
48
|
+
self._vectors.append(normalized)
|
|
49
|
+
self._metadata.append(metadata or {})
|
|
50
|
+
return idx
|
|
51
|
+
|
|
52
|
+
def add_many(self, embeddings: list[list[float]], metadatas: list[dict[str, Any]] | None = None) -> list[int]:
|
|
53
|
+
"""Add multiple vectors at once. Returns list of assigned ids."""
|
|
54
|
+
if metadatas is None:
|
|
55
|
+
metadatas = [{} for _ in embeddings]
|
|
56
|
+
if len(embeddings) != len(metadatas):
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"Length mismatch: {len(embeddings)} embeddings but {len(metadatas)} metadatas."
|
|
59
|
+
)
|
|
60
|
+
return [self.add(emb, meta) for emb, meta in zip(embeddings, metadatas)]
|
|
61
|
+
|
|
62
|
+
def remove(self, id: int) -> None:
|
|
63
|
+
"""Soft-delete a vector by id. Call save() to persist."""
|
|
64
|
+
if id < 0 or id >= len(self._vectors):
|
|
65
|
+
raise IndexError(f"Id {id} out of range.")
|
|
66
|
+
self._deleted.add(id)
|
|
67
|
+
|
|
68
|
+
def update(
|
|
69
|
+
self,
|
|
70
|
+
id: int,
|
|
71
|
+
embedding: list[float] | None = None,
|
|
72
|
+
metadata: dict[str, Any] | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Update vector and/or metadata for an existing id. At least one must be provided."""
|
|
75
|
+
if embedding is None and metadata is None:
|
|
76
|
+
raise ValueError("At least one of embedding or metadata must be provided.")
|
|
77
|
+
if id < 0 or id >= len(self._vectors) or id in self._deleted:
|
|
78
|
+
raise IndexError(f"Id {id} is out of range or has been deleted.")
|
|
79
|
+
if embedding is not None:
|
|
80
|
+
if self._dim is not None and len(embedding) != self._dim:
|
|
81
|
+
raise ValueError(f"Dimension mismatch: expected {self._dim}, got {len(embedding)}.")
|
|
82
|
+
self._vectors[id] = _normalize(embedding)
|
|
83
|
+
if metadata is not None:
|
|
84
|
+
self._metadata[id] = metadata
|
|
85
|
+
|
|
86
|
+
def save(self) -> None:
|
|
87
|
+
"""Flush all data to disk, compacting deleted entries."""
|
|
88
|
+
active_ids = [i for i in range(len(self._vectors)) if i not in self._deleted]
|
|
89
|
+
|
|
90
|
+
if not active_ids:
|
|
91
|
+
# Wipe files if store is empty
|
|
92
|
+
for p in (self._vecs_path, self._meta_path):
|
|
93
|
+
if os.path.exists(p):
|
|
94
|
+
os.remove(p)
|
|
95
|
+
self._vectors = []
|
|
96
|
+
self._metadata = []
|
|
97
|
+
self._deleted = set()
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
dim = self._dim
|
|
101
|
+
count = len(active_ids)
|
|
102
|
+
vec_fmt = f"{dim}f"
|
|
103
|
+
|
|
104
|
+
with open(self._vecs_path, "wb") as f:
|
|
105
|
+
f.write(struct.pack(_HEADER_FMT, dim, count))
|
|
106
|
+
for i in active_ids:
|
|
107
|
+
f.write(struct.pack(vec_fmt, *self._vectors[i]))
|
|
108
|
+
|
|
109
|
+
meta_out = [self._metadata[i] for i in active_ids]
|
|
110
|
+
with open(self._meta_path, "w", encoding="utf-8") as f:
|
|
111
|
+
json.dump(meta_out, f)
|
|
112
|
+
|
|
113
|
+
# Compact in-memory state to match saved state
|
|
114
|
+
self._vectors = [self._vectors[i] for i in active_ids]
|
|
115
|
+
self._metadata = meta_out
|
|
116
|
+
self._deleted = set()
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
# Search API
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def search(
|
|
123
|
+
self,
|
|
124
|
+
query: list[float],
|
|
125
|
+
top_k: int = 5,
|
|
126
|
+
radius: float | None = None,
|
|
127
|
+
filter: dict[str, Any] | None = None,
|
|
128
|
+
) -> list[tuple[dict[str, Any], float]]:
|
|
129
|
+
"""
|
|
130
|
+
Return up to top_k results as [(metadata, score)], highest score first.
|
|
131
|
+
If radius is set, return all results with score >= radius (top_k ignored).
|
|
132
|
+
filter: only consider vectors whose metadata contains all key-value pairs.
|
|
133
|
+
"""
|
|
134
|
+
if not self._vectors:
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
q = _normalize(query)
|
|
138
|
+
results = []
|
|
139
|
+
|
|
140
|
+
for i, vec in enumerate(self._vectors):
|
|
141
|
+
if i in self._deleted:
|
|
142
|
+
continue
|
|
143
|
+
if filter and not _matches(self._metadata[i], filter):
|
|
144
|
+
continue
|
|
145
|
+
score = _dot(q, vec)
|
|
146
|
+
results.append((self._metadata[i], score))
|
|
147
|
+
|
|
148
|
+
if radius is not None:
|
|
149
|
+
results = [(m, s) for m, s in results if s >= radius]
|
|
150
|
+
results.sort(key=lambda x: x[1], reverse=True)
|
|
151
|
+
else:
|
|
152
|
+
results.sort(key=lambda x: x[1], reverse=True)
|
|
153
|
+
results = results[:top_k]
|
|
154
|
+
|
|
155
|
+
return results
|
|
156
|
+
|
|
157
|
+
def search_many(
|
|
158
|
+
self,
|
|
159
|
+
queries: list[list[float]],
|
|
160
|
+
top_k: int = 5,
|
|
161
|
+
radius: float | None = None,
|
|
162
|
+
filter: dict[str, Any] | None = None,
|
|
163
|
+
) -> list[list[tuple[dict[str, Any], float]]]:
|
|
164
|
+
"""Batch search. Returns a list of result lists, one per query."""
|
|
165
|
+
return [self.search(q, top_k=top_k, radius=radius, filter=filter) for q in queries]
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
# Introspection
|
|
169
|
+
# ------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def __len__(self) -> int:
|
|
172
|
+
return len(self._vectors) - len(self._deleted)
|
|
173
|
+
|
|
174
|
+
def __repr__(self) -> str:
|
|
175
|
+
return f"VectorStore(path={self._path!r}, vectors={len(self)}, dim={self._dim})"
|
|
176
|
+
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
# Internal
|
|
179
|
+
# ------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def _load(self) -> None:
|
|
182
|
+
with open(self._vecs_path, "rb") as f:
|
|
183
|
+
dim, count = struct.unpack(_HEADER_FMT, f.read(_HEADER_SIZE))
|
|
184
|
+
self._dim = dim
|
|
185
|
+
vec_fmt = f"{dim}f"
|
|
186
|
+
vec_size = struct.calcsize(vec_fmt)
|
|
187
|
+
self._vectors = [list(struct.unpack(vec_fmt, f.read(vec_size))) for _ in range(count)]
|
|
188
|
+
|
|
189
|
+
with open(self._meta_path, "r", encoding="utf-8") as f:
|
|
190
|
+
self._metadata = json.load(f)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _matches(metadata: dict[str, Any], filter: dict[str, Any]) -> bool:
|
|
194
|
+
return all(metadata.get(k) == v for k, v in filter.items())
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: baked-vector
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zero-dependency vector store for small-scale embedding search.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/sunnytuladhar/baked-vector
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Provides-Extra: st
|
|
10
|
+
Requires-Dist: sentence-transformers>=3.0; extra == "st"
|
|
11
|
+
Provides-Extra: openai
|
|
12
|
+
Requires-Dist: openai>=1.0; extra == "openai"
|
|
13
|
+
Provides-Extra: bedrock
|
|
14
|
+
Requires-Dist: boto3>=1.34; extra == "bedrock"
|
|
15
|
+
Provides-Extra: google
|
|
16
|
+
Requires-Dist: google-generativeai>=0.8; extra == "google"
|
|
17
|
+
Provides-Extra: anthropic
|
|
18
|
+
Requires-Dist: anthropic>=0.40; extra == "anthropic"
|
|
19
|
+
|
|
20
|
+
# baked-vector
|
|
21
|
+
|
|
22
|
+
Zero-dependency vector store for small-scale embedding search. No numpy, no server, no setup — just a binary file and a JSON file baked into your project.
|
|
23
|
+
|
|
24
|
+
Designed for under 10,000 vectors. Bring your own embeddings.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install baked-vector
|
|
30
|
+
# or
|
|
31
|
+
uv add baked-vector
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import baked
|
|
38
|
+
|
|
39
|
+
store = baked.VectorStore("mydata")
|
|
40
|
+
|
|
41
|
+
# add vectors with arbitrary metadata
|
|
42
|
+
store.add(embedding, {"source": "doc1.pdf", "page": 3, "text": "..."})
|
|
43
|
+
store.save()
|
|
44
|
+
|
|
45
|
+
# reload from disk automatically
|
|
46
|
+
store = baked.VectorStore("mydata")
|
|
47
|
+
|
|
48
|
+
# search
|
|
49
|
+
results = store.search(query_embedding, top_k=5)
|
|
50
|
+
# [(metadata_dict, score), ...]
|
|
51
|
+
|
|
52
|
+
# filter by metadata
|
|
53
|
+
results = store.search(query_embedding, top_k=5, filter={"source": "doc1.pdf"})
|
|
54
|
+
|
|
55
|
+
# range search — all results with score >= threshold
|
|
56
|
+
results = store.search(query_embedding, radius=0.85)
|
|
57
|
+
|
|
58
|
+
# batch search
|
|
59
|
+
results = store.search_many([vec1, vec2], top_k=5)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## API
|
|
63
|
+
|
|
64
|
+
### `VectorStore(path)`
|
|
65
|
+
|
|
66
|
+
Loads from `<path>.vecs` and `<path>.json` if they exist, otherwise starts empty.
|
|
67
|
+
|
|
68
|
+
| Method | Description |
|
|
69
|
+
|--------|-------------|
|
|
70
|
+
| `add(embedding, metadata)` | Add a vector. Returns its id. |
|
|
71
|
+
| `remove(id)` | Soft-delete. Takes effect on next `save()`. |
|
|
72
|
+
| `save()` | Flush to disk, compacting deleted entries. |
|
|
73
|
+
| `search(query, top_k, radius, filter)` | Find similar vectors. |
|
|
74
|
+
| `search_many(queries, top_k, radius, filter)` | Batch search. |
|
|
75
|
+
| `len(store)` | Number of active vectors. |
|
|
76
|
+
|
|
77
|
+
### Search parameters
|
|
78
|
+
|
|
79
|
+
- `top_k` — return top N results (default: 5)
|
|
80
|
+
- `radius` — return all results with score ≥ threshold (overrides `top_k`)
|
|
81
|
+
- `filter` — dict of key-value pairs that must match the metadata exactly
|
|
82
|
+
|
|
83
|
+
Scores are cosine similarity in range [-1, 1]. Higher = more similar.
|
|
84
|
+
|
|
85
|
+
## Storage format
|
|
86
|
+
|
|
87
|
+
Two files:
|
|
88
|
+
|
|
89
|
+
- `<path>.vecs` — binary packed floats. 8-byte header (dim, count), then N × dim × 4 bytes.
|
|
90
|
+
- `<path>.json` — JSON array of metadata dicts, one per vector.
|
|
91
|
+
|
|
92
|
+
Vectors are L2-normalized at insert time. IDs are positional and shift after `save()` following a `remove()`.
|
|
93
|
+
|
|
94
|
+
## Performance
|
|
95
|
+
|
|
96
|
+
Pure Python, brute-force scan. Rough query times:
|
|
97
|
+
|
|
98
|
+
| Vectors | 384-dim | 768-dim | 1536-dim |
|
|
99
|
+
|---------|---------|---------|----------|
|
|
100
|
+
| 1k | <0.1s | <0.1s | ~0.1s |
|
|
101
|
+
| 5k | ~0.1s | ~0.3s | ~0.5s |
|
|
102
|
+
| 10k | ~0.3s | ~0.6s | ~1–2s |
|
|
103
|
+
|
|
104
|
+
## EmbeddingStore
|
|
105
|
+
|
|
106
|
+
`EmbeddingStore` wraps `VectorStore` and handles embedding automatically. Install an extra for your provider, pass the adapter, and work with text directly.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
import baked
|
|
110
|
+
|
|
111
|
+
embed = baked.openai_embedder() # see adapters below
|
|
112
|
+
store = baked.EmbeddingStore("mydata", embedder=embed)
|
|
113
|
+
|
|
114
|
+
store.add("Wealth is not money...", {"title": "How to Make Wealth"})
|
|
115
|
+
store.save()
|
|
116
|
+
|
|
117
|
+
results = store.search("how startups create value", top_k=3)
|
|
118
|
+
# [({"text": "...", "title": "..."}, score), ...]
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`EmbeddingStore` exposes the same `add`, `remove`, `save`, `search`, and `len` interface as `VectorStore`, but takes `str` instead of `list[float]`. The original text is stored in metadata under `"text"` automatically.
|
|
122
|
+
|
|
123
|
+
### Adapters
|
|
124
|
+
|
|
125
|
+
| Provider | Function | Install extra |
|
|
126
|
+
|----------|----------|---------------|
|
|
127
|
+
| OpenAI | `openai_embedder(model, api_key)` | `baked-vector[openai]` |
|
|
128
|
+
| Azure OpenAI | `azure_openai_embedder(deployment, endpoint, api_key, api_version)` | `baked-vector[openai]` |
|
|
129
|
+
| AWS Bedrock | `bedrock_embedder(model_id, region_name)` | `baked-vector[bedrock]` |
|
|
130
|
+
| Google | `google_embedder(model, api_key)` | `baked-vector[google]` |
|
|
131
|
+
| Local (sentence-transformers) | `sentence_transformer_embedder(model_name)` | `baked-vector[st]` |
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
pip install baked-vector[openai]
|
|
135
|
+
pip install baked-vector[bedrock]
|
|
136
|
+
pip install baked-vector[google]
|
|
137
|
+
pip install baked-vector[st]
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Defaults: OpenAI → `text-embedding-3-small`, Bedrock → `amazon.titan-embed-text-v2:0`, Google → `models/text-embedding-004`, local → `all-MiniLM-L6-v2`.
|
|
141
|
+
|
|
142
|
+
You can also pass any `(str) -> list[float]` callable as `embedder` — no adapter required.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Providers (manual)
|
|
147
|
+
|
|
148
|
+
baked-vector stores and searches vectors — bring embeddings from any provider.
|
|
149
|
+
|
|
150
|
+
### OpenAI
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from openai import OpenAI
|
|
154
|
+
|
|
155
|
+
client = OpenAI()
|
|
156
|
+
|
|
157
|
+
def embed(text: str) -> list[float]:
|
|
158
|
+
resp = client.embeddings.create(model="text-embedding-3-small", input=text)
|
|
159
|
+
return resp.data[0].embedding
|
|
160
|
+
|
|
161
|
+
store.add(embed("hello world"), {"text": "hello world"})
|
|
162
|
+
results = store.search(embed("hi there"), top_k=5)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### AWS Bedrock (Titan)
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
import json
|
|
169
|
+
import boto3
|
|
170
|
+
|
|
171
|
+
bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")
|
|
172
|
+
|
|
173
|
+
def embed(text: str) -> list[float]:
|
|
174
|
+
resp = bedrock.invoke_model(
|
|
175
|
+
modelId="amazon.titan-embed-text-v2:0",
|
|
176
|
+
body=json.dumps({"inputText": text, "dimensions": 512}),
|
|
177
|
+
)
|
|
178
|
+
return json.loads(resp["body"].read())["embedding"]
|
|
179
|
+
|
|
180
|
+
store.add(embed("hello world"), {"text": "hello world"})
|
|
181
|
+
results = store.search(embed("hi there"), top_k=5)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Azure OpenAI
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
from openai import AzureOpenAI
|
|
188
|
+
|
|
189
|
+
client = AzureOpenAI(
|
|
190
|
+
api_key="YOUR_KEY",
|
|
191
|
+
api_version="2024-02-01",
|
|
192
|
+
azure_endpoint="https://YOUR_RESOURCE.openai.azure.com",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def embed(text: str) -> list[float]:
|
|
196
|
+
resp = client.embeddings.create(model="YOUR_DEPLOYMENT_NAME", input=text)
|
|
197
|
+
return resp.data[0].embedding
|
|
198
|
+
|
|
199
|
+
store.add(embed("hello world"), {"text": "hello world"})
|
|
200
|
+
results = store.search(embed("hi there"), top_k=5)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Cohere
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
import cohere
|
|
207
|
+
|
|
208
|
+
co = cohere.Client("YOUR_API_KEY")
|
|
209
|
+
|
|
210
|
+
def embed(text: str) -> list[float]:
|
|
211
|
+
resp = co.embed(texts=[text], model="embed-english-v3.0", input_type="search_document")
|
|
212
|
+
return resp.embeddings[0]
|
|
213
|
+
|
|
214
|
+
store.add(embed("hello world"), {"text": "hello world"})
|
|
215
|
+
|
|
216
|
+
# use input_type="search_query" for query embeddings
|
|
217
|
+
results = store.search(
|
|
218
|
+
co.embed(texts=["hi there"], model="embed-english-v3.0", input_type="search_query").embeddings[0],
|
|
219
|
+
top_k=5,
|
|
220
|
+
)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## License
|
|
224
|
+
|
|
225
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
baked/__init__.py,sha256=6oq1qKrl91TlZAsSTjwREHpEXgudXjfqa9cch2CGXxM,614
|
|
2
|
+
baked/chunking.py,sha256=RSpuKyt5N_aYGadTlqwuAWL-d9p0ZAwJJHnX4NBXBUI,8962
|
|
3
|
+
baked/embed.py,sha256=7QRmjCZEgcdAVQ48tKnJ6fyt3mOXRQdIDW9Cqw9RwlQ,5464
|
|
4
|
+
baked/store.py,sha256=OB_0EFST4CU8oNl0gkyxQ99IxfP1XIE1xq-qex7wc0w,7485
|
|
5
|
+
baked/evals/__init__.py,sha256=qPx_cSYt15YdWi62ZIsxnnO7GrJ6MYl-OvPtpHvBvqo,1295
|
|
6
|
+
baked/evals/harness.py,sha256=qRLKC7rcgvifnTSzd4Wy-aOZvxIE-0pgd2HbAzzCLSw,4642
|
|
7
|
+
baked/evals/synthetic.py,sha256=ItJa5qM5Hmtyqh7oqOPNLiQaMMQC28N3Zl8lOIa3NKQ,4838
|
|
8
|
+
baked_vector-0.1.0.dist-info/METADATA,sha256=O4kheCxsLylvXE3gORcN5488d7iTRwOvdnt7NvoD78A,6777
|
|
9
|
+
baked_vector-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
10
|
+
baked_vector-0.1.0.dist-info/top_level.txt,sha256=NY-Zme5Vc7zB6CFbZVzkTmAZAhJYe4-smYnQie3ax1g,6
|
|
11
|
+
baked_vector-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
baked
|