WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
@@ -0,0 +1,314 @@
package retriever
import (
"context"
"fmt"
"maps"
"slices"
"sync"
"sync/atomic"
"github.com/Tencent/WeKnora/internal/common"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/models/embedding"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// engineInfo holds information about a retrieve engine and its supported retriever types
type engineInfo struct {
retrieveEngine interfaces.RetrieveEngineService
retrieverType []types.RetrieverType
}
// CompositeRetrieveEngine implements a composite pattern for retrieval engines,
// delegating operations to all registered engines
type CompositeRetrieveEngine struct {
engineInfos []*engineInfo
}
// Retrieve performs retrieval operations by delegating to the appropriate engine
// based on the retriever type specified in the parameters
func (c *CompositeRetrieveEngine) Retrieve(ctx context.Context,
retrieveParams []types.RetrieveParams,
) ([]*types.RetrieveResult, error) {
return concurrentRetrieve(ctx, retrieveParams,
func(ctx context.Context, param types.RetrieveParams, results *[]*types.RetrieveResult, mu *sync.Mutex) error {
found := false
for _, engineInfo := range c.engineInfos {
if engineInfo == nil {
continue
}
if slices.Contains(engineInfo.retrieverType, param.RetrieverType) {
result, err := engineInfo.retrieveEngine.Retrieve(ctx, param)
if err != nil {
return err
}
mu.Lock()
*results = append(*results, result...)
mu.Unlock()
found = true
break
}
}
if !found {
return fmt.Errorf("retriever type %s not found", param.RetrieverType)
}
return nil
},
)
}
// NewCompositeRetrieveEngine creates a new composite retrieve engine with the given parameters
func NewCompositeRetrieveEngine(
registry interfaces.RetrieveEngineRegistry,
engineParams []types.RetrieverEngineParams,
) (*CompositeRetrieveEngine, error) {
engineInfos := make(map[types.RetrieverEngineType]*engineInfo)
for _, engineParam := range engineParams {
repo, err := registry.GetRetrieveEngineService(engineParam.RetrieverEngineType)
if err != nil {
return nil, err
}
if !slices.Contains(repo.Support(), engineParam.RetrieverType) {
return nil, fmt.Errorf("retrieval engine %s does not support retriever type: %s",
repo.EngineType(), engineParam.RetrieverType)
}
if _, exists := engineInfos[repo.EngineType()]; exists {
engineInfos[repo.EngineType()].retrieverType = append(engineInfos[repo.EngineType()].retrieverType,
engineParam.RetrieverType)
continue
}
engineInfos[repo.EngineType()] = &engineInfo{
retrieveEngine: repo,
retrieverType: []types.RetrieverType{engineParam.RetrieverType},
}
}
return &CompositeRetrieveEngine{engineInfos: slices.Collect(maps.Values(engineInfos))}, nil
}
// SupportRetriever checks if a retriever type is supported by any of the registered engines
func (c *CompositeRetrieveEngine) SupportRetriever(r types.RetrieverType) bool {
for _, engineInfo := range c.engineInfos {
if engineInfo == nil {
continue
}
if slices.Contains(engineInfo.retrieverType, r) {
return true
}
}
return false
}
// BatchUpdateChunkEnabledStatus updates the enabled status of chunks in batch
func (c *CompositeRetrieveEngine) BatchUpdateChunkEnabledStatus(
ctx context.Context,
chunkStatusMap map[string]bool,
) error {
return c.concurrentExecWithError(ctx, func(ctx context.Context, engineInfo *engineInfo) error {
if err := engineInfo.retrieveEngine.BatchUpdateChunkEnabledStatus(ctx, chunkStatusMap); err != nil {
return err
}
return nil
})
}
// BatchUpdateChunkTagID updates the tag ID of chunks in batch
func (c *CompositeRetrieveEngine) BatchUpdateChunkTagID(
ctx context.Context,
chunkTagMap map[string]string,
) error {
return c.concurrentExecWithError(ctx, func(ctx context.Context, engineInfo *engineInfo) error {
if err := engineInfo.retrieveEngine.BatchUpdateChunkTagID(ctx, chunkTagMap); err != nil {
return err
}
return nil
})
}
// concurrentRetrieve is a helper function for concurrent processing of retrieval parameters
// and collecting results
func concurrentRetrieve(
ctx context.Context,
retrieveParams []types.RetrieveParams,
fn func(ctx context.Context, param types.RetrieveParams, results *[]*types.RetrieveResult, mu *sync.Mutex) error,
) ([]*types.RetrieveResult, error) {
var results []*types.RetrieveResult
var mu sync.Mutex
var wg sync.WaitGroup
errCh := make(chan error, len(retrieveParams))
for _, param := range retrieveParams {
wg.Add(1)
p := param // Create local copy for safe use in closure
go func() {
defer wg.Done()
if err := fn(ctx, p, &results, &mu); err != nil {
errCh <- err
}
}()
}
wg.Wait()
close(errCh)
// Check for errors
for err := range errCh {
if err != nil {
return nil, err
}
}
return results, nil
}
// concurrentExecWithError is a generic function for concurrent execution of operations
// and handling errors
func (c *CompositeRetrieveEngine) concurrentExecWithError(
ctx context.Context,
fn func(ctx context.Context, engineInfo *engineInfo) error,
) error {
var wg sync.WaitGroup
errCh := make(chan error, len(c.engineInfos))
for _, engineInfo := range c.engineInfos {
wg.Add(1)
eng := engineInfo // Create local copy for safe use in closure
go func() {
defer wg.Done()
if err := fn(ctx, eng); err != nil {
errCh <- err
}
}()
}
wg.Wait()
close(errCh)
// Return the first error (if any)
for err := range errCh {
if err != nil {
return err
}
}
return nil
}
// Index saves vector embeddings to all registered repositories
func (c *CompositeRetrieveEngine) Index(ctx context.Context,
embedder embedding.Embedder, indexInfo *types.IndexInfo,
) error {
err := c.concurrentExecWithError(ctx, func(ctx context.Context, engineInfo *engineInfo) error {
if err := engineInfo.retrieveEngine.Index(ctx, embedder, indexInfo, engineInfo.retrieverType); err != nil {
logger.Errorf(ctx, "Repository %s failed to save: %v", engineInfo.retrieveEngine.EngineType(), err)
return err
}
return nil
})
return err
}
// BatchIndex batch saves vector embeddings to all registered repositories
func (c *CompositeRetrieveEngine) BatchIndex(ctx context.Context,
embedder embedding.Embedder, indexInfoList []*types.IndexInfo,
) error {
// Deduplicate sourceIDs
indexInfoList = common.Deduplicate(func(info *types.IndexInfo) string { return info.SourceID }, indexInfoList...)
err := c.concurrentExecWithError(ctx, func(ctx context.Context, engineInfo *engineInfo) error {
if err := engineInfo.retrieveEngine.BatchIndex(
ctx,
embedder,
indexInfoList,
engineInfo.retrieverType,
); err != nil {
logger.Errorf(ctx, "Repository %s failed to batch save: %v", engineInfo.retrieveEngine.EngineType(), err)
return err
}
return nil
})
return err
}
// DeleteByChunkIDList deletes vector embeddings by chunk ID list from all registered repositories
func (c *CompositeRetrieveEngine) DeleteByChunkIDList(ctx context.Context,
chunkIDList []string, dimension int, knowledgeType string,
) error {
return c.concurrentExecWithError(ctx, func(ctx context.Context, engineInfo *engineInfo) error {
if err := engineInfo.retrieveEngine.DeleteByChunkIDList(ctx, chunkIDList, dimension, knowledgeType); err != nil {
logger.GetLogger(ctx).Errorf("Repository %s failed to delete chunk ID list: %v",
engineInfo.retrieveEngine.EngineType(), err)
return err
}
return nil
})
}
// DeleteBySourceIDList deletes vector embeddings by source ID list from all registered repositories
func (c *CompositeRetrieveEngine) DeleteBySourceIDList(ctx context.Context,
sourceIDList []string, dimension int, knowledgeType string,
) error {
return c.concurrentExecWithError(ctx, func(ctx context.Context, engineInfo *engineInfo) error {
if err := engineInfo.retrieveEngine.DeleteBySourceIDList(ctx, sourceIDList, dimension, knowledgeType); err != nil {
logger.GetLogger(ctx).Errorf("Repository %s failed to delete source ID list: %v",
engineInfo.retrieveEngine.EngineType(), err)
return err
}
return nil
})
}
// CopyIndices copies indices from a source knowledge base to a target knowledge base
func (c *CompositeRetrieveEngine) CopyIndices(
ctx context.Context,
sourceKnowledgeBaseID string,
targetKnowledgeBaseID string,
sourceToTargetKBIDMap map[string]string,
sourceToTargetChunkIDMap map[string]string,
dimension int,
knowledgeType string,
) error {
return c.concurrentExecWithError(ctx, func(ctx context.Context, engineInfo *engineInfo) error {
if err := engineInfo.retrieveEngine.CopyIndices(
ctx,
sourceKnowledgeBaseID,
sourceToTargetKBIDMap,
sourceToTargetChunkIDMap,
targetKnowledgeBaseID,
dimension,
knowledgeType,
); err != nil {
logger.Errorf(ctx, "Repository %s failed to copy indices: %v", engineInfo.retrieveEngine.EngineType(), err)
return err
}
return nil
})
}
// DeleteByKnowledgeIDList deletes vector embeddings by knowledge ID list from all registered repositories
func (c *CompositeRetrieveEngine) DeleteByKnowledgeIDList(ctx context.Context,
knowledgeIDList []string, dimension int, knowledgeType string,
) error {
return c.concurrentExecWithError(ctx, func(ctx context.Context, engineInfo *engineInfo) error {
if err := engineInfo.retrieveEngine.DeleteByKnowledgeIDList(ctx, knowledgeIDList, dimension, knowledgeType); err != nil {
logger.GetLogger(ctx).Errorf("Repository %s failed to delete knowledge ID list: %v",
engineInfo.retrieveEngine.EngineType(), err)
return err
}
return nil
})
}
// EstimateStorageSize estimates the storage size required for the provided index information
func (c *CompositeRetrieveEngine) EstimateStorageSize(ctx context.Context,
embedder embedding.Embedder, indexInfoList []*types.IndexInfo,
) int64 {
sum := atomic.Int64{}
err := c.concurrentExecWithError(ctx, func(ctx context.Context, engineInfo *engineInfo) error {
sum.Add(engineInfo.retrieveEngine.EstimateStorageSize(ctx, embedder, indexInfoList, engineInfo.retrieverType))
return nil
})
if err != nil {
logger.Errorf(ctx, "EstimateStorageSize failed: %v", err)
}
return sum.Load()
}
@@ -0,0 +1,260 @@
package retriever
import (
"context"
"errors"
"slices"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// Sentinel errors returned by factory functions. Callers may use errors.Is to
// classify. User-facing responses MUST wrap or replace these with generic
// messages — the sentinels intentionally omit store UUIDs to avoid enumeration
// leaks. Structured logs inside the factory record the tenant/store IDs.
var (
// ErrTenantInfoMissing is returned when the factory needs a tenant from
// context (synchronous, unbound KB path) and none is present.
ErrTenantInfoMissing = errors.New("tenant info not found in context")
// ErrVectorStoreNotFound is returned when the store does not exist for the
// tenant. Async workers should treat this as non-retryable: no amount of
// waiting brings back a store that is not in the database.
ErrVectorStoreNotFound = errors.New("vector store not available")
// ErrVectorStoreUnavailable is returned when the store exists but its
// engine could not be produced right now — the metadata database was
// unreachable, or building the engine failed against a backend that may
// simply be down. Async workers should retry rather than discard the task,
// which is why this is a separate sentinel: reporting it as not-found
// would turn a passing outage into permanently dropped work. It carries no
// detail because the underlying errors embed endpoints and credentials;
// the cause is logged where it happens.
ErrVectorStoreUnavailable = errors.New("vector store engine unavailable")
// ErrVectorStoreForbidden is returned when the resolved store is not
// owned by the given tenant. This guards against cross-tenant access
// in case the upstream validation layer has a gap. Async workers should
// treat this as non-retryable.
ErrVectorStoreForbidden = errors.New("vector store access denied")
)
// TenantStoreOwnership abstracts the lookup used by factory functions to
// verify that a given vector store ID is owned by the given tenant ID.
//
// Production implementations wrap the VectorStoreRepository; tests inject
// in-memory fakes so they can cover the ownership branches without touching
// a database.
type TenantStoreOwnership interface {
// StoreOwnedBy reports whether the store with the given ID is owned
// by the given tenant. When the store does not exist, it returns
// (false, nil). Errors are reserved for infrastructure failures such as
// database connectivity issues.
StoreOwnedBy(ctx context.Context, storeID string, tenantID uint64) (bool, error)
}
// VerifyBinding asserts that a non-empty storeID is owned by tenantID and
// registered in the in-memory engine registry. It encapsulates the two
// checks that gate every store-bound resolution so that callers outside
// the retriever package (notably the KB create-validation path) can reuse
// the same sentinel hierarchy instead of duplicating the logic.
//
// Resolution rules:
//
// - ownership.StoreOwnedBy returns an infrastructure error → that error
// is returned verbatim so callers can decide retry/abort.
// - ownership returns (false, nil) → ErrVectorStoreForbidden.
// - ownership returns (true, nil) + registry.GetByStoreID fails →
// ErrVectorStoreNotFound.
// - all checks succeed → nil.
//
// VerifyBinding itself never echoes the store UUID; callers MUST wrap the
// sentinels into user-facing errors at the boundary (and log the
// tenant/store pair via structured fields when appropriate).
//
// resolveBoundEngine (below) intentionally does NOT delegate to VerifyBinding
// because it also needs the resolved engine service; sharing would require
// either a second registry lookup or returning the service from VerifyBinding,
// both of which dilute the helper's single purpose. The two paths are kept
// in lockstep by the factory_test.go matrix.
func VerifyBinding(
ctx context.Context,
registry interfaces.RetrieveEngineRegistry,
ownership TenantStoreOwnership,
tenantID uint64,
storeID string,
) error {
owned, err := ownership.StoreOwnedBy(ctx, storeID, tenantID)
if err != nil {
return err
}
if !owned {
return ErrVectorStoreForbidden
}
if _, err := registry.GetOrLoadByStoreID(ctx, tenantID, storeID); err != nil {
return classifyLookupError(err)
}
return nil
}
// classifyLookupError narrows an engine-lookup failure to what the caller is
// allowed to see, while preserving the distinction that decides whether work
// gets retried or discarded. Context errors and the store sentinels pass
// through; anything unexpected is reported as retryable, because treating an
// unknown failure as permanent is what silently drops work.
func classifyLookupError(err error) error {
switch {
case isContextError(err),
errors.Is(err, ErrVectorStoreNotFound),
errors.Is(err, ErrVectorStoreUnavailable),
errors.Is(err, ErrVectorStoreForbidden):
return err
default:
return ErrVectorStoreUnavailable
}
}
// isContextError reports whether err is the caller giving up rather than a
// verdict about the store. The distinction matters because async workers treat
// the store sentinels as permanent and stop retrying, so a cancelled or
// timed-out request must not be reported as one.
func isContextError(err error) bool {
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
}
// CreateRetrieveEngineForKB returns a CompositeRetrieveEngine resolved from
// a KB's VectorStore binding.
//
// Resolution rules:
//
// - vectorStoreID == nil || *vectorStoreID == "" →
// falls back to the tenant's effective engines (env-store flow driven
// by RETRIEVE_DRIVER). TenantInfo is read from ctx.
// - otherwise →
// 1) ownership.StoreOwnedBy(*storeID, tenantID) must return true;
// cross-tenant attempts yield ErrVectorStoreForbidden.
// 2) registry.GetByStoreID(*storeID) must succeed;
// unregistered stores yield ErrVectorStoreNotFound.
// 3) the single engine is wrapped by NewCompositeRetrieveEngine so
// that its Support()-based retriever-type matching is preserved.
//
// Use this for 23 synchronous call sites across the application services.
// Async task handlers that cannot rely on ctx-based TenantInfo (currently:
// ProcessKBDeleteTask, ProcessIndexDelete) must use
// CreateRetrieveEngineFromPayload instead.
func CreateRetrieveEngineForKB(
ctx context.Context,
registry interfaces.RetrieveEngineRegistry,
ownership TenantStoreOwnership,
tenantID uint64,
vectorStoreID *string,
) (*CompositeRetrieveEngine, error) {
// Normalize nil and empty-string pointer to "unbound" so that callers
// cannot accidentally route an empty UUID into GetByStoreID.
if vectorStoreID == nil || *vectorStoreID == "" {
tenantInfo, ok := types.TenantInfoFromContext(ctx)
if !ok {
return nil, ErrTenantInfoMissing
}
return NewCompositeRetrieveEngine(registry, tenantInfo.GetEffectiveEngines())
}
return resolveBoundEngine(ctx, registry, ownership, tenantID, *vectorStoreID)
}
// CreateRetrieveEngineFromPayload is the async-task variant. It does not
// read TenantInfo from ctx because async handlers do not populate it.
// Instead, tenantID is passed explicitly from the deserialized payload and
// is verified against the store's tenant when vectorStoreID is non-empty.
//
// Tasks enqueued before vectorStoreID was added to the payload decode it as
// nil and transparently fall back to the pre-serialized effectiveEngines
// path — no in-flight task is lost across upgrades.
func CreateRetrieveEngineFromPayload(
ctx context.Context,
registry interfaces.RetrieveEngineRegistry,
ownership TenantStoreOwnership,
tenantID uint64,
effectiveEngines []types.RetrieverEngineParams,
vectorStoreID *string,
) (*CompositeRetrieveEngine, error) {
if vectorStoreID == nil || *vectorStoreID == "" {
return NewCompositeRetrieveEngine(registry, effectiveEngines)
}
return resolveBoundEngine(ctx, registry, ownership, tenantID, *vectorStoreID)
}
// resolveBoundEngine is the shared ownership-verified lookup path used by
// both CreateRetrieveEngineForKB and CreateRetrieveEngineFromPayload. It
// returns sentinel errors so that handlers can classify them (for example,
// async workers convert Forbidden/NotFound into asynq.SkipRetry).
func resolveBoundEngine(
ctx context.Context,
registry interfaces.RetrieveEngineRegistry,
ownership TenantStoreOwnership,
tenantID uint64,
storeID string,
) (*CompositeRetrieveEngine, error) {
owned, err := ownership.StoreOwnedBy(ctx, storeID, tenantID)
if err != nil {
// This lookup queries the database with the caller's context, so it is
// where a shutdown or a disconnect is usually noticed first. Reporting
// that as a store verdict would let async workers discard work that
// only needs running again.
if isContextError(err) {
return nil, err
}
// Infrastructure failure — record the raw error for operators but
// do not leak internals to the caller. The store itself may be fine,
// so this is retryable rather than not-found.
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"tenant_id": tenantID,
"store_id": storeID,
"reason": "ownership lookup failed",
})
return nil, ErrVectorStoreUnavailable
}
if !owned {
// Cross-tenant attempt (or the store has been deleted in the
// meantime). Log with WARN so that audits can surface probing.
logger.Warnf(ctx,
"[retriever.factory] cross-tenant store access attempted: tenant=%d store=%s",
tenantID, storeID)
return nil, ErrVectorStoreForbidden
}
svc, err := registry.GetOrLoadByStoreID(ctx, tenantID, storeID)
if err != nil {
if isContextError(err) {
return nil, err
}
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"tenant_id": tenantID,
"store_id": storeID,
"reason": "store engine could not be resolved",
})
return nil, classifyLookupError(err)
}
// Build the composite directly from the resolved service.
//
// We cannot delegate to NewCompositeRetrieveEngine here because that
// function resolves engines through registry.GetRetrieveEngineService,
// which reads from the byEngineType map (env stores). DB stores live
// in the byStoreID map and are not reachable via engine type alone —
// multiple stores can share the same engine type.
//
// Semantics: a KB bound to a DB store uses every retriever type that
// store supports. This intentionally overrides the tenant-level
// effective-engines filter, because binding a KB to a specific store
// is an explicit opt-out of tenant-default routing.
return &CompositeRetrieveEngine{
engineInfos: []*engineInfo{{
retrieveEngine: svc,
retrieverType: slices.Clone(svc.Support()),
}},
}, nil
}
@@ -0,0 +1,457 @@
package retriever
import (
"context"
"encoding/json"
"errors"
"sync"
"testing"
"github.com/Tencent/WeKnora/internal/models/embedding"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ----- test fakes -----
// fakeOwnership is an in-memory TenantStoreOwnership implementation.
// All mutable state is guarded by mu so that the race test exercises a
// safe fake and surfaces only genuine races inside the factory code.
type fakeOwnership struct {
mu sync.Mutex
// owned maps storeID → tenantID that owns it. Absence means "not owned".
owned map[string]uint64
// err, when non-nil, is returned from every StoreOwnedBy call
// (used to simulate infrastructure failures).
err error
// calls records every (storeID, tenantID) pair asked. Tests use this
// to assert that the factory short-circuits to the fallback path
// without calling ownership when vectorStoreID is nil/empty.
calls []ownershipCall
}
type ownershipCall struct {
storeID string
tenantID uint64
}
func (f *fakeOwnership) StoreOwnedBy(ctx context.Context, storeID string, tenantID uint64) (bool, error) {
f.mu.Lock()
f.calls = append(f.calls, ownershipCall{storeID: storeID, tenantID: tenantID})
err := f.err
ownedTenant, ok := f.owned[storeID]
f.mu.Unlock()
if err != nil {
return false, err
}
return ok && ownedTenant == tenantID, nil
}
// callCount returns the number of StoreOwnedBy calls recorded so far.
// Safe for concurrent use alongside StoreOwnedBy.
func (f *fakeOwnership) callCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.calls)
}
// fakeEngine implements interfaces.RetrieveEngineService with only the
// methods that factory paths exercise. All other methods panic so that
// accidental use during testing is loud.
type fakeEngine struct {
engineType types.RetrieverEngineType
support []types.RetrieverType
}
func (f *fakeEngine) EngineType() types.RetrieverEngineType { return f.engineType }
func (f *fakeEngine) Support() []types.RetrieverType { return f.support }
func (f *fakeEngine) Retrieve(ctx context.Context, _ types.RetrieveParams) ([]*types.RetrieveResult, error) {
panic("fakeEngine.Retrieve: not used in factory tests")
}
func (f *fakeEngine) Index(ctx context.Context, _ embedding.Embedder, _ *types.IndexInfo, _ []types.RetrieverType) error {
panic("fakeEngine.Index: not used in factory tests")
}
func (f *fakeEngine) BatchIndex(ctx context.Context, _ embedding.Embedder, _ []*types.IndexInfo, _ []types.RetrieverType) error {
panic("fakeEngine.BatchIndex: not used in factory tests")
}
func (f *fakeEngine) EstimateStorageSize(ctx context.Context, _ embedding.Embedder, _ []*types.IndexInfo, _ []types.RetrieverType) int64 {
panic("fakeEngine.EstimateStorageSize: not used in factory tests")
}
func (f *fakeEngine) CopyIndices(ctx context.Context, _ string, _ map[string]string, _ map[string]string, _ string, _ int, _ string) error {
panic("fakeEngine.CopyIndices: not used in factory tests")
}
func (f *fakeEngine) DeleteByChunkIDList(ctx context.Context, _ []string, _ int, _ string) error {
panic("fakeEngine.DeleteByChunkIDList: not used in factory tests")
}
func (f *fakeEngine) DeleteBySourceIDList(ctx context.Context, _ []string, _ int, _ string) error {
panic("fakeEngine.DeleteBySourceIDList: not used in factory tests")
}
func (f *fakeEngine) DeleteByKnowledgeIDList(ctx context.Context, _ []string, _ int, _ string) error {
panic("fakeEngine.DeleteByKnowledgeIDList: not used in factory tests")
}
func (f *fakeEngine) BatchUpdateChunkEnabledStatus(ctx context.Context, _ map[string]bool) error {
panic("fakeEngine.BatchUpdateChunkEnabledStatus: not used in factory tests")
}
func (f *fakeEngine) BatchUpdateChunkTagID(ctx context.Context, _ map[string]string) error {
panic("fakeEngine.BatchUpdateChunkTagID: not used in factory tests")
}
// registryWithStores builds a registry with the given storeID → service
// pairs populated in the byStoreID map. Engine-type entries are also
// registered so that the unbound path can resolve engines via the
// tenant's effective engines.
func registryWithStores(t *testing.T, stores map[string]*fakeEngine, engineTypes map[types.RetrieverEngineType]*fakeEngine) *RetrieveEngineRegistry {
t.Helper()
r := &RetrieveEngineRegistry{
byEngineType: map[types.RetrieverEngineType]interfaces.RetrieveEngineService{},
byStoreID: map[string]interfaces.RetrieveEngineService{},
}
for id, svc := range stores {
r.byStoreID[id] = svc
}
for et, svc := range engineTypes {
r.byEngineType[et] = svc
}
return r
}
// newTenantCtx returns a context carrying a Tenant with the given
// EffectiveEngines. Factory's unbound path consumes this via
// types.TenantInfoFromContext.
func newTenantCtx(engines []types.RetrieverEngineParams) context.Context {
tenant := &types.Tenant{
RetrieverEngines: types.RetrieverEngines{Engines: engines},
}
return context.WithValue(context.Background(), types.TenantInfoContextKey, tenant)
}
// ----- CreateRetrieveEngineForKB -----
func TestCreateRetrieveEngineForKB_Unbound(t *testing.T) {
postgresEngine := &fakeEngine{
engineType: types.PostgresRetrieverEngineType,
support: []types.RetrieverType{types.KeywordsRetrieverType, types.VectorRetrieverType},
}
registry := registryWithStores(t, nil, map[types.RetrieverEngineType]*fakeEngine{
types.PostgresRetrieverEngineType: postgresEngine,
})
ownership := &fakeOwnership{}
tenantCtx := newTenantCtx([]types.RetrieverEngineParams{
{RetrieverEngineType: types.PostgresRetrieverEngineType, RetrieverType: types.VectorRetrieverType},
})
cases := []struct {
name string
store *string
}{
{"nil pointer", nil},
{"empty string pointer", strPtr("")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
engine, err := CreateRetrieveEngineForKB(tenantCtx, registry, ownership, 1, tc.store)
require.NoError(t, err)
require.NotNil(t, engine)
assert.Len(t, engine.engineInfos, 1, "unbound path uses tenant effective engines")
assert.Same(t, postgresEngine, engine.engineInfos[0].retrieveEngine)
assert.Zero(t, ownership.callCount(),
"ownership must not be called on the unbound path")
})
}
}
func TestCreateRetrieveEngineForKB_UnboundMissingTenant(t *testing.T) {
registry := registryWithStores(t, nil, nil)
ownership := &fakeOwnership{}
_, err := CreateRetrieveEngineForKB(context.Background(), registry, ownership, 1, nil)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrTenantInfoMissing),
"err %q must wrap ErrTenantInfoMissing", err)
}
func TestCreateRetrieveEngineForKB_StoreBound(t *testing.T) {
esEngine := &fakeEngine{
engineType: types.ElasticsearchRetrieverEngineType,
support: []types.RetrieverType{types.KeywordsRetrieverType, types.VectorRetrieverType},
}
registry := registryWithStores(t,
map[string]*fakeEngine{"store-A": esEngine},
nil,
)
ownership := &fakeOwnership{owned: map[string]uint64{"store-A": 1}}
storeID := "store-A"
engine, err := CreateRetrieveEngineForKB(context.Background(), registry, ownership, 1, &storeID)
require.NoError(t, err)
require.NotNil(t, engine)
require.Len(t, engine.engineInfos, 1)
assert.Same(t, esEngine, engine.engineInfos[0].retrieveEngine)
assert.Equal(t,
[]types.RetrieverType{types.KeywordsRetrieverType, types.VectorRetrieverType},
engine.engineInfos[0].retrieverType,
"store-bound KB uses every retriever type the store supports")
}
func TestCreateRetrieveEngineForKB_CrossTenant(t *testing.T) {
esEngine := &fakeEngine{
engineType: types.ElasticsearchRetrieverEngineType,
support: []types.RetrieverType{types.VectorRetrieverType},
}
registry := registryWithStores(t,
map[string]*fakeEngine{"store-A": esEngine},
nil,
)
// store-A is owned by tenant 2, not tenant 1
ownership := &fakeOwnership{owned: map[string]uint64{"store-A": 2}}
storeID := "store-A"
_, err := CreateRetrieveEngineForKB(context.Background(), registry, ownership, 1, &storeID)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrVectorStoreForbidden),
"cross-tenant access must yield ErrVectorStoreForbidden; got %q", err)
// Sanity: no store UUID in the sentinel's message — the structured log
// emitted inside the factory is where UUIDs live. The user-visible
// error is intentionally opaque.
assert.NotContains(t, err.Error(), "store-A",
"sentinel error must not expose store UUIDs to callers")
}
func TestCreateRetrieveEngineForKB_StoreNotRegistered(t *testing.T) {
// Ownership says the tenant owns the store, but registry has not
// loaded it. This happens when a VectorStore row exists in the DB
// but its engine initialization failed at app start.
registry := registryWithStores(t, nil, nil)
ownership := &fakeOwnership{owned: map[string]uint64{"store-A": 1}}
storeID := "store-A"
_, err := CreateRetrieveEngineForKB(context.Background(), registry, ownership, 1, &storeID)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrVectorStoreNotFound),
"unregistered store must yield ErrVectorStoreNotFound; got %q", err)
}
func TestCreateRetrieveEngineForKB_OwnershipLookupError(t *testing.T) {
registry := registryWithStores(t,
map[string]*fakeEngine{"store-A": {engineType: types.PostgresRetrieverEngineType}},
nil,
)
ownership := &fakeOwnership{err: errors.New("db connection refused")}
storeID := "store-A"
_, err := CreateRetrieveEngineForKB(context.Background(), registry, ownership, 1, &storeID)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrVectorStoreUnavailable),
"a database failure says nothing about whether the store exists, so it must be "+
"retryable rather than the permanent not-found sentinel; got %q", err)
assert.False(t, errors.Is(err, ErrVectorStoreNotFound),
"async workers stop retrying on not-found, which would discard the task")
}
// ----- CreateRetrieveEngineFromPayload -----
func TestCreateRetrieveEngineFromPayload_LegacyUnbound(t *testing.T) {
// Four ways a payload can express "no binding": missing field,
// explicit null, empty string, and programmatically nil. All four
// should take the fallback path using the payload's effectiveEngines.
postgresEngine := &fakeEngine{
engineType: types.PostgresRetrieverEngineType,
support: []types.RetrieverType{types.VectorRetrieverType},
}
registry := registryWithStores(t, nil, map[types.RetrieverEngineType]*fakeEngine{
types.PostgresRetrieverEngineType: postgresEngine,
})
ownership := &fakeOwnership{}
engines := []types.RetrieverEngineParams{{
RetrieverEngineType: types.PostgresRetrieverEngineType,
RetrieverType: types.VectorRetrieverType,
}}
// simulate payload decoding from JSON for each legacy shape
decode := func(t *testing.T, body string) *string {
t.Helper()
var p types.KBDeletePayload
require.NoError(t, json.Unmarshal([]byte(body), &p))
return p.VectorStoreID
}
cases := []struct {
name string
ptr *string
}{
{"missing field", decode(t, `{"tenant_id":1}`)},
{"explicit null", decode(t, `{"tenant_id":1,"vector_store_id":null}`)},
{"empty string", decode(t, `{"tenant_id":1,"vector_store_id":""}`)},
{"programmatic nil", nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
engine, err := CreateRetrieveEngineFromPayload(
context.Background(), registry, ownership, 1, engines, tc.ptr)
require.NoError(t, err)
require.NotNil(t, engine)
require.Len(t, engine.engineInfos, 1)
assert.Same(t, postgresEngine, engine.engineInfos[0].retrieveEngine)
assert.Zero(t, ownership.callCount(),
"unbound payload must not trigger ownership lookup")
})
}
}
func TestCreateRetrieveEngineFromPayload_Bound(t *testing.T) {
qdrantEngine := &fakeEngine{
engineType: types.QdrantRetrieverEngineType,
support: []types.RetrieverType{types.VectorRetrieverType},
}
registry := registryWithStores(t,
map[string]*fakeEngine{"qd-1": qdrantEngine},
nil,
)
ownership := &fakeOwnership{owned: map[string]uint64{"qd-1": 42}}
storeID := "qd-1"
engine, err := CreateRetrieveEngineFromPayload(
context.Background(), registry, ownership, 42, nil, &storeID)
require.NoError(t, err)
require.NotNil(t, engine)
require.Len(t, engine.engineInfos, 1)
assert.Same(t, qdrantEngine, engine.engineInfos[0].retrieveEngine)
}
func TestCreateRetrieveEngineFromPayload_TamperedCrossTenant(t *testing.T) {
esEngine := &fakeEngine{engineType: types.ElasticsearchRetrieverEngineType,
support: []types.RetrieverType{types.VectorRetrieverType}}
registry := registryWithStores(t,
map[string]*fakeEngine{"store-A": esEngine}, nil)
// Store is owned by tenant 99, but the (possibly tampered) payload
// claims tenant 1. Factory must reject.
ownership := &fakeOwnership{owned: map[string]uint64{"store-A": 99}}
storeID := "store-A"
_, err := CreateRetrieveEngineFromPayload(
context.Background(), registry, ownership, 1,
[]types.RetrieverEngineParams{}, &storeID)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrVectorStoreForbidden))
}
// ----- Race -----
// TestFactoryParallelInvocation is primarily meaningful when run under
// `go test -race`. Factory functions themselves are stateless; the test
// guards against accidental shared state being introduced later.
func TestFactoryParallelInvocation(t *testing.T) {
esEngine := &fakeEngine{
engineType: types.ElasticsearchRetrieverEngineType,
support: []types.RetrieverType{types.VectorRetrieverType},
}
registry := registryWithStores(t,
map[string]*fakeEngine{"store-A": esEngine},
nil,
)
ownership := &fakeOwnership{owned: map[string]uint64{"store-A": 1}}
done := make(chan error, 16)
for i := 0; i < 16; i++ {
go func() {
storeID := "store-A"
_, err := CreateRetrieveEngineForKB(
context.Background(), registry, ownership, 1, &storeID)
done <- err
}()
}
for i := 0; i < 16; i++ {
if err := <-done; err != nil {
t.Errorf("concurrent call %d failed: %v", i, err)
}
}
}
// ---------------------------------------------------------------------------
// VerifyBinding tests — sentinel SSOT used by the KB create-validation
// path. Mirrors the branches of resolveBoundEngine without constructing an
// actual CompositeRetrieveEngine.
// ---------------------------------------------------------------------------
func TestVerifyBinding(t *testing.T) {
ctx := context.Background()
esEngine := &fakeEngine{
engineType: types.ElasticsearchRetrieverEngineType,
support: []types.RetrieverType{types.VectorRetrieverType},
}
t.Run("ownership infra error is returned verbatim", func(t *testing.T) {
registry := registryWithStores(t, nil, nil)
ownership := &fakeOwnership{err: errors.New("db boom")}
err := VerifyBinding(ctx, registry, ownership, 1, "store-A")
if err == nil || err.Error() != "db boom" {
t.Fatalf("expected verbatim infra error, got %v", err)
}
})
t.Run("not owned -> ErrVectorStoreForbidden", func(t *testing.T) {
registry := registryWithStores(t, nil, nil)
ownership := &fakeOwnership{owned: map[string]uint64{}}
err := VerifyBinding(ctx, registry, ownership, 1, "store-A")
if !errors.Is(err, ErrVectorStoreForbidden) {
t.Fatalf("expected ErrVectorStoreForbidden, got %v", err)
}
})
t.Run("owned but unregistered -> ErrVectorStoreNotFound", func(t *testing.T) {
registry := registryWithStores(t, nil, nil)
ownership := &fakeOwnership{owned: map[string]uint64{"store-A": 1}}
err := VerifyBinding(ctx, registry, ownership, 1, "store-A")
if !errors.Is(err, ErrVectorStoreNotFound) {
t.Fatalf("expected ErrVectorStoreNotFound, got %v", err)
}
})
t.Run("owned and registered -> nil", func(t *testing.T) {
registry := registryWithStores(t,
map[string]*fakeEngine{"store-A": esEngine},
nil,
)
ownership := &fakeOwnership{owned: map[string]uint64{"store-A": 1}}
if err := VerifyBinding(ctx, registry, ownership, 1, "store-A"); err != nil {
t.Fatalf("expected nil, got %v", err)
}
})
t.Run("cross-tenant returns Forbidden (not Found)", func(t *testing.T) {
// store owned by tenant 2, queried by tenant 1
registry := registryWithStores(t,
map[string]*fakeEngine{"store-A": esEngine},
nil,
)
ownership := &fakeOwnership{owned: map[string]uint64{"store-A": 2}}
err := VerifyBinding(ctx, registry, ownership, 1, "store-A")
if !errors.Is(err, ErrVectorStoreForbidden) {
t.Fatalf("expected ErrVectorStoreForbidden, got %v", err)
}
})
}
// ----- helpers -----
func strPtr(s string) *string { return &s }
@@ -0,0 +1,348 @@
package retriever
import (
"context"
"regexp"
"slices"
"strings"
"time"
"unicode/utf8"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/models/embedding"
"github.com/Tencent/WeKnora/internal/models/utils"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"golang.org/x/sync/errgroup"
)
// safetyMaxChars is an absolute upper bound for any single embedding input.
// Beyond this we truncate (with a warning) instead of blindly forwarding to
// the embedding API, which would either error out or silently truncate in a
// model-specific way. Set well above any current chunkSize budget so it only
// kicks in for genuinely pathological inputs.
const safetyMaxChars = 20000
// embedRetryAttempts and embedRetryBaseDelay control the exponential backoff
// applied to BatchEmbedWithPool calls.
const (
embedRetryAttempts = 5
embedRetryBaseDelay = 200 * time.Millisecond
)
var embeddingImagePayloadPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?is)<img\b[^>]*\bsrc=["']\s*data:image/[a-z0-9.+-]+;base64,[^"']+["'][^>]*>`),
regexp.MustCompile(`(?is)!\[[^\]]*\]\(\s*data:image/[a-z0-9.+-]+;base64,[^)]+\)`),
regexp.MustCompile(`(?i)data:image/[a-z0-9.+-]+;base64,[a-z0-9+/=]{200,}`),
regexp.MustCompile(`(?i)data:[a-z0-9.+/-]+;base64,[a-z0-9+/=]{200,}`),
}
// KeywordsVectorHybridRetrieveEngineService implements a hybrid retrieval engine
// that supports both keyword-based and vector-based retrieval
type KeywordsVectorHybridRetrieveEngineService struct {
indexRepository interfaces.RetrieveEngineRepository
engineType types.RetrieverEngineType
}
// NewKVHybridRetrieveEngine creates a new instance of the hybrid retrieval engine
// KV stands for KeywordsVector
func NewKVHybridRetrieveEngine(indexRepository interfaces.RetrieveEngineRepository,
engineType types.RetrieverEngineType,
) interfaces.RetrieveEngineService {
return &KeywordsVectorHybridRetrieveEngineService{indexRepository: indexRepository, engineType: engineType}
}
// EngineType returns the type of the retrieval engine
func (v *KeywordsVectorHybridRetrieveEngineService) EngineType() types.RetrieverEngineType {
return v.engineType
}
// Retrieve performs retrieval based on the provided parameters
func (v *KeywordsVectorHybridRetrieveEngineService) Retrieve(ctx context.Context,
params types.RetrieveParams,
) ([]*types.RetrieveResult, error) {
return v.indexRepository.Retrieve(ctx, params)
}
// Index creates embeddings for the content and saves it to the repository
// if vector retrieval is enabled in the retriever types
func (v *KeywordsVectorHybridRetrieveEngineService) Index(ctx context.Context,
embedder embedding.Embedder, indexInfo *types.IndexInfo, retrieverTypes []types.RetrieverType,
) error {
params := make(map[string]any)
embeddingMap := make(map[string][]float32)
if slices.Contains(retrieverTypes, types.VectorRetrieverType) {
embedding, err := embedder.Embed(ctx, sanitizeForEmbedding(ctx, indexInfo.Content))
if err != nil {
return err
}
embeddingMap[indexInfo.SourceID] = embedding
}
params["embedding"] = embeddingMap
return v.indexRepository.Save(ctx, indexInfo, params)
}
// BatchIndex creates embeddings for multiple content items and saves them to the repository
// in batches for efficiency. Uses concurrent batch saving to improve performance.
func (v *KeywordsVectorHybridRetrieveEngineService) BatchIndex(ctx context.Context,
embedder embedding.Embedder, indexInfoList []*types.IndexInfo, retrieverTypes []types.RetrieverType,
) error {
if len(indexInfoList) == 0 {
return nil
}
if slices.Contains(retrieverTypes, types.VectorRetrieverType) {
var contentList []string
for _, indexInfo := range indexInfoList {
contentList = append(contentList, sanitizeForEmbedding(ctx, indexInfo.Content))
}
embeddings, err := batchEmbedWithBackoff(ctx, embedder, contentList)
if err != nil {
return err
}
batchSize := 40
chunks := utils.ChunkSlice(indexInfoList, batchSize)
// Use concurrent batch saving for better performance
// Limit concurrency to avoid overwhelming the backend
const maxConcurrency = 5
if len(chunks) <= maxConcurrency {
// For small number of batches, use simple concurrency
return v.concurrentBatchSave(ctx, chunks, embeddings, batchSize)
}
// For large number of batches, use bounded concurrency
return v.boundedConcurrentBatchSave(ctx, chunks, embeddings, batchSize, maxConcurrency)
}
// For non-vector retrieval, use concurrent batch saving as well
chunks := utils.ChunkSlice(indexInfoList, 10)
const maxConcurrency = 5
if len(chunks) <= maxConcurrency {
return v.concurrentBatchSaveNoEmbedding(ctx, chunks)
}
return v.boundedConcurrentBatchSaveNoEmbedding(ctx, chunks, maxConcurrency)
}
// batchEmbedWithBackoff calls BatchEmbedWithPool with exponential backoff on
// transient failures (200 / 400 / 800 / 1600 / 3200 ms). It returns the last
// embedding result on success or the last error if every attempt failed.
func batchEmbedWithBackoff(ctx context.Context, embedder embedding.Embedder, contentList []string) ([][]float32, error) {
delay := embedRetryBaseDelay
var (
embeddings [][]float32
err error
)
for attempt := 0; attempt < embedRetryAttempts; attempt++ {
embeddings, err = embedder.BatchEmbedWithPool(ctx, embedder, contentList)
if err == nil {
return embeddings, nil
}
logger.Errorf(ctx, "BatchEmbedWithPool attempt %d/%d failed: %v", attempt+1, embedRetryAttempts, err)
if attempt+1 < embedRetryAttempts {
select {
case <-time.After(delay):
case <-ctx.Done():
return nil, ctx.Err()
}
delay *= 2
}
}
return embeddings, err
}
// sanitizeForEmbedding caps content length at safetyMaxChars characters so
// pathologically large inputs cannot blow up the embedding API call. The
// truncation point is char-based, not token-based, so it sits well above any
// realistic token limit. We log a warning whenever truncation kicks in.
func sanitizeForEmbedding(ctx context.Context, content string) string {
sanitized := content
// Scrubbing only matters when an inline base64 payload is present; skip the
// regex passes otherwise so the common (no-image) path stays cheap.
if strings.Contains(content, "base64,") {
for _, pattern := range embeddingImagePayloadPatterns {
sanitized = pattern.ReplaceAllString(sanitized, "[image]")
}
}
if utf8.RuneCountInString(sanitized) <= safetyMaxChars {
return sanitized
}
runes := []rune(sanitized)
logger.Warnf(ctx, "embedding input truncated: %d runes -> %d", len(runes), safetyMaxChars)
return string(runes[:safetyMaxChars])
}
// concurrentBatchSave saves all batches concurrently without concurrency limit
func (v *KeywordsVectorHybridRetrieveEngineService) concurrentBatchSave(
ctx context.Context,
chunks [][]*types.IndexInfo,
embeddings [][]float32,
batchSize int,
) error {
g, ctx := errgroup.WithContext(ctx)
for i, indexChunk := range chunks {
g.Go(func() error {
params := make(map[string]any)
embeddingMap := make(map[string][]float32)
for j, indexInfo := range indexChunk {
embeddingMap[indexInfo.SourceID] = embeddings[i*batchSize+j]
}
params["embedding"] = embeddingMap
return v.indexRepository.BatchSave(ctx, indexChunk, params)
})
}
return g.Wait()
}
// boundedConcurrentBatchSave saves batches with bounded concurrency using semaphore pattern
func (v *KeywordsVectorHybridRetrieveEngineService) boundedConcurrentBatchSave(
ctx context.Context,
chunks [][]*types.IndexInfo,
embeddings [][]float32,
batchSize int,
maxConcurrency int,
) error {
g, ctx := errgroup.WithContext(ctx)
sem := make(chan struct{}, maxConcurrency)
for i, indexChunk := range chunks {
g.Go(func() error {
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return ctx.Err()
}
params := make(map[string]any)
embeddingMap := make(map[string][]float32)
for j, indexInfo := range indexChunk {
embeddingMap[indexInfo.SourceID] = embeddings[i*batchSize+j]
}
params["embedding"] = embeddingMap
return v.indexRepository.BatchSave(ctx, indexChunk, params)
})
}
return g.Wait()
}
// concurrentBatchSaveNoEmbedding saves all batches concurrently without embeddings
func (v *KeywordsVectorHybridRetrieveEngineService) concurrentBatchSaveNoEmbedding(
ctx context.Context,
chunks [][]*types.IndexInfo,
) error {
g, ctx := errgroup.WithContext(ctx)
for _, indexChunk := range chunks {
g.Go(func() error {
params := make(map[string]any)
return v.indexRepository.BatchSave(ctx, indexChunk, params)
})
}
return g.Wait()
}
// boundedConcurrentBatchSaveNoEmbedding saves batches with bounded concurrency without embeddings
func (v *KeywordsVectorHybridRetrieveEngineService) boundedConcurrentBatchSaveNoEmbedding(
ctx context.Context,
chunks [][]*types.IndexInfo,
maxConcurrency int,
) error {
g, ctx := errgroup.WithContext(ctx)
sem := make(chan struct{}, maxConcurrency)
for _, indexChunk := range chunks {
g.Go(func() error {
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return ctx.Err()
}
params := make(map[string]any)
return v.indexRepository.BatchSave(ctx, indexChunk, params)
})
}
return g.Wait()
}
// DeleteByChunkIDList deletes vectors by their chunk IDs
func (v *KeywordsVectorHybridRetrieveEngineService) DeleteByChunkIDList(ctx context.Context,
indexIDList []string, dimension int, knowledgeType string,
) error {
return v.indexRepository.DeleteByChunkIDList(ctx, indexIDList, dimension, knowledgeType)
}
// DeleteBySourceIDList deletes vectors by their source IDs
func (v *KeywordsVectorHybridRetrieveEngineService) DeleteBySourceIDList(ctx context.Context,
sourceIDList []string, dimension int, knowledgeType string,
) error {
return v.indexRepository.DeleteBySourceIDList(ctx, sourceIDList, dimension, knowledgeType)
}
// DeleteByKnowledgeIDList deletes vectors by their knowledge IDs
func (v *KeywordsVectorHybridRetrieveEngineService) DeleteByKnowledgeIDList(ctx context.Context,
knowledgeIDList []string, dimension int, knowledgeType string,
) error {
return v.indexRepository.DeleteByKnowledgeIDList(ctx, knowledgeIDList, dimension, knowledgeType)
}
// Support returns the retriever types supported by this engine
func (v *KeywordsVectorHybridRetrieveEngineService) Support() []types.RetrieverType {
return v.indexRepository.Support()
}
// EstimateStorageSize estimates the storage space needed for the provided index information
func (v *KeywordsVectorHybridRetrieveEngineService) EstimateStorageSize(
ctx context.Context,
embedder embedding.Embedder,
indexInfoList []*types.IndexInfo,
retrieverTypes []types.RetrieverType,
) int64 {
params := make(map[string]any)
if slices.Contains(retrieverTypes, types.VectorRetrieverType) {
embeddingMap := make(map[string][]float32)
// just for estimate storage size
for _, indexInfo := range indexInfoList {
embeddingMap[indexInfo.ChunkID] = make([]float32, embedder.GetDimensions())
}
params["embedding"] = embeddingMap
}
return v.indexRepository.EstimateStorageSize(ctx, indexInfoList, params)
}
// CopyIndices copies indices from a source knowledge base to a target knowledge base
func (v *KeywordsVectorHybridRetrieveEngineService) CopyIndices(
ctx context.Context,
sourceKnowledgeBaseID string,
sourceToTargetKBIDMap map[string]string,
sourceToTargetChunkIDMap map[string]string,
targetKnowledgeBaseID string,
dimension int,
knowledgeType string,
) error {
logger.Infof(ctx, "Copy indices from knowledge base %s to %s, mapping relation count: %d",
sourceKnowledgeBaseID, targetKnowledgeBaseID, len(sourceToTargetChunkIDMap),
)
return v.indexRepository.CopyIndices(
ctx, sourceKnowledgeBaseID, sourceToTargetKBIDMap, sourceToTargetChunkIDMap, targetKnowledgeBaseID, dimension, knowledgeType,
)
}
// BatchUpdateChunkEnabledStatus updates the enabled status of chunks in batch
func (v *KeywordsVectorHybridRetrieveEngineService) BatchUpdateChunkEnabledStatus(
ctx context.Context,
chunkStatusMap map[string]bool,
) error {
return v.indexRepository.BatchUpdateChunkEnabledStatus(ctx, chunkStatusMap)
}
// BatchUpdateChunkTagID updates the tag ID of chunks in batch
func (v *KeywordsVectorHybridRetrieveEngineService) BatchUpdateChunkTagID(
ctx context.Context,
chunkTagMap map[string]string,
) error {
return v.indexRepository.BatchUpdateChunkTagID(ctx, chunkTagMap)
}
@@ -0,0 +1,118 @@
package retriever
import (
"context"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/models/embedding"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
type capturingEmbedder struct {
embedding.Embedder
text string
batchTexts []string
}
func (e *capturingEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
e.text = text
return []float32{1}, nil
}
func (e *capturingEmbedder) BatchEmbedWithPool(
ctx context.Context,
model embedding.Embedder,
texts []string,
) ([][]float32, error) {
e.batchTexts = append([]string(nil), texts...)
embeddings := make([][]float32, len(texts))
for i := range texts {
embeddings[i] = []float32{1}
}
return embeddings, nil
}
type saveOnlyRepository struct {
interfaces.RetrieveEngineRepository
}
func (r *saveOnlyRepository) Save(ctx context.Context, indexInfo *types.IndexInfo, params map[string]any) error {
return nil
}
func (r *saveOnlyRepository) BatchSave(
ctx context.Context,
indexInfoList []*types.IndexInfo,
params map[string]any,
) error {
return nil
}
func TestIndexRemovesInlineImagePayloadBeforeEmbedding(t *testing.T) {
ctx := context.Background()
embedder := &capturingEmbedder{}
service := &KeywordsVectorHybridRetrieveEngineService{indexRepository: &saveOnlyRepository{}}
payload := strings.Repeat("A", 300)
content := "before <img src=\"data:image/png;base64," + payload + "\"> after"
err := service.Index(ctx, embedder, &types.IndexInfo{
Content: content,
SourceID: "source-1",
}, []types.RetrieverType{types.VectorRetrieverType})
if err != nil {
t.Fatalf("Index returned error: %v", err)
}
assertImagePayloadRemoved(t, embedder.text, payload)
}
func TestBatchIndexRemovesInlineImagePayloadBeforeEmbedding(t *testing.T) {
ctx := context.Background()
embedder := &capturingEmbedder{}
service := &KeywordsVectorHybridRetrieveEngineService{indexRepository: &saveOnlyRepository{}}
payload := strings.Repeat("A", 300)
content := "before ![chart](data:image/png;base64," + payload + ") after"
err := service.BatchIndex(ctx, embedder, []*types.IndexInfo{{
Content: content,
SourceID: "source-1",
}}, []types.RetrieverType{types.VectorRetrieverType})
if err != nil {
t.Fatalf("BatchIndex returned error: %v", err)
}
if len(embedder.batchTexts) != 1 {
t.Fatalf("expected one embedding input, got %d", len(embedder.batchTexts))
}
assertImagePayloadRemoved(t, embedder.batchTexts[0], payload)
}
func TestBatchIndexTruncatesOversizedEmbeddingInput(t *testing.T) {
ctx := context.Background()
embedder := &capturingEmbedder{}
service := &KeywordsVectorHybridRetrieveEngineService{indexRepository: &saveOnlyRepository{}}
err := service.BatchIndex(ctx, embedder, []*types.IndexInfo{{
Content: strings.Repeat("x", safetyMaxChars+10),
SourceID: "source-1",
}}, []types.RetrieverType{types.VectorRetrieverType})
if err != nil {
t.Fatalf("BatchIndex returned error: %v", err)
}
if len(embedder.batchTexts) != 1 {
t.Fatalf("expected one embedding input, got %d", len(embedder.batchTexts))
}
if got := len([]rune(embedder.batchTexts[0])); got > safetyMaxChars {
t.Fatalf("embedding input length = %d, want <= %d", got, safetyMaxChars)
}
}
func assertImagePayloadRemoved(t *testing.T, content string, payload string) {
t.Helper()
if strings.Contains(content, "data:image/png;base64") || strings.Contains(content, payload) {
t.Fatalf("embedding input still contains inline image payload: %q", content)
}
if !strings.Contains(content, "before") || !strings.Contains(content, "after") {
t.Fatalf("embedding input should preserve surrounding text, got %q", content)
}
}
@@ -0,0 +1,175 @@
package retriever
import (
"context"
"math"
"github.com/Tencent/WeKnora/internal/types"
)
// ScoreNormalizer maps raw retriever scores to a common [0, 1] scale so that
// vector scores produced by different engines can be compared in a single
// ranked list. Implementations MUST be safe for concurrent use and MUST be
// IO-free (Normalize is called inside a hot loop and may not log or block).
//
// Only vector scores are normalized. Keyword (BM25) scores have an unbounded
// positive range; rescaling them would collapse the long tail. Downstream
// RRF fusion is rank-based and immune to scale, so keyword scores pass
// through unchanged.
type ScoreNormalizer interface {
Normalize(
ctx context.Context,
score float64,
retrieverType types.RetrieverType,
engineType types.RetrieverEngineType,
) float64
}
// EngineAwareNormalizer applies the documented per-engine cosine-score
// formula. The caller (HybridSearch) enforces a same-embedding-model
// precondition via ResolveEmbeddingModelKeys, so post-normalization values
// are semantically comparable across engines.
//
// Source formulas (verified against repository implementations on
// upstream/main 3214e3d9, OpenSearch 2.17 / 3.5 docs, the k-NN plugin's
// SpaceType.java source, the Tencent VectorDB Go SDK base_document.go,
// pgvector / sqlite-vec / Qdrant / Weaviate docs, and the Lucene
// script_score non-negative invariant). Engines are grouped by the
// effective score range observed at the normalizer's input:
//
// Range [-1, 1] (raw cosine, normalized via (score + 1) / 2):
// - Milvus (COSINE metric mode). Milvus docs explicitly state the
// COSINE metric "has a range of [-1, 1]".
//
// Range [0, 1] (passthrough — already on the target scale):
// - Elasticsearch v8 — driver issues `cosineSimilarity(query, 'embedding')`
// as a script_score script. Lucene rejects negative final scores
// ("Final relevance scores from the script_score query cannot be
// negative") so the effective range observed here is [0, 1] for
// IR-normalized embeddings (see IR-normalization note below). If
// a future driver swap inserts `1.0 + cosineSimilarity(...)` (the
// pattern recommended by ES docs) this group will need to move out.
// - OpenSearch — the k-NN plugin's SpaceType.COSINESIMIL.scoreTranslation
// applies Math.max((2.0F - rawScore) / 2.0F, 0.0F) where rawScore is
// the distance (1 - cosine) from Lucene/Faiss, so the score returned
// to the client is (1 + cosine) / 2 ∈ [0, 1]. The OpenSearch driver
// itself ships in a later Phase 3 PR (see #1440); this branch is
// unreachable from production retrieval paths until then, but the
// unit tests pin the formula in advance. Source:
// github.com/opensearch-project/k-NN at
// src/main/java/org/opensearch/knn/index/SpaceType.java (COSINESIMIL
// enum, scoreTranslation method).
// - Weaviate — driver requests `certainty`, defined by Weaviate as
// (2 - distance) / 2 = (1 + cosine) / 2, intrinsically ∈ [0, 1].
// - Postgres pgvector — driver computes `(1 - distance) as score`
// where `<=>` is cosine distance ∈ [0, 2]; the theoretical range
// is therefore [-1, 1] but IR-normalized embeddings (see below)
// keep the observed range in [0, 1].
// - SQLite sqlite-vec — driver computes `1 - v.Distance` where the
// vec0 `distance_metric=cosine` returns distance ∈ [0, 2] (per
// sqlite-vec docs: "0 for identical, 2 for opposite"). Same IR
// caveat as pgvector.
// - Qdrant — Distance.Cosine; Qdrant normalizes vectors at insert
// time and the score returned by ScoredPoint.score is the dot
// product of the normalized vectors = raw cosine ∈ [-1, 1] in
// theory, [0, 1] under the IR caveat.
// - TencentVectorDB — MetricType COSINE; per the SDK godoc
// (tcvectordb base_document.go): "COSINE: return when score >=
// radius, value range [-1, 1]." Same IR caveat.
// - Doris — driver runs `inner_product_approximate` against
// L2-normalized embeddings (or legacy `(1 - cosine_distance_approximate)`),
// which equals raw cosine ∈ [-1, 1]. Same IR caveat.
//
// IR-normalization caveat (applies to pgvector, sqlite-vec, Qdrant,
// TencentVectorDB, Doris): modern RAG embeddings are L2-normalized
// positive-component unit vectors (sentence-transformers, BGE, OpenAI
// text-embedding-3, Cohere, E5, etc.) that empirically keep cosine in
// [0, 1]. Theoretical [-1, 1] inputs would clamp to 0 below — silent
// information loss only for embedding models that produce negative
// cosines (rare in IR workloads but possible for centered word2vec,
// GloVe, or non-IR embeddings). If a future use case violates this,
// the affected engines should move into the [-1, 1] group.
//
// Dead enum references (kept as RetrieverEngineType constants but
// without a driver implementation — see internal/types/vectorstore.go
// near the GetVectorStoreTypes definition, where they are flagged
// "legacy/experimental, no standalone deployable instance"):
// - InfinityRetrieverEngineType
// - ElasticFaissRetrieverEngineType
//
// Their case labels below route to clamp01(score) defensively, but
// production code never returns these engine types.
//
// Unknown engines clamp to [0, 1]; the fan-out caller emits a single WARN
// per request via warnIfUnknownEngine so Normalize itself stays lock-free
// and panic-free even on nil ctx.
type EngineAwareNormalizer struct{}
// Compile-time interface satisfaction assertion.
var _ ScoreNormalizer = EngineAwareNormalizer{}
// Normalize implements ScoreNormalizer.
func (EngineAwareNormalizer) Normalize(
_ context.Context,
score float64,
retrieverType types.RetrieverType,
engineType types.RetrieverEngineType,
) float64 {
if retrieverType != types.VectorRetrieverType {
// BM25 and other non-vector retrievers: passthrough. RRF rank-based
// fusion handles scale-mixed input correctly.
return score
}
switch engineType {
case types.MilvusRetrieverEngineType:
// Raw cosine in [-1, 1] → [0, 1]. Milvus is the only engine in
// this codebase whose driver surfaces the raw cosine signed range
// to the normalizer. Pass through clamp01 once more so that a
// misbehaving engine returning 1.0000002 does not leak past the
// [0, 1] envelope (caller sorts by score afterwards).
return clamp01((score + 1) / 2)
case types.ElasticsearchRetrieverEngineType,
types.ElasticFaissRetrieverEngineType,
types.OpenSearchRetrieverEngineType,
types.WeaviateRetrieverEngineType,
types.PostgresRetrieverEngineType,
types.SQLiteRetrieverEngineType,
types.QdrantRetrieverEngineType,
types.InfinityRetrieverEngineType,
types.TencentVectorDBRetrieverEngineType,
types.DorisRetrieverEngineType:
// Already in [0, 1] when the value reaches us. See struct godoc
// above for the per-engine derivation: Elasticsearch's Lucene
// script_score non-negative invariant; OpenSearch's k-NN plugin
// SpaceType.COSINESIMIL.scoreTranslation pre-translation;
// Weaviate's certainty intrinsic; and the IR-normalization
// caveat covering pgvector / sqlite-vec / Qdrant /
// TencentVectorDB / Doris (theoretical [-1, 1] but observed
// [0, 1] for L2-normalized positive-component IR embeddings).
//
// ElasticFaiss and Infinity are dead enum references — their
// case labels are kept so the switch is exhaustive over the
// declared engine constants, but no driver returns these.
return clamp01(score)
default:
// Unknown engine. Clamp defensively; the caller emits WARN with ctx.
return clamp01(score)
}
}
// clamp01 maps any float64 into [0, 1] safely, including NaN/Inf inputs that
// could otherwise break slices.SortFunc's strict-weak-ordering invariant
// downstream (NaN compares neither greater nor less than anything).
func clamp01(s float64) float64 {
if math.IsNaN(s) {
return 0
}
if s <= 0 || math.IsInf(s, -1) {
return 0
}
if s >= 1 || math.IsInf(s, 1) {
return 1
}
return s
}
@@ -0,0 +1,330 @@
package retriever
import (
"context"
"math"
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
func TestEngineAwareNormalizer_KeywordPassthrough(t *testing.T) {
t.Parallel()
n := EngineAwareNormalizer{}
cases := []float64{-12.5, 0, 0.7, 1, 27.3, math.Inf(1)}
for _, s := range cases {
// Any keyword retriever, any engine — must be identity.
got := n.Normalize(context.Background(), s, types.KeywordsRetrieverType,
types.ElasticsearchRetrieverEngineType)
if got != s {
// math.IsNaN special handling for the Inf case.
if math.IsInf(s, 1) && math.IsInf(got, 1) {
continue
}
t.Fatalf("keyword passthrough expected %v, got %v", s, got)
}
}
}
func TestEngineAwareNormalizer_CosineRange(t *testing.T) {
t.Parallel()
n := EngineAwareNormalizer{}
// Only Milvus surfaces the raw cosine signed range [-1, 1] to the
// normalizer in this codebase. Elasticsearch v8 used to be in this
// group, but the driver issues a script_score cosineSimilarity script
// and Lucene rejects negative final scores ("Final relevance scores
// from the script_score query cannot be negative" — ES docs), so the
// score observed by the normalizer is already in [0, 1]; ES is now
// part of the passthrough group below. ElasticFaiss is a dead enum
// (no driver) and follows ES.
cases := []struct {
score float64
want float64
}{
{-1.0, 0},
{-0.5, 0.25},
{0, 0.5},
{0.5, 0.75},
{1.0, 1.0},
// Drift beyond [-1, 1] is clamped.
{-1.5, 0},
{1.5, 1.0},
}
for _, engine := range []types.RetrieverEngineType{
types.MilvusRetrieverEngineType,
} {
for _, tc := range cases {
got := n.Normalize(context.Background(), tc.score,
types.VectorRetrieverType, engine)
if math.Abs(got-tc.want) > 1e-9 {
t.Fatalf("cosine[%s] score=%v: want %v, got %v",
engine, tc.score, tc.want, got)
}
}
}
}
func TestEngineAwareNormalizer_UnitInterval(t *testing.T) {
t.Parallel()
n := EngineAwareNormalizer{}
// All engines whose effective score arriving at the normalizer is
// already in [0, 1]:
// - Elasticsearch v8 / ElasticFaiss — Lucene script_score's
// non-negative invariant truncates the theoretical [-1, 1]
// cosineSimilarity output to [0, 1].
// - OpenSearch — the k-NN plugin's
// SpaceType.COSINESIMIL.scoreTranslation pre-maps to (1 + cos)/2.
// - Weaviate — driver requests certainty, intrinsically in [0, 1].
// - Postgres pgvector / SQLite sqlite-vec / Qdrant /
// TencentVectorDB / Doris — theoretically [-1, 1] but the
// IR-normalized embeddings WeKnora targets (BGE / OpenAI /
// Cohere / sentence-transformers) keep the observed range
// in [0, 1]; the silent floor at clamp01(<0) → 0 is the
// defense for embedding models that violate that assumption.
// - Infinity — dead enum reference; case label kept for switch
// exhaustiveness, never reached in production.
cases := []struct {
score float64
want float64
}{
{-0.1, 0},
{0, 0},
{0.25, 0.25},
{0.999, 0.999},
{1, 1},
{1.5, 1},
}
for _, engine := range []types.RetrieverEngineType{
types.ElasticsearchRetrieverEngineType,
types.ElasticFaissRetrieverEngineType,
types.OpenSearchRetrieverEngineType,
types.WeaviateRetrieverEngineType,
types.PostgresRetrieverEngineType,
types.SQLiteRetrieverEngineType,
types.QdrantRetrieverEngineType,
types.InfinityRetrieverEngineType,
types.TencentVectorDBRetrieverEngineType,
types.DorisRetrieverEngineType,
} {
for _, tc := range cases {
got := n.Normalize(context.Background(), tc.score,
types.VectorRetrieverType, engine)
if math.Abs(got-tc.want) > 1e-9 {
t.Fatalf("unit[%s] score=%v: want %v, got %v",
engine, tc.score, tc.want, got)
}
}
}
}
// TestEngineAwareNormalizer_ElasticsearchCosinePassthrough is an explicit
// regression guard for the score-range correction landed in this PR. ES's
// driver returns the raw output of the `cosineSimilarity(...)` script_score
// script. Lucene rejects negative final scores (per the ES documentation:
// "Final relevance scores from the script_score query cannot be negative.
// To support certain search optimizations, Lucene requires scores be
// positive or 0"), so for IR-normalized embeddings the effective range
// observed at the normalizer is already in [0, 1]. ES therefore belongs
// in the passthrough group and the cosine=0.5 case must map to 0.5 (not
// (0.5 + 1) / 2 = 0.75 as an earlier draft assumed).
func TestEngineAwareNormalizer_ElasticsearchCosinePassthrough(t *testing.T) {
t.Parallel()
n := EngineAwareNormalizer{}
cases := []struct {
name string
score float64
want float64
}{
{"cos=0 orthogonal IR-clamped to 0", 0, 0},
{"cos=0.25 IR-typical low passes through", 0.25, 0.25},
{"cos=0.5 IR-typical mid passes through (not 0.75)", 0.5, 0.5},
{"cos=0.75 IR-typical high passes through", 0.75, 0.75},
{"cos=1 identical passes through as 1", 1, 1},
// Lucene's non-negative invariant means the normalizer should
// not receive <0 from a sane driver, but the clamp guards the
// downstream sort comparator either way.
{"negative defensive clamps to 0", -0.5, 0},
// Engine drift past 1 clamps to 1.
{"drift past 1 clamps to 1", 1.0001, 1},
// Float-edge cases handled by clamp01.
{"+Inf clamps to 1", math.Inf(1), 1},
{"-Inf clamps to 0", math.Inf(-1), 0},
{"NaN clamps to 0", math.NaN(), 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := n.Normalize(context.Background(), tc.score,
types.VectorRetrieverType, types.ElasticsearchRetrieverEngineType)
if math.Abs(got-tc.want) > 1e-9 {
t.Fatalf("elasticsearch score=%v: want %v, got %v",
tc.score, tc.want, got)
}
})
}
}
func TestEngineAwareNormalizer_OpenSearchCosinePassthrough(t *testing.T) {
t.Parallel()
n := EngineAwareNormalizer{}
// OpenSearch k-NN cosinesimil scores arrive already mapped to [0, 1]
// by the plugin's SpaceType.COSINESIMIL.scoreTranslation function
// (rawScore = 1 - cosine; output = max((2 - rawScore) / 2, 0) =
// (1 + cosine) / 2). The normalizer therefore passes the value through
// (clamping only to guard against engine drift and NaN/Inf inputs that
// could otherwise break the slices.SortFunc strict-weak-ordering
// invariant downstream).
// Subtests run serially (no inner t.Parallel) — consistent with
// the other table-driven tests in this file.
cases := []struct {
name string
score float64
want float64
}{
// Basic mapping (k-NN-translated _score in [0, 1])
{"score 0 (cos=-1) passes through as 0", 0, 0},
{"score 0.5 (cos=0, orthogonal) passes through", 0.5, 0.5},
{"score 0.75 (cos=0.5, IR-typical mid) passes through", 0.75, 0.75},
{"score 1 (cos=1, identical) passes through as 1", 1, 1},
// Engine drift past [0, 1] envelope clamps.
{"score 1.0001 clamps to 1", 1.0001, 1},
// Negative defensive: cosinesimil's translation has a max(., 0)
// floor so <0 should never reach us, but clamp guards the
// comparator-ordering invariant downstream.
{"score -0.5 clamps to 0", -0.5, 0},
// Float-edge cases handled by clamp01.
{"+Inf clamps to 1", math.Inf(1), 1},
{"-Inf clamps to 0", math.Inf(-1), 0},
{"NaN clamps to 0", math.NaN(), 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := n.Normalize(context.Background(), tc.score,
types.VectorRetrieverType, types.OpenSearchRetrieverEngineType)
if math.Abs(got-tc.want) > 1e-9 {
t.Fatalf("opensearch score=%v: want %v, got %v",
tc.score, tc.want, got)
}
})
}
}
func TestEngineAwareNormalizer_OpenSearchKeywordPassthrough(t *testing.T) {
t.Parallel()
n := EngineAwareNormalizer{}
// BM25 keyword scores on OpenSearch are unbounded positive raw
// values — they MUST pass through unchanged so the service-layer
// RRF rank-based fusion handles the scale-mixed combination.
got := n.Normalize(context.Background(), 12.7,
types.KeywordsRetrieverType, types.OpenSearchRetrieverEngineType)
if got != 12.7 {
t.Fatalf("opensearch keyword passthrough: want 12.7, got %v", got)
}
}
func TestEngineAwareNormalizer_OpenSearchNilCtx_DoesNotPanic(t *testing.T) {
// nil ctx must not panic. The OpenSearch case is IO-free by
// contract — it never reads from ctx and never logs.
t.Parallel()
defer func() {
if r := recover(); r != nil {
t.Fatalf("OpenSearch Normalize panicked on nil ctx: %v", r)
}
}()
_ = EngineAwareNormalizer{}.Normalize(
nil, 1.0, types.VectorRetrieverType,
types.OpenSearchRetrieverEngineType,
)
}
func TestEngineAwareNormalizer_Unknown_ClampsAndDoesNotPanic(t *testing.T) {
t.Parallel()
n := EngineAwareNormalizer{}
got := n.Normalize(context.Background(), 0.42,
types.VectorRetrieverType, types.RetrieverEngineType("nosuch"))
if got != 0.42 {
t.Fatalf("unknown engine passthrough-clamp: want 0.42, got %v", got)
}
got = n.Normalize(context.Background(), 5.0,
types.VectorRetrieverType, types.RetrieverEngineType("nosuch"))
if got != 1.0 {
t.Fatalf("unknown engine clamp on >1: want 1, got %v", got)
}
}
func TestEngineAwareNormalizer_NilCtx_DoesNotPanic(t *testing.T) {
// nil ctx must not panic. Normalize is IO-free by contract — it never
// reads from ctx and never logs. The unknown-engine warning is
// emitted by the caller (retrieveFromStores), where ctx is always
// live.
t.Parallel()
defer func() {
if r := recover(); r != nil {
t.Fatalf("Normalize panicked on nil ctx: %v", r)
}
}()
_ = EngineAwareNormalizer{}.Normalize(
nil, 0.5, types.VectorRetrieverType,
types.RetrieverEngineType("nosuch"),
)
}
func TestEngineAwareNormalizer_NaNAndInf(t *testing.T) {
t.Parallel()
n := EngineAwareNormalizer{}
// NaN → 0 (so SortFunc does not get a comparator-poisoning value).
got := n.Normalize(context.Background(), math.NaN(),
types.VectorRetrieverType, types.PostgresRetrieverEngineType)
if got != 0 {
t.Fatalf("NaN clamp: want 0, got %v", got)
}
// +Inf → 1.
got = n.Normalize(context.Background(), math.Inf(1),
types.VectorRetrieverType, types.PostgresRetrieverEngineType)
if got != 1 {
t.Fatalf("+Inf clamp: want 1, got %v", got)
}
// -Inf → 0.
got = n.Normalize(context.Background(), math.Inf(-1),
types.VectorRetrieverType, types.PostgresRetrieverEngineType)
if got != 0 {
t.Fatalf("-Inf clamp: want 0, got %v", got)
}
// NaN through cosine formula too.
got = n.Normalize(context.Background(), math.NaN(),
types.VectorRetrieverType, types.ElasticsearchRetrieverEngineType)
if got != 0 {
t.Fatalf("NaN cosine: want 0, got %v", got)
}
}
func TestClamp01(t *testing.T) {
t.Parallel()
cases := []struct {
in, want float64
}{
{-1, 0},
{0, 0},
{0.5, 0.5},
{1, 1},
{2, 1},
{math.NaN(), 0},
{math.Inf(1), 1},
{math.Inf(-1), 0},
}
for _, tc := range cases {
got := clamp01(tc.in)
if math.Abs(got-tc.want) > 1e-9 {
// math.IsNaN comparison would have already failed via the
// equality above; this branch covers the rest.
t.Fatalf("clamp01(%v): want %v, got %v", tc.in, tc.want, got)
}
}
}
// TestEngineAwareNormalizer_InterfaceSatisfied is a compile-time check that
// catches accidental breakage of the ScoreNormalizer interface via the
// package-scope var assertion in normalizer.go. The runtime body is a
// no-op; failure would surface at build time.
func TestEngineAwareNormalizer_InterfaceSatisfied(t *testing.T) {
var _ ScoreNormalizer = EngineAwareNormalizer{}
}
@@ -0,0 +1,35 @@
package retriever
import (
"context"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// vectorStoreRepoOwnership adapts a VectorStoreRepository to the
// TenantStoreOwnership interface consumed by the factory functions.
//
// The repository's GetByID already scopes to the given tenantID
// (WHERE id = ? AND tenant_id = ?), so "exists under this tenant"
// is equivalent to "owned by this tenant" — no additional tenant
// comparison is needed on top of the returned row.
type vectorStoreRepoOwnership struct {
repo interfaces.VectorStoreRepository
}
// NewVectorStoreRepoOwnership returns the production
// TenantStoreOwnership implementation backed by VectorStoreRepository.
func NewVectorStoreRepoOwnership(repo interfaces.VectorStoreRepository) TenantStoreOwnership {
return &vectorStoreRepoOwnership{repo: repo}
}
// StoreOwnedBy returns true iff a vector store with the given ID exists
// under the given tenant. Errors are reserved for infrastructure failures;
// a non-existent (but well-formed) store ID returns (false, nil).
func (o *vectorStoreRepoOwnership) StoreOwnedBy(ctx context.Context, storeID string, tenantID uint64) (bool, error) {
store, err := o.repo.GetByID(ctx, tenantID, storeID)
if err != nil {
return false, err
}
return store != nil, nil
}
@@ -0,0 +1,370 @@
package retriever
import (
"context"
"fmt"
"runtime/debug"
"sync"
"time"
"golang.org/x/sync/singleflight"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// EngineBuildTimeout bounds a single on-demand engine construction. It is
// exported because callers that budget a sequence of resolutions need to size
// their own ceiling above one build; leaving the two coupled only by a comment
// invites them to drift apart.
//
// Not every engine constructor observes the context it is handed, so this
// bounds the ones that dial eagerly rather than every possible backend.
const EngineBuildTimeout = 10 * time.Second
// rebuildCooldown throttles rebuild attempts for a store whose engine just
// failed to build. Without it a backend that stays down costs a full build
// timeout on every request, because collapsing only helps concurrent callers —
// sequential ones each open a new attempt.
const rebuildCooldown = 30 * time.Second
// RetrieveEngineRegistry implements the retrieval engine registry.
// It maintains two maps:
// - byEngineType: env stores registered via RETRIEVE_DRIVER (backward compatible)
// - byStoreID: DB stores registered via VectorStore table (instance-based)
//
// Implements both interfaces.RetrieveEngineRegistry and interfaces.StoreRegistry.
type RetrieveEngineRegistry struct {
byEngineType map[types.RetrieverEngineType]interfaces.RetrieveEngineService
byStoreID map[string]interfaces.RetrieveEngineService
mu sync.RWMutex
// repo and factory let the registry rebuild an engine that is missing from
// byStoreID. Both are optional: when either is nil the registry cannot
// rebuild anything and GetOrLoadByStoreID stays a plain lookup.
repo interfaces.VectorStoreRepository
factory interfaces.EngineFactory
sf singleflight.Group
// storeGen counts every mutation of a store's entry. An on-demand build
// samples it before starting and publishes only if it has not moved, so a
// build cannot undo a registration or a removal that landed while it ran.
storeGen map[string]uint64
// failedUntil holds the cooldown deadline per store, keyed only by stores
// that reached a build attempt.
failedUntil map[string]time.Time
// onFlightJoin, when set, fires once a caller is attached to a build.
// Tests need it to order a second caller against a build already running:
// until a caller is attached there is nothing to observe from outside, and
// releasing the build too early lets that caller miss it and read the
// finished engine instead, which looks identical from the results alone.
onFlightJoin func()
// flightObserver, when set, reports whether a caller shared its build with
// other callers. Collapsing is the point of this path but leaves no trace a
// test can check from the outside: a caller that misses the flight and then
// finds the finished engine is indistinguishable from one that waited on
// it. Production leaves this nil.
flightObserver func(shared bool)
}
// storeGeneration samples the mutation counter for a store.
func (r *RetrieveEngineRegistry) storeGeneration(storeID string) uint64 {
r.mu.RLock()
defer r.mu.RUnlock()
return r.storeGen[storeID]
}
// registerIfGenUnchanged publishes svc only when the store's entry has not
// been touched since gen was sampled. Reports whether the engine was published.
func (r *RetrieveEngineRegistry) registerIfGenUnchanged(
storeID string, gen uint64, svc interfaces.RetrieveEngineService,
) bool {
r.mu.Lock()
defer r.mu.Unlock()
if r.storeGen[storeID] != gen {
return false
}
r.byStoreID[storeID] = svc
delete(r.failedUntil, storeID)
return true
}
// inFailureCooldown reports whether a recent build failure should short-circuit
// another attempt.
func (r *RetrieveEngineRegistry) inFailureCooldown(storeID string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
until, exists := r.failedUntil[storeID]
return exists && time.Now().Before(until)
}
// markBuildFailed starts the cooldown for a store whose engine build failed.
func (r *RetrieveEngineRegistry) markBuildFailed(storeID string) {
r.mu.Lock()
defer r.mu.Unlock()
if r.failedUntil == nil {
r.failedUntil = make(map[string]time.Time)
}
r.failedUntil[storeID] = time.Now().Add(rebuildCooldown)
}
// NewRetrieveEngineRegistry creates a new retrieval engine registry.
//
// repo and factory let the registry rebuild an engine that is missing from its
// store map. Passing nil for either disables that, leaving GetOrLoadByStoreID
// equivalent to GetByStoreID; both are required arguments rather than an
// optional extra so that every construction site has to say which it wants.
func NewRetrieveEngineRegistry(
repo interfaces.VectorStoreRepository, factory interfaces.EngineFactory,
) interfaces.RetrieveEngineRegistry {
return &RetrieveEngineRegistry{
byEngineType: make(map[types.RetrieverEngineType]interfaces.RetrieveEngineService),
byStoreID: make(map[string]interfaces.RetrieveEngineService),
storeGen: make(map[string]uint64),
failedUntil: make(map[string]time.Time),
repo: repo,
factory: factory,
}
}
// --- interfaces.RetrieveEngineRegistry methods (unchanged behavior) ---
// Register registers a retrieval engine service by engine type.
// Returns an error if the engine type is already registered.
func (r *RetrieveEngineRegistry) Register(repo interfaces.RetrieveEngineService) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.byEngineType[repo.EngineType()]; exists {
return fmt.Errorf("repository type %s already registered", repo.EngineType())
}
r.byEngineType[repo.EngineType()] = repo
return nil
}
// GetRetrieveEngineService retrieves a retrieval engine service by type.
// Only searches the byEngineType map (env stores).
func (r *RetrieveEngineRegistry) GetRetrieveEngineService(repoType types.RetrieverEngineType) (
interfaces.RetrieveEngineService, error,
) {
r.mu.RLock()
defer r.mu.RUnlock()
repo, exists := r.byEngineType[repoType]
if !exists {
return nil, fmt.Errorf("repository of type %s not found", repoType)
}
return repo, nil
}
// GetAllRetrieveEngineServices retrieves all registered retrieval engine services.
// Only returns byEngineType entries (env stores) for backward compatibility.
func (r *RetrieveEngineRegistry) GetAllRetrieveEngineServices() []interfaces.RetrieveEngineService {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]interfaces.RetrieveEngineService, 0, len(r.byEngineType))
for _, v := range r.byEngineType {
result = append(result, v)
}
return result
}
// --- interfaces.StoreRegistry methods (new, for VectorStore-based engines) ---
// RegisterWithStoreID registers an engine service by VectorStore ID.
// Unlike Register(), the same EngineType can be registered multiple times
// with different StoreIDs (e.g., two Elasticsearch clusters).
// Upsert semantics: existing entry is overwritten silently.
func (r *RetrieveEngineRegistry) RegisterWithStoreID(storeID string, svc interfaces.RetrieveEngineService) {
r.mu.Lock()
defer r.mu.Unlock()
r.byStoreID[storeID] = svc
// Count this alongside unregistrations: an on-demand build that started
// earlier must not overwrite the entry published here, which would leave
// the engine this call installed orphaned with its connections open.
r.bumpGenerationLocked(storeID)
}
// bumpGenerationLocked invalidates any on-demand build already in flight for
// this store. Callers must hold the write lock.
func (r *RetrieveEngineRegistry) bumpGenerationLocked(storeID string) {
if r.storeGen == nil {
r.storeGen = make(map[string]uint64)
}
r.storeGen[storeID]++
}
// GetByStoreID retrieves an engine service by VectorStore ID.
// Callers must verify tenant ownership before using the returned service.
func (r *RetrieveEngineRegistry) GetByStoreID(storeID string) (interfaces.RetrieveEngineService, error) {
r.mu.RLock()
defer r.mu.RUnlock()
svc, exists := r.byStoreID[storeID]
if !exists {
return nil, fmt.Errorf("store %s not found in registry", storeID)
}
return svc, nil
}
// GetOrLoadByStoreID returns the engine for storeID, rebuilding it from the
// database when this process has no entry for it.
//
// When either repo or factory is nil the registry cannot rebuild anything and
// a miss is reported exactly as GetByStoreID reports it.
func (r *RetrieveEngineRegistry) GetOrLoadByStoreID(
ctx context.Context, tenantID uint64, storeID string,
) (interfaces.RetrieveEngineService, error) {
if svc, err := r.GetByStoreID(storeID); err == nil {
return svc, nil
}
if r.repo == nil || r.factory == nil {
return nil, ErrVectorStoreNotFound
}
if r.inFailureCooldown(storeID) {
// A recent build failed; do not spend another timeout on it yet.
return nil, ErrVectorStoreUnavailable
}
// Sampled before the build starts: an unregistration landing after this
// point must prevent the finished engine from being published.
gen := r.storeGeneration(storeID)
// Key by tenant as well as store so that a caller reaching this method
// without an ownership check cannot join another tenant's flight.
key := fmt.Sprintf("%d:%s", tenantID, storeID)
results := r.doChanJoin(key, func() (res interface{}, err error) {
// singleflight re-raises a panic from this function on a goroutine of
// its own, out of reach of the HTTP recovery middleware. The build
// below calls third-party client constructors, so without this a
// broken one would take down the process instead of failing a request.
defer func() {
if recovered := recover(); recovered != nil {
logger.GetLogger(ctx).Errorf(
"[retriever.registry] engine build panicked for store %s: %v\n%s",
storeID, recovered, debug.Stack())
res, err = nil, ErrVectorStoreUnavailable
}
}()
// Detach from the initiating request. Callers collapse onto one build,
// so letting the first caller's cancellation abort it would fail every
// other caller waiting on the same engine.
buildCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), EngineBuildTimeout)
defer cancel()
// An earlier flight for this key may have finished after the miss above.
if svc, lookupErr := r.GetByStoreID(storeID); lookupErr == nil {
return svc, nil
}
store, err := r.repo.GetByID(buildCtx, tenantID, storeID)
if err != nil {
// The store may well exist; the metadata database just could not
// answer. Saying "not found" here would make async workers discard
// their task over a passing outage.
logger.GetLogger(ctx).Errorf(
"[retriever.registry] loading store %s for rebuild failed: %v", storeID, err)
return nil, ErrVectorStoreUnavailable
}
if store == nil {
return nil, ErrVectorStoreNotFound
}
svc, err := r.factory(buildCtx, *store)
if err != nil {
// The cause dies with this log: it names the backend endpoint, so
// it must not travel back to the caller.
logger.GetLogger(ctx).Errorf(
"[retriever.registry] rebuilding engine for store %s failed, "+
"retrying no sooner than %s: %v", storeID, rebuildCooldown, err)
r.markBuildFailed(storeID)
return nil, ErrVectorStoreUnavailable
}
if svc == nil {
// Publishing nil would panic every later reader of this store.
logger.GetLogger(ctx).Errorf(
"[retriever.registry] engine factory returned no engine for store %s", storeID)
return nil, ErrVectorStoreUnavailable
}
if !r.registerIfGenUnchanged(storeID, gen, svc) {
// The entry changed while this engine was being built, so this one
// is stale before it is published. Whatever landed instead is
// authoritative; the caller retries and picks it up.
return nil, ErrVectorStoreUnavailable
}
return svc, nil
})
select {
case <-ctx.Done():
// The shared build keeps running for the other callers; this caller
// simply stops waiting. Reporting the context error rather than the
// not-found sentinel matters: callers map the sentinel to a permanent
// failure and would drop retryable work on a shutdown.
return nil, ctx.Err()
case result := <-results:
if r.flightObserver != nil {
r.flightObserver(result.Shared)
}
if result.Err != nil {
// Already a sentinel: the build logged its own cause, and the raw
// error is deliberately not carried back to the caller.
return nil, result.Err
}
svc, ok := result.Val.(interfaces.RetrieveEngineService)
if !ok {
return nil, ErrVectorStoreUnavailable
}
return svc, nil
}
}
// UnregisterByStoreID removes an engine service from the byStoreID map.
// Idempotent: returns silently if the storeID is not found.
//
// NOTE: gRPC-based clients (Qdrant, Milvus) hold connections that are not closed here.
// Known Phase 1 limitation — store deletion is rare, connections cleaned up on process exit.
// Phase 2 should add Close() to RetrieveEngineService interface and call it here.
func (r *RetrieveEngineRegistry) UnregisterByStoreID(storeID string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.byStoreID, storeID)
r.bumpGenerationLocked(storeID)
// Let an operator retry immediately after removing a store rather than
// waiting out a cooldown left over from the previous configuration.
delete(r.failedUntil, storeID)
}
// Compile-time assertion: *RetrieveEngineRegistry satisfies the
// interfaces.RetrieveEngineRegistry contract, including GetByStoreID.
var _ interfaces.RetrieveEngineRegistry = (*RetrieveEngineRegistry)(nil)
// CanRebuildStores reports whether the registry was given what it needs to
// rebuild a store engine on demand. Exposed so that wiring can be asserted:
// a registry without those dependencies still serves lookups, so nothing else
// would reveal that rebuilding was silently left off.
func (r *RetrieveEngineRegistry) CanRebuildStores() bool {
return r.repo != nil && r.factory != nil
}
// doChanJoin attaches the caller to a build for key, starting one if none is
// running, and reports the attachment to onFlightJoin.
func (r *RetrieveEngineRegistry) doChanJoin(
key string, build func() (interface{}, error),
) <-chan singleflight.Result {
results := r.sf.DoChan(key, build)
if r.onFlightJoin != nil {
r.onFlightJoin()
}
return results
}
@@ -0,0 +1,410 @@
package retriever
import (
"context"
stderrors "errors"
"sync"
"sync/atomic"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const rehydrateStoreID = "00000000-0000-0000-0000-0000000000aa"
// fakeStoreRepo serves a single store by ID. Embedding the interface keeps the
// fake to the one method the rehydration path uses; any other call would
// nil-panic loudly rather than silently returning a zero value.
type fakeStoreRepo struct {
interfaces.VectorStoreRepository
store *types.VectorStore
err error
}
func (f *fakeStoreRepo) GetByID(_ context.Context, _ uint64, _ string) (*types.VectorStore, error) {
return f.store, f.err
}
// blockingFactory models a real engine constructor: it dials, which means it
// observes the context it is handed and aborts when that context is cancelled.
type blockingFactory struct {
entered chan struct{}
release chan struct{}
once sync.Once
calls atomic.Int32
}
func newBlockingFactory() *blockingFactory {
return &blockingFactory{entered: make(chan struct{}), release: make(chan struct{})}
}
func (b *blockingFactory) build(ctx context.Context, _ types.VectorStore) (
interfaces.RetrieveEngineService, error,
) {
b.calls.Add(1)
b.once.Do(func() { close(b.entered) })
select {
case <-b.release:
return &mockEngineService{engineType: types.ElasticsearchRetrieverEngineType}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func newRehydratingRegistry(factory interfaces.EngineFactory) *RetrieveEngineRegistry {
return &RetrieveEngineRegistry{
byEngineType: make(map[types.RetrieverEngineType]interfaces.RetrieveEngineService),
byStoreID: make(map[string]interfaces.RetrieveEngineService),
repo: &fakeStoreRepo{store: &types.VectorStore{ID: rehydrateStoreID}},
factory: factory,
}
}
// TestGetOrLoadByStoreID_LeaderCancelDoesNotPoisonWaiters pins the reason this
// path uses DoChan with a detached build context.
//
// Two requests miss the registry for the same store, so they collapse into one
// engine build. If that shared build inherits the first caller's context, the
// first caller going away — a closed browser tab, a shutting-down worker —
// aborts the build and hands its cancellation error to every other caller
// waiting on it. Those callers are alive and would then be told the store is
// unavailable, which is the very failure this rehydration exists to remove.
func TestGetOrLoadByStoreID_LeaderCancelDoesNotPoisonWaiters(t *testing.T) {
factory := newBlockingFactory()
registry := newRehydratingRegistry(factory.build)
// Installed before any caller starts: the hooks are plain fields, and a
// caller attaches to its build before the build itself reports progress,
// so setting them later both races and misses the first attachment.
shared := make(chan bool, 4)
registry.flightObserver = func(s bool) { shared <- s }
joined := make(chan struct{}, 4)
registry.onFlightJoin = func() { joined <- struct{}{} }
leaderCtx, cancelLeader := context.WithCancel(context.Background())
defer cancelLeader()
var leaderErr error
leaderDone := make(chan struct{})
go func() {
defer close(leaderDone)
_, leaderErr = registry.GetOrLoadByStoreID(leaderCtx, 1, rehydrateStoreID)
}()
<-factory.entered // the build is running
<-joined // ...and the leader is attached to it
var (
waiterSvc interfaces.RetrieveEngineService
waiterErr error
)
waiterDone := make(chan struct{})
go func() {
defer close(waiterDone)
waiterSvc, waiterErr = registry.GetOrLoadByStoreID(context.Background(), 1, rehydrateStoreID)
}()
// Wait for the waiter to attach before letting the build finish. Released
// any earlier, the build would complete and register, and the waiter would
// read that engine directly instead of ever sharing the build — which the
// result assertions alone cannot tell apart.
<-joined
cancelLeader()
<-leaderDone
close(factory.release)
<-waiterDone
require.NoError(t, waiterErr, "waiter must not inherit the leader's cancellation")
require.NotNil(t, waiterSvc, "waiter must receive the engine the shared build produced")
assert.ErrorIs(t, leaderErr, context.Canceled, "the cancelled leader still reports its own cancellation")
assert.Equal(t, int32(1), factory.calls.Load(), "the waiter must join the flight, not start a second build")
require.Len(t, shared, 1, "only the waiter reaches the result branch; the leader left on its context")
assert.Empty(t, joined, "exactly two attachments, both accounted for above")
assert.True(t, <-shared, "the waiter must have waited on the leader's build, not run its own")
svc, err := registry.GetByStoreID(rehydrateStoreID)
require.NoError(t, err, "a completed build must leave the engine registered")
assert.NotNil(t, svc)
}
// TestGetOrLoadByStoreID_ConcurrentMissesBuildOnce checks that a burst of
// requests for the same missing store produces one engine, not one per caller.
// Engine construction dials a backend, so duplicating it per request would turn
// a cold store into a connection storm.
func TestGetOrLoadByStoreID_ConcurrentMissesBuildOnce(t *testing.T) {
factory := newBlockingFactory()
registry := newRehydratingRegistry(factory.build)
close(factory.release) // let every build finish immediately
const callers = 16
var wg sync.WaitGroup
services := make([]interfaces.RetrieveEngineService, callers)
errs := make([]error, callers)
wg.Add(callers)
for i := range callers {
go func() {
defer wg.Done()
services[i], errs[i] = registry.GetOrLoadByStoreID(context.Background(), 1, rehydrateStoreID)
}()
}
wg.Wait()
for i := range callers {
require.NoError(t, errs[i], "caller %d", i)
require.NotNil(t, services[i], "caller %d", i)
assert.Same(t, services[0], services[i], "every caller must get the same engine")
}
assert.Equal(t, int32(1), factory.calls.Load(), "one build for the whole burst")
}
// TestGetOrLoadByStoreID_FactoryPanicDoesNotCrashProcess pins the panic guard.
// singleflight re-raises a panic from the shared build on its own goroutine,
// where the HTTP recovery middleware cannot catch it, so an unguarded panic
// from a third-party client constructor would end the process rather than the
// request. Reaching the assertions below at all is the real assertion.
func TestGetOrLoadByStoreID_FactoryPanicDoesNotCrashProcess(t *testing.T) {
registry := newRehydratingRegistry(
func(context.Context, types.VectorStore) (interfaces.RetrieveEngineService, error) {
panic("client constructor exploded")
},
)
svc, err := registry.GetOrLoadByStoreID(context.Background(), 1, rehydrateStoreID)
require.ErrorIs(t, err, ErrVectorStoreUnavailable, "a panicking build is reported as retryable")
assert.Nil(t, svc)
_, lookupErr := registry.GetByStoreID(rehydrateStoreID)
assert.Error(t, lookupErr, "a panicking build must leave nothing registered")
}
// TestGetOrLoadByStoreID_UnregisterDuringBuildIsNotUndone covers the delete
// race: a store removed while its engine is still being built must stay
// removed, otherwise the finishing build silently resurrects it.
func TestGetOrLoadByStoreID_UnregisterDuringBuildIsNotUndone(t *testing.T) {
factory := newBlockingFactory()
registry := newRehydratingRegistry(factory.build)
var (
svc interfaces.RetrieveEngineService
err error
)
done := make(chan struct{})
go func() {
defer close(done)
svc, err = registry.GetOrLoadByStoreID(context.Background(), 1, rehydrateStoreID)
}()
<-factory.entered
registry.UnregisterByStoreID(rehydrateStoreID) // delete lands mid-build
close(factory.release)
<-done
require.ErrorIs(t, err, ErrVectorStoreUnavailable, "a build overtaken by a delete must not succeed")
assert.Nil(t, svc)
_, lookupErr := registry.GetByStoreID(rehydrateStoreID)
assert.Error(t, lookupErr, "the deleted store must not be back in the registry")
}
// TestGetOrLoadByStoreID_FailedBuildEntersCooldown checks that a store whose
// build just failed is not retried on the next request. A backend that stays
// down would otherwise cost a full build timeout per request, in exactly the
// outage this rehydration is meant to soften.
func TestGetOrLoadByStoreID_FailedBuildEntersCooldown(t *testing.T) {
var calls atomic.Int32
registry := newRehydratingRegistry(
func(context.Context, types.VectorStore) (interfaces.RetrieveEngineService, error) {
calls.Add(1)
return nil, assert.AnError
},
)
_, firstErr := registry.GetOrLoadByStoreID(context.Background(), 1, rehydrateStoreID)
_, secondErr := registry.GetOrLoadByStoreID(context.Background(), 1, rehydrateStoreID)
require.ErrorIs(t, firstErr, ErrVectorStoreUnavailable)
require.ErrorIs(t, secondErr, ErrVectorStoreUnavailable, "raw build errors must not leak past the sentinel")
assert.Equal(t, int32(1), calls.Load(), "the second request is served by the cooldown, not a new build")
// Removing the store clears the cooldown so an operator can retry at once.
registry.UnregisterByStoreID(rehydrateStoreID)
_, thirdErr := registry.GetOrLoadByStoreID(context.Background(), 1, rehydrateStoreID)
require.Error(t, thirdErr)
assert.Equal(t, int32(2), calls.Load(), "unregistering must clear the cooldown")
}
// TestGetOrLoadByStoreID_WithoutRepoOrFactoryIsPlainLookup pins the degraded
// mode: a registry built without the rebuild dependencies must behave exactly
// as it did before rehydration existed.
func TestGetOrLoadByStoreID_WithoutRepoOrFactoryIsPlainLookup(t *testing.T) {
registry := &RetrieveEngineRegistry{
byEngineType: make(map[types.RetrieverEngineType]interfaces.RetrieveEngineService),
byStoreID: make(map[string]interfaces.RetrieveEngineService),
}
_, err := registry.GetOrLoadByStoreID(context.Background(), 1, rehydrateStoreID)
require.ErrorIs(t, err, ErrVectorStoreNotFound)
registered := &mockEngineService{engineType: types.ElasticsearchRetrieverEngineType}
registry.RegisterWithStoreID(rehydrateStoreID, registered)
svc, err := registry.GetOrLoadByStoreID(context.Background(), 1, rehydrateStoreID)
require.NoError(t, err)
assert.Same(t, registered, svc, "a hit must not consult the database")
}
// cancellingRegistry reports a caller-side cancellation from the rebuild path,
// the way the real registry does when the request that was waiting goes away.
// The lookup still misses, so the factory must reach the rebuild path first.
type cancellingRegistry struct {
interfaces.RetrieveEngineRegistry
}
func (cancellingRegistry) GetByStoreID(string) (interfaces.RetrieveEngineService, error) {
return nil, stderrors.New("store not in registry")
}
func (cancellingRegistry) GetOrLoadByStoreID(
context.Context, uint64, string,
) (interfaces.RetrieveEngineService, error) {
return nil, context.Canceled
}
// TestBoundLookup_CancellationIsNotReportedAsMissingStore keeps a cancellation
// from being reported as a missing store.
//
// The async delete handlers treat ErrVectorStoreNotFound as permanent and
// answer it with asynq.SkipRetry. A shutdown cancels in-flight handlers, so
// folding that cancellation into the sentinel would make a rolling restart
// discard knowledge-base and index delete tasks that should have been retried,
// leaving vector data behind with nothing scheduled to remove it.
func TestBoundLookup_CancellationIsNotReportedAsMissingStore(t *testing.T) {
ownership := &fakeOwnership{owned: map[string]uint64{rehydrateStoreID: 1}}
storeID := rehydrateStoreID
t.Run("CreateRetrieveEngineForKB", func(t *testing.T) {
_, err := CreateRetrieveEngineForKB(
context.Background(), cancellingRegistry{}, ownership, 1, &storeID)
require.ErrorIs(t, err, context.Canceled, "the cancellation must reach the caller")
require.NotErrorIs(t, err, ErrVectorStoreNotFound,
"async handlers map this sentinel to SkipRetry and would drop the task")
})
t.Run("VerifyBinding", func(t *testing.T) {
err := VerifyBinding(context.Background(), cancellingRegistry{}, ownership, 1, storeID)
require.ErrorIs(t, err, context.Canceled, "the cancellation must reach the caller")
require.NotErrorIs(t, err, ErrVectorStoreNotFound,
"async handlers map this sentinel to SkipRetry and would drop the task")
})
}
// TestGetOrLoadByStoreID_DatabaseFailureIsRetryable separates "the database
// could not answer" from "the store does not exist".
//
// The repository reports a missing row as (nil, nil) and an outage as an
// error, and only the first is permanent. Reporting an outage as not-found
// would make the async delete handlers answer a passing blip with SkipRetry
// and discard work that had nothing wrong with it.
func TestGetOrLoadByStoreID_DatabaseFailureIsRetryable(t *testing.T) {
registry := newRehydratingRegistry(
func(context.Context, types.VectorStore) (interfaces.RetrieveEngineService, error) {
t.Fatal("the engine factory must not run when the store could not be loaded")
return nil, nil
},
)
registry.repo = &fakeStoreRepo{err: stderrors.New("connection refused")}
_, err := registry.GetOrLoadByStoreID(context.Background(), 1, rehydrateStoreID)
require.ErrorIs(t, err, ErrVectorStoreUnavailable, "an outage is retryable")
require.NotErrorIs(t, err, ErrVectorStoreNotFound,
"async handlers stop retrying on not-found and would drop the task")
}
// TestGetOrLoadByStoreID_AbsentStoreIsNotFound is the other half: a store that
// genuinely is not there must stay permanent, so workers stop instead of
// retrying something that can never succeed.
func TestGetOrLoadByStoreID_AbsentStoreIsNotFound(t *testing.T) {
registry := newRehydratingRegistry(
func(context.Context, types.VectorStore) (interfaces.RetrieveEngineService, error) {
t.Fatal("the engine factory must not run for a store that does not exist")
return nil, nil
},
)
registry.repo = &fakeStoreRepo{store: nil}
_, err := registry.GetOrLoadByStoreID(context.Background(), 1, rehydrateStoreID)
require.ErrorIs(t, err, ErrVectorStoreNotFound)
require.NotErrorIs(t, err, ErrVectorStoreUnavailable)
}
// TestBoundLookup_OwnershipCancellationIsNotAStoreVerdict covers the branch
// that runs before the registry is ever consulted.
//
// The ownership check queries the database with the caller's context, so on a
// shutdown it is normally the first place the cancellation is seen. Reporting
// it as a store verdict there would let the async handlers discard the task
// just as surely as reporting it later would.
func TestBoundLookup_OwnershipCancellationIsNotAStoreVerdict(t *testing.T) {
ownership := &fakeOwnership{err: context.Canceled}
storeID := rehydrateStoreID
_, err := CreateRetrieveEngineForKB(
context.Background(), newRehydratingRegistry(nil), ownership, 1, &storeID)
require.ErrorIs(t, err, context.Canceled, "the cancellation must reach the caller")
require.NotErrorIs(t, err, ErrVectorStoreNotFound,
"async handlers map this sentinel to SkipRetry and would drop the task")
require.NotErrorIs(t, err, ErrVectorStoreUnavailable,
"a cancellation is not a statement about the store")
}
// TestBoundLookup_OwnershipOutageIsRetryable is the neighbouring case: the
// database failed for a reason of its own, which says nothing about whether
// the store exists, so the task deserves another attempt.
func TestBoundLookup_OwnershipOutageIsRetryable(t *testing.T) {
ownership := &fakeOwnership{err: stderrors.New("connection refused")}
storeID := rehydrateStoreID
_, err := CreateRetrieveEngineForKB(
context.Background(), newRehydratingRegistry(nil), ownership, 1, &storeID)
require.ErrorIs(t, err, ErrVectorStoreUnavailable)
require.NotErrorIs(t, err, ErrVectorStoreNotFound)
}
// TestGetOrLoadByStoreID_BuildDoesNotOverwriteAConcurrentRegistration guards
// the create path against the rebuild path.
//
// Registering a store publishes its engine directly. If a rebuild that started
// earlier finished afterwards and published its own, the engine callers had
// just been given would be replaced by a second one built from the same row —
// and the first would be left running with its connections open and nobody
// holding it.
func TestGetOrLoadByStoreID_BuildDoesNotOverwriteAConcurrentRegistration(t *testing.T) {
factory := newBlockingFactory()
registry := newRehydratingRegistry(factory.build)
done := make(chan struct{})
go func() {
defer close(done)
_, _ = registry.GetOrLoadByStoreID(context.Background(), 1, rehydrateStoreID)
}()
<-factory.entered
registered := &mockEngineService{engineType: types.PostgresRetrieverEngineType}
registry.RegisterWithStoreID(rehydrateStoreID, registered)
close(factory.release)
<-done
live, err := registry.GetByStoreID(rehydrateStoreID)
require.NoError(t, err)
assert.Same(t, registered, live,
"the engine published by the registration must survive the finishing build")
}
@@ -0,0 +1,237 @@
package retriever
import (
"context"
"fmt"
"sync"
"testing"
"github.com/Tencent/WeKnora/internal/models/embedding"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mockEngineService is a minimal mock for testing registry operations.
// Only EngineType() is meaningful; all other methods are no-ops.
type mockEngineService struct {
engineType types.RetrieverEngineType
}
func (m *mockEngineService) EngineType() types.RetrieverEngineType { return m.engineType }
func (m *mockEngineService) Retrieve(_ context.Context, _ types.RetrieveParams) ([]*types.RetrieveResult, error) {
return nil, nil
}
func (m *mockEngineService) Support() []types.RetrieverType { return nil }
func (m *mockEngineService) Index(_ context.Context, _ embedding.Embedder, _ *types.IndexInfo, _ []types.RetrieverType) error {
return nil
}
func (m *mockEngineService) BatchIndex(_ context.Context, _ embedding.Embedder, _ []*types.IndexInfo, _ []types.RetrieverType) error {
return nil
}
func (m *mockEngineService) EstimateStorageSize(_ context.Context, _ embedding.Embedder, _ []*types.IndexInfo, _ []types.RetrieverType) int64 {
return 0
}
func (m *mockEngineService) CopyIndices(_ context.Context, _ string, _ map[string]string, _ map[string]string, _ string, _ int, _ string) error {
return nil
}
func (m *mockEngineService) DeleteByChunkIDList(_ context.Context, _ []string, _ int, _ string) error {
return nil
}
func (m *mockEngineService) DeleteBySourceIDList(_ context.Context, _ []string, _ int, _ string) error {
return nil
}
func (m *mockEngineService) DeleteByKnowledgeIDList(_ context.Context, _ []string, _ int, _ string) error {
return nil
}
func (m *mockEngineService) BatchUpdateChunkEnabledStatus(_ context.Context, _ map[string]bool) error {
return nil
}
func (m *mockEngineService) BatchUpdateChunkTagID(_ context.Context, _ map[string]string) error {
return nil
}
func newMock(engineType types.RetrieverEngineType) interfaces.RetrieveEngineService {
return &mockEngineService{engineType: engineType}
}
// --- Register (byEngineType) tests ---
func TestRegistry_Register(t *testing.T) {
reg := NewRetrieveEngineRegistry(nil, nil).(*RetrieveEngineRegistry)
t.Run("success", func(t *testing.T) {
err := reg.Register(newMock(types.PostgresRetrieverEngineType))
assert.NoError(t, err)
})
t.Run("duplicate engine type returns error", func(t *testing.T) {
err := reg.Register(newMock(types.PostgresRetrieverEngineType))
assert.Error(t, err)
assert.Contains(t, err.Error(), "already registered")
})
}
func TestRegistry_GetRetrieveEngineService(t *testing.T) {
reg := NewRetrieveEngineRegistry(nil, nil).(*RetrieveEngineRegistry)
_ = reg.Register(newMock(types.PostgresRetrieverEngineType))
t.Run("found", func(t *testing.T) {
svc, err := reg.GetRetrieveEngineService(types.PostgresRetrieverEngineType)
assert.NoError(t, err)
assert.NotNil(t, svc)
})
t.Run("not found", func(t *testing.T) {
_, err := reg.GetRetrieveEngineService(types.QdrantRetrieverEngineType)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
})
}
func TestRegistry_GetAllRetrieveEngineServices(t *testing.T) {
reg := NewRetrieveEngineRegistry(nil, nil).(*RetrieveEngineRegistry)
_ = reg.Register(newMock(types.PostgresRetrieverEngineType))
_ = reg.Register(newMock(types.ElasticsearchRetrieverEngineType))
t.Run("returns all byEngineType entries", func(t *testing.T) {
all := reg.GetAllRetrieveEngineServices()
assert.Len(t, all, 2)
})
t.Run("returns copy - modifying result does not affect registry", func(t *testing.T) {
all := reg.GetAllRetrieveEngineServices()
all = append(all, newMock(types.QdrantRetrieverEngineType))
assert.Len(t, reg.GetAllRetrieveEngineServices(), 2)
})
}
// --- RegisterWithStoreID (byStoreID) tests ---
func TestRegistry_RegisterWithStoreID(t *testing.T) {
reg := NewRetrieveEngineRegistry(nil, nil).(*RetrieveEngineRegistry)
t.Run("success", func(t *testing.T) {
reg.RegisterWithStoreID("store-1", newMock(types.PostgresRetrieverEngineType))
svc, err := reg.GetByStoreID("store-1")
assert.NoError(t, err)
assert.NotNil(t, svc)
})
t.Run("upsert overwrites existing", func(t *testing.T) {
newSvc := newMock(types.ElasticsearchRetrieverEngineType)
reg.RegisterWithStoreID("store-1", newSvc)
svc, err := reg.GetByStoreID("store-1")
assert.NoError(t, err)
assert.Equal(t, types.ElasticsearchRetrieverEngineType, svc.EngineType())
})
t.Run("same engine type different store IDs", func(t *testing.T) {
reg.RegisterWithStoreID("es-hot", newMock(types.ElasticsearchRetrieverEngineType))
reg.RegisterWithStoreID("es-warm", newMock(types.ElasticsearchRetrieverEngineType))
svc1, err1 := reg.GetByStoreID("es-hot")
svc2, err2 := reg.GetByStoreID("es-warm")
assert.NoError(t, err1)
assert.NoError(t, err2)
assert.NotSame(t, svc1, svc2)
})
}
func TestRegistry_GetByStoreID(t *testing.T) {
reg := NewRetrieveEngineRegistry(nil, nil).(*RetrieveEngineRegistry)
reg.RegisterWithStoreID("store-1", newMock(types.PostgresRetrieverEngineType))
t.Run("found", func(t *testing.T) {
svc, err := reg.GetByStoreID("store-1")
assert.NoError(t, err)
assert.NotNil(t, svc)
})
t.Run("not found", func(t *testing.T) {
_, err := reg.GetByStoreID("nonexistent")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
})
}
func TestRegistry_UnregisterByStoreID(t *testing.T) {
reg := NewRetrieveEngineRegistry(nil, nil).(*RetrieveEngineRegistry)
reg.RegisterWithStoreID("store-1", newMock(types.PostgresRetrieverEngineType))
t.Run("removes registered store", func(t *testing.T) {
reg.UnregisterByStoreID("store-1")
_, err := reg.GetByStoreID("store-1")
assert.Error(t, err)
})
t.Run("idempotent on nonexistent store", func(t *testing.T) {
reg.UnregisterByStoreID("nonexistent") // should not panic
})
}
// --- Dual map isolation tests ---
func TestRegistry_DualMapIsolation(t *testing.T) {
reg := NewRetrieveEngineRegistry(nil, nil).(*RetrieveEngineRegistry)
_ = reg.Register(newMock(types.PostgresRetrieverEngineType))
reg.RegisterWithStoreID("store-pg", newMock(types.PostgresRetrieverEngineType))
reg.RegisterWithStoreID("store-es", newMock(types.ElasticsearchRetrieverEngineType))
t.Run("GetAllRetrieveEngineServices returns only byEngineType", func(t *testing.T) {
all := reg.GetAllRetrieveEngineServices()
assert.Len(t, all, 1)
})
t.Run("byStoreID does not affect byEngineType lookup", func(t *testing.T) {
_, err := reg.GetRetrieveEngineService(types.ElasticsearchRetrieverEngineType)
assert.Error(t, err) // ES is only in byStoreID, not byEngineType
})
t.Run("unregister byStoreID does not affect byEngineType", func(t *testing.T) {
reg.UnregisterByStoreID("store-pg")
svc, err := reg.GetRetrieveEngineService(types.PostgresRetrieverEngineType)
assert.NoError(t, err)
assert.NotNil(t, svc)
})
}
// --- Concurrency test ---
func TestRegistry_ConcurrentAccess(t *testing.T) {
reg := NewRetrieveEngineRegistry(nil, nil).(*RetrieveEngineRegistry)
const goroutines = 10
var wg sync.WaitGroup
wg.Add(goroutines * 3)
for i := 0; i < goroutines; i++ {
storeID := fmt.Sprintf("store-%d", i)
go func() {
defer wg.Done()
reg.RegisterWithStoreID(storeID, newMock(types.PostgresRetrieverEngineType))
}()
go func() {
defer wg.Done()
_, _ = reg.GetByStoreID(storeID)
}()
go func() {
defer wg.Done()
reg.UnregisterByStoreID(storeID)
}()
}
wg.Wait()
}
// --- Interface compliance ---
func TestRegistry_ImplementsStoreRegistry(t *testing.T) {
reg := NewRetrieveEngineRegistry(nil, nil)
concreteReg, ok := reg.(*RetrieveEngineRegistry)
require.True(t, ok)
var _ interfaces.StoreRegistry = concreteReg
}