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 +123 -0
- esp32kb-0.1.0/README.md +98 -0
- esp32kb-0.1.0/pyproject.toml +49 -0
- esp32kb-0.1.0/pyproject.toml.orig +40 -0
- esp32kb-0.1.0/src/esp32kb/__init__.py +14 -0
- esp32kb-0.1.0/src/esp32kb/__main__.py +4 -0
- esp32kb-0.1.0/src/esp32kb/build.py +476 -0
- esp32kb-0.1.0/src/esp32kb/cli.py +144 -0
- esp32kb-0.1.0/src/esp32kb/flash.py +56 -0
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 |
|
esp32kb-0.1.0/README.md
ADDED
|
@@ -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,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)
|