Files
xinmi/internal/application/service/retriever/normalizer_test.go
T
yawan f7fc992f57
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
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

331 lines
11 KiB
Go

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{}
}