diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ecc60b7..047ee14 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/memory-vector/build.gradle.kts b/memory-vector/build.gradle.kts index aab2610..cdd4708 100644 --- a/memory-vector/build.gradle.kts +++ b/memory-vector/build.gradle.kts @@ -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")) diff --git a/memory-vector/src/jvmMain/kotlin/pw/binom/agentik/memory/vector/embedding/SiglipEmbeddingProvider.kt b/memory-vector/src/jvmMain/kotlin/pw/binom/agentik/memory/vector/embedding/SiglipEmbeddingProvider.kt new file mode 100644 index 0000000..742912c --- /dev/null +++ b/memory-vector/src/jvmMain/kotlin/pw/binom/agentik/memory/vector/embedding/SiglipEmbeddingProvider.kt @@ -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() + } +} diff --git a/memory-vector/src/jvmTest/kotlin/pw/binom/agentik/memory/vector/embedding/SiglipEmbeddingProviderTest.kt b/memory-vector/src/jvmTest/kotlin/pw/binom/agentik/memory/vector/embedding/SiglipEmbeddingProviderTest.kt new file mode 100644 index 0000000..c2070fb --- /dev/null +++ b/memory-vector/src/jvmTest/kotlin/pw/binom/agentik/memory/vector/embedding/SiglipEmbeddingProviderTest.kt @@ -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 { + SiglipEmbeddingProvider( + modelPath = nonExistent.absolutePath, + tokenizerPath = nonExistent.absolutePath, + ).use { it.dimension } + } + } + + private inline fun assume(condition: Boolean, message: () -> String) { + if (!condition) { + println("SKIPPED: ${message()}") + return + } + } +} diff --git a/scripts/install-text-embedding-stub.sh b/scripts/install-text-embedding-stub.sh new file mode 100755 index 0000000..cd96407 --- /dev/null +++ b/scripts/install-text-embedding-stub.sh @@ -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" < + + 4.0.0 + pw.binom.ai.embeddingtext + api + 3.0.0-SNAPSHOT + jar + +POM + +cat > "${DST_DIR}/maven-metadata-local.xml" < + + pw.binom.ai.embeddingtext + api + 3.0.0-SNAPSHOT + + 20260101.000000 + 20260101000000 + + +META + +echo "Installed stub: pw.binom.ai.embeddingtext:api:3.0.0-SNAPSHOT -> ${DST_DIR}" diff --git a/settings.gradle.kts b/settings.gradle.kts index b41621c..0399a54 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,6 +12,8 @@ dependencyResolutionManagement { repositories { mavenCentral() google() + // Локально опубликованные snapshot-ы text-embedding-kmp (см. `~/.m2`). + mavenLocal() // Home Nexus, репо "caffeine": pw.binom.* (A2A, ...) maven { name = "caffeine" diff --git a/standalone/README.md b/standalone/README.md index 4133d6f..35d961b 100644 --- a/standalone/README.md +++ b/standalone/README.md @@ -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) и индекс строится через скалярный diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt index c76285b..a8eade3 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/Main.kt @@ -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)") } } } diff --git a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AgentikConfig.kt b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AgentikConfig.kt index 6b154a5..ad1399f 100644 --- a/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AgentikConfig.kt +++ b/standalone/src/jvmMain/kotlin/pw/binom/agentik/standalone/config/AgentikConfig.kt @@ -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() }, ) } }