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
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:
@@ -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  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
|
||||
}
|
||||
Reference in New Issue
Block a user