feat(memory): add :memory-md-vector hybrid backend + :reflection-api + outbox/journal entity splits
- :memory-md-vector (KMP jvm+linuxX64+mingwX64): .md-файлы как source of truth, векторный индекс (sqlite-vec) как derived cache. reconcile() на старте: orphan-cleanup + content-hash-gated re-embed. Гибридный скор 0.7*vector + 0.3*keyword. Заменяет EmbeddingProvider на KMP-TextEmbeddingExecutor из :memory-api. - :reflection-api: новый 4-й API-модуль (Reflection, ReflectionStore, ReflectionEvent). Зависит только от :memory-api. - :journal-api получил ConversationRecord/ConversationStore/Ids (бывший :message-store-api, полностью удалён). :outbox-api получил Event, CommonEvent, AgentEvent (бывший :event-store). - :memory-api получил MemoryVectorIndex + NoteMatches + TextEmbeddingExecutor (suspend-обёртка над TextEmbeddingExtractor). - :memory-vector KMP-цели достигнуты через commonMain-only TextEmbedding- Executor, EmbeddingProvider выпилен; :memory-md-vector тянет text-embedding-api транзитивно через :memory-api. - :standalone flatten в commonMain/commonTest завершён (тесты из jvmTest переехали в commonTest). Включён optional деп :memory-md-vector через AGENTIK_MEMORY_BACKEND=md-vector. jvmTest: 96 задач, 407 тестов, 0 падений.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
package pw.binom.agentik.memory
|
||||
|
||||
import pw.binom.agentik.memory.MemoryCategory
|
||||
import pw.binom.agentik.memory.MemoryNote
|
||||
|
||||
/**
|
||||
* Результат одного hit'а vector-поиска: id заметки + cosine-similarity score
|
||||
* в [0..1]. Чем ближе к 1.0, тем семантически ближе query к заметке.
|
||||
*
|
||||
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`),
|
||||
* переехал в `:memory-api` 2026-09-21 чтобы быть доступным из
|
||||
* `:memory-md-vector` (KMP linuxX64/mingwX64), который больше не зависит
|
||||
* от JVM-only `:memory-vector`.
|
||||
*/
|
||||
data class ScoredVector(
|
||||
val id: String,
|
||||
val score: Float,
|
||||
)
|
||||
|
||||
/**
|
||||
* Контракт vector-индекса. Реализация отвечает за ANN-поиск top-K ближайших
|
||||
* векторов к query. Метаданные заметок лежат в `MemoryStore` (для
|
||||
* vector-бэкенда — отдельный `MemoryMetaStore` в `:memory-vector`);
|
||||
* индекс хранит только embedding'и + id-маппинг.
|
||||
*
|
||||
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`),
|
||||
* переехал в `:memory-api` 2026-09-21 чтобы быть доступным из
|
||||
* `:memory-md-vector` (KMP).
|
||||
*
|
||||
* Потокобезопасность: реализации обязаны быть безопасны для конкурентных
|
||||
* read'ов. write'ы (add/remove) могут требовать внешней синхронизации —
|
||||
* это инвариант JVector (его OnHeapGraphIndex не thread-safe для мутаций).
|
||||
*/
|
||||
interface MemoryVectorIndex : AutoCloseable {
|
||||
/** Текущая размерность embeddings. Фиксируется при первом [add]. */
|
||||
val dimension: Int
|
||||
|
||||
/** Количество записей в индексе. */
|
||||
suspend fun size(): Long
|
||||
|
||||
/** Добавить или заменить запись по [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()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package pw.binom.agentik.memory
|
||||
|
||||
/**
|
||||
* Доп. контекст для vector-индекса: фильтр по категории и conversationId.
|
||||
*
|
||||
* Раньше жил в `:memory-vector/commonMain` (`MemoryVectorIndex.kt`), но с
|
||||
* переездом `:memory-md-vector` на KMP (linuxX64/mingwX64 и др.) он перенесён
|
||||
* сюда — `:memory-md-vector` больше не зависит от JVM-only `:memory-vector`.
|
||||
*
|
||||
* Реализация `MemoryStore` (и `:memory-md`, и `:memory-vector`, и любые
|
||||
* будущие) должны использовать этот хелпер при фильтрации результатов search,
|
||||
* чтобы контракт был единый.
|
||||
*/
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package pw.binom.agentik.memory
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import pw.binom.voice.embeddingtext.TextEmbeddingExtractor
|
||||
|
||||
/**
|
||||
* Suspend-обёртка над [TextEmbeddingExtractor] из `pw.binom.ai.embeddingtext:api`.
|
||||
*
|
||||
* `TextEmbeddingExtractor.embed()` — **блокирующий** (ONNX-инференс, HTTP),
|
||||
* поэтому [embed] оборачивает его в [Dispatchers.Default] — caller'ы получают
|
||||
* честный suspend, а блокирующая работа уходит в background dispatcher.
|
||||
*
|
||||
* Размерность вектора фиксируется extractor'ом (SigLIP2-base = 768, OpenAI
|
||||
* text-embedding-3 = 1536, и т.п.). Если [knownDimension] указан — используем
|
||||
* его; иначе — определяем лениво по первому [embed] (probe-vector на пустом
|
||||
* тексте). `MemoryVectorIndex`-ы требуют размерность на момент конструирования,
|
||||
* так что для prod-использования рекомендуется всегда передавать [knownDimension]
|
||||
* явно (избегаем лишнего embed'а + непредсказуемой стоимости probe'а).
|
||||
*
|
||||
* @param extractor underlying extractor (не null)
|
||||
* @param knownDimension заранее известная размерность; null = определить по probe
|
||||
*/
|
||||
class TextEmbeddingExecutor(
|
||||
val extractor: TextEmbeddingExtractor,
|
||||
val knownDimension: Int? = null,
|
||||
) : AutoCloseable {
|
||||
|
||||
/** Размерность векторов. Эффективно константа после первого обращения. */
|
||||
val dimension: Int by lazy {
|
||||
knownDimension ?: extractor.embed("").dim
|
||||
}
|
||||
|
||||
/**
|
||||
* Эмбеддинг одного текста. Блокирующий [TextEmbeddingExtractor.embed] уходит
|
||||
* в [Dispatchers.Default] — caller может безопасно await'ить.
|
||||
*/
|
||||
suspend fun embed(text: String): FloatArray =
|
||||
withContext(Dispatchers.Default) { extractor.embed(text).values }
|
||||
|
||||
/** Батч-эмбеддинг (последовательно). Для ONNX/HTTP оверхед минимален. */
|
||||
suspend fun embedBatch(texts: List<String>): List<FloatArray> =
|
||||
texts.map { embed(it) }
|
||||
|
||||
/** Делегирует [TextEmbeddingExtractor.close]. Идемпотентно. */
|
||||
override fun close() {
|
||||
extractor.close()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user