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 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
@@ -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]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ baked