4cce6f2e99
为 RetrieveEngine 体系新增 Doris 后端,与现有 Qdrant/Milvus/Weaviate
等保持完整能力对齐:向量检索、关键词检索、健康检查、环境变量与多实例
DB 配置、前端类型注册、单元测试、Docker Compose 模板。
实现要点:
- 协议分工:主链路用 MySQL 协议(database/sql + go-sql-driver/mysql)
做 DDL / 查询 / 删除;批量更新走 Stream Load HTTP API,并启用
partial_update=true、merge_type=APPEND,自动按 1MiB 切分批次并处理
307 重定向。
- 表结构:UNIQUE KEY(id) + enable_unique_key_merge_on_write=true 以
支持 upsert/部分列更新;按维度分表(<base>_<dim>),每张表上建
HNSW ANN 索引(metric_type=cosine_distance)和 INVERTED 索引
(parser=chinese)。
- 分数语义:使用 cosine_distance_approximate,再以 1 - dist 转换为
"越大越相似",与现有 KVHybridRetrieveEngine 约定一致。
- 异步索引:ANN 索引为后台构建,ensureTable 通过轮询 SHOW INDEX 等
待索引就绪后再放行写入,避免首次检索召回为空。
- ARRAY<FLOAT> 序列化:go-sql-driver/mysql 不支持数组占位符,
embeddingLiteral 将 []float32 转成 SQL 字面量字符串再拼接。
新增文件:
- internal/application/repository/retriever/doris/{structs,schema,
query,repository,streamload,repository_test}.go
- scripts/e2e-doris.sh:E2E 验证清单
- docs/wiki/集成扩展/Doris改动与上游同步.md:fork-and-rebase 工作流
与改动清单
修改文件(接线 + 文档):
- internal/types/{retriever,tenant,vectorstore}.go:新增
DorisRetrieverEngineType、env 解析、表单 schema 与索引参数校验
- internal/container/{container,engine_factory}.go:环境变量驱动
与 VectorStore 配置驱动两条路径都支持 Doris
- internal/application/service/vectorstore{,_healthcheck}.go:连接
校验 + Ping/Version 健康检查
- docker-compose.yml:新增 doris-fe / doris-be 服务(profile=doris)
- .env.example:DORIS_* 环境变量与示例
- docs/{使用其他向量数据库,wiki/集成扩展/集成向量数据库}.md:
使用说明与索引/分数行为说明
依赖:go.mod/go.sum 新增 github.com/go-sql-driver/mysql(运行时)和
github.com/DATA-DOG/go-sqlmock(测试)。
测试:repository 层 SQL 形状、Stream Load HTTP 行为、whereBuilder
逻辑、embeddingLiteral 往返、健康检查错误路径均有单测覆盖。
197 lines
7.1 KiB
Go
197 lines
7.1 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/Tencent/WeKnora/internal/errors"
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
secutils "github.com/Tencent/WeKnora/internal/utils"
|
|
)
|
|
|
|
// vectorStoreService implements interfaces.VectorStoreService
|
|
type vectorStoreService struct {
|
|
repo interfaces.VectorStoreRepository
|
|
storeRegistry interfaces.StoreRegistry // for dynamic registry updates on CRUD
|
|
factory interfaces.EngineFactory // creates engine services from VectorStore config
|
|
}
|
|
|
|
// NewVectorStoreService creates a new vector store service
|
|
func NewVectorStoreService(
|
|
repo interfaces.VectorStoreRepository,
|
|
storeRegistry interfaces.StoreRegistry,
|
|
factory interfaces.EngineFactory,
|
|
) interfaces.VectorStoreService {
|
|
return &vectorStoreService{
|
|
repo: repo,
|
|
storeRegistry: storeRegistry,
|
|
factory: factory,
|
|
}
|
|
}
|
|
|
|
// CreateStore validates and creates a new vector store.
|
|
func (s *vectorStoreService) CreateStore(ctx context.Context, store *types.VectorStore) error {
|
|
// 1. Basic validation (name, engine_type, tenant_id)
|
|
if err := store.Validate(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 2. Engine-specific connection config validation
|
|
if err := validateConnectionConfig(store.EngineType, store.ConnectionConfig); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 2.5. Index config validation (bounds, name characters)
|
|
if err := types.ValidateIndexConfig(store.IndexConfig); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 3. Duplicate check — DB stores
|
|
endpoint := store.ConnectionConfig.GetEndpoint()
|
|
indexName := store.IndexConfig.GetIndexNameOrDefault(store.EngineType)
|
|
|
|
exists, err := s.repo.ExistsByEndpointAndIndex(ctx, store.TenantID, store.EngineType, endpoint, indexName)
|
|
if err != nil {
|
|
return errors.NewInternalServerError("failed to check for duplicates")
|
|
}
|
|
if exists {
|
|
return errors.NewConflictError("a vector store with the same endpoint and index already exists")
|
|
}
|
|
|
|
// 4. Duplicate check — env stores (pure function, no os.Getenv in types)
|
|
for _, envStore := range types.BuildEnvVectorStores(os.Getenv("RETRIEVE_DRIVER"), os.Getenv) {
|
|
if envStore.EngineType == store.EngineType &&
|
|
envStore.ConnectionConfig.GetEndpoint() == endpoint &&
|
|
envStore.IndexConfig.GetIndexNameOrDefault(store.EngineType) == indexName {
|
|
return errors.NewConflictError(
|
|
"a vector store with the same endpoint and index is already configured via environment variables")
|
|
}
|
|
}
|
|
|
|
// 5. Auto-detect server version via connection test.
|
|
// This is required for engines where the version determines the SDK (e.g., ES v7 vs v8).
|
|
// Without it, the wrong SDK may be used causing protocol errors (406, etc.).
|
|
version, err := s.TestConnection(ctx, store.EngineType, store.ConnectionConfig)
|
|
if err != nil {
|
|
return errors.NewBadRequestError(
|
|
fmt.Sprintf("connection test failed: %s. Ensure the server is reachable before saving.", err.Error()))
|
|
}
|
|
if version != "" {
|
|
store.ConnectionConfig.Version = version
|
|
}
|
|
|
|
// 6. Persist
|
|
logger.Infof(ctx, "Creating vector store: tenant=%d, name=%s, engine=%s",
|
|
store.TenantID, secutils.SanitizeForLog(store.Name), store.EngineType)
|
|
if err := s.repo.Create(ctx, store); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 6. Register in registry (best-effort; failure doesn't roll back DB).
|
|
// The store is already persisted, and will be loaded on next app restart (self-healing).
|
|
s.registerInRegistry(ctx, store)
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateStore updates an existing vector store (name only).
|
|
// NOTE: If connection_config or index_config become mutable in the future,
|
|
// registry re-registration must be added here (unregister old + register new).
|
|
func (s *vectorStoreService) UpdateStore(ctx context.Context, store *types.VectorStore) error {
|
|
if store.TenantID == 0 {
|
|
return errors.NewValidationError("tenant_id is required")
|
|
}
|
|
if store.Name == "" {
|
|
return errors.NewValidationError("name is required")
|
|
}
|
|
|
|
logger.Infof(ctx, "Updating vector store: tenant=%d, id=%s", store.TenantID, store.ID)
|
|
return s.repo.Update(ctx, store)
|
|
}
|
|
|
|
// DeleteStore deletes a vector store by tenant + id.
|
|
// Phase 2: KB binding check will be added here.
|
|
func (s *vectorStoreService) DeleteStore(ctx context.Context, tenantID uint64, id string) error {
|
|
if err := s.repo.Delete(ctx, tenantID, id); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Unregister from registry (idempotent)
|
|
if s.storeRegistry != nil {
|
|
s.storeRegistry.UnregisterByStoreID(id)
|
|
}
|
|
|
|
logger.Infof(ctx, "Deleted vector store: tenant=%d, id=%s", tenantID, id)
|
|
return nil
|
|
}
|
|
|
|
// SaveDetectedVersion updates the connection_config.version for a stored vector store.
|
|
// Works on a copy to avoid mutating the caller's object.
|
|
func (s *vectorStoreService) SaveDetectedVersion(ctx context.Context, store *types.VectorStore, version string) error {
|
|
updated := *store
|
|
updated.ConnectionConfig.Version = version
|
|
return s.repo.UpdateConnectionConfig(ctx, &updated)
|
|
}
|
|
|
|
// registerInRegistry creates an engine service and registers it in the registry.
|
|
// Logs and skips on failure — the store is already persisted in DB,
|
|
// and will be loaded on next app restart (self-healing).
|
|
func (s *vectorStoreService) registerInRegistry(ctx context.Context, store *types.VectorStore) {
|
|
if s.storeRegistry == nil || s.factory == nil {
|
|
return
|
|
}
|
|
|
|
// Use a short timeout for engine creation to avoid blocking on unreachable hosts
|
|
// (e.g., gRPC dial to Qdrant/Milvus). The store is already persisted in DB,
|
|
// so it will be loaded on next app restart if this times out.
|
|
factoryCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
svc, err := s.factory(factoryCtx, *store)
|
|
if err != nil {
|
|
logger.Warnf(ctx, "Failed to create engine for store %s, will be available after restart: %v", store.ID, err)
|
|
return
|
|
}
|
|
s.storeRegistry.RegisterWithStoreID(store.ID, svc)
|
|
}
|
|
|
|
// validateConnectionConfig validates required fields per engine type.
|
|
func validateConnectionConfig(engineType types.RetrieverEngineType, config types.ConnectionConfig) error {
|
|
switch engineType {
|
|
case types.ElasticsearchRetrieverEngineType:
|
|
if config.Addr == "" {
|
|
return errors.NewValidationError("addr is required for elasticsearch")
|
|
}
|
|
case types.PostgresRetrieverEngineType:
|
|
if !config.UseDefaultConnection && config.Addr == "" {
|
|
return errors.NewValidationError("addr or use_default_connection is required for postgres")
|
|
}
|
|
case types.QdrantRetrieverEngineType:
|
|
if config.Host == "" {
|
|
return errors.NewValidationError("host is required for qdrant")
|
|
}
|
|
case types.MilvusRetrieverEngineType:
|
|
if config.Addr == "" {
|
|
return errors.NewValidationError("addr is required for milvus")
|
|
}
|
|
case types.WeaviateRetrieverEngineType:
|
|
if config.Host == "" {
|
|
return errors.NewValidationError("host is required for weaviate")
|
|
}
|
|
case types.DorisRetrieverEngineType:
|
|
if config.Addr == "" {
|
|
return errors.NewValidationError("addr is required for doris (FE MySQL host:port)")
|
|
}
|
|
if config.Database == "" {
|
|
return errors.NewValidationError("database is required for doris")
|
|
}
|
|
case types.SQLiteRetrieverEngineType:
|
|
// No connection config needed for SQLite
|
|
}
|
|
return nil
|
|
}
|