Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package com.embabel.chat.store.autoconfigure

import com.embabel.agent.api.common.Ai
import com.embabel.common.ai.model.EmbeddingService
import com.embabel.chat.ConversationFactory
import com.embabel.chat.ConversationFactoryProvider
import com.embabel.chat.MapConversationFactoryProvider
Expand Down Expand Up @@ -108,19 +109,43 @@ open class ChatStoreAutoConfiguration {
*
* Apps can override by defining their own [MessageEmbedder] bean (for example,
* to embed all roles, or to use a different embedding service per session).
*
* The embedding service is resolved per call rather than here, so that a host with no
* embedding model configured yet still gets a context — see [LazyEmbeddingService].
*/
@Bean
@ConditionalOnMissingBean(MessageEmbedder::class)
@ConditionalOnBean(Ai::class)
open fun messageEmbedder(ai: Ai): MessageEmbedder = RoleFilteringMessageEmbedder(
delegate = DefaultMessageEmbedder(ai.withDefaultEmbeddingService())
open fun messageEmbedder(
ai: Ai,
embeddingServices: ObjectProvider<EmbeddingService>,
): MessageEmbedder = RoleFilteringMessageEmbedder(
delegate = DefaultMessageEmbedder(LazyEmbeddingService { embeddingService(ai, embeddingServices) })
)

/**
* The application's own [EmbeddingService] bean where there is an unambiguous one
* (a `@Primary` bean counts), otherwise the platform default.
*
* Preferring the bean matters for a host that can start with NO embedding model
* configured — one whose provider key arrives at first run rather than at boot. Such a
* host registers an embedding service that reports its own absence and can be switched
* on later, whereas `ai.withDefaultEmbeddingService()` resolves the default eagerly and
* throws when no model is registered, taking the application context down with it.
*/
private fun embeddingService(ai: Ai, embeddingServices: ObjectProvider<EmbeddingService>): EmbeddingService =
embeddingServices.getIfUnique() ?: ai.withDefaultEmbeddingService()

/**
* Declares the chat-store uniqueness constraints — one per node-identity property.
* Drivine's [org.drivine.schema.SchemaManager] (registered by the Drivine starter)
* ensures every [SchemaCatalog] bean idempotently on startup, so this needs no
* runner of its own. Enforcement is governed by `drivine.schema.enabled` (default true).
*
* Owned separately from the vector catalog: catalogs sharing an owner are merged, and
* their versions with them, so an unowned constraint catalog would be versioned by the
* embedding model and would drag the model version to null whenever the vector catalog
* is skipped.
*/
@Bean
open fun chatStoreConstraintSchema(): SchemaCatalog = SchemaCatalog.of(
Expand All @@ -129,7 +154,7 @@ open class ChatStoreAutoConfiguration {
UniquenessConstraintSpec(label = "User", property = "id"),
UniquenessConstraintSpec(label = "Attachment", property = "attachmentId"),
RangeIndexSpec(label = "ChatSession", property = "lastActivityAt"),
)
).named(CONSTRAINT_SCHEMA_OWNER)

/**
* Backfills activity for installations that predate most-recently-active ordering.
Expand Down Expand Up @@ -178,18 +203,41 @@ open class ChatStoreAutoConfiguration {
open fun chatStoreVectorIndexSchema(
ai: Ai,
properties: ChatStoreProperties,
embeddingServices: ObjectProvider<EmbeddingService>,
): SchemaCatalog {
val embeddingService = ai.withDefaultEmbeddingService()
// A vector index is created AT the embedding model's dimension, so with no model
// there is no dimension to create it at. Register nothing rather than guess: an
// index at the wrong dimension is worse than none, because writes to it succeed.
// The catalog is rebuilt on the next boot, by which time a model configured at
// first run is registered. An absent-tolerant service may signal absence either by
// throwing or by reporting no dimensions, so both are treated as "no model".
val (dimensions, modelName) = runCatching {
val es = embeddingService(ai, embeddingServices)
val dimensions = es.dimensions
require(dimensions > 0) { "embedding service reports $dimensions dimensions" }
dimensions to es.name
}.getOrElse {
logger.warn("Skipping chat-message vector index schema: no embedding model ({})", it.message, it)
return SchemaCatalog.of().named(VECTOR_SCHEMA_OWNER)
}
val vi = properties.vectorIndex
val spec = VectorIndexSpec(
label = vi.label,
property = vi.property,
dimensions = embeddingService.dimensions,
dimensions = dimensions,
similarity = SimilarityFunction.valueOf(vi.similarityFunction.uppercase()),
name = vi.name,
)
logger.info("Registering chat-message vector index schema: {} (model={})", spec, embeddingService.name)
return SchemaCatalog.of(spec).withVersion(embeddingService.name)
logger.info("Registering chat-message vector index schema: {} (model={})", spec, modelName)
return SchemaCatalog.of(spec).named(VECTOR_SCHEMA_OWNER).withVersion(modelName)
}

companion object {
/** Drivine schema owner for the chat-store constraints. */
const val CONSTRAINT_SCHEMA_OWNER = "embabel-chat-store"

/** Drivine schema owner for the chat-message vector index, versioned by embedding model. */
const val VECTOR_SCHEMA_OWNER = "embabel-chat-store-vector"
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright 2024-2026 Embabel Pty Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.embabel.chat.store.autoconfigure

import com.embabel.common.ai.model.EmbeddingService
import com.embabel.common.ai.model.PricingModel

/**
* An [EmbeddingService] that resolves its delegate on first use rather than at
* construction, and pins it thereafter.
*
* Deferring resolution keeps bean creation independent of embedding-model availability,
* and of the order in which provider configurations register their services. A host whose
* model arrives after startup picks up the real service on the first embedding call, and a
* host with no model at all fails that call instead of failing the application context —
* embedding failure is already non-fatal, see
* [com.embabel.chat.store.adapter.StoredConversation].
*
* Pinning matters for provenance: a vector and the [name] recorded alongside it are read
* separately, so a delegate that changed between the two reads would label a vector with
* the wrong model. Resolution that throws is not pinned, so a service that only becomes
* resolvable later is still picked up.
*/
class LazyEmbeddingService(
private val resolve: () -> EmbeddingService,
) : EmbeddingService {

@Volatile
private var delegate: EmbeddingService? = null

private fun delegate(): EmbeddingService = delegate ?: resolve().also { delegate = it }

override val name: String get() = delegate().name

override val provider: String get() = delegate().provider

override val pricingModel: PricingModel? get() = delegate().pricingModel

override val dimensions: Int get() = delegate().dimensions

override fun embed(text: String): FloatArray = delegate().embed(text)

override fun embed(texts: List<String>): List<FloatArray> = delegate().embed(texts)
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package com.embabel.chat.store.autoconfigure

import com.embabel.agent.api.common.Ai
import com.embabel.chat.store.embedding.MessageEmbedder
import com.embabel.chat.store.repository.ChatSessionRepository
import com.embabel.common.ai.model.EmbeddingService
import org.assertj.core.api.Assertions.assertThat
Expand All @@ -25,6 +26,7 @@ import org.drivine.schema.UniquenessConstraintSpec
import org.drivine.schema.VectorIndexSpec
import org.junit.jupiter.api.Test
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.doThrow
import org.mockito.kotlin.mock
import org.springframework.boot.autoconfigure.AutoConfigurations
import org.springframework.boot.test.context.runner.ApplicationContextRunner
Expand Down Expand Up @@ -75,6 +77,77 @@ class ChatStoreSchemaWiringTest {
}
}

@Test
fun `owns the constraint and vector catalogs separately so a skipped vector index cannot clear the model version`() {
runner.withBean(Ai::class.java, { ai }).run { context ->
val catalogs = context.getBeansOfType(SchemaCatalog::class.java).values
assertThat(catalogs.map { it.name })
.containsExactlyInAnyOrder(
ChatStoreAutoConfiguration.CONSTRAINT_SCHEMA_OWNER,
ChatStoreAutoConfiguration.VECTOR_SCHEMA_OWNER,
)
assertThat(catalogs.first { it.constraints.isNotEmpty() }.version).isNull()
}
}

@Test
fun `skips the vector index when the embedding service reports its own absence`() {
val absent = mock<EmbeddingService> {
on { dimensions } doThrow IllegalStateException("no embedding model configured")
}
runner.withBean(Ai::class.java, { ai })
.withBean(EmbeddingService::class.java, { absent })
.run { context ->
val vectorCatalog = context.getBeansOfType(SchemaCatalog::class.java).values
.single { it.name == ChatStoreAutoConfiguration.VECTOR_SCHEMA_OWNER }
assertThat(vectorCatalog.isEmpty()).isTrue()
assertThat(vectorCatalog.version).isNull()
}
}

@Test
fun `skips the vector index when the embedding service reports no dimensions`() {
val absent = mock<EmbeddingService> {
on { dimensions } doReturn 0
on { name } doReturn "absent"
}
runner.withBean(Ai::class.java, { ai })
.withBean(EmbeddingService::class.java, { absent })
.run { context ->
val vectorCatalog = context.getBeansOfType(SchemaCatalog::class.java).values
.single { it.name == ChatStoreAutoConfiguration.VECTOR_SCHEMA_OWNER }
assertThat(vectorCatalog.isEmpty()).isTrue()
}
}

@Test
fun `prefers a unique EmbeddingService bean over the platform default`() {
val hostService = mock<EmbeddingService> {
on { dimensions } doReturn 768
on { name } doReturn "host-embed-model"
}
runner.withBean(Ai::class.java, { ai })
.withBean(EmbeddingService::class.java, { hostService })
.run { context ->
val vectorCatalog = context.getBeansOfType(SchemaCatalog::class.java).values
.single { it.name == ChatStoreAutoConfiguration.VECTOR_SCHEMA_OWNER }
val spec = vectorCatalog.indexes.single() as VectorIndexSpec
assertThat(spec.dimensions).isEqualTo(768)
assertThat(vectorCatalog.version).isEqualTo("host-embed-model")
}
}

@Test
fun `starts with a message embedder even when no embedding model is configured`() {
val failing = mock<Ai> {
on { withDefaultEmbeddingService() } doThrow IllegalStateException("no embedding model configured")
}
runner.withBean(Ai::class.java, { failing }).run { context ->
assertThat(context).hasNotFailed()
assertThat(context).hasSingleBean(MessageEmbedder::class.java)
}
}

@Test
fun `omits the vector schema catalog when the vector index is disabled`() {
runner.withBean(Ai::class.java, { ai })
Expand Down
Loading