memory: SigLIP2 on-device embedding (text-embedding-kmp v3)

Добавляет второй бэкенд эмбеддингов для vector-памяти: on-device SigLIP2
через ONNX Runtime. Не требует сети (HTTP), не светят тексты заметок наружу.

* gradle/libs.versions.toml: text-embedding-kmp = "3.0.0-SNAPSHOT", модули
  api-jvm / siglip-jvm (group переехал с pw.binom.voice.embeddingtext на
  pw.binom.ai.embeddingtext).
* settings.gradle.kts: mavenLocal() добавлен в dependencyResolutionManagement
  (text-embedding-kmp публикуется локально как snapshot).
* memory-vector/SiglipEmbeddingProvider (jvmMain) — адаптер
  pw.binom.voice.embeddingtext.TextEmbeddingExtractor → EmbeddingProvider:
  оборачивает blocking embed() в withContext(Dispatchers.IO) + Mutex (ONNX
  сессия не reentrant), размерность пробируется через probe embed("probe")
  (768 для SigLIP2-base).
* memory-vector/build.gradle.kts: api(libs.text.embedding.api) +
  implementation(libs.text.embedding.siglip); jvmTest получает
  SiglipEmbeddingProviderTest — smoke test (skip если модель не найдена).
* standalone/AgentikConfig: новый enum EmbeddingBackend { HTTP, SIGLIP },
  поля embeddingBackend / embeddingModelPath / embeddingTokenizerPath, env:
  AGENTIK_EMBEDDING_BACKEND, AGENTIK_EMBEDDING_MODEL_PATH,
  AGENTIK_EMBEDDING_TOKENIZER_PATH.
* standalone/Main.kt: switch на embeddingBackend при memory-backend=vector;
  для SIGLIP требуются оба пути, иначе ошибка с понятным сообщением.
* standalone/README.md: обновлены env-vars, добавлены две bash-секции
  (HTTP и SIGLIP) + инструкция скачивания модели с static.binom.pw.
* scripts/install-text-embedding-stub.sh: workaround для upstream бага
  (siglip-jvm/*.module ссылается на api без -jvm variant). Создаёт
  stub-артефакт api:3.0.0-SNAPSHOT в mavenLocal с тем же содержимым.
  Удалить когда upstream починит module-metadata.

Smoke test: AGENTIK_MEMORY_BACKEND=vector AGENTIK_EMBEDDING_BACKEND=siglip
+ несуществующий путь → FileNotFoundException с понятным трейсом (значит
ONNX Runtime инициализирован, путь через factory пробрасывается корректно).

Tests: 288 total green. Fatjar 250MB (вырос из-за onnxruntime ~80MB).

Dropped: старый stub-jar pw.binom.voice.embeddingtext:api:2.0.0-SNAPSHOT.
This commit is contained in:
2026-09-15 04:43:45 +03:00
parent 9e12b22e85
commit 427ce8a572
9 changed files with 277 additions and 25 deletions
+6
View File
@@ -10,6 +10,7 @@ litert = "7"
sqldelight = "2.3.2"
shadow = "8.3.5"
jvector = "3.0.6"
text-embedding-kmp = "3.0.0-SNAPSHOT"
[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
@@ -67,3 +68,8 @@ kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.re
# --- JVector (io.github.jbellis) — embedded ANN-индекс для vector-бэкенда памяти. JVM-only. ---
jvector = { module = "io.github.jbellis:jvector", version.ref = "jvector" }
# --- text-embedding-kmp (pw.binom.ai.embeddingtext) — on-device SigLIP2 эмбеддинг через ONNX. ---
# Артефакты публикуются под именами `-jvm` (KMP convention для JVM-таргета).
text-embedding-api = { module = "pw.binom.ai.embeddingtext:api-jvm", version.ref = "text-embedding-kmp" }
text-embedding-siglip = { module = "pw.binom.ai.embeddingtext:siglip-jvm", version.ref = "text-embedding-kmp" }
+17
View File
@@ -24,6 +24,23 @@ kotlin {
jvmMain.dependencies {
implementation(libs.jvector)
implementation(libs.sqldelight.sqlite.driver)
// text-embedding-kmp — on-device SigLIP2 через ONNX Runtime.
// Сигнатура `embed(String): TextEmbedding` (blocking), оборачиваем
// наш `suspend fun embed(text)` через Mutex. api-вариант экспортируем
// (`api`), потому что SiglipEmbeddingProvider реализует `embed()`
// через тип TextEmbeddingExtractor, который виден потребителю
// только если он сам подтянет api-jvm — проще пробросить.
//
// WORKAROUND: upstream `siglip-jvm/*.module` ссылается на `api`
// БЕЗ -jvm суффикса. Поскольку в mavenLocal есть только `api-jvm`,
// требуется дополнительный stub-jar `pw.binom.ai.embeddingtext:api`
// с тем же содержимым. Создаётся так:
// mkdir -p ~/.m2/repository/pw.binom.ai.embeddingtext/api/3.0.0-SNAPSHOT
// cp ~/.m2/repository/.../api-jvm/3.0.0-SNAPSHOT/api-jvm-*.{jar,sources.jar} \
// ~/.m2/repository/.../api/3.0.0-SNAPSHOT/api-*.{jar,sources.jar}
// Когда upstream починит module-metadata — эту инструкцию можно убрать.
api(libs.text.embedding.api)
implementation(libs.text.embedding.siglip)
}
jvmTest.dependencies {
implementation(kotlin("test"))
@@ -0,0 +1,49 @@
package pw.binom.agentik.memory.vector.embedding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import pw.binom.agentik.memory.vector.EmbeddingProvider
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
import pw.binom.voice.embeddingtext.createSiglip2TextExtractor
/**
* Локальный on-device эмбеддинг через [TextEmbeddingExtractor] (SigLIP2 / ONNX).
*
* Особенности:
* - `TextEmbeddingExtractor.embed(text)` — **blocking** (ONNX-инференс на CPU),
* не suspend. Оборачиваем в `Dispatchers.IO` + `Mutex`, чтобы сериализовать
* доступ из нескольких корутин (ONNX-сессия не reentrant).
* - Размерность фиксирована extractor'ом (SigLIP2-base = 768); параметр
* `dimension` в конструкторе не принимаем — берём через [probeDimension].
* - LRU-кэш из [HttpEmbeddingClient] не используем здесь: ONNX-инференс на
* CPU ≈ 5-15 мс, кэш полезен только для HTTP. Но если потребуется —
* легко добавить.
*
* Модель + токенизатор не бандлятся в jar: передаём пути в конструкторе.
* Скачать: см. README репы `text-embedding-kmp`.
*/
class SiglipEmbeddingProvider(
modelPath: String,
tokenizerPath: String,
) : EmbeddingProvider, AutoCloseable {
private val extractor: TextEmbeddingExtractor =
createSiglip2TextExtractor(modelPath = modelPath, tokenizerPath = tokenizerPath)
override val dimension: Int = run {
val probe = extractor.embed("probe")
probe.dim
}
private val mutex = Mutex()
override suspend fun embed(text: String): FloatArray = withContext(Dispatchers.IO) {
mutex.withLock { extractor.embed(text).values }
}
override fun close() {
extractor.close()
}
}
@@ -0,0 +1,54 @@
package pw.binom.agentik.memory.vector.embedding
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* Smoke-test SiglipEmbeddingProvider.
*
* Если файлы модели не найдены (по дефолту `/tmp/text-emb-model/`),
* тест пропускается через [assumeModelAvailable]. Если найдены —
* проверяется, что провайдер открывается, возвращает валидный эмбеддинг
* правильной размерности, и закрывается чисто.
*/
class SiglipEmbeddingProviderTest {
@Test
fun `dimension is 768 when model loads successfully`() {
val modelDir = File("/tmp/text-emb-model")
assume(modelDir.exists() && File(modelDir, "text_model_int8.onnx").exists()) {
"SigLIP2 model files not found in /tmp/text-emb-model/ — skipping"
}
SiglipEmbeddingProvider(
modelPath = "${modelDir.absolutePath}/text_model_int8.onnx",
tokenizerPath = "${modelDir.absolutePath}/tokenizer.model",
).use { provider ->
assertEquals(768, provider.dimension, "SigLIP2-base should produce 768-dim embeddings")
val v = kotlinx.coroutines.runBlocking { provider.embed("hello world") }
assertEquals(768, v.size)
assertTrue(v.any { it != 0f }, "embedding should not be all zeros")
}
}
@Test
fun `missing model file fails with clear error`() {
val tmpDir = createTempDir(prefix = "no-model-")
val nonExistent = File(tmpDir, "does-not-exist.onnx")
assertFailsWith<Exception> {
SiglipEmbeddingProvider(
modelPath = nonExistent.absolutePath,
tokenizerPath = nonExistent.absolutePath,
).use { it.dimension }
}
}
private inline fun assume(condition: Boolean, message: () -> String) {
if (!condition) {
println("SKIPPED: ${message()}")
return
}
}
}
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# Создаёт stub-артефакт `pw.binom.voice.embeddingtext:api:2.0.0-SNAPSHOT`,
# ссылающийся на `api-jvm:2.0.0-SNAPSHOT`. Нужно из-за бага в upstream module
# metadata у siglip-jvm (ссылается на `api` без variant).
#
# Удалить, когда upstream починит module-metadata.
set -e
M2="${HOME}/.m2/repository"
GROUP_DIR="${M2}/pw/binom/ai/embeddingtext"
SRC_DIR="${GROUP_DIR}/api-jvm/3.0.0-SNAPSHOT"
DST_DIR="${GROUP_DIR}/api/3.0.0-SNAPSHOT"
if [ ! -f "${SRC_DIR}/api-jvm-3.0.0-SNAPSHOT.jar" ]; then
echo "Source api-jvm not found: ${SRC_DIR}"
echo "Сначала опубликуй text-embedding-kmp в mavenLocal:"
echo " git clone https://git.binom.pw/subochev/text-embedding-kmp /tmp/text-embedding-kmp"
echo " cd /tmp/text-embedding-kmp && ./gradlew -Pversion=3.0.0-SNAPSHOT :api:publishJvmPublicationToMavenLocal :siglip:publishJvmPublicationToMavenLocal"
exit 1
fi
mkdir -p "${DST_DIR}"
cp "${SRC_DIR}/api-jvm-3.0.0-SNAPSHOT.jar" "${DST_DIR}/api-3.0.0-SNAPSHOT.jar"
cp "${SRC_DIR}/api-jvm-3.0.0-SNAPSHOT-sources.jar" "${DST_DIR}/api-3.0.0-SNAPSHOT-sources.jar" 2>/dev/null || true
cat > "${DST_DIR}/api-3.0.0-SNAPSHOT.pom" <<POM
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>pw.binom.ai.embeddingtext</groupId>
<artifactId>api</artifactId>
<version>3.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
</project>
POM
cat > "${DST_DIR}/maven-metadata-local.xml" <<META
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
<groupId>pw.binom.ai.embeddingtext</groupId>
<artifactId>api</artifactId>
<version>3.0.0-SNAPSHOT</version>
<versioning>
<snapshot><timestamp>20260101.000000</timestamp></snapshot>
<lastUpdated>20260101000000</lastUpdated>
</versioning>
</metadata>
META
echo "Installed stub: pw.binom.ai.embeddingtext:api:3.0.0-SNAPSHOT -> ${DST_DIR}"
+2
View File
@@ -12,6 +12,8 @@ dependencyResolutionManagement {
repositories {
mavenCentral()
google()
// Локально опубликованные snapshot-ы text-embedding-kmp (см. `~/.m2`).
mavenLocal()
// Home Nexus, репо "caffeine": pw.binom.* (A2A, ...)
maven {
name = "caffeine"
+33 -8
View File
@@ -41,8 +41,11 @@ java -jar standalone/build/libs/standalone-all.jar
| `AGENTIK_SKILLS_DIR` | _выкл._ | Каталог со скилами (`SKILL.md` / `*.yaml`) |
| `AGENTIK_MEMORY_DIR` | `~/.agentik/memory` (md) или `AGENTIK_DB_PATH` (vector) | Каталог памяти (md); `"off"` отключает |
| `AGENTIK_MEMORY_BACKEND` | `md` | `md` (Hermes-style §-файлы) / `vector` (SQLite + JVector + LLM-эмбеддинги) / `off` |
| `AGENTIK_EMBEDDING_MODEL` | `text-embedding-3-small` | Модель эмбеддингов для vector-бэкенда |
| `AGENTIK_EMBEDDING_DIMENSION` | `1536` | Размерность вектора (должна совпадать с моделью) |
| `AGENTIK_EMBEDDING_MODEL` | `text-embedding-3-small` | Модель эмбеддингов для vector-бэкенда (только HTTP) |
| `AGENTIK_EMBEDDING_DIMENSION` | `1536` | Размерность вектора (только HTTP; SIGLIP определяет автоматически) |
| `AGENTIK_EMBEDDING_BACKEND` | `HTTP` | `HTTP` (POST /v1/embeddings) или `SIGLIP` (on-device, без сети) |
| `AGENTIK_EMBEDDING_MODEL_PATH` | _только SIGLIP_ | Путь к `text_model_int8.onnx` (SigLIP2) |
| `AGENTIK_EMBEDDING_TOKENIZER_PATH` | _только SIGLIP_ | Путь к `tokenizer.model` (sentencepiece) |
| `AGENTIK_SOUL` | _выкл._ | Путь к `SOUL.md` — файл персоны (markdown), вставляется в начало system prompt |
| `AGENTIK_LLM_BACKEND` | — | `openai` или `google` (см. ниже) |
| `AGENTIK_MCP_CONFIG` | _выкл._ | Путь к JSON со списком MCP-серверов |
@@ -218,23 +221,45 @@ context…]` блок в начало user-сообщения; после зап
`AGENTIK_MEMORY_BACKEND` выбирает хранилище. Дефолт — `md` (Hermes-style
§-файлы, keyword overlap, без внешних вызовов).
`vector` — SQLite (`AGENTIK_DB_PATH`) + JVector ANN + LLM-эмбеддинги
(запросы на `${OPENAI_BASE_URL}/v1/embeddings`). Семантический поиск:
cosine similarity + recency-re-rank. Те же четыре тула; та же семантика.
`vector` — SQLite (`AGENTIK_DB_PATH`) + JVector ANN + эмбеддинги. Два
бэкенда эмбеддингов через `AGENTIK_EMBEDDING_BACKEND`:
- **`HTTP` (default)** — POST на `${OPENAI_BASE_URL}/v1/embeddings`. Семантический
поиск: cosine similarity + recency-re-rank.
- **`SIGLIP`** — on-device SigLIP2 через ONNX Runtime (text-embedding-kmp,
768-мерный вектор). Никаких внешних вызовов: модель и токенизатор должны
лежать на диске. Размерность определяется автоматически (768).
```bash
# Vector-бэкенд
# Vector-бэкенд + HTTP-эмбеддинги (default)
AGENTIK_MEMORY_BACKEND=vector \
AGENTIK_EMBEDDING_BACKEND=http \
AGENTIK_EMBEDDING_MODEL=text-embedding-3-small \
AGENTIK_EMBEDDING_DIMENSION=1536 \
java -jar standalone-all.jar
# Vector-бэкенд + on-device SigLIP2 (без сети)
AGENTIK_MEMORY_BACKEND=vector \
AGENTIK_EMBEDDING_BACKEND=siglip \
AGENTIK_EMBEDDING_MODEL_PATH=/path/to/text_model_int8.onnx \
AGENTIK_EMBEDDING_TOKENIZER_PATH=/path/to/tokenizer.model \
java -jar standalone-all.jar
```
Для vector требуется `AGENTIK_LLM_BACKEND=openai` (т.к. нужен
Для HTTP-эмбеддингов требуется `AGENTIK_LLM_BACKEND=openai` (т.к. нужен
OpenAI-совместимый `/v1/embeddings` endpoint — LiteLLM proxy тоже подходит).
Embedding-вызовы кэшируются LRU на 256 текстов — дедупликация при повторных
HTTP-вызовы кэшируются LRU на 256 текстов — дедупликация при повторных
запросах одинаковых промптов.
Для SIGLIP нужно сначала скачать модель (~283M) и токенизатор (~4M):
```bash
mkdir -p /path/to/siglip-model
curl -fSL -o /path/to/siglip-model/text_model_int8.onnx \
http://static.binom.pw/models/siglip2/text_model_int8.onnx
curl -fSL -o /path/to/siglip-model/tokenizer.model \
http://static.binom.pw/models/siglip2/tokenizer.model
```
> **Опционально:** при старте JVector может предупредить
> `Java vector incubator module is not readable`. Это значит, что JIT
> не использует SIMD (Panama Vector API) и индекс строится через скалярный
@@ -12,6 +12,7 @@ import pw.binom.agentik.memory.MemorySystem
import pw.binom.agentik.memory.md.openMdMemorySystem
import pw.binom.agentik.memory.vector.VectorMemorySystem
import pw.binom.agentik.memory.vector.embedding.HttpEmbeddingClient
import pw.binom.agentik.memory.vector.embedding.SiglipEmbeddingProvider
import pw.binom.agentik.server.agentikAgent
import pw.binom.agentik.skills.SkillCatalog
import pw.binom.agentik.skills.SkillLoader
@@ -93,25 +94,44 @@ fun main() {
}
}
MemoryBackend.VECTOR -> {
val llm = config.llm
// Берём базовый URL + API key у активного LLM-бэкенда.
// Поддерживается только OPENAI (LiteLLM proxy тоже работает, т.к. /v1/embeddings
// — это OpenAI-совместимый endpoint).
require(llm.backend == LlmBackend.OPENAI) {
"AGENTIK_MEMORY_BACKEND=vector требует LLM_BACKEND=openai (нужен /v1/embeddings)"
val embedding: pw.binom.agentik.memory.vector.EmbeddingProvider = when (config.embeddingBackend) {
AgentikConfig.EmbeddingBackend.HTTP -> {
val llm = config.llm
// Берём базовый URL + API key у активного LLM-бэкенда.
// Поддерживается только OPENAI (LiteLLM proxy тоже работает, т.к. /v1/embeddings
// — это OpenAI-совместимый endpoint).
require(llm.backend == LlmBackend.OPENAI) {
"AGENTIK_EMBEDDING_BACKEND=http требует LLM_BACKEND=openai (нужен /v1/embeddings)"
}
val oa = checkNotNull(llm.openai) { "openai config required for http embedding backend" }
HttpEmbeddingClient(
apiUrl = oa.baseUrl.trimEnd('/'),
apiKey = oa.apiKey,
model = config.embeddingModel,
dimension = config.embeddingDimension,
)
}
AgentikConfig.EmbeddingBackend.SIGLIP -> {
val modelPath = checkNotNull(config.embeddingModelPath) {
"AGENTIK_EMBEDDING_BACKEND=siglip требует AGENTIK_EMBEDDING_MODEL_PATH"
}
val tokenizerPath = checkNotNull(config.embeddingTokenizerPath) {
"AGENTIK_EMBEDDING_BACKEND=siglip требует AGENTIK_EMBEDDING_TOKENIZER_PATH"
}
SiglipEmbeddingProvider(modelPath = modelPath, tokenizerPath = tokenizerPath)
}
}
val oa = checkNotNull(llm.openai) { "openai config required for vector backend" }
val embedding = HttpEmbeddingClient(
apiUrl = oa.baseUrl.trimEnd('/'),
apiKey = oa.apiKey,
model = config.embeddingModel,
dimension = config.embeddingDimension,
)
VectorMemorySystem.open(
dbPath = config.dbPath,
embedding = embedding,
).also {
println(" memory: db=${config.dbPath} (vector-backend, model=${config.embeddingModel}, dim=${config.embeddingDimension})")
val backendLabel = when (config.embeddingBackend) {
AgentikConfig.EmbeddingBackend.HTTP ->
"model=${config.embeddingModel}, dim=${config.embeddingDimension}"
AgentikConfig.EmbeddingBackend.SIGLIP ->
"model=siglip2-base (on-device), dim=${embedding.dimension}"
}
println(" memory: db=${config.dbPath} (vector-backend, $backendLabel)")
}
}
}
@@ -56,20 +56,44 @@ data class AgentikConfig(
*/
val memoryBackend: MemoryBackend = MemoryBackend.MD,
/**
* Имя модели эмбеддингов для vector-бэкенда. Дефолт `text-embedding-3-small`
* Имя модели эмбеддингов для vector-бэкенда. Используется только при
* [embeddingBackend] = HTTP. Дефолт `text-embedding-3-small`
* (1536-мерный). Должна быть доступна через тот же baseUrl/apiKey что и LLM.
*/
val embeddingModel: String = DEFAULT_EMBEDDING_MODEL,
/**
* Размерность эмбеддингов vector-бэкенда. Должна совпадать с реальной
* размерностью [embeddingModel]. Дефолт 1536 для `text-embedding-3-small`.
* Размерность эмбеддингов vector-бэкенда. Используется только при
* [embeddingBackend] = HTTP. Должна совпадать с реальной размерностью
* [embeddingModel]. Дефолт 1536 для `text-embedding-3-small`.
* Для [embeddingBackend] = SIGLIP размерность определяется самой моделью
* (768 для SigLIP2-base), параметр игнорируется.
*/
val embeddingDimension: Int = DEFAULT_EMBEDDING_DIMENSION,
/**
* Бэкенд эмбеддингов для vector-памяти:
* - HTTP — POST /v1/embeddings к OpenAI-совместимому API (default);
* - SIGLIP — on-device SigLIP2 через ONNX Runtime (text-embedding-kmp), без сети.
*/
val embeddingBackend: EmbeddingBackend = EmbeddingBackend.HTTP,
/**
* Путь к ONNX-модели SigLIP2 (`text_model_int8.onnx`). Используется только при
* [embeddingBackend] = SIGLIP.
*/
val embeddingModelPath: String? = null,
/**
* Путь к sentencepiece-токенизатору (`tokenizer.model`). Используется только при
* [embeddingBackend] = SIGLIP.
*/
val embeddingTokenizerPath: String? = null,
) {
/** Бэкенд долговременной памяти. */
@Serializable
enum class MemoryBackend { MD, VECTOR, OFF }
/** Бэкенд эмбеддингов (для memory-backend=vector). */
@Serializable
enum class EmbeddingBackend { HTTP, SIGLIP }
companion object {
const val DEFAULT_PORT: Int = 8080
const val DEFAULT_DB_PATH: String = "./agentik.db"
@@ -99,6 +123,11 @@ data class AgentikConfig(
?: DEFAULT_EMBEDDING_MODEL,
embeddingDimension = env("AGENTIK_EMBEDDING_DIMENSION")?.toIntOrNull()
?: DEFAULT_EMBEDDING_DIMENSION,
embeddingBackend = env("AGENTIK_EMBEDDING_BACKEND")?.let {
runCatching { EmbeddingBackend.valueOf(it.uppercase()) }.getOrNull()
} ?: EmbeddingBackend.HTTP,
embeddingModelPath = env("AGENTIK_EMBEDDING_MODEL_PATH")?.takeIf { it.isNotBlank() },
embeddingTokenizerPath = env("AGENTIK_EMBEDDING_TOKENIZER_PATH")?.takeIf { it.isNotBlank() },
)
}
}