feat(memory): migrate EmbeddingProvider to KMP-compatible TextEmbeddingExecutor, add :memory-md-vector, and hybrid backend support
ci / JVM build + tests (push) Failing after 11s
release / Publish KMP libraries → caffeine Nexus (release) Failing after 10s

- Replaced `EmbeddingProvider` with cross-platform `TextEmbeddingExecutor` for native target compatibility.
- Introduced `:memory-md-vector` module combining vector-cache and `.md` file-based memory systems (`hybrid` backend).
- Updated `SiglipEmbeddingProvider` to use KMP `TextEmbeddingExtractor` and streamlined compatibility via `asExecutor`.
- Added hybrid memory backend to `standalone`, supporting `.md` reconciliation with vector-cache for semantic
This commit is contained in:
2026-09-21 12:28:09 +03:00
parent f946186ef5
commit 68543357c2
19 changed files with 658 additions and 165 deletions
+6 -5
View File
@@ -25,6 +25,10 @@ kotlin {
api(project(":memory-api"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
// `pw.binom.ai.embeddingtext:api` (TextEmbeddingExtractor + TextEmbedding)
// теперь KMP с нативом (linuxX64/mingwX64/macOS/ios); тянем в commonMain.
// Реализации (`siglip`, `http`) — JVM+Android only, см. jvmMain ниже.
api(libs.text.embedding.api)
}
commonTest.dependencies {
implementation(kotlin("test"))
@@ -34,14 +38,11 @@ kotlin {
jvmMain.dependencies {
implementation(libs.jvector)
implementation(libs.sqldelight.sqlite.driver)
// Конкретная реализация TextEmbeddingExtractor поверх ONNX.
implementation(libs.text.embedding.siglip)
}
jvmTest.dependencies {
implementation(kotlin("test"))
}
}
}
dependencies {
add("jvmMainApi", libs.text.embedding.api)
add("jvmMainImplementation", libs.text.embedding.siglip)
}
@@ -1,39 +0,0 @@
package pw.binom.agentik.memory.vector
/**
* Провайдер эмбеддингов: превращает текст в FloatArray фиксированной размерности.
*
* Реализация по умолчанию — HTTP-вызов `POST /v1/embeddings` к OpenAI-совместимому
* API (OpenAI / litellm-proxy / vllm). С LRU-кэшом, чтобы не ходить в сеть
* на каждый search/upsert.
*/
interface EmbeddingProvider {
val dimension: Int
suspend fun embed(text: String): FloatArray
/** Batch-вариант. По умолчанию — последовательный вызов [embed]. */
suspend fun embedBatch(texts: List<String>): List<FloatArray> =
texts.map { embed(it) }
}
/**
* Детерминированный провайдер для тестов: хеширует текст в псевдо-вектор.
* Используется только в commonTest; в продакшн заменяется на HttpEmbeddingProvider.
*/
class FakeEmbeddingProvider(override val dimension: Int = 32) : EmbeddingProvider {
override suspend fun embed(text: String): FloatArray {
val v = FloatArray(dimension)
// Простейший детерминированный seed — сумма char'ов по модулю.
var seed = text.hashCode().toLong() and 0xFFFFFFFFL
for (i in 0 until dimension) {
seed = (seed * 6364136223846793005L + 1442695040888963407L) and 0xFFFFFFFFL
v[i] = ((seed.toInt() and 0xFFFF) / 65535f) * 2f - 1f
}
// L2-normalize чтобы cosine работал осмысленно.
var norm = 0f
for (x in v) norm += x * x
norm = kotlin.math.sqrt(norm)
if (norm > 0f) for (i in v.indices) v[i] /= norm
return v
}
}
@@ -1,63 +1,34 @@
package pw.binom.agentik.memory.vector
import pw.binom.agentik.memory.MemoryCategory
import pw.binom.agentik.memory.MemoryNote
import pw.binom.agentik.memory.MemoryVectorIndex as KmpMemoryVectorIndex
import pw.binom.agentik.memory.ScoredVector as KmpScoredVector
/**
* Результат одного hit'а vector-поиска: id заметки + cosine-similarity score в [0..1].
* Чем ближе к 1.0, тем семантически ближе query к заметке.
*/
data class ScoredVector(
val id: String,
val score: Float,
)
/**
* Контракт vector-индекса. Реализация отвечает за ANN-поиск top-K ближайших
* векторов к query. Метаданные заметок лежат в [MemoryStore] (SQLite для
* vector-бэкенда); индекс хранит только embedding'и + id-маппинг.
* JVM-only alias на KMP-контракт из `:memory-api`. Удалять нельзя — пока
* `:memory-vector` существует как JVM-only модуль с JVector-имплементацией,
* все его internal helper'ы продолжают импортировать `MemoryVectorIndex` из
* `pw.binom.agentik.memory.vector.*` (старое FQN). После удаления модуля —
* можно убрать этот файл и переименовать пакеты импортов.
*
* Потокобезопасность: реализации обязаны быть безопасны для конкурентных
* read'ов. write'ы (add/remove) могут требовать внешней синхронизации — это
* инвариант JVector (его OnHeapGraphIndex не thread-safe для мутаций).
* Раньше жил прямо здесь (`MemoryVectorIndex` + `ScoredVector` в
* `:memory-vector/commonMain`), но переехал в `:memory-api` 2026-09-21
* чтобы стать доступным из KMP-модуля `:memory-md-vector`.
*/
interface MemoryVectorIndex : AutoCloseable {
/** Текущая размерность embeddings. Фиксируется при первом [add]. */
val dimension: Int
/** Количество записей в индексе. */
suspend fun size(): Long
@Deprecated(
message = "Переехал в :memory-api (KMP-доступный). Импортируйте из pw.binom.agentik.memory.",
replaceWith = ReplaceWith(
"MemoryVectorIndex",
"pw.binom.agentik.memory.MemoryVectorIndex",
),
)
typealias MemoryVectorIndex = KmpMemoryVectorIndex
/** Добавить или заменить запись по [id]. [embedding] должен иметь длину [dimension]. */
suspend fun add(id: String, embedding: FloatArray)
/** Удалить запись по [id]. Возвращает true если запись была. */
suspend fun remove(id: String): Boolean
/** ANN-поиск: top-[k] ближайших к [query]. [filter] применяется к id (например, по категории). */
suspend fun search(
query: FloatArray,
k: Int,
filter: (MemoryNote) -> Boolean = { true },
): List<ScoredVector>
/** Принудительно переписать on-disk файл из текущего in-RAM состояния. */
suspend fun flush()
override fun close()
}
/**
* Доп. контекст для vector-индекса: фильтр по категории и conversationId
* передаётся через замыкание, которое получает [MemoryNote]. Так [MemoryStore]
* остаётся единственным источником правды по метаданным.
*/
fun noteMatches(
note: MemoryNote,
category: MemoryCategory? = null,
conversationId: String? = null,
): Boolean {
if (category != null && note.category != category) return false
if (conversationId != null && note.conversationId != conversationId) return false
return true
}
@Deprecated(
message = "Переехал в :memory-api (KMP-доступный). Импортируйте из pw.binom.agentik.memory.",
replaceWith = ReplaceWith(
"ScoredVector",
"pw.binom.agentik.memory.ScoredVector",
),
)
typealias ScoredVector = KmpScoredVector
@@ -6,6 +6,8 @@ import pw.binom.agentik.memory.MemorySearchQuery
import pw.binom.agentik.memory.MemorySearchResult
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemoryStoreEvent
import pw.binom.agentik.memory.TextEmbeddingExecutor
import pw.binom.agentik.memory.noteMatches
import kotlin.math.exp
import kotlin.time.Clock
import kotlin.time.Instant
@@ -23,13 +25,13 @@ import kotlinx.coroutines.sync.withLock
* и эмбеддинг; [delete] — и то и другое; [search] использует ANN для кандидатов,
* потом re-rank по recency.
*
* [embeddingProvider] обязателен — используется для эмбеддинга контента при
* [embedding] обязателен — используется для эмбеддинга контента при
* upsert и query при search. Без него vector-бэкенд не имеет смысла.
*/
class VectorMemoryStore(
private val index: MemoryVectorIndex,
private val metaStore: MemoryMetaStore,
private val embeddingProvider: EmbeddingProvider,
private val embedding: TextEmbeddingExecutor,
) : MemoryStore {
private val mutex = Mutex()
@@ -37,9 +39,9 @@ class VectorMemoryStore(
override fun events(): Flow<MemoryStoreEvent> = _events.asSharedFlow()
override suspend fun upsert(note: MemoryNote) = mutex.withLock {
val embedding = embeddingProvider.embed(note.content)
metaStore.put(note, embedding)
index.add(note.id, embedding)
val vec = embedding.embed(note.content)
metaStore.put(note, vec)
index.add(note.id, vec)
_events.emit(MemoryStoreEvent.Upserted(note))
}
@@ -53,7 +55,7 @@ class VectorMemoryStore(
): List<MemoryNote> = metaStore.list(category, conversationId, limit, offset)
override suspend fun search(query: MemorySearchQuery): List<MemorySearchResult> {
val queryEmbedding = embeddingProvider.embed(query.query)
val queryEmbedding = embedding.embed(query.query)
val overFetch = (query.topK * 5).coerceAtLeast(query.topK)
// Берём больше кандидатов, чем нужно — финальный фильтр по category/convId
// через [metaStore.get] + [noteMatches] отрежет лишних.
@@ -9,6 +9,7 @@ import pw.binom.agentik.memory.MemorySearchQuery
import pw.binom.agentik.memory.MemoryStore
import pw.binom.agentik.memory.MemorySystem
import pw.binom.agentik.memory.ReviewedTurn
import pw.binom.agentik.memory.TextEmbeddingExecutor
/**
* Бандл компонентов vector-бэкенда памяти — то же, что
@@ -36,19 +37,20 @@ class VectorMemorySystem(
* Открыть vector-бэкенд: SQLite + JVector + HTTP embedding client.
*
* @param dbPath путь к agentik.db (SQLite для metadata + embedding-blobs)
* @param embedding [EmbeddingProvider] — обычно HttpEmbeddingClient
* @param embedding [TextEmbeddingExecutor] — обычно HttpEmbeddingClient.asExecutor()
* @param topK размер top-K для prefetch
*/
fun open(
dbPath: String,
embedding: EmbeddingProvider,
embedding: TextEmbeddingExecutor,
topK: Int = 10,
): VectorMemorySystem {
val metaStore = SqliteMemoryMetaStore.open(dbPath, embedding.dimension)
val dim = embedding.dimension
val metaStore = SqliteMemoryMetaStore.open(dbPath, dim)
// Граф пересобирается из SQLite (источник правды): без seed'ов
// после рестарта in-RAM индекс пуст и search возвращал бы [],
// пока не появятся новые upsert'ы.
val index = JVectorMemoryIndex(embedding.dimension, metaStore.allEntries())
val index = JVectorMemoryIndex(dim, metaStore.allEntries())
val store = VectorMemoryStore(index, metaStore, embedding)
val prefetcher = VectorPrefetcher(store, topK)
val reviewer = VectorMemoryReviewer(store)
@@ -59,7 +61,7 @@ class VectorMemorySystem(
closables = listOfNotNull(
metaStore,
index,
embedding as? AutoCloseable,
embedding,
),
)
}
@@ -5,25 +5,26 @@ import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import pw.binom.agentik.memory.vector.EmbeddingProvider
import pw.binom.agentik.memory.TextEmbeddingExecutor
import pw.binom.voice.embeddingtext.TextEmbedding
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
/**
* HTTP клиент для OpenAI-совместимого `/v1/embeddings` endpoint.
* Используется при memory-backend=vector.
*
* LRU-кэш на [cacheSize] текстов (default 256) — дедупликация запросов
* к API на одинаковых промптах.
* Реализует [TextEmbeddingExtractor] (из text-embedding-kmp:api) + оборачивается
* в [TextEmbeddingExecutor] через [asExecutor] для совместимости с
* VectorMemoryStore. LRU-кэш на [cacheSize] текстов (default 256) — дедупликация
* запросов к API на одинаковых промптах.
*
* @param apiUrl базовый URL (без trailing slash), например `https://api.openai.com`
* @param apiKey bearer-токен
@@ -35,9 +36,9 @@ class HttpEmbeddingClient(
private val apiUrl: String,
private val apiKey: String,
private val model: String,
override val dimension: Int,
private val dimension: Int,
cacheSize: Int = 256,
) : EmbeddingProvider, AutoCloseable {
) : TextEmbeddingExtractor {
private val cache = LruCache<String, FloatArray>(cacheSize)
private val http: HttpClient = HttpClient.newBuilder()
@@ -45,11 +46,11 @@ class HttpEmbeddingClient(
.build()
private val json = Json { ignoreUnknownKeys = true }
override suspend fun embed(text: String): FloatArray {
cache.get(text)?.let { return it }
override fun embed(text: String): TextEmbedding {
cache.get(text)?.let { return TextEmbedding(it) }
val vector = fetchEmbedding(text)
cache.put(text, vector)
return vector
return TextEmbedding(vector)
}
private fun fetchEmbedding(text: String): FloatArray {
@@ -83,6 +84,9 @@ class HttpEmbeddingClient(
}
override fun close() = http.close()
/** Оборачивает в [TextEmbeddingExecutor] с пред-объявленной размерностью. */
fun asExecutor(): TextEmbeddingExecutor = TextEmbeddingExecutor(this, knownDimension = dimension)
}
private class LruCache<K, V>(private val capacity: Int) {
@@ -1,25 +1,22 @@
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 kotlinx.coroutines.runBlocking
import pw.binom.agentik.memory.TextEmbeddingExecutor
import pw.binom.voice.embeddingtext.TextEmbedding
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. Но если потребуется —
* легко добавить.
* Реализует [TextEmbeddingExtractor] напрямую (делегирует в
* `createSiglip2TextExtractor` из text-embedding-kmp:siglip) + оборачивается
* в [TextEmbeddingExecutor] через [asExecutor] для совместимости с
* VectorMemoryStore. Сиглизация через `Dispatchers.IO` теперь внутри
* `TextEmbeddingExecutor.embed` — раньше лежала здесь.
*
* Размерность фиксирована extractor'ом (SigLIP2-base = 768); передаём
* явно в [asExecutor].
*
* Модель + токенизатор не бандлятся в jar: передаём пути в конструкторе.
* Скачать: см. README репы `text-embedding-kmp`.
@@ -27,23 +24,22 @@ import pw.binom.voice.embeddingtext.createSiglip2TextExtractor
class SiglipEmbeddingProvider(
modelPath: String,
tokenizerPath: String,
) : EmbeddingProvider, AutoCloseable {
) : TextEmbeddingExtractor {
private val extractor: TextEmbeddingExtractor =
private val delegate: TextEmbeddingExtractor =
createSiglip2TextExtractor(modelPath = modelPath, tokenizerPath = tokenizerPath)
override val dimension: Int = run {
val probe = extractor.embed("probe")
probe.dim
}
override fun embed(text: String): TextEmbedding = delegate.embed(text)
private val mutex = Mutex()
override fun close() = delegate.close()
override suspend fun embed(text: String): FloatArray = withContext(Dispatchers.IO) {
mutex.withLock { extractor.embed(text).values }
}
/**
* Оборачивает в [TextEmbeddingExecutor] с пред-объявленной размерностью 768
* (SigLIP2-base). Сигнатура стабильна — extractor всегда возвращает 768-dim.
*/
fun asExecutor(): TextEmbeddingExecutor = TextEmbeddingExecutor(this, knownDimension = SIGLIP2_DIM)
override fun close() {
extractor.close()
companion object {
const val SIGLIP2_DIM: Int = 768
}
}
@@ -32,7 +32,7 @@ class VectorMemoryStoreTest {
// Загружаем начальные entries из metaStore (на случай если что-то там есть).
val seedEntries = metaStore.allEntries()
index = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries)
store = VectorMemoryStore(index, metaStore, FakeEmbeddingProvider(dimension = dim))
store = VectorMemoryStore(index, metaStore, fakeEmbeddingExecutor(dimension = dim))
}
@AfterTest
@@ -127,7 +127,7 @@ class VectorMemoryStoreTest {
val meta2 = SqliteMemoryMetaStore("jdbc:sqlite:${file.absolutePath}", dimension = dim)
val seedEntries = meta2.allEntries()
val idx2 = JVectorMemoryIndex(dimension = dim, seedEntries = seedEntries)
val store2 = VectorMemoryStore(idx2, meta2, FakeEmbeddingProvider(dimension = dim))
val store2 = VectorMemoryStore(idx2, meta2, fakeEmbeddingExecutor(dimension = dim))
try {
assertEquals(2L, idx2.size())
val results = store2.search(MemorySearchQuery(query = "persistent 1", topK = 5))
@@ -141,11 +141,11 @@ class VectorMemoryStoreTest {
fun openSeedsIndexFromSqliteAfterRestart() = runTest {
// Регрессия: VectorMemorySystem.open() обязан пересадить in-RAM граф
// из SQLite — иначе после рестарта search возвращает [] до первого upsert.
val first = VectorMemorySystem.open(file.absolutePath, FakeEmbeddingProvider(dimension = dim))
val first = VectorMemorySystem.open(file.absolutePath, fakeEmbeddingExecutor(dimension = dim))
first.store.upsert(makeNote("r", "restarted fact: dog rex poodle"))
first.close()
val second = VectorMemorySystem.open(file.absolutePath, FakeEmbeddingProvider(dimension = dim))
val second = VectorMemorySystem.open(file.absolutePath, fakeEmbeddingExecutor(dimension = dim))
try {
val results = second.store.search(MemorySearchQuery(query = "restarted fact", topK = 5))
assertTrue(results.any { it.note.id == "r" })
@@ -17,17 +17,18 @@ import kotlin.test.assertTrue
class SiglipEmbeddingProviderTest {
@Test
fun `dimension is 768 when model loads successfully`() {
fun `dimension is 768 when model loads successfully`() = runBlocking {
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(
val provider = SiglipEmbeddingProvider(
modelPath = "${modelDir.absolutePath}/text_model_int8.onnx",
tokenizerPath = "${modelDir.absolutePath}/tokenizer.model",
).use { provider ->
).asExecutor()
provider.use {
assertEquals(768, provider.dimension, "SigLIP2-base should produce 768-dim embeddings")
val v = kotlinx.coroutines.runBlocking { provider.embed("hello world") }
val v = provider.embed("hello world")
assertEquals(768, v.size)
assertTrue(v.any { it != 0f }, "embedding should not be all zeros")
}
@@ -41,7 +42,7 @@ class SiglipEmbeddingProviderTest {
SiglipEmbeddingProvider(
modelPath = nonExistent.absolutePath,
tokenizerPath = nonExistent.absolutePath,
).use { it.dimension }
)
}
}
@@ -49,3 +50,8 @@ class SiglipEmbeddingProviderTest {
org.junit.Assume.assumeTrue(message(), condition)
}
}
// runBlocking нужен потому что suspend-вызов provider.embed в suspend-тесте.
// Локальный импорт чтобы не тащить runBlocking в прод-код.
private fun <T> runBlocking(block: suspend () -> T): T =
kotlinx.coroutines.runBlocking { block() }