esp32kb 0.1.0__tar.gz

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.
esp32kb-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.3
2
+ Name: esp32kb
3
+ Version: 0.1.0
4
+ Summary: Build an on-device knowledge base (text chunks + INT8 MiniLM embeddings) for the ESP32-P4 vector-search demo.
5
+ Keywords: esp32,esp32-p4,embeddings,vector-search,tinyml,onnx,minilm
6
+ Author: Simone Salerno
7
+ Author-email: Simone Salerno <info@salernosimone.com>
8
+ License: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Classifier: Topic :: Software Development :: Embedded Systems
16
+ Requires-Dist: numpy
17
+ Requires-Dist: onnxruntime
18
+ Requires-Dist: transformers
19
+ Requires-Dist: typer
20
+ Requires-Python: >=3.10
21
+ Project-URL: Homepage, https://github.com/salernosimone/python-esp32kb
22
+ Project-URL: Repository, https://github.com/salernosimone/python-esp32kb
23
+ Project-URL: Issues, https://github.com/salernosimone/python-esp32kb/issues
24
+ Description-Content-Type: text/markdown
25
+
26
+ # esp32kb
27
+
28
+ Build an on-device knowledge base for the KB ESP32-P4 demo — a MiniLM-L3
29
+ embedding search that runs entirely on the board, with no PC attached at
30
+ runtime.
31
+
32
+ Point the tool at a folder of markdown/text documents and it emits everything
33
+ the firmware flashes: sentence-aware chunks, INT8 embeddings, the packed
34
+ `kb.img` container (vectors + text + file index + WordPiece vocabulary) and
35
+ the tiny generated headers the sketch compiles against.
36
+
37
+ ## Install
38
+
39
+ ```sh
40
+ uvx esp32kb --help # or: pip install esp32kb
41
+ # from a checkout:
42
+ uv run --project esp32kb esp32kb --help
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ ```sh
48
+ # 1. documents -> kb.img + generated headers
49
+ esp32kb embed --root path/to/documents --sketch-dir path/to/Arduino/project
50
+
51
+ # 2. print the arduino-cli command that flashes app + model + kb.img
52
+ esp32kb flash --sketch-dir path/to/Arduino/project # then answer "y" to run it
53
+ ```
54
+
55
+ Everything defaults relative to `--sketch-dir` (the current folder by default),
56
+ so the demo ships with its MiniLM-L3 model and one flag is enough.
57
+
58
+ ### `esp32kb embed`
59
+
60
+ | flag | default | meaning |
61
+ |------|---------|---------|
62
+ | `--root` / `-r` | required | folder with the documents to index (recursively) |
63
+ | `--output-folder` / `-o` | `<sketch-dir>/model/kb` | where the `.bin` files and `kb.img` are written |
64
+ | `--sketch-dir` / `-s` | current folder | holds `KB.ino`/`VectorSearch.h`; `kb_config.h` and `kb_queries.h` go here |
65
+ | `--onnx` | `<sketch-dir>/model/maskseq.onnx` | float32 MiniLM-L3 the PC embeds with |
66
+ | `--queries` / `-q` | sampled from the corpus | text file, one demo query per line |
67
+
68
+ ### `esp32kb flash`
69
+
70
+ | flag | default | meaning |
71
+ |------|---------|---------|
72
+ | `--sketch-dir` / `-s` | current folder | the sketch to compile |
73
+ | `--model` | `<sketch-dir>/model/minilm_l3.espdl` | INT8 model to flash |
74
+ | `--kb` | `<sketch-dir>/model/kb/kb.img` | knowledge base to flash |
75
+ | `--port` / `-p` | `$PORT` or `/dev/ttyUSB0` | serial port |
76
+ | `--fqbn` | P4 board spec | arduino-cli board spec |
77
+ | `--library` | `$ARDUINO_LIBS` | extra Arduino library folder |
78
+ | `--yes` / `-y` | off | run without asking |
79
+ | `--print-only` | off | print the command and exit |
80
+ | `--port` | `$PORT` or `/dev/ttyUSB0` | serial port for the printed flash command |
81
+ | `--fqbn` | esp32p4 FQBN | board spec for the printed flash command |
82
+
83
+ The sequence length and embedding width are read straight from the ONNX model,
84
+ so the chunks, the INT8 vectors **and** the generated `kb_config.h` the
85
+ firmware compiles against can never disagree with the model. No flags to keep
86
+ in sync.
87
+
88
+ ## Pipeline
89
+
90
+ ```
91
+ documents
92
+ -> chunks (<= seq-2 WordPiece tokens each)
93
+ -> MiniLM-L3 embeddings (masked mean pool, L2 normalized)
94
+ -> symmetric INT8 quantization, one global scale
95
+ -> vectors.bin / meta.bin / chunks.bin / files.bin / vocab.txt
96
+ -> kb.img single little-endian container with a section table
97
+ -> kb_config.h KB_SEQ / KB_DIM / KB_TOPK + container magics (C++ constants)
98
+ -> kb_queries.h KB_EXAMPLES[] example query texts for the sketch
99
+ ```
100
+
101
+ The vocabulary travels inside `kb.img`, so the device tokenizes queries itself.
102
+
103
+ ## Additive attention mask
104
+
105
+ If the ONNX model exposes a second `attention_mask` input (the variant built
106
+ by `model/export_onnx_mask.py`), `esp32kb` feeds it the correct per-query
107
+ padding bias (0 on real tokens, negative on `[PAD]`). This preserves retrieval
108
+ quality at longer sequence lengths, where the frozen all-ones mask used by the
109
+ plain export otherwise collapses query embeddings. With the stock single-input
110
+ ONNX the behavior is unchanged.
111
+
112
+ ## Outputs
113
+
114
+ | file | contents |
115
+ |------|----------|
116
+ | `vectors.bin` | 32 B header + N × dim INT8 (16-byte aligned rows) |
117
+ | `meta.bin` | 16 B header + N × 16 B records (file id, text offset/length) |
118
+ | `chunks.bin` | 16 B header + concatenated UTF-8 chunk text |
119
+ | `files.bin` | 16 B header + N × padded filename records |
120
+ | `vocab.txt` | WordPiece vocabulary, one token per line |
121
+ | `kb.img` | container: magic/version/section table + the payloads above |
122
+ | `kb_config.h` | `KB_SEQ`, `KB_DIM`, `KB_TOPK` and the container magics |
123
+ | `kb_queries.h` | `KB_EXAMPLES[]` example query texts for the sketch |
@@ -0,0 +1,98 @@
1
+ # esp32kb
2
+
3
+ Build an on-device knowledge base for the KB ESP32-P4 demo — a MiniLM-L3
4
+ embedding search that runs entirely on the board, with no PC attached at
5
+ runtime.
6
+
7
+ Point the tool at a folder of markdown/text documents and it emits everything
8
+ the firmware flashes: sentence-aware chunks, INT8 embeddings, the packed
9
+ `kb.img` container (vectors + text + file index + WordPiece vocabulary) and
10
+ the tiny generated headers the sketch compiles against.
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ uvx esp32kb --help # or: pip install esp32kb
16
+ # from a checkout:
17
+ uv run --project esp32kb esp32kb --help
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```sh
23
+ # 1. documents -> kb.img + generated headers
24
+ esp32kb embed --root path/to/documents --sketch-dir path/to/Arduino/project
25
+
26
+ # 2. print the arduino-cli command that flashes app + model + kb.img
27
+ esp32kb flash --sketch-dir path/to/Arduino/project # then answer "y" to run it
28
+ ```
29
+
30
+ Everything defaults relative to `--sketch-dir` (the current folder by default),
31
+ so the demo ships with its MiniLM-L3 model and one flag is enough.
32
+
33
+ ### `esp32kb embed`
34
+
35
+ | flag | default | meaning |
36
+ |------|---------|---------|
37
+ | `--root` / `-r` | required | folder with the documents to index (recursively) |
38
+ | `--output-folder` / `-o` | `<sketch-dir>/model/kb` | where the `.bin` files and `kb.img` are written |
39
+ | `--sketch-dir` / `-s` | current folder | holds `KB.ino`/`VectorSearch.h`; `kb_config.h` and `kb_queries.h` go here |
40
+ | `--onnx` | `<sketch-dir>/model/maskseq.onnx` | float32 MiniLM-L3 the PC embeds with |
41
+ | `--queries` / `-q` | sampled from the corpus | text file, one demo query per line |
42
+
43
+ ### `esp32kb flash`
44
+
45
+ | flag | default | meaning |
46
+ |------|---------|---------|
47
+ | `--sketch-dir` / `-s` | current folder | the sketch to compile |
48
+ | `--model` | `<sketch-dir>/model/minilm_l3.espdl` | INT8 model to flash |
49
+ | `--kb` | `<sketch-dir>/model/kb/kb.img` | knowledge base to flash |
50
+ | `--port` / `-p` | `$PORT` or `/dev/ttyUSB0` | serial port |
51
+ | `--fqbn` | P4 board spec | arduino-cli board spec |
52
+ | `--library` | `$ARDUINO_LIBS` | extra Arduino library folder |
53
+ | `--yes` / `-y` | off | run without asking |
54
+ | `--print-only` | off | print the command and exit |
55
+ | `--port` | `$PORT` or `/dev/ttyUSB0` | serial port for the printed flash command |
56
+ | `--fqbn` | esp32p4 FQBN | board spec for the printed flash command |
57
+
58
+ The sequence length and embedding width are read straight from the ONNX model,
59
+ so the chunks, the INT8 vectors **and** the generated `kb_config.h` the
60
+ firmware compiles against can never disagree with the model. No flags to keep
61
+ in sync.
62
+
63
+ ## Pipeline
64
+
65
+ ```
66
+ documents
67
+ -> chunks (<= seq-2 WordPiece tokens each)
68
+ -> MiniLM-L3 embeddings (masked mean pool, L2 normalized)
69
+ -> symmetric INT8 quantization, one global scale
70
+ -> vectors.bin / meta.bin / chunks.bin / files.bin / vocab.txt
71
+ -> kb.img single little-endian container with a section table
72
+ -> kb_config.h KB_SEQ / KB_DIM / KB_TOPK + container magics (C++ constants)
73
+ -> kb_queries.h KB_EXAMPLES[] example query texts for the sketch
74
+ ```
75
+
76
+ The vocabulary travels inside `kb.img`, so the device tokenizes queries itself.
77
+
78
+ ## Additive attention mask
79
+
80
+ If the ONNX model exposes a second `attention_mask` input (the variant built
81
+ by `model/export_onnx_mask.py`), `esp32kb` feeds it the correct per-query
82
+ padding bias (0 on real tokens, negative on `[PAD]`). This preserves retrieval
83
+ quality at longer sequence lengths, where the frozen all-ones mask used by the
84
+ plain export otherwise collapses query embeddings. With the stock single-input
85
+ ONNX the behavior is unchanged.
86
+
87
+ ## Outputs
88
+
89
+ | file | contents |
90
+ |------|----------|
91
+ | `vectors.bin` | 32 B header + N × dim INT8 (16-byte aligned rows) |
92
+ | `meta.bin` | 16 B header + N × 16 B records (file id, text offset/length) |
93
+ | `chunks.bin` | 16 B header + concatenated UTF-8 chunk text |
94
+ | `files.bin` | 16 B header + N × padded filename records |
95
+ | `vocab.txt` | WordPiece vocabulary, one token per line |
96
+ | `kb.img` | container: magic/version/section table + the payloads above |
97
+ | `kb_config.h` | `KB_SEQ`, `KB_DIM`, `KB_TOPK` and the container magics |
98
+ | `kb_queries.h` | `KB_EXAMPLES[]` example query texts for the sketch |
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "esp32kb"
3
+ version = "0.1.0"
4
+ description = "Build an on-device knowledge base (text chunks + INT8 MiniLM embeddings) for the ESP32-P4 vector-search demo."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ keywords = [
8
+ "esp32",
9
+ "esp32-p4",
10
+ "embeddings",
11
+ "vector-search",
12
+ "tinyml",
13
+ "onnx",
14
+ "minilm",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
23
+ "Topic :: Software Development :: Embedded Systems",
24
+ ]
25
+ dependencies = [
26
+ "numpy",
27
+ "onnxruntime",
28
+ "transformers",
29
+ "typer",
30
+ ]
31
+
32
+ [project.license]
33
+ text = "MIT"
34
+
35
+ [[project.authors]]
36
+ name = "Simone Salerno"
37
+ email = "info@salernosimone.com"
38
+
39
+ [project.scripts]
40
+ esp32kb = "esp32kb.cli:main"
41
+
42
+ [project.urls]
43
+ Homepage = "https://github.com/salernosimone/python-esp32kb"
44
+ Repository = "https://github.com/salernosimone/python-esp32kb"
45
+ Issues = "https://github.com/salernosimone/python-esp32kb/issues"
46
+
47
+ [build-system]
48
+ requires = ["uv_build>=0.12.17,<0.13.0"]
49
+ build-backend = "uv_build"
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "esp32kb"
3
+ version = "0.1.0"
4
+ description = "Build an on-device knowledge base (text chunks + INT8 MiniLM embeddings) for the ESP32-P4 vector-search demo."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [
9
+ { name = "Simone Salerno", email = "info@salernosimone.com" },
10
+ ]
11
+ keywords = ["esp32", "esp32-p4", "embeddings", "vector-search", "tinyml", "onnx", "minilm"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Environment :: Console",
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
19
+ "Topic :: Software Development :: Embedded Systems",
20
+ ]
21
+ dependencies = [
22
+ "numpy",
23
+ # CPU wheel; onnxruntime-gpu is a separate package and is not used.
24
+ "onnxruntime",
25
+ # Only AutoTokenizer is used, so PyTorch is never pulled in.
26
+ "transformers",
27
+ "typer",
28
+ ]
29
+
30
+ [project.scripts]
31
+ esp32kb = "esp32kb.cli:main"
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/salernosimone/python-esp32kb"
35
+ Repository = "https://github.com/salernosimone/python-esp32kb"
36
+ Issues = "https://github.com/salernosimone/python-esp32kb/issues"
37
+
38
+ [build-system]
39
+ requires = ["uv_build>=0.12.17,<0.13.0"]
40
+ build-backend = "uv_build"
@@ -0,0 +1,14 @@
1
+ """esp32kb: build an on-device knowledge base for the KB ESP32-P4 demo.
2
+
3
+ The command line interface has two subcommands:
4
+
5
+ esp32kb embed --root DOCS [--output-folder DIR] [--sketch-dir DIR]
6
+ esp32kb flash [--sketch-dir DIR] [-y]
7
+
8
+ `embed` turns a folder of documents into a packed `kb.img` plus the generated
9
+ `kb_config.h` / `kb_queries.h` the firmware compiles against. `flash` prints
10
+ the arduino-cli command that flashes the sketch, the model and `kb.img`, and
11
+ offers to run it.
12
+ """
13
+
14
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,476 @@
1
+ """Embed documents and pack them into the firmware's knowledge base image.
2
+
3
+ The whole pipeline runs on the PC; the ESP32 only reads the result:
4
+
5
+ documents
6
+ -> chunks (<= seq-2 WordPiece tokens each)
7
+ -> MiniLM-L3 embeddings (masked mean pool, L2 normalized)
8
+ -> symmetric INT8 quantization with one global scale
9
+ -> vectors.bin / meta.bin / chunks.bin / files.bin / vocab.txt
10
+ -> kb.img all payloads in one little-endian container
11
+ -> kb_config.h build constants for VectorSearch.h (seq, dim, magics)
12
+ -> kb_queries.h example query texts for the sketch
13
+
14
+ The sequence length and embedding width are read straight from the ONNX model,
15
+ so `kb.img` and `kb_config.h` can never disagree with the model the firmware
16
+ loads. The vocabulary travels inside `kb.img`, so the device tokenizes queries
17
+ itself and no PC is needed at runtime.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import glob
23
+ import os
24
+ import re
25
+ import struct
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ import numpy as np
30
+ import onnxruntime as ort
31
+ from transformers import AutoTokenizer
32
+
33
+ MODEL_ID = "sentence-transformers/paraphrase-MiniLM-L3-v2"
34
+ FALLBACK_SEQ = 64 # used only if the ONNX has dynamic axes
35
+ FALLBACK_DIM = 384
36
+ KB_TOPK = 5 # hits the firmware keeps
37
+ DOCUMENT_SUFFIXES = {".md", ".markdown", ".txt"}
38
+
39
+ # on-disk container constants; kb_config.h is generated from these so packer
40
+ # and firmware can never disagree.
41
+ KB_MAGIC = 0x3130424B
42
+ VEC_MAGIC = 0x3156424B
43
+ META_MAGIC = 0x314D424B
44
+ CHUNK_MAGIC = 0x3143424B
45
+ FILE_MAGIC = 0x3146424B
46
+ VOCAB_MAGIC = 0x3142424B
47
+ SEC_VECTORS, SEC_META, SEC_CHUNKS, SEC_FILES, SEC_VOCAB = 1, 2, 3, 4, 5
48
+
49
+ # flash layout: must match partitions.csv
50
+ MODEL_OFFSET = 0x510000
51
+ KB_OFFSET = 0x1910000
52
+ KB_PARTITION_SIZE = 0x600000
53
+ DEFAULT_FQBN = (
54
+ "esp32:esp32:esp32p4:ChipVariant=postv3,FlashSize=32M,"
55
+ "PSRAM=enabled,PartitionScheme=custom"
56
+ )
57
+
58
+
59
+ # ---------------------------------------------------------------- data model
60
+
61
+ @dataclass
62
+ class Document:
63
+ filename: str
64
+ text: str
65
+
66
+
67
+ @dataclass
68
+ class Chunk:
69
+ text: str
70
+ filename: str
71
+ index: int
72
+ embedding: np.ndarray | None = None
73
+ embedding_int8: np.ndarray | None = None
74
+
75
+
76
+ @dataclass
77
+ class BuildResult:
78
+ kb_image: Path
79
+ config_header: Path
80
+ queries_header: Path
81
+ chunks: int
82
+ files: int
83
+ seq: int
84
+ dim: int
85
+ scale: float
86
+
87
+
88
+ def _fixed_dim(shape, axis: int, fallback: int) -> int:
89
+ """A concrete value from an ONNX tensor shape, or `fallback` if dynamic."""
90
+ try:
91
+ dim = shape[axis]
92
+ return int(dim) if dim is not None and int(dim) > 0 else fallback
93
+ except (IndexError, TypeError, ValueError):
94
+ return fallback
95
+
96
+
97
+ def stride_of(dim: int) -> int:
98
+ """16-byte aligned vector row length (esp-nn wants aligned rows)."""
99
+ return (dim + 15) // 16 * 16
100
+
101
+
102
+ # ---------------------------------------------------------------- embedding
103
+
104
+ class Embedder:
105
+ """MiniLM-L3: text -> L2-normalized vector, mirroring the firmware.
106
+
107
+ The ONNX model returns the full token sequence [1, seq, dim]; pooling is
108
+ done here (masked mean) exactly like VectorSearch.h does on the device.
109
+ """
110
+
111
+ # Additive padding bias fed to the "attention_mask" input of the
112
+ # additive-mask ONNX variant (see model/export_onnx_mask.py). Large enough
113
+ # that the Softmax over [PAD] keys saturates to ~0.
114
+ MASK_NEG = -30.0
115
+
116
+ def __init__(self, onnx_path: str | os.PathLike):
117
+ if not os.path.isfile(onnx_path):
118
+ raise FileNotFoundError(onnx_path)
119
+ self.tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
120
+ self.session = ort.InferenceSession(
121
+ str(onnx_path), providers=["CPUExecutionProvider"]
122
+ )
123
+ self.input_names = {i.name for i in self.session.get_inputs()}
124
+ # The ONNX is the single source of truth for the geometry.
125
+ self.seq = _fixed_dim(self.session.get_inputs()[0].shape, 1, FALLBACK_SEQ)
126
+ self.dim = _fixed_dim(self.session.get_outputs()[0].shape, -1, FALLBACK_DIM)
127
+
128
+ def tokenize(self, text: str) -> tuple[np.ndarray, int]:
129
+ """Return (padded int64 token ids, number of real tokens)."""
130
+ encoded = self.tokenizer(
131
+ text,
132
+ padding="max_length",
133
+ truncation=True,
134
+ max_length=self.seq,
135
+ return_tensors="np",
136
+ )
137
+ ids = encoded["input_ids"][0].astype(np.int64)
138
+ length = int(encoded["attention_mask"][0].sum())
139
+ return ids, length
140
+
141
+ def embed(self, text: str) -> np.ndarray:
142
+ """Return the normalized embedding of one piece of text."""
143
+ ids, length = self.tokenize(text)
144
+ feeds = {"input_ids": ids[None]}
145
+ if "attention_mask" in self.input_names:
146
+ bias = np.zeros((1, 1, 1, self.seq), dtype=np.float32)
147
+ bias[0, 0, 0, length:] = self.MASK_NEG
148
+ feeds["attention_mask"] = bias
149
+ hidden = self.session.run(None, feeds)[0][0] # seq x dim
150
+ vector = hidden[:length].mean(axis=0)
151
+ return vector / (np.linalg.norm(vector) + 1e-9)
152
+
153
+ def vocabulary(self) -> bytes:
154
+ """The WordPiece vocab as vocab.txt: one token per line, id = line."""
155
+ vocab = self.tokenizer.get_vocab()
156
+ ordered = sorted(vocab.items(), key=lambda item: item[1])
157
+ return ("\n".join(token for token, _ in ordered) + "\n").encode("utf-8")
158
+
159
+
160
+ # ---------------------------------------------------------------- chunking
161
+
162
+ def count_tokens(tokenizer, text: str) -> int:
163
+ return len(tokenizer(text, add_special_tokens=False)["input_ids"])
164
+
165
+
166
+ def split_markdown_into_fragments(text: str, tokenizer, max_tokens: int) -> list[str]:
167
+ """Cut text into sentence/line fragments no longer than max_tokens."""
168
+ fragments = []
169
+ for raw in re.split(r"(?<=[.!?])\s+|\n+", text):
170
+ fragment = raw.strip()
171
+ if not fragment:
172
+ continue
173
+ if count_tokens(tokenizer, fragment) <= max_tokens:
174
+ fragments.append(fragment)
175
+ else:
176
+ fragments.extend(split_oversize_fragment(fragment, tokenizer, max_tokens))
177
+ return fragments
178
+
179
+
180
+ def split_oversize_fragment(fragment: str, tokenizer, max_tokens: int) -> list[str]:
181
+ """An oversize fragment is packed word by word to the token budget."""
182
+ pieces, current = [], []
183
+ for word in fragment.split():
184
+ if current and count_tokens(tokenizer, " ".join(current + [word])) > max_tokens:
185
+ pieces.append(" ".join(current))
186
+ current = [word]
187
+ else:
188
+ current.append(word)
189
+ if current:
190
+ pieces.append(" ".join(current))
191
+ return pieces
192
+
193
+
194
+ def pack_fragments_into_chunks(fragments: list[str], tokenizer, max_tokens: int) -> list[str]:
195
+ """Greedily pack fragments into chunks that fit the model sequence."""
196
+ chunks, current, length = [], [], 0
197
+ for fragment in fragments:
198
+ tokens = count_tokens(tokenizer, fragment)
199
+ if tokens == 0:
200
+ continue
201
+ if current and length + tokens > max_tokens:
202
+ chunks.append(" ".join(current))
203
+ current, length = [], 0
204
+ current.append(fragment)
205
+ length += tokens
206
+ if current:
207
+ chunks.append(" ".join(current))
208
+ return chunks
209
+
210
+
211
+ def load_documents_from_folder(root: str | os.PathLike) -> list[Document]:
212
+ """Read every markdown/text file under root (recursively)."""
213
+ documents = []
214
+ for path in sorted(glob.glob(os.path.join(str(root), "**", "*"), recursive=True)):
215
+ if not os.path.isfile(path):
216
+ continue
217
+ if os.path.splitext(path)[1].lower() not in DOCUMENT_SUFFIXES:
218
+ continue
219
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
220
+ documents.append(Document(filename=os.path.relpath(path, root), text=text))
221
+ return documents
222
+
223
+
224
+ def split_into_chunks(documents: list[Document], tokenizer, max_tokens: int) -> list[Chunk]:
225
+ """Chunk every document; chunks never cross a file boundary."""
226
+ chunks = []
227
+ for document in documents:
228
+ fragments = split_markdown_into_fragments(document.text, tokenizer, max_tokens)
229
+ packed = pack_fragments_into_chunks(fragments, tokenizer, max_tokens)
230
+ for index, text in enumerate(packed):
231
+ chunks.append(Chunk(text=text, filename=document.filename, index=index))
232
+ return chunks
233
+
234
+
235
+ def embed_chunks(chunks: list[Chunk], embedder: Embedder, progress=None) -> None:
236
+ for i, chunk in enumerate(chunks):
237
+ chunk.embedding = embedder.embed(chunk.text)
238
+ if progress and (i + 1) % 100 == 0:
239
+ progress(i + 1, len(chunks))
240
+
241
+
242
+ def quantize_chunks(chunks: list[Chunk]) -> float:
243
+ """Symmetric INT8 with one global scale shared by corpus and queries."""
244
+ max_abs = max(float(np.max(np.abs(chunk.embedding))) for chunk in chunks)
245
+ scale = 127.0 / max_abs
246
+ for chunk in chunks:
247
+ chunk.embedding_int8 = np.clip(
248
+ np.rint(chunk.embedding * scale), -127, 127
249
+ ).astype(np.int8)
250
+ return scale
251
+
252
+
253
+ # ---------------------------------------------------------------- binary layout
254
+
255
+ def pack_vector_section(chunks: list[Chunk], scale: float, dim: int) -> bytes:
256
+ """32 B header + N x stride INT8 (16-byte aligned rows)."""
257
+ stride = stride_of(dim)
258
+ header = struct.pack(
259
+ "<5If2I", VEC_MAGIC, 1, dim, len(chunks), stride, float(scale), 0, 0
260
+ )
261
+ rows = b"".join(chunk.embedding_int8.tobytes() for chunk in chunks)
262
+ return header + rows
263
+
264
+
265
+ def pack_chunks_and_meta(chunks: list[Chunk]) -> tuple[bytes, bytes]:
266
+ """chunks.bin (header + UTF-8 text) and meta.bin (header + 16 B records)."""
267
+ file_ids = {name: i for i, name in enumerate(dict.fromkeys(c.filename for c in chunks))}
268
+
269
+ text = bytearray()
270
+ records = bytearray()
271
+ for chunk in chunks:
272
+ data = chunk.text.encode("utf-8")
273
+ records += struct.pack("<2H3I", file_ids[chunk.filename], 0, len(text), len(data), 0)
274
+ text += data
275
+
276
+ chunks_bin = struct.pack("<4I", CHUNK_MAGIC, 1, len(text), 0) + bytes(text)
277
+ meta_bin = struct.pack("<4I", META_MAGIC, 1, len(chunks), 0) + bytes(records)
278
+ return chunks_bin, meta_bin
279
+
280
+
281
+ def pack_files_section(chunks: list[Chunk]) -> bytes:
282
+ """16 B header + N zero-padded file-name records."""
283
+ names = list(dict.fromkeys(chunk.filename for chunk in chunks))
284
+ name_len = max((len(name) + 1 for name in names), default=1)
285
+ name_len = (name_len + 15) // 16 * 16
286
+
287
+ records = bytearray()
288
+ for name in names:
289
+ encoded = name.encode("utf-8")[: name_len - 1]
290
+ records += encoded + b"\x00" * (name_len - len(encoded))
291
+
292
+ return struct.pack("<4I", FILE_MAGIC, 1, len(names), name_len) + bytes(records)
293
+
294
+
295
+ def pack_vocab_section(vocab: bytes) -> bytes:
296
+ """16 B header + the raw vocab.txt bytes (one token per line)."""
297
+ return struct.pack("<4I", VOCAB_MAGIC, 1, len(vocab), 0) + vocab
298
+
299
+
300
+ def pack_container(sections: list[tuple[int, bytes]]) -> bytes:
301
+ """Concatenate (type, payload) sections into one little-endian image."""
302
+ image = bytearray(struct.pack("<4I", KB_MAGIC, 1, len(sections), 0))
303
+ image += b"\x00" * (16 * len(sections))
304
+
305
+ table = []
306
+ for section_type, payload in sections:
307
+ while len(image) % 16:
308
+ image.append(0)
309
+ table.append((section_type, len(image), len(payload), 0))
310
+ image += payload
311
+
312
+ for i, entry in enumerate(table):
313
+ struct.pack_into("<4I", image, 16 + 16 * i, *entry)
314
+ return bytes(image)
315
+
316
+
317
+ # ---------------------------------------------------------------- writers
318
+
319
+ def store_embeddings(chunks: list[Chunk], scale: float, dim: int, output_folder: Path) -> None:
320
+ Path(output_folder).mkdir(parents=True, exist_ok=True)
321
+ Path(output_folder, "vectors.bin").write_bytes(pack_vector_section(chunks, scale, dim))
322
+
323
+
324
+ def store_files_index(chunks: list[Chunk], output_folder: Path) -> None:
325
+ Path(output_folder, "files.bin").write_bytes(pack_files_section(chunks))
326
+
327
+
328
+ def store_texts(chunks: list[Chunk], output_folder: Path) -> None:
329
+ chunks_bin, meta_bin = pack_chunks_and_meta(chunks)
330
+ Path(output_folder, "chunks.bin").write_bytes(chunks_bin)
331
+ Path(output_folder, "meta.bin").write_bytes(meta_bin)
332
+
333
+
334
+ def store_vocab(vocab: bytes, output_folder: Path) -> None:
335
+ Path(output_folder, "vocab.txt").write_bytes(vocab)
336
+
337
+
338
+ def store_container(
339
+ chunks: list[Chunk], scale: float, vocab: bytes, dim: int, output_folder: Path
340
+ ) -> None:
341
+ """Repack every payload into kb.img for the 'kb' partition."""
342
+ chunks_bin, meta_bin = pack_chunks_and_meta(chunks)
343
+ sections = [
344
+ (SEC_VECTORS, pack_vector_section(chunks, scale, dim)),
345
+ (SEC_META, meta_bin),
346
+ (SEC_CHUNKS, chunks_bin),
347
+ (SEC_FILES, pack_files_section(chunks)),
348
+ (SEC_VOCAB, pack_vocab_section(vocab)),
349
+ ]
350
+ Path(output_folder, "kb.img").write_bytes(pack_container(sections))
351
+
352
+
353
+ def store_config_header(seq: int, dim: int, sketch_dir: Path) -> Path:
354
+ """Write kb_config.h: the build constants VectorSearch.h compiles against."""
355
+ path = Path(sketch_dir, "kb_config.h")
356
+ path.write_text(
357
+ "#pragma once\n"
358
+ "// Generated by esp32kb. Do not edit; re-run esp32kb instead.\n"
359
+ f"#define KB_SEQ {seq}\n"
360
+ f"#define KB_DIM {dim}\n"
361
+ f"#define KB_TOPK {KB_TOPK}\n"
362
+ "\n"
363
+ f"#define KB_MAGIC 0x{KB_MAGIC:08X}\n"
364
+ f"#define KB_VEC_MAGIC 0x{VEC_MAGIC:08X}\n"
365
+ f"#define KB_META_MAGIC 0x{META_MAGIC:08X}\n"
366
+ f"#define KB_CHUNK_MAGIC 0x{CHUNK_MAGIC:08X}\n"
367
+ f"#define KB_FILE_MAGIC 0x{FILE_MAGIC:08X}\n"
368
+ f"#define KB_VOCAB_MAGIC 0x{VOCAB_MAGIC:08X}\n"
369
+ "\n"
370
+ f"#define KB_SEC_VECTORS {SEC_VECTORS}\n"
371
+ f"#define KB_SEC_META {SEC_META}\n"
372
+ f"#define KB_SEC_CHUNKS {SEC_CHUNKS}\n"
373
+ f"#define KB_SEC_FILES {SEC_FILES}\n"
374
+ f"#define KB_SEC_VOCAB {SEC_VOCAB}\n"
375
+ )
376
+ return path
377
+
378
+
379
+ def cstr(text: str) -> str:
380
+ """A C string literal (double quoted, escaped) — never Python repr()."""
381
+ escaped = text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
382
+ return f'"{escaped}"'
383
+
384
+
385
+ def store_queries(queries: list[str], sketch_dir: Path) -> Path:
386
+ """Write kb_queries.h: example query texts, tokenized on device."""
387
+ examples = ",".join(cstr(query) for query in queries)
388
+ size = len(queries) or 1 # C arrays can't have length 0
389
+ lines = [
390
+ "#pragma once",
391
+ f"#define KB_NEXAMPLE {len(queries)}",
392
+ f"static const char* KB_EXAMPLES[{size}] = {{{examples}}};",
393
+ ]
394
+ path = Path(sketch_dir, "kb_queries.h")
395
+ path.write_text("\n".join(lines) + "\n")
396
+ return path
397
+
398
+
399
+ # ---------------------------------------------------------------- queries
400
+
401
+ def load_queries_from_file(path: str | os.PathLike) -> list[str]:
402
+ return [
403
+ line.strip()
404
+ for line in Path(path).read_text(encoding="utf-8").splitlines()
405
+ if line.strip() and not line.lstrip().startswith("#")
406
+ ]
407
+
408
+
409
+ def first_sentence(text: str) -> str:
410
+ sentence = re.split(r"(?<=[.!?])\s+|\n+", text.strip())[0].strip(" #*->`")
411
+ return sentence or text.strip()[:120]
412
+
413
+
414
+ def default_queries(chunks: list[Chunk], count: int = 5) -> list[str]:
415
+ """Sample a few chunks and use their opening sentence as demo queries."""
416
+ if not chunks:
417
+ return []
418
+ step = max(1, len(chunks) // count)
419
+ return [first_sentence(chunk.text) for chunk in chunks[::step][:count]]
420
+
421
+
422
+ # ---------------------------------------------------------------- pipeline
423
+
424
+ def build(
425
+ root: str | os.PathLike,
426
+ output_folder: str | os.PathLike,
427
+ sketch_dir: str | os.PathLike,
428
+ onnx_path: str | os.PathLike,
429
+ queries_path: str | os.PathLike | None = None,
430
+ log=print,
431
+ ) -> BuildResult:
432
+ """Run the whole PC pipeline and return the paths that were written."""
433
+ output_folder = Path(output_folder)
434
+ sketch_dir = Path(sketch_dir)
435
+
436
+ documents = load_documents_from_folder(root)
437
+ if not documents:
438
+ raise ValueError(
439
+ f"no {', '.join(sorted(DOCUMENT_SUFFIXES))} files found under {root}"
440
+ )
441
+
442
+ embedder = Embedder(onnx_path)
443
+ chunks = split_into_chunks(documents, embedder.tokenizer, embedder.seq - 2)
444
+ log(f"indexed {len(documents)} files -> {len(chunks)} chunks "
445
+ f"(seq={embedder.seq}, dim={embedder.dim})")
446
+ embed_chunks(chunks, embedder, progress=lambda i, n: log(f" embedded {i}/{n}"))
447
+ scale = quantize_chunks(chunks)
448
+
449
+ vocab = embedder.vocabulary()
450
+ store_embeddings(chunks, scale, embedder.dim, output_folder)
451
+ store_files_index(chunks, output_folder)
452
+ store_texts(chunks, output_folder)
453
+ store_vocab(vocab, output_folder)
454
+ store_container(chunks, scale, vocab, embedder.dim, output_folder)
455
+
456
+ config_header = store_config_header(embedder.seq, embedder.dim, sketch_dir)
457
+ queries = (
458
+ load_queries_from_file(queries_path) if queries_path else default_queries(chunks)
459
+ )
460
+ queries_header = store_queries(queries, sketch_dir)
461
+
462
+ kb_image = output_folder / "kb.img"
463
+ if kb_image.stat().st_size > KB_PARTITION_SIZE:
464
+ log(f"warning: kb.img is {kb_image.stat().st_size} B, larger than the "
465
+ f"{KB_PARTITION_SIZE} B 'kb' partition (see partitions.csv)")
466
+
467
+ return BuildResult(
468
+ kb_image=kb_image,
469
+ config_header=config_header,
470
+ queries_header=queries_header,
471
+ chunks=len(chunks),
472
+ files=len(documents),
473
+ seq=embedder.seq,
474
+ dim=embedder.dim,
475
+ scale=scale,
476
+ )
@@ -0,0 +1,144 @@
1
+ """`esp32kb embed` / `esp32kb flash` — the command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ from . import build as _build
12
+ from . import flash as _flash
13
+
14
+ app = typer.Typer(
15
+ add_completion=False,
16
+ no_args_is_help=True,
17
+ help="Build an on-device knowledge base for the KB ESP32-P4 demo.",
18
+ )
19
+
20
+
21
+ def _resolve_sketch(sketch_dir: Path | None) -> Path:
22
+ return (sketch_dir or Path.cwd()).expanduser().resolve()
23
+
24
+
25
+ @app.command()
26
+ def embed(
27
+ root: Path = typer.Option(
28
+ ..., "--root", "-r", exists=True, file_okay=False, dir_okay=True,
29
+ help="folder with the documents to index (recursively)",
30
+ ),
31
+ output_folder: Path | None = typer.Option(
32
+ None, "--output-folder", "-o",
33
+ help="where the .bin files and kb.img are written "
34
+ "(default: <sketch-dir>/model/kb)",
35
+ ),
36
+ sketch_dir: Path | None = typer.Option(
37
+ None, "--sketch-dir", "-s",
38
+ help="folder holding KB.ino/VectorSearch.h; kb_config.h and kb_queries.h "
39
+ "are written here (default: current folder)",
40
+ ),
41
+ onnx: Path | None = typer.Option(
42
+ None, "--onnx",
43
+ help="MiniLM-L3 ONNX the PC embeds with "
44
+ "(default: <sketch-dir>/model/maskseq.onnx)",
45
+ ),
46
+ queries: Path | None = typer.Option(
47
+ None, "--queries", "-q", exists=True, dir_okay=False,
48
+ help="text file, one demo query per line (default: sampled from the corpus)",
49
+ ),
50
+ ) -> None:
51
+ """Embed documents and write kb.img + the generated C++ headers."""
52
+ sketch = _resolve_sketch(sketch_dir)
53
+ onnx_path = (onnx or sketch / "model" / "maskseq.onnx").expanduser().resolve()
54
+ out = (output_folder or sketch / "model" / "kb").expanduser().resolve()
55
+
56
+ if not onnx_path.is_file():
57
+ typer.secho(f"error: ONNX model not found: {onnx_path}", fg=typer.colors.RED, err=True)
58
+ typer.echo("Build it with model/export_onnx_mask.py, or pass --onnx.", err=True)
59
+ raise typer.Exit(1)
60
+
61
+ try:
62
+ result = _build.build(
63
+ root=root,
64
+ output_folder=out,
65
+ sketch_dir=sketch,
66
+ onnx_path=onnx_path,
67
+ queries_path=queries,
68
+ )
69
+ except ValueError as error:
70
+ typer.secho(f"error: {error}", fg=typer.colors.RED, err=True)
71
+ raise typer.Exit(1)
72
+
73
+ typer.echo(f"\nwrote {result.kb_image.parent}/ "
74
+ f"(vectors.bin, meta.bin, chunks.bin, files.bin, vocab.txt, kb.img)")
75
+ typer.echo(f"wrote {result.config_header} "
76
+ f"(seq={result.seq}, dim={result.dim}, topk={_build.KB_TOPK})")
77
+ typer.echo(f"wrote {result.queries_header} (scale={result.scale:.3f})")
78
+ typer.echo("\n# flash it with: esp32kb flash --sketch-dir " + str(sketch))
79
+
80
+
81
+ @app.command()
82
+ def flash(
83
+ sketch_dir: Path | None = typer.Option(
84
+ None, "--sketch-dir", "-s", help="folder holding KB.ino/VectorSearch.h "
85
+ "(default: current folder)",
86
+ ),
87
+ model: Path | None = typer.Option(
88
+ None, "--model", help="quantized .espdl to flash "
89
+ "(default: <sketch-dir>/model/minilm_l3.espdl)",
90
+ ),
91
+ kb: Path | None = typer.Option(
92
+ None, "--kb", help="kb.img to flash (default: <sketch-dir>/model/kb/kb.img)",
93
+ ),
94
+ port: str | None = typer.Option(
95
+ None, "--port", "-p", help="serial port (default: $PORT or /dev/ttyUSB0)",
96
+ ),
97
+ fqbn: str | None = typer.Option(
98
+ None, "--fqbn", help="arduino-cli board spec (default: the P4 board)",
99
+ ),
100
+ library: str | None = typer.Option(
101
+ None, "--library", help="extra Arduino library folder (default: $ARDUINO_LIBS)",
102
+ ),
103
+ yes: bool = typer.Option(False, "--yes", "-y", help="run without asking"),
104
+ print_only: bool = typer.Option(False, "--print-only", help="print the command and exit"),
105
+ ) -> None:
106
+ """Print the command that flashes app + model + kb.img, and offer to run it."""
107
+ sketch = _resolve_sketch(sketch_dir)
108
+ model_path = (model or sketch / "model" / "minilm_l3.espdl").expanduser().resolve()
109
+ kb_path = (kb or sketch / "model" / "kb" / "kb.img").expanduser().resolve()
110
+ port = port or os.environ.get("PORT", "/dev/ttyUSB0")
111
+ fqbn = fqbn or os.environ.get("FQBN", _flash.DEFAULT_FQBN)
112
+ library = library or os.environ.get("ARDUINO_LIBS")
113
+
114
+ for label, path in (("model", model_path), ("kb.img", kb_path)):
115
+ if not path.is_file():
116
+ typer.secho(f"error: {label} not found: {path}", fg=typer.colors.RED, err=True)
117
+ typer.echo("Run `esp32kb embed` and build the model first.", err=True)
118
+ raise typer.Exit(1)
119
+
120
+ compile_cmd, upload_cmd = _flash.commands(
121
+ sketch, model_path, kb_path, port, fqbn, library
122
+ )
123
+ typer.echo(_flash.as_shell(compile_cmd, upload_cmd))
124
+
125
+ if print_only:
126
+ return
127
+ if not (yes or typer.confirm("Run it now?", default=False)):
128
+ return
129
+
130
+ try:
131
+ _flash.run(compile_cmd, upload_cmd)
132
+ except FileNotFoundError:
133
+ typer.secho("error: arduino-cli not found on PATH", fg=typer.colors.RED, err=True)
134
+ raise typer.Exit(1)
135
+ except subprocess.CalledProcessError as error:
136
+ raise typer.Exit(error.returncode or 1)
137
+
138
+
139
+ def main() -> None:
140
+ app()
141
+
142
+
143
+ if __name__ == "__main__":
144
+ main()
@@ -0,0 +1,56 @@
1
+ """Build the arduino-cli command that flashes app + model + kb.img.
2
+
3
+ arduino-cli's upload recipe is hardcoded to the four sketch binaries, so the
4
+ model and the knowledge base are appended to the same esptool write-flash via
5
+ `--upload-property upload.extra_flags`. No Arduino system files are touched.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shlex
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ MODEL_OFFSET = 0x510000 # must match the 'model' partition in partitions.csv
15
+ KB_OFFSET = 0x1910000 # must match the 'kb' partition in partitions.csv
16
+
17
+ DEFAULT_FQBN = (
18
+ "esp32:esp32:esp32p4:ChipVariant=postv3,FlashSize=32M,"
19
+ "PSRAM=enabled,PartitionScheme=custom"
20
+ )
21
+
22
+
23
+ def commands(
24
+ sketch_dir: Path,
25
+ model_path: Path,
26
+ kb_image: Path,
27
+ port: str,
28
+ fqbn: str,
29
+ library: str | None = None,
30
+ ) -> tuple[list[str], list[str]]:
31
+ """Return (compile_cmd, upload_cmd) as argv lists (no shell involved)."""
32
+ compile_cmd = ["arduino-cli", "compile", "-b", fqbn]
33
+ if library:
34
+ compile_cmd += ["--library", library]
35
+ compile_cmd.append(str(sketch_dir))
36
+
37
+ extra = f"{hex(MODEL_OFFSET)} {model_path} {hex(KB_OFFSET)} {kb_image}"
38
+ upload_cmd = [
39
+ "arduino-cli", "upload",
40
+ "-p", port,
41
+ "-b", fqbn,
42
+ "--upload-property", f"upload.extra_flags={extra}",
43
+ str(sketch_dir),
44
+ ]
45
+ return compile_cmd, upload_cmd
46
+
47
+
48
+ def as_shell(compile_cmd: list[str], upload_cmd: list[str]) -> str:
49
+ """A copy-pasteable one-liner: compile, then upload app + model + kb.img."""
50
+ return f"{shlex.join(compile_cmd)} && \\\n {shlex.join(upload_cmd)}"
51
+
52
+
53
+ def run(compile_cmd: list[str], upload_cmd: list[str]) -> None:
54
+ """Run both commands, streaming their output."""
55
+ subprocess.run(compile_cmd, check=True)
56
+ subprocess.run(upload_cmd, check=True)