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
@@ -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
}
}
}