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

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
package rerank
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/Tencent/WeKnora/internal/logger"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// AliyunReranker implements a reranking system based on Aliyun DashScope models
type AliyunReranker struct {
modelName string // Name of the model used for reranking
modelID string // Unique identifier of the model
apiKey string // API key for authentication
baseURL string // Base URL for API requests
client *http.Client // HTTP client for making API requests
customHeaders map[string]string
}
// SetCustomHeaders 设置用户自定义 HTTP 请求头(类似 OpenAI Python SDK 的 extra_headers)。
func (r *AliyunReranker) SetCustomHeaders(headers map[string]string) {
r.customHeaders = headers
}
// AliyunRerankRequest represents a request to rerank documents using Aliyun DashScope API
type AliyunRerankRequest struct {
Model string `json:"model"` // Model to use for reranking
Input AliyunRerankInput `json:"input"` // Input containing query and documents
Parameters AliyunRerankParameters `json:"parameters"` // Parameters for the reranking
}
// AliyunRerankInput contains the query and documents for reranking
type AliyunRerankInput struct {
Query string `json:"query"` // Query text to compare documents against
Documents []string `json:"documents"` // List of document texts to rerank
}
// AliyunRerankParameters contains parameters for the reranking request
type AliyunRerankParameters struct {
ReturnDocuments bool `json:"return_documents"` // Whether to return documents in response
TopN int `json:"top_n"` // Number of top results to return
}
// AliyunRerankResponse represents the response from Aliyun DashScope reranking request
type AliyunRerankResponse struct {
Output AliyunOutput `json:"output"` // Output containing results
Usage AliyunUsage `json:"usage"` // Token usage information
}
// AliyunOutput contains the reranking results
type AliyunOutput struct {
Results []AliyunRankResult `json:"results"` // Ranked results with relevance scores
}
// AliyunRankResult represents a single reranking result from Aliyun
type AliyunRankResult struct {
Document AliyunDocument `json:"document"` // Document information
Index int `json:"index"` // Original index of the document
RelevanceScore float64 `json:"relevance_score"` // Relevance score
}
// AliyunDocument represents document information in Aliyun response
type AliyunDocument struct {
Text string `json:"text"` // Document text
}
// AliyunUsage contains information about token usage in the Aliyun API request
type AliyunUsage struct {
TotalTokens int `json:"total_tokens"` // Total tokens consumed
}
// NewAliyunReranker creates a new instance of Aliyun reranker with the provided configuration
func NewAliyunReranker(config *RerankerConfig) (*AliyunReranker, error) {
apiKey := config.APIKey
baseURL := "https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank"
if url := config.BaseURL; url != "" {
baseURL = url
}
if err := validateRerankBaseURL(baseURL); err != nil {
return nil, err
}
return &AliyunReranker{
modelName: config.ModelName,
modelID: config.ModelID,
apiKey: apiKey,
baseURL: baseURL,
client: newRerankHTTPClient(0),
}, nil
}
// Rerank performs document reranking based on relevance to the query using Aliyun DashScope API
func (r *AliyunReranker) Rerank(ctx context.Context, query string, documents []string) ([]RankResult, error) {
// Build the request body
requestBody := &AliyunRerankRequest{
Model: r.modelName,
Input: AliyunRerankInput{
Query: query,
Documents: documents,
},
Parameters: AliyunRerankParameters{
ReturnDocuments: true,
TopN: len(documents), // Return all documents
},
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("marshal request body: %w", err)
}
// Send the request
req, err := http.NewRequestWithContext(ctx, "POST", r.baseURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", r.apiKey))
secutils.ApplyCustomHeaders(req, r.customHeaders)
logger.Debugf(ctx, "%s", buildRerankRequestDebug(r.modelName, r.baseURL, query, documents))
resp, err := r.client.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
// Read the response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("aliyun rerank API error: Http Status: %s, Body: %s", resp.Status, string(body))
}
var response AliyunRerankResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
// Convert Aliyun results to standard RankResult format
results := make([]RankResult, len(response.Output.Results))
for i, aliyunResult := range response.Output.Results {
results[i] = RankResult{
Index: aliyunResult.Index,
Document: DocumentInfo{
Text: aliyunResult.Document.Text,
},
RelevanceScore: aliyunResult.RelevanceScore,
}
}
return results, nil
}
// GetModelName returns the name of the reranking model
func (r *AliyunReranker) GetModelName() string {
return r.modelName
}
// GetModelID returns the unique identifier of the reranking model
func (r *AliyunReranker) GetModelID() string {
return r.modelID
}
@@ -0,0 +1,32 @@
package rerank
import (
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
func TestConfigFromModel(t *testing.T) {
m := &types.Model{
ID: "rr-1",
Name: "bge-reranker-v2-m3",
Source: types.ModelSourceRemote,
Parameters: types.ModelParameters{
BaseURL: "https://api.example.com/v1",
APIKey: "sk-xxx",
Provider: "siliconflow",
ExtraConfig: map[string]string{"flag": "on"},
CustomHeaders: map[string]string{"X-Gateway": "g"},
},
}
cfg := ConfigFromModel(m, "app", "secret")
if cfg == nil || cfg.ModelID != "rr-1" || cfg.ModelName != "bge-reranker-v2-m3" {
t.Fatalf("identity mismatch: %+v", cfg)
}
if cfg.Provider != "siliconflow" || cfg.CustomHeaders["X-Gateway"] != "g" {
t.Errorf("provider/headers not propagated: %+v", cfg)
}
if cfg.AppID != "app" || cfg.AppSecret != "secret" {
t.Errorf("cloud creds mismatch: %+v", cfg)
}
}
+128
View File
@@ -0,0 +1,128 @@
package rerank
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/Tencent/WeKnora/internal/logger"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// JinaReranker implements a reranking system using Jina AI API
// Jina API uses different parameters than standard OpenAI-compatible APIs
type JinaReranker struct {
modelName string // Name of the model used for reranking
modelID string // Unique identifier of the model
apiKey string // API key for authentication
baseURL string // Base URL for API requests
client *http.Client // HTTP client for making API requests
customHeaders map[string]string
}
// SetCustomHeaders 设置用户自定义 HTTP 请求头(类似 OpenAI Python SDK 的 extra_headers)。
func (r *JinaReranker) SetCustomHeaders(headers map[string]string) {
r.customHeaders = headers
}
// JinaRerankRequest represents a Jina rerank request
// Note: Jina does NOT support truncate_prompt_tokens parameter
type JinaRerankRequest struct {
Model string `json:"model"` // Model to use for reranking
Query string `json:"query"` // Query text to compare documents against
Documents []string `json:"documents"` // List of document texts to rerank
TopN int `json:"top_n,omitempty"` // Number of top results to return
ReturnDocuments bool `json:"return_documents,omitempty"` // Whether to return document text in response
}
// JinaRerankResponse represents the response from a Jina reranking request
type JinaRerankResponse struct {
Model string `json:"model"` // Model used for reranking
Results []RankResult `json:"results"` // Ranked results with relevance scores
Usage struct {
TotalTokens int `json:"total_tokens"` // Total tokens consumed
} `json:"usage"`
}
// NewJinaReranker creates a new instance of Jina reranker with the provided configuration
func NewJinaReranker(config *RerankerConfig) (*JinaReranker, error) {
apiKey := config.APIKey
baseURL := "https://api.jina.ai/v1"
if url := config.BaseURL; url != "" {
baseURL = url
}
if err := validateRerankBaseURL(baseURL); err != nil {
return nil, err
}
return &JinaReranker{
modelName: config.ModelName,
modelID: config.ModelID,
apiKey: apiKey,
baseURL: baseURL,
client: newRerankHTTPClient(0),
}, nil
}
// Rerank performs document reranking based on relevance to the query
func (r *JinaReranker) Rerank(ctx context.Context, query string, documents []string) ([]RankResult, error) {
// Build the request body - Jina does NOT use truncate_prompt_tokens
requestBody := &JinaRerankRequest{
Model: r.modelName,
Query: query,
Documents: documents,
ReturnDocuments: true, // Return document text in response
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("marshal request body: %w", err)
}
// Send the request
req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/rerank", r.baseURL), bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", r.apiKey))
secutils.ApplyCustomHeaders(req, r.customHeaders)
logger.Debugf(ctx, "%s", buildRerankRequestDebug(r.modelName, fmt.Sprintf("%s/rerank", r.baseURL), query, documents))
resp, err := r.client.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
// Read the response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
logger.GetLogger(ctx).Errorf("JinaReranker API error: Http Status: %s, Body: %s", resp.Status, string(body))
return nil, fmt.Errorf("Rerank API error: Http Status: %s", resp.Status)
}
var response JinaRerankResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
return response.Results, nil
}
// GetModelName returns the name of the reranking model
func (r *JinaReranker) GetModelName() string {
return r.modelName
}
// GetModelID returns the unique identifier of the reranking model
func (r *JinaReranker) GetModelID() string {
return r.modelID
}
+168
View File
@@ -0,0 +1,168 @@
package rerank
import (
"context"
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
)
const langfuseRerankPreviewDocs = 8
const langfuseRerankMaxScores = 50
// langfuseReranker wraps a Reranker and reports each rerank call as a
// Langfuse generation observation. Rerankers don't return token usage, but
// the call still incurs cost (billed per 1K documents by most vendors); we
// estimate input tokens from the query + documents so the Langfuse cost
// dashboard gets a proportional signal.
type langfuseReranker struct {
inner Reranker
}
func (l *langfuseReranker) GetModelName() string { return l.inner.GetModelName() }
func (l *langfuseReranker) GetModelID() string { return l.inner.GetModelID() }
func (l *langfuseReranker) Rerank(ctx context.Context, query string, documents []string) ([]RankResult, error) {
mgr := langfuse.GetManager()
if !mgr.Enabled() {
return l.inner.Rerank(ctx, query, documents)
}
totalChars := len([]rune(query))
for _, doc := range documents {
totalChars += len([]rune(doc))
}
genCtx, gen := mgr.StartGeneration(ctx, langfuse.GenerationOptions{
Name: "rerank",
Model: l.inner.GetModelName(),
Input: map[string]interface{}{
"query": query,
"document_count": len(documents),
"documents_preview": previewDocs(documents, langfuseRerankPreviewDocs),
},
Metadata: map[string]interface{}{
"model_id": l.inner.GetModelID(),
"num_queries": 1,
"total_chars": totalChars,
"avg_doc_chars": avgDocChars(documents),
},
})
results, err := l.inner.Rerank(genCtx, query, documents)
output := map[string]interface{}{
"results": summarizeResults(results, documents, langfuseRerankMaxScores),
"total_count": len(results),
"score_stats": scoreStats(results),
}
if len(results) > langfuseRerankMaxScores {
output["truncated"] = len(results) - langfuseRerankMaxScores
}
gen.Finish(output, approxRerankUsage(query, documents), err)
return results, err
}
func avgDocChars(documents []string) int {
if len(documents) == 0 {
return 0
}
total := 0
for _, doc := range documents {
total += len([]rune(doc))
}
return total / len(documents)
}
func scoreStats(results []RankResult) map[string]interface{} {
if len(results) == 0 {
return nil
}
minScore := results[0].RelevanceScore
maxScore := results[0].RelevanceScore
sum := 0.0
for _, r := range results {
if r.RelevanceScore < minScore {
minScore = r.RelevanceScore
}
if r.RelevanceScore > maxScore {
maxScore = r.RelevanceScore
}
sum += r.RelevanceScore
}
return map[string]interface{}{
"min": minScore,
"max": maxScore,
"avg": sum / float64(len(results)),
}
}
func approxRerankUsage(query string, documents []string) *langfuse.TokenUsage {
total := len([]rune(query))/4 + 1
for _, d := range documents {
total += len([]rune(d))/4 + 1
}
if total == 0 {
return nil
}
return &langfuse.TokenUsage{
Input: total,
Total: total,
Unit: "TOKENS",
}
}
func previewDocs(docs []string, n int) []map[string]interface{} {
if len(docs) < n {
n = len(docs)
}
out := make([]map[string]interface{}, 0, n)
for i := 0; i < n; i++ {
out = append(out, map[string]interface{}{
"index": i,
"preview": truncateRunes(docs[i], 160),
"length": len([]rune(docs[i])),
})
}
return out
}
func summarizeResults(results []RankResult, documents []string, n int) []map[string]interface{} {
if len(results) < n {
n = len(results)
}
out := make([]map[string]interface{}, 0, n)
for i := 0; i < n; i++ {
row := map[string]interface{}{
"rank": i + 1,
"index": results[i].Index,
"model_score": results[i].RelevanceScore,
}
idx := results[i].Index
if idx >= 0 && idx < len(documents) {
row["preview"] = truncateRunes(documents[idx], 160)
}
out = append(out, row)
}
return out
}
func truncateRunes(s string, maxRunes int) string {
r := []rune(s)
if len(r) <= maxRunes {
return s
}
return string(r[:maxRunes]) + "..."
}
// wrapRerankerLangfuse applies the Langfuse decorator when the manager is
// enabled. Called from NewReranker after the debug wrapper so both sinks see
// the same calls.
func wrapRerankerLangfuse(r Reranker, err error) (Reranker, error) {
if err != nil || r == nil {
return r, err
}
if !langfuse.GetManager().Enabled() {
return r, nil
}
return &langfuseReranker{inner: r}, nil
}
+181
View File
@@ -0,0 +1,181 @@
package rerank
import (
"context"
"fmt"
"strings"
"unicode/utf8"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
lkeap "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/lkeap/v20240522"
)
const (
// LKEAPMaxDocumentsPerRequest is the maximum number of documents accepted by RunRerank.
LKEAPMaxDocumentsPerRequest = 60
// LKEAPMaxRequestCharacters is the maximum combined length of Query and Docs accepted by RunRerank.
LKEAPMaxRequestCharacters = 2000
// LKEAPRerankEndpoint 腾讯云知识引擎原子能力 Rerank API 域名
LKEAPRerankEndpoint = "lkeap.tencentcloudapi.com"
// LKEAPDefaultRegion RunRerank 支持的地域,默认广州
LKEAPDefaultRegion = "ap-guangzhou"
// LKEAPDefaultRerankModel 默认 rerank 模型名
LKEAPDefaultRerankModel = "lke-reranker-base"
)
// LKEAPReranker 使用腾讯云知识引擎原子能力 RunRerank 接口进行重排序。
// 鉴权使用腾讯云 API 密钥:APIKey 为 SecretId,AppSecret 为 SecretKey。
type LKEAPReranker struct {
modelName string
modelID string
client *lkeap.Client
}
// NewLKEAPReranker 创建 LKEAP rerank 客户端。
func NewLKEAPReranker(config *RerankerConfig) (*LKEAPReranker, error) {
secretID := strings.TrimSpace(config.APIKey)
secretKey := strings.TrimSpace(config.AppSecret)
if secretKey == "" && config.ExtraConfig != nil {
secretKey = strings.TrimSpace(config.ExtraConfig["secret_key"])
}
if secretID == "" || secretKey == "" {
return nil, fmt.Errorf("secret_id and secret_key are required for LKEAP rerank (set API Key and Secret Key)")
}
region := LKEAPDefaultRegion
if config.ExtraConfig != nil {
if r := strings.TrimSpace(config.ExtraConfig["region"]); r != "" {
region = r
}
}
credential := common.NewCredential(secretID, secretKey)
cpf := profile.NewClientProfile()
cpf.HttpProfile.Endpoint = LKEAPRerankEndpoint
client, err := lkeap.NewClient(credential, region, cpf)
if err != nil {
return nil, fmt.Errorf("create LKEAP client: %w", err)
}
modelName := strings.TrimSpace(config.ModelName)
if modelName == "" {
modelName = LKEAPDefaultRerankModel
}
return &LKEAPReranker{
modelName: modelName,
modelID: config.ModelID,
client: client,
}, nil
}
// Rerank 调用 RunRerank 对文档按与 query 的相关性打分。
func (r *LKEAPReranker) Rerank(ctx context.Context, query string, documents []string) ([]RankResult, error) {
if len(documents) == 0 {
return []RankResult{}, nil
}
batches, err := lkeapRerankBatches(query, documents)
if err != nil {
return nil, err
}
results := make([]RankResult, 0, len(documents))
for _, batch := range batches {
batchResults, err := r.rerankBatch(ctx, query, batch.documents)
if err != nil {
return nil, err
}
for i := range batchResults {
batchResults[i].Index += batch.start
}
results = append(results, batchResults...)
}
return results, nil
}
type lkeapRerankBatch struct {
start int
documents []string
}
func lkeapRerankBatches(query string, documents []string) ([]lkeapRerankBatch, error) {
queryLength := utf8.RuneCountInString(query)
if queryLength >= LKEAPMaxRequestCharacters {
return nil, fmt.Errorf("LKEAP rerank query is %d characters; Query and Docs together support at most %d characters", queryLength, LKEAPMaxRequestCharacters)
}
batches := make([]lkeapRerankBatch, 0, (len(documents)+LKEAPMaxDocumentsPerRequest-1)/LKEAPMaxDocumentsPerRequest)
batchStart := 0
batchLength := queryLength
batchDocuments := make([]string, 0, LKEAPMaxDocumentsPerRequest)
for index, document := range documents {
documentLength := utf8.RuneCountInString(document)
if queryLength+documentLength > LKEAPMaxRequestCharacters {
return nil, fmt.Errorf("LKEAP rerank document at index %d is %d characters; Query and each document together support at most %d characters", index, documentLength, LKEAPMaxRequestCharacters)
}
if len(batchDocuments) == LKEAPMaxDocumentsPerRequest || batchLength+documentLength > LKEAPMaxRequestCharacters {
batches = append(batches, lkeapRerankBatch{start: batchStart, documents: batchDocuments})
batchStart = index
batchLength = queryLength
batchDocuments = make([]string, 0, LKEAPMaxDocumentsPerRequest)
}
batchDocuments = append(batchDocuments, document)
batchLength += documentLength
}
if len(batchDocuments) > 0 {
batches = append(batches, lkeapRerankBatch{start: batchStart, documents: batchDocuments})
}
return batches, nil
}
func (r *LKEAPReranker) rerankBatch(ctx context.Context, query string, documents []string) ([]RankResult, error) {
req := lkeap.NewRunRerankRequest()
req.Query = common.StringPtr(query)
req.Docs = common.StringPtrs(documents)
req.Model = common.StringPtr(r.modelName)
logger.Debugf(ctx, "%s", buildRerankRequestDebug(r.modelName, LKEAPRerankEndpoint, query, documents))
resp, err := r.client.RunRerankWithContext(ctx, req)
if err != nil {
return nil, fmt.Errorf("LKEAP RunRerank: %w", err)
}
if resp == nil || resp.Response == nil || len(resp.Response.ScoreList) == 0 {
return nil, fmt.Errorf("LKEAP rerank API returned empty score list")
}
scores := resp.Response.ScoreList
if len(scores) != len(documents) {
return nil, fmt.Errorf("LKEAP rerank score count mismatch: got %d scores for %d documents",
len(scores), len(documents))
}
results := make([]RankResult, len(documents))
for i, score := range scores {
if score == nil {
continue
}
results[i] = RankResult{
Index: i,
Document: DocumentInfo{
Text: documents[i],
},
RelevanceScore: *score,
}
}
return results, nil
}
// GetModelName returns the rerank model name.
func (r *LKEAPReranker) GetModelName() string {
return r.modelName
}
// GetModelID returns the model ID.
func (r *LKEAPReranker) GetModelID() string {
return r.modelID
}
@@ -0,0 +1,178 @@
package rerank
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
lkeap "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/lkeap/v20240522"
)
func TestNewLKEAPReranker_requiresCredentials(t *testing.T) {
_, err := NewLKEAPReranker(&RerankerConfig{
ModelName: "lke-reranker-base",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "secret_id")
}
func TestNewLKEAPReranker_secretKeyFromExtraConfig(t *testing.T) {
r, err := NewLKEAPReranker(&RerankerConfig{
APIKey: "AKIDtest",
ModelName: "lke-reranker-base",
ExtraConfig: map[string]string{
"secret_key": "sk-test",
"region": "ap-beijing",
},
})
require.NoError(t, err)
require.NotNil(t, r)
assert.Equal(t, "lke-reranker-base", r.GetModelName())
}
func TestNewLKEAPReranker_defaultModelName(t *testing.T) {
r, err := NewLKEAPReranker(&RerankerConfig{
APIKey: "AKIDtest",
AppSecret: "sk-test",
})
require.NoError(t, err)
assert.Equal(t, LKEAPDefaultRerankModel, r.GetModelName())
}
func TestLKEAPReranker_Rerank_emptyDocuments(t *testing.T) {
r, err := NewLKEAPReranker(&RerankerConfig{
APIKey: "AKIDtest",
AppSecret: "sk-test",
})
require.NoError(t, err)
results, err := r.Rerank(t.Context(), "query", nil)
require.NoError(t, err)
assert.Empty(t, results)
}
func TestLKEAPReranker_Rerank_batchesMoreThan60Documents(t *testing.T) {
var batches [][]string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
var request struct {
Docs []string `json:"Docs"`
}
if err := json.NewDecoder(req.Body).Decode(&request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
batches = append(batches, request.Docs)
scores := make([]float64, len(request.Docs))
for i, doc := range request.Docs {
index, err := lkeapTestDocumentIndex(doc)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
scores[i] = float64(index)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"Response": map[string]any{
"ScoreList": scores,
"RequestId": "test-request",
},
})
}))
defer server.Close()
r := newTestLKEAPReranker(t, server.URL)
documents := make([]string, 61)
for i := range documents {
documents[i] = strconv.Itoa(i) + ":document"
}
results, err := r.Rerank(t.Context(), "query", documents)
require.NoError(t, err)
require.Len(t, results, len(documents))
require.Len(t, batches, 2)
assert.Len(t, batches[0], 60)
assert.Len(t, batches[1], 1)
for i, result := range results {
assert.Equal(t, i, result.Index)
assert.Equal(t, documents[i], result.Document.Text)
assert.Equal(t, float64(i), result.RelevanceScore)
}
}
func TestLKEAPReranker_Rerank_batchesWithinCharacterLimit(t *testing.T) {
var batches [][]string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
var request struct {
Docs []string `json:"Docs"`
}
if err := json.NewDecoder(req.Body).Decode(&request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
batches = append(batches, request.Docs)
scores := make([]float64, len(request.Docs))
for i, doc := range request.Docs {
index, err := lkeapTestDocumentIndex(doc)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
scores[i] = float64(index)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"Response": map[string]any{
"ScoreList": scores,
"RequestId": "test-request",
},
})
}))
defer server.Close()
r := newTestLKEAPReranker(t, server.URL)
documents := []string{
"0:" + strings.Repeat("a", 1000),
"1:" + strings.Repeat("b", 1000),
"2:" + strings.Repeat("c", 1000),
}
results, err := r.Rerank(t.Context(), "query", documents)
require.NoError(t, err)
require.Len(t, results, len(documents))
require.Len(t, batches, 3)
for i, batch := range batches {
assert.Len(t, batch, 1)
assert.Equal(t, documents[i], batch[0])
}
}
func newTestLKEAPReranker(t *testing.T, serverURL string) *LKEAPReranker {
t.Helper()
endpoint, err := url.Parse(serverURL)
require.NoError(t, err)
clientProfile := profile.NewClientProfile()
clientProfile.HttpProfile.Endpoint = endpoint.Host
clientProfile.HttpProfile.Scheme = "HTTP"
client, err := lkeap.NewClient(common.NewCredential("AKIDtest", "sk-test"), LKEAPDefaultRegion, clientProfile)
require.NoError(t, err)
return &LKEAPReranker{
modelName: LKEAPDefaultRerankModel,
client: client,
}
}
func lkeapTestDocumentIndex(document string) (int, error) {
value, _, _ := strings.Cut(document, ":")
return strconv.Atoi(value)
}
+70
View File
@@ -0,0 +1,70 @@
package rerank
import (
"context"
"fmt"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/logger"
)
// debugReranker wraps a Reranker with LLM debug logging.
type debugReranker struct {
inner Reranker
}
func (d *debugReranker) Rerank(ctx context.Context, query string, documents []string) ([]RankResult, error) {
start := time.Now()
result, err := d.inner.Rerank(ctx, query, documents)
logRerankDebug(ctx, d.inner.GetModelName(), query, documents, result, err, time.Since(start))
return result, err
}
func (d *debugReranker) GetModelName() string { return d.inner.GetModelName() }
func (d *debugReranker) GetModelID() string { return d.inner.GetModelID() }
func logRerankDebug(ctx context.Context, model string, query string, documents []string, results []RankResult, callErr error, dur time.Duration) {
if !logger.LLMDebugEnabled() {
return
}
record := &logger.LLMCallRecord{
CallType: "Rerank",
Model: model,
Duration: dur,
}
// Query section
record.Sections = append(record.Sections, logger.RecordSection{
Title: "Query",
Content: query,
})
// Documents section
var docBuf strings.Builder
docBuf.WriteString(fmt.Sprintf("count=%d\n", len(documents)))
for i, doc := range documents {
preview := strings.ReplaceAll(doc, "\n", "\\n")
preview = logger.TruncateRunes(preview, 200)
docBuf.WriteString(fmt.Sprintf("[%d] (len=%d) %s\n", i, len([]rune(doc)), preview))
}
record.Sections = append(record.Sections, logger.RecordSection{Title: "Documents", Content: docBuf.String()})
// Results section
if results != nil {
var resBuf strings.Builder
resBuf.WriteString(fmt.Sprintf("count=%d\n", len(results)))
for _, r := range results {
docPreview := strings.ReplaceAll(r.Document.Text, "\n", "\\n")
docPreview = logger.TruncateRunes(docPreview, 200)
resBuf.WriteString(fmt.Sprintf(" [%d] score=%.6f %s\n", r.Index, r.RelevanceScore, docPreview))
}
record.Sections = append(record.Sections, logger.RecordSection{Title: "Results", Content: resBuf.String()})
}
if callErr != nil {
record.Error = callErr.Error()
}
logger.LLMDebugLog(ctx, record)
}
+42
View File
@@ -0,0 +1,42 @@
package rerank
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
)
const (
maxLogDocuments = 3
maxLogTextRunes = 120
)
func buildRerankRequestDebug(model, endpoint, query string, documents []string) string {
previews := make([]string, 0, maxLogDocuments)
for i, doc := range documents {
if i >= maxLogDocuments {
break
}
previews = append(previews, compactForLog(doc, maxLogTextRunes))
}
previewJSON, _ := json.Marshal(previews)
return fmt.Sprintf(
"rerank request endpoint=%s model=%s query_preview=%q query_runes=%d documents=%d preview_docs=%s",
endpoint,
model,
compactForLog(query, maxLogTextRunes),
utf8.RuneCountInString(query),
len(documents),
string(previewJSON),
)
}
func compactForLog(text string, maxRunes int) string {
normalized := strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
if utf8.RuneCountInString(normalized) <= maxRunes {
return normalized
}
return string([]rune(normalized)[:maxRunes]) + "...(truncated)"
}
+156
View File
@@ -0,0 +1,156 @@
package rerank
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"github.com/Tencent/WeKnora/internal/logger"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// NvidiaReranker implements a reranking system using Jina AI API
// Jina API uses different parameters than standard OpenAI-compatible APIs
type NvidiaReranker struct {
modelName string // Name of the model used for reranking
modelID string // Unique identifier of the model
apiKey string // API key for authentication
baseURL string // Base URL for API requests
client *http.Client // HTTP client for making API requests
customHeaders map[string]string
}
// SetCustomHeaders 设置用户自定义 HTTP 请求头(类似 OpenAI Python SDK 的 extra_headers)。
func (r *NvidiaReranker) SetCustomHeaders(headers map[string]string) {
r.customHeaders = headers
}
type NvidiaRerankDocument struct {
Text string `json:"text"`
}
// NvidiaRerankRequest represents a Jina rerank request
// Note: Jina does NOT support truncate_prompt_tokens parameter
type NvidiaRerankRequest struct {
Model string `json:"model"` // Model to use for reranking
Query NvidiaRerankDocument `json:"query"` // Query text to compare documents against
Documents []NvidiaRerankDocument `json:"passages"` // List of document texts to rerank
}
type NvidiaRankResult struct {
Index int `json:"index"`
Logit float64 `json:"logit"`
}
// NvidiaRerankResponse represents the response from a Jina reranking request
type NvidiaRerankResponse struct {
Model string `json:"model"` // Model used for reranking
Results []NvidiaRankResult `json:"rankings"` // Ranked results with relevance scores
}
// NewNvidiaReranker creates a new instance of Jina reranker with the provided configuration
func NewNvidiaReranker(config *RerankerConfig) (*NvidiaReranker, error) {
apiKey := config.APIKey
baseURL := "https://ai.api.nvidia.com/v1/retrieval/nvidia/reranking"
if url := config.BaseURL; url != "" {
baseURL = url
}
if err := validateRerankBaseURL(baseURL); err != nil {
return nil, err
}
return &NvidiaReranker{
modelName: config.ModelName,
modelID: config.ModelID,
apiKey: apiKey,
baseURL: baseURL,
client: newRerankHTTPClient(0),
}, nil
}
// Rerank performs document reranking based on relevance to the query
func (r *NvidiaReranker) Rerank(ctx context.Context, query string, documents []string) ([]RankResult, error) {
// Build the request body - Jina does NOT use truncate_prompt_tokens
requestBody := &NvidiaRerankRequest{
Model: r.modelName,
Query: NvidiaRerankDocument{Text: query},
Documents: make([]NvidiaRerankDocument, len(documents)),
}
for i := range requestBody.Documents {
requestBody.Documents[i].Text = documents[i]
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("marshal request body: %w", err)
}
// Send the request
req, err := http.NewRequestWithContext(ctx, "POST", r.baseURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", r.apiKey))
secutils.ApplyCustomHeaders(req, r.customHeaders)
// Log the curl equivalent for debugging (API key masked for security)
logger.GetLogger(ctx).Infof(
"curl -X POST %s/rerank -H \"Content-Type: application/json\" -H \"Authorization: Bearer ***\" -d '%s'",
r.baseURL, string(jsonData),
)
resp, err := r.client.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
// Read the response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
logger.GetLogger(ctx).Errorf("JinaReranker API error: Http Status: %s, Body: %s", resp.Status, string(body))
return nil, fmt.Errorf("Rerank API error: Http Status: %s", resp.Status)
}
var response NvidiaRerankResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
ret := make([]RankResult, len(response.Results))
for i, result := range response.Results {
ret[i] = RankResult{
Index: result.Index,
Document: DocumentInfo{Text: documents[result.Index]},
RelevanceScore: normalizeNvidiaLogit(result.Logit),
}
}
return ret, nil
}
// normalizeNvidiaLogit converts NVIDIA's raw reranker logit into a probability.
func normalizeNvidiaLogit(logit float64) float64 {
if logit >= 0 {
return 1 / (1 + math.Exp(-logit))
}
expLogit := math.Exp(logit)
return expLogit / (1 + expLogit)
}
// GetModelName returns the name of the reranking model
func (r *NvidiaReranker) GetModelName() string {
return r.modelName
}
// GetModelID returns the unique identifier of the reranking model
func (r *NvidiaReranker) GetModelID() string {
return r.modelID
}
@@ -0,0 +1,49 @@
package rerank
import (
"math"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNvidiaRerankerNormalizesLogitsToProbabilities(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"model": "nvidia-rerank-model",
"rankings": [
{"index": 0, "logit": 23.0},
{"index": 1, "logit": 0.0},
{"index": 2, "logit": -23.0}
]
}`))
}))
defer server.Close()
reranker := &NvidiaReranker{
modelName: "nvidia-rerank-model",
apiKey: "nvapi-test",
baseURL: server.URL,
client: server.Client(),
}
results, err := reranker.Rerank(t.Context(), "query", []string{"high", "neutral", "low"})
require.NoError(t, err)
require.Len(t, results, 3)
logits := []float64{23, 0, -23}
for i, logit := range logits {
want := 1 / (1 + math.Exp(-logit))
assert.InDelta(t, want, results[i].RelevanceScore, 1e-12)
assert.Greater(t, results[i].RelevanceScore, 0.0)
assert.Less(t, results[i].RelevanceScore, 1.0)
}
assert.Greater(t, results[0].RelevanceScore, results[1].RelevanceScore)
assert.Greater(t, results[1].RelevanceScore, results[2].RelevanceScore)
}
+153
View File
@@ -0,0 +1,153 @@
package rerank
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/Tencent/WeKnora/internal/logger"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// OpenAIReranker implements a reranking system based on OpenAI models
type OpenAIReranker struct {
modelName string // Name of the model used for reranking
modelID string // Unique identifier of the model
apiKey string // API key for authentication
baseURL string // Base URL for API requests
client *http.Client // HTTP client for making API requests
customHeaders map[string]string
// truncatePromptTokens, when > 0, is sent as the vLLM-specific
// truncate_prompt_tokens request field. It must never be sent by default:
// providers that honor it (e.g. SiliconFlow) keep only the LAST N tokens of
// the templated rerank prompt, which cuts the query off long documents and
// collapses every relevance score to near zero (issue #2143).
truncatePromptTokens int
}
// SetCustomHeaders 设置用户自定义 HTTP 请求头(类似 OpenAI Python SDK 的 extra_headers)。
func (r *OpenAIReranker) SetCustomHeaders(headers map[string]string) {
r.customHeaders = headers
}
// RerankRequest represents a request to rerank documents based on relevance to a query
type RerankRequest struct {
Model string `json:"model"` // Model to use for reranking
Query string `json:"query"` // Query text to compare documents against
Documents []string `json:"documents"` // List of document texts to rerank
AdditionalData map[string]interface{} `json:"additional_data,omitempty"` // Optional additional data for the model
TruncatePromptTokens int `json:"truncate_prompt_tokens,omitempty"` // Maximum prompt tokens to use (vLLM-specific, opt-in)
}
// RerankResponse represents the response from a reranking request
type RerankResponse struct {
ID string `json:"id"` // Request ID
Model string `json:"model"` // Model used for reranking
Usage UsageInfo `json:"usage"` // Token usage information
Results []RankResult `json:"results"` // Ranked results with relevance scores
}
// UsageInfo contains information about token usage in the API request
type UsageInfo struct {
TotalTokens int `json:"total_tokens"` // Total tokens consumed
}
// NewOpenAIReranker creates a new instance of OpenAI reranker with the provided configuration
func NewOpenAIReranker(config *RerankerConfig) (*OpenAIReranker, error) {
apiKey := config.APIKey
baseURL := "https://api.openai.com/v1"
if url := config.BaseURL; url != "" {
baseURL = url
}
if err := validateRerankBaseURL(baseURL); err != nil {
return nil, err
}
// Optional opt-in for vLLM-style deployments that need server-side prompt
// truncation. Configured via extra_config; never enabled by default.
truncatePromptTokens := 0
if config.ExtraConfig != nil {
if raw := strings.TrimSpace(config.ExtraConfig["truncate_prompt_tokens"]); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return nil, fmt.Errorf("invalid truncate_prompt_tokens in extra_config: %q", raw)
}
truncatePromptTokens = n
}
}
return &OpenAIReranker{
modelName: config.ModelName,
modelID: config.ModelID,
apiKey: apiKey,
baseURL: baseURL,
client: newRerankHTTPClient(0),
truncatePromptTokens: truncatePromptTokens,
}, nil
}
// Rerank performs document reranking based on relevance to the query
func (r *OpenAIReranker) Rerank(ctx context.Context, query string, documents []string) ([]RankResult, error) {
// Build the request body. truncate_prompt_tokens is only included when
// explicitly configured: sending it unconditionally corrupts scores on
// providers that honor it (see OpenAIReranker.truncatePromptTokens).
requestBody := &RerankRequest{
Model: r.modelName,
Query: query,
Documents: documents,
TruncatePromptTokens: r.truncatePromptTokens,
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("marshal request body: %w", err)
}
// Send the request
req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/rerank", r.baseURL), bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", r.apiKey))
secutils.ApplyCustomHeaders(req, r.customHeaders)
logger.Debugf(ctx, "%s", buildRerankRequestDebug(r.modelName, fmt.Sprintf("%s/rerank", r.baseURL), query, documents))
resp, err := r.client.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
// Read the response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Rerank API error: Http Status: %s", resp.Status)
}
var response RerankResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
return response.Results, nil
}
// GetModelName returns the name of the reranking model
func (r *OpenAIReranker) GetModelName() string {
return r.modelName
}
// GetModelID returns the unique identifier of the reranking model
func (r *OpenAIReranker) GetModelID() string {
return r.modelID
}
+157
View File
@@ -0,0 +1,157 @@
package rerank
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// newRerankScoreTestServer emulates an OpenAI-compatible /rerank endpoint the
// way a vLLM-backed provider (e.g. SiliconFlow) behaves, and records the last
// decoded request body.
//
// If the request carries truncate_prompt_tokens, the backend keeps only the
// last N tokens of the templated rerank prompt — the query gets cut off long
// documents and every relevance score collapses to near zero (issue #2143).
// Otherwise it returns the real scores, sorted by relevance_score descending,
// with index pointing back at the original input-document position.
func newRerankScoreTestServer(t *testing.T, lastRequest *map[string]interface{}) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode rerank request: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
*lastRequest = req
w.Header().Set("Content-Type", "application/json")
if _, truncated := req["truncate_prompt_tokens"]; truncated {
// Query truncated away from the prompt: scores collapse.
_, _ = w.Write([]byte(`{
"id": "rerank-collapsed",
"results": [
{"index": 0, "relevance_score": 0.00670681, "document": {"text": "A"}},
{"index": 1, "relevance_score": 0.00587342, "document": {"text": "B"}},
{"index": 2, "relevance_score": 0.00412907, "document": {"text": "C"}}
],
"usage": {"total_tokens": 42}
}`))
return
}
// Healthy response: sorted by relevance_score descending, index values
// out of order relative to the input documents [A, B, C].
_, _ = w.Write([]byte(`{
"id": "rerank-ok",
"results": [
{"index": 2, "relevance_score": 0.998, "document": {"text": "C"}},
{"index": 0, "relevance_score": 0.51, "document": {"text": "A"}},
{"index": 1, "relevance_score": 0.006, "document": {"text": "B"}}
],
"usage": {"total_tokens": 42}
}`))
}))
}
// TestOpenAIRerankerDoesNotTruncatePromptByDefault is the regression test for
// issue #2143: the generic OpenAI-compatible reranker must not send the
// vLLM-specific truncate_prompt_tokens field unless explicitly configured, and
// each input document must keep its own relevance score via the index field.
func TestOpenAIRerankerDoesNotTruncatePromptByDefault(t *testing.T) {
withRerankSSRFWhitelist(t, "127.0.0.1")
var lastRequest map[string]interface{}
server := newRerankScoreTestServer(t, &lastRequest)
defer server.Close()
reranker, err := NewOpenAIReranker(&RerankerConfig{
BaseURL: server.URL,
ModelName: "Qwen/Qwen3-VL-Reranker-8B",
APIKey: "sk-test",
})
if err != nil {
t.Fatalf("NewOpenAIReranker: %v", err)
}
documents := []string{"A", "B", "C"}
results, err := reranker.Rerank(t.Context(), "query", documents)
if err != nil {
t.Fatalf("Rerank: %v", err)
}
if v, ok := lastRequest["truncate_prompt_tokens"]; ok {
t.Errorf("request contains truncate_prompt_tokens=%v; it must not be sent unless configured", v)
}
if v, ok := lastRequest["additional_data"]; ok {
t.Errorf("request contains additional_data=%v; empty optional fields must be omitted", v)
}
if len(results) != len(documents) {
t.Fatalf("got %d results, want %d", len(results), len(documents))
}
// Results stay in the order returned by the API (sorted by score desc).
if results[0].Index != 2 || results[0].RelevanceScore != 0.998 {
t.Errorf("top result = {index: %d, score: %v}, want {index: 2, score: 0.998}",
results[0].Index, results[0].RelevanceScore)
}
// Each input document keeps its own score, resolved through the index field.
wantScoreByDoc := map[string]float64{"C": 0.998, "A": 0.51, "B": 0.006}
for _, rr := range results {
if rr.Index < 0 || rr.Index >= len(documents) {
t.Fatalf("result index %d out of range for %d documents", rr.Index, len(documents))
}
doc := documents[rr.Index]
if want := wantScoreByDoc[doc]; rr.RelevanceScore != want {
t.Errorf("document %q got score %v, want %v", doc, rr.RelevanceScore, want)
}
}
}
// TestOpenAIRerankerTruncatePromptTokensOptIn verifies that deployments which
// really need server-side prompt truncation (self-hosted vLLM with small-
// context rerankers) can still opt in via extra_config.
func TestOpenAIRerankerTruncatePromptTokensOptIn(t *testing.T) {
withRerankSSRFWhitelist(t, "127.0.0.1")
var lastRequest map[string]interface{}
server := newRerankScoreTestServer(t, &lastRequest)
defer server.Close()
reranker, err := NewOpenAIReranker(&RerankerConfig{
BaseURL: server.URL,
ModelName: "bge-reranker-base",
APIKey: "sk-test",
ExtraConfig: map[string]string{"truncate_prompt_tokens": "511"},
})
if err != nil {
t.Fatalf("NewOpenAIReranker: %v", err)
}
if _, err := reranker.Rerank(t.Context(), "query", []string{"A", "B", "C"}); err != nil {
t.Fatalf("Rerank: %v", err)
}
v, ok := lastRequest["truncate_prompt_tokens"]
if !ok {
t.Fatal("request is missing truncate_prompt_tokens despite extra_config opt-in")
}
if n, _ := v.(float64); n != 511 {
t.Errorf("truncate_prompt_tokens = %v, want 511", v)
}
}
func TestNewOpenAIRerankerRejectsInvalidTruncatePromptTokens(t *testing.T) {
for _, raw := range []string{"abc", "-1", "0"} {
_, err := NewOpenAIReranker(&RerankerConfig{
ModelName: "rerank-test",
ExtraConfig: map[string]string{"truncate_prompt_tokens": raw},
})
if err == nil {
t.Errorf("NewOpenAIReranker accepted invalid truncate_prompt_tokens %q", raw)
}
}
}
+169
View File
@@ -0,0 +1,169 @@
package rerank
import (
"context"
"encoding/json"
"fmt"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/models/provider"
"github.com/Tencent/WeKnora/internal/types"
)
// Reranker defines the interface for document reranking
type Reranker interface {
// Rerank reranks documents based on relevance to the query
Rerank(ctx context.Context, query string, documents []string) ([]RankResult, error)
// GetModelName returns the model name
GetModelName() string
// GetModelID returns the model ID
GetModelID() string
}
type RankResult struct {
Index int `json:"index"`
Document DocumentInfo `json:"document"`
RelevanceScore float64 `json:"relevance_score"`
}
// Handles the RelevanceScore field by checking if RelevanceScore exists first, otherwise falls back to Score field
func (r *RankResult) UnmarshalJSON(data []byte) error {
var temp struct {
Index int `json:"index"`
Document DocumentInfo `json:"document"`
RelevanceScore *float64 `json:"relevance_score"`
Score *float64 `json:"score"`
}
if err := json.Unmarshal(data, &temp); err != nil {
return fmt.Errorf("failed to unmarshal rank result: %w", err)
}
r.Index = temp.Index
r.Document = temp.Document
if temp.RelevanceScore != nil {
r.RelevanceScore = *temp.RelevanceScore
} else if temp.Score != nil {
r.RelevanceScore = *temp.Score
}
return nil
}
type DocumentInfo struct {
Text string `json:"text"`
}
// UnmarshalJSON handles both string and object formats for DocumentInfo
func (d *DocumentInfo) UnmarshalJSON(data []byte) error {
// First try to unmarshal as a string
var text string
if err := json.Unmarshal(data, &text); err == nil {
d.Text = text
return nil
}
// If that fails, try to unmarshal as an object with text field
var temp struct {
Text string `json:"text"`
}
if err := json.Unmarshal(data, &temp); err != nil {
return fmt.Errorf("failed to unmarshal DocumentInfo: %w", err)
}
d.Text = temp.Text
return nil
}
type RerankerConfig struct {
APIKey string
BaseURL string
ModelName string
Source types.ModelSource
ModelID string
Provider string // Provider identifier: openai, aliyun, zhipu, siliconflow, jina, generic
ExtraConfig map[string]string
// CustomHeaders 允许在调用远程 API 时附加自定义 HTTP 请求头(类似 OpenAI Python SDK 的 extra_headers)。
CustomHeaders map[string]string
AppID string
AppSecret string // 加密值,工厂函数调用方传入,使用前已解密
}
// ConfigFromModel 根据 types.Model 构造 RerankerConfig。
// 生产路径(从 DB 拉起)和测试连接路径(临时表单)共享这份映射。
// appID / appSecret 是已解密的 WeKnoraCloud 凭证,调用方负责传入。
func ConfigFromModel(m *types.Model, appID, appSecret string) *RerankerConfig {
if m == nil {
return nil
}
return &RerankerConfig{
ModelID: m.ID,
APIKey: m.Parameters.APIKey,
BaseURL: m.Parameters.BaseURL,
ModelName: m.Name,
Source: m.Source,
Provider: m.Parameters.Provider,
ExtraConfig: m.Parameters.ExtraConfig,
CustomHeaders: m.Parameters.CustomHeaders,
AppID: appID,
AppSecret: appSecret,
}
}
// NewReranker creates a reranker based on the configuration
func NewReranker(config *RerankerConfig) (Reranker, error) {
r, err := newReranker(config)
if err != nil {
return r, err
}
if logger.LLMDebugEnabled() {
r = &debugReranker{inner: r}
}
return wrapRerankerLangfuse(r, nil)
}
// customHeaderSetter 表示支持注入自定义 HTTP header 的 reranker 实现。
type customHeaderSetter interface {
SetCustomHeaders(map[string]string)
}
func newReranker(config *RerankerConfig) (Reranker, error) {
// Use provider field if set, otherwise detect from URL using provider registry
providerName := provider.ProviderName(config.Provider)
if providerName == "" {
providerName = provider.DetectProvider(config.BaseURL)
}
var (
reranker Reranker
err error
)
switch providerName {
case provider.ProviderAliyun:
reranker, err = NewAliyunReranker(config)
case provider.ProviderZhipu:
reranker, err = NewZhipuReranker(config)
case provider.ProviderJina:
reranker, err = NewJinaReranker(config)
case provider.ProviderNvidia:
reranker, err = NewNvidiaReranker(config)
case provider.ProviderWeKnoraCloud:
reranker, err = NewWeKnoraCloudReranker(config)
case provider.ProviderLKEAP:
reranker, err = NewLKEAPReranker(config)
case provider.ProviderVolcengine:
reranker, err = NewVolcengineReranker(config)
default:
reranker, err = NewOpenAIReranker(config)
}
if err != nil {
return nil, err
}
if setter, ok := reranker.(customHeaderSetter); ok {
setter.SetCustomHeaders(config.CustomHeaders)
}
return reranker, nil
}
+156
View File
@@ -0,0 +1,156 @@
package rerank
import (
"encoding/json"
"testing"
)
func TestRankResultUnmarshalJSON(t *testing.T) {
tests := []struct {
name string
input string
expectedText string
expectedIndex int
expectedScore float64
expectError bool
}{
{
name: "document as string with relevance_score",
input: `{"index": 0, "document": "This is a document", "relevance_score": 0.95}`,
expectedText: "This is a document",
expectedIndex: 0,
expectedScore: 0.95,
expectError: false,
},
{
name: "document as object with relevance_score",
input: `{"index": 1, "document": {"text": "This is a document"}, "relevance_score": 0.87}`,
expectedText: "This is a document",
expectedIndex: 1,
expectedScore: 0.87,
expectError: false,
},
{
name: "document as string with score field",
input: `{"index": 2, "document": "This is a document", "score": 0.92}`,
expectedText: "This is a document",
expectedIndex: 2,
expectedScore: 0.92,
expectError: false,
},
{
name: "document as object with score field",
input: `{"index": 3, "document": {"text": "This is a document"}, "score": 0.78}`,
expectedText: "This is a document",
expectedIndex: 3,
expectedScore: 0.78,
expectError: false,
},
{
name: "document as string with both score fields - relevance_score takes priority",
input: `{"index": 4, "document": "This is a document", "relevance_score": 0.95, "score": 0.80}`,
expectedText: "This is a document",
expectedIndex: 4,
expectedScore: 0.95,
expectError: false,
},
{
name: "document as object with both score fields - relevance_score takes priority",
input: `{"index": 5, "document": {"text": "This is a document"}, "relevance_score": 0.88, "score": 0.75}`,
expectedText: "This is a document",
expectedIndex: 5,
expectedScore: 0.88,
expectError: false,
},
{
name: "document as string with no score fields",
input: `{"index": 6, "document": "This is a document"}`,
expectedText: "This is a document",
expectedIndex: 6,
expectedScore: 0.0,
expectError: false,
},
{
name: "document as object with no score fields",
input: `{"index": 7, "document": {"text": "This is a document"}}`,
expectedText: "This is a document",
expectedIndex: 7,
expectedScore: 0.0,
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var result RankResult
err := json.Unmarshal([]byte(tt.input), &result)
if tt.expectError {
if err == nil {
t.Errorf("Expected error but got none")
}
return
}
if err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if result.Document.Text != tt.expectedText {
t.Errorf("Expected document text %q, got %q", tt.expectedText, result.Document.Text)
}
if result.Index != tt.expectedIndex {
t.Errorf("Expected index %d, got %d", tt.expectedIndex, result.Index)
}
if result.RelevanceScore != tt.expectedScore {
t.Errorf("Expected score %f, got %f", tt.expectedScore, result.RelevanceScore)
}
})
}
}
// TestDocumentInfoMarshalJSON tests that DocumentInfo can be marshaled back to JSON
func TestDocumentInfoMarshalJSON(t *testing.T) {
doc := DocumentInfo{Text: "Test document content"}
data, err := json.Marshal(doc)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
expected := `{"text":"Test document content"}`
if string(data) != expected {
t.Errorf("Expected %s, got %s", expected, string(data))
}
}
// TestRankResultMarshalJSON tests that RankResult can be marshaled back to JSON
func TestRankResultMarshalJSON(t *testing.T) {
result := RankResult{
Index: 1,
Document: DocumentInfo{Text: "Test document"},
RelevanceScore: 0.95,
}
data, err := json.Marshal(result)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
// Parse back to verify structure
var parsed RankResult
err = json.Unmarshal(data, &parsed)
if err != nil {
t.Fatalf("Round-trip unmarshal failed: %v", err)
}
if parsed.Index != result.Index {
t.Errorf("Index mismatch: expected %d, got %d", result.Index, parsed.Index)
}
if parsed.Document.Text != result.Document.Text {
t.Errorf("Document text mismatch: expected %q, got %q", result.Document.Text, parsed.Document.Text)
}
if parsed.RelevanceScore != result.RelevanceScore {
t.Errorf("Score mismatch: expected %f, got %f", result.RelevanceScore, parsed.RelevanceScore)
}
}
+37
View File
@@ -0,0 +1,37 @@
package rerank
import (
"fmt"
"net/http"
"time"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
func validateRerankBaseURL(baseURL string) error {
if baseURL == "" {
return nil
}
if err := secutils.ValidateURLForSSRF(baseURL); err != nil {
return fmt.Errorf("base URL SSRF check failed: %w", err)
}
return nil
}
// sharedRerankHTTPTransport keeps a single SSRF-safe connection pool for all
// rerank clients. Rerankers are recreated on every GetRerankModel call, but
// their outbound connections can be safely reused, so the transport (and its
// keep-alive pool) is built once at package load.
var sharedRerankHTTPTransport = secutils.NewSSRFSafeTransport(
secutils.DefaultSSRFSafeHTTPClientConfig(),
)
// newRerankHTTPClient returns an HTTP client with connection-level SSRF
// protection and redirect validation. All clients share
// sharedRerankHTTPTransport so keep-alive connections are pooled globally,
// while each keeps its own timeout.
func newRerankHTTPClient(timeout time.Duration) *http.Client {
cfg := secutils.DefaultSSRFSafeHTTPClientConfig()
cfg.Timeout = timeout
return secutils.NewSSRFSafeHTTPClientWithTransport(cfg, sharedRerankHTTPTransport)
}
@@ -0,0 +1,52 @@
package rerank
import (
stderrors "errors"
"net/http"
"net/http/httptest"
"testing"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
func withRerankSSRFWhitelist(t *testing.T, raw string) {
t.Helper()
t.Setenv("SSRF_WHITELIST", raw)
secutils.ResetSSRFWhitelistForTest()
t.Cleanup(secutils.ResetSSRFWhitelistForTest)
}
func TestOpenAIRerankerRejectsInternalBaseURL(t *testing.T) {
withRerankSSRFWhitelist(t, "")
_, err := NewOpenAIReranker(&RerankerConfig{
BaseURL: "http://169.254.169.254/latest/meta-data/",
ModelName: "rerank-test",
})
if err == nil {
t.Fatalf("NewOpenAIReranker returned nil error for blocked internal BaseURL")
}
}
func TestOpenAIRerankerBlocksRedirectToInternalURL(t *testing.T) {
withRerankSSRFWhitelist(t, "127.0.0.1")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound)
}))
defer server.Close()
reranker, err := NewOpenAIReranker(&RerankerConfig{
BaseURL: server.URL,
ModelName: "rerank-test",
APIKey: "sk-test",
})
if err != nil {
t.Fatalf("NewOpenAIReranker: %v", err)
}
_, err = reranker.Rerank(t.Context(), "query", []string{"doc"})
if !stderrors.Is(err, secutils.ErrSSRFRedirectBlocked) {
t.Fatalf("Rerank error = %v, want ErrSSRFRedirectBlocked", err)
}
}
@@ -0,0 +1,185 @@
package rerank
import (
"context"
"fmt"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/models/provider"
"github.com/volcengine/vikingdb-go-sdk/knowledge"
knowledgemodel "github.com/volcengine/vikingdb-go-sdk/knowledge/model"
"golang.org/x/sync/errgroup"
)
const (
VolcengineRerankBaseURL = provider.VolcengineRerankBaseURL
volcengineRerankPath = "/api/knowledge/service/rerank"
volcengineRerankDefaultModel = "doubao-seed-rerank"
volcengineRerankDefaultRegion = "cn-beijing"
volcengineRerankDefaultInstruction = "Whether the Document answers the Query or matches the content retrieval intent"
volcengineRerankMaxDocuments = 50
// volcengineRerankMaxConcurrency bounds the number of in-flight batch
// requests when the candidate set exceeds volcengineRerankMaxDocuments, so a
// very large embedding_top_k cannot fan out into an unbounded burst of calls.
volcengineRerankMaxConcurrency = 4
)
// VolcengineReranker calls the managed Knowledge Service Rerank API with AK/SK signing.
type VolcengineReranker struct {
modelName string
instruction string
modelID string
endpoint string
client *knowledge.Client
}
func NewVolcengineReranker(config *RerankerConfig) (*VolcengineReranker, error) {
accessKey := strings.TrimSpace(config.APIKey)
secretKey := strings.TrimSpace(config.AppSecret)
if secretKey == "" && config.ExtraConfig != nil {
secretKey = strings.TrimSpace(config.ExtraConfig["secret_key"])
}
if accessKey == "" || secretKey == "" {
return nil, fmt.Errorf("access key and secret key are required for Volcengine rerank")
}
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
if baseURL == "" {
baseURL = VolcengineRerankBaseURL
}
if err := validateRerankBaseURL(baseURL); err != nil {
return nil, err
}
modelName := strings.TrimSpace(config.ModelName)
if modelName == "" {
modelName = volcengineRerankDefaultModel
}
region := volcengineRerankDefaultRegion
instruction := volcengineRerankDefaultInstruction
if config.ExtraConfig != nil {
if value := strings.TrimSpace(config.ExtraConfig["region"]); value != "" {
region = value
}
if value := strings.TrimSpace(config.ExtraConfig["instruction"]); value != "" {
instruction = value
}
}
client, err := knowledge.New(
knowledge.AuthIAM(accessKey, secretKey),
knowledge.WithEndpoint(baseURL),
knowledge.WithRegion(region),
knowledge.WithTimeout(30*time.Second),
knowledge.WithHTTPClient(newRerankHTTPClient(30*time.Second)),
knowledge.WithMaxRetries(1),
)
if err != nil {
return nil, fmt.Errorf("create Volcengine rerank client: %w", err)
}
return &VolcengineReranker{
modelName: modelName,
instruction: instruction,
modelID: config.ModelID,
endpoint: baseURL,
client: client,
}, nil
}
func (r *VolcengineReranker) Rerank(
ctx context.Context, query string, documents []string,
) ([]RankResult, error) {
if len(documents) == 0 {
return []RankResult{}, nil
}
// The managed Knowledge Service Rerank API rejects requests carrying more
// than volcengineRerankMaxDocuments items. Upstream callers (chat pipeline,
// agent knowledge search, message search) feed in every retrieval candidate
// and do not cap the count per provider, so a large embedding_top_k or a
// multi-target search can exceed the limit. Each Data item is scored
// independently against the same (query, instruction) pair, so the scores
// are comparable across requests — we can split the documents into limit-
// sized batches, rerank them concurrently, and merge without losing any
// candidate (unlike truncation) or biasing the ranking.
results := make([]RankResult, len(documents))
g, gctx := errgroup.WithContext(ctx)
g.SetLimit(volcengineRerankMaxConcurrency)
for start := 0; start < len(documents); start += volcengineRerankMaxDocuments {
start := start
end := min(start+volcengineRerankMaxDocuments, len(documents))
g.Go(func() error {
scores, err := r.rerankBatch(gctx, query, documents[start:end])
if err != nil {
return err
}
for i, score := range scores {
results[start+i] = RankResult{
Index: start + i,
Document: DocumentInfo{Text: documents[start+i]},
RelevanceScore: score,
}
}
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
// rerankBatch scores a single batch of documents (already sized within the API
// limit) and returns the per-document relevance scores in input order.
func (r *VolcengineReranker) rerankBatch(
ctx context.Context, query string, documents []string,
) ([]float64, error) {
data := make([]knowledgemodel.RerankDataItem, len(documents))
for i := range documents {
data[i] = knowledgemodel.RerankDataItem{
Query: query,
Content: &documents[i],
}
}
request := knowledgemodel.RerankRequest{
Datas: data,
RerankModel: &r.modelName,
RerankInstruction: &r.instruction,
}
logger.Debugf(
ctx,
"%s",
buildRerankRequestDebug(r.modelName, r.endpoint+volcengineRerankPath, query, documents),
)
response, err := r.client.Rerank(ctx, request)
if err != nil {
return nil, fmt.Errorf("call Volcengine rerank: %w", err)
}
if response == nil || response.Data == nil {
return nil, fmt.Errorf("Volcengine rerank returned an empty response")
}
if response.Code != 0 {
return nil, fmt.Errorf("Volcengine rerank API error %d: %s", response.Code, response.Message)
}
if len(response.Data.Scores) != len(documents) {
return nil, fmt.Errorf(
"Volcengine rerank score count mismatch: got %d scores for %d documents",
len(response.Data.Scores),
len(documents),
)
}
return response.Data.Scores, nil
}
func (r *VolcengineReranker) GetModelName() string {
return r.modelName
}
func (r *VolcengineReranker) GetModelID() string {
return r.modelID
}
@@ -0,0 +1,178 @@
package rerank
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestVolcengineReranker_Rerank(t *testing.T) {
withRerankSSRFWhitelist(t, "127.0.0.1")
var request struct {
Datas []struct {
Query string `json:"query"`
Content *string `json:"content"`
} `json:"datas"`
RerankModel *string `json:"rerank_model"`
RerankInstruction *string `json:"rerank_instruction"`
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, volcengineRerankPath, r.URL.Path)
assert.Contains(t, r.Header.Get("Authorization"), "AKLT-test")
assert.NotContains(t, r.Header.Get("Authorization"), "secret-test")
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"scores":[0.91,0.27]}}`))
}))
defer server.Close()
reranker, err := NewVolcengineReranker(&RerankerConfig{
APIKey: "AKLT-test",
AppSecret: "secret-test",
BaseURL: server.URL,
ModelName: "doubao-seed-rerank",
ModelID: "volc-rerank",
})
require.NoError(t, err)
results, err := reranker.Rerank(t.Context(), "保留对话数据吗", []string{"会保留", "不会保留"})
require.NoError(t, err)
require.Len(t, results, 2)
assert.Equal(t, 0, results[0].Index)
assert.Equal(t, "会保留", results[0].Document.Text)
assert.InDelta(t, 0.91, results[0].RelevanceScore, 0.0001)
assert.Equal(t, 1, results[1].Index)
assert.Equal(t, "不会保留", results[1].Document.Text)
assert.InDelta(t, 0.27, results[1].RelevanceScore, 0.0001)
require.NotNil(t, request.RerankModel)
assert.Equal(t, "doubao-seed-rerank", *request.RerankModel)
require.Len(t, request.Datas, 2)
assert.Equal(t, "保留对话数据吗", request.Datas[0].Query)
require.NotNil(t, request.Datas[0].Content)
assert.Equal(t, "会保留", *request.Datas[0].Content)
require.NotNil(t, request.RerankInstruction)
assert.True(t, strings.Contains(*request.RerankInstruction, "Document"))
}
func TestNewVolcengineReranker_RequiresAKSK(t *testing.T) {
_, err := NewVolcengineReranker(&RerankerConfig{
APIKey: "ark-api-key-only",
BaseURL: VolcengineRerankBaseURL,
ModelName: "doubao-seed-rerank",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "access key and secret key")
}
func newTestVolcengineReranker(t *testing.T, handler http.HandlerFunc) *VolcengineReranker {
t.Helper()
withRerankSSRFWhitelist(t, "127.0.0.1")
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
reranker, err := NewVolcengineReranker(&RerankerConfig{
APIKey: "AKLT-test",
AppSecret: "secret-test",
BaseURL: server.URL,
ModelName: "doubao-seed-rerank",
})
require.NoError(t, err)
return reranker
}
func TestVolcengineReranker_EmptyDocuments(t *testing.T) {
reranker := newTestVolcengineReranker(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("rerank endpoint should not be called for empty documents")
})
results, err := reranker.Rerank(t.Context(), "query", nil)
require.NoError(t, err)
assert.Empty(t, results)
}
// TestVolcengineReranker_BatchesOverLimit verifies that a candidate set larger
// than the API document limit is split into batches and reranked in full,
// rather than truncated: every document gets a score and no batch exceeds the
// per-request limit.
func TestVolcengineReranker_BatchesOverLimit(t *testing.T) {
var (
mu sync.Mutex
batchSizes []int
requestCount int
)
reranker := newTestVolcengineReranker(t, func(w http.ResponseWriter, r *http.Request) {
var body struct {
Datas []struct {
Content *string `json:"content"`
} `json:"datas"`
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
mu.Lock()
requestCount++
batchSizes = append(batchSizes, len(body.Datas))
mu.Unlock()
scores := make([]string, len(body.Datas))
for i := range scores {
scores[i] = "0.5"
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"scores":[` +
strings.Join(scores, ",") + `]}}`))
})
total := volcengineRerankMaxDocuments*2 + 10
documents := make([]string, total)
for i := range documents {
documents[i] = fmt.Sprintf("doc-%d", i)
}
results, err := reranker.Rerank(t.Context(), "query", documents)
require.NoError(t, err)
// All documents reranked, each mapped back to its original index/text.
require.Len(t, results, total)
for i := range results {
assert.Equal(t, i, results[i].Index)
assert.Equal(t, documents[i], results[i].Document.Text)
}
// Split into ceil(total/limit) batches, none exceeding the API limit.
assert.Equal(t, 3, requestCount)
for _, size := range batchSizes {
assert.LessOrEqual(t, size, volcengineRerankMaxDocuments)
}
}
func TestVolcengineReranker_APIErrorCode(t *testing.T) {
reranker := newTestVolcengineReranker(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":100004,"message":"quota exceeded","data":{}}`))
})
_, err := reranker.Rerank(t.Context(), "query", []string{"a", "b"})
require.Error(t, err)
assert.Contains(t, err.Error(), "100004")
assert.Contains(t, err.Error(), "quota exceeded")
}
func TestVolcengineReranker_ScoreCountMismatch(t *testing.T) {
reranker := newTestVolcengineReranker(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"scores":[0.9]}}`))
})
_, err := reranker.Rerank(t.Context(), "query", []string{"a", "b"})
require.Error(t, err)
assert.Contains(t, err.Error(), "score count mismatch")
}
+134
View File
@@ -0,0 +1,134 @@
package rerank
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/models/utils"
"github.com/google/uuid"
)
const weKnoraCloudRerankPath = "/api/v1/rerank"
// WeKnoraCloudReranker 实现 rerank.Reranker 接口,对接 WeKnoraCloud /api/v1/rerank
type WeKnoraCloudReranker struct {
modelName string
remoteModelName string
modelID string
appID string
apiKey string
baseURL string
client *http.Client
}
// NewWeKnoraCloudReranker 构造 WeKnoraCloudReranker
func NewWeKnoraCloudReranker(config *RerankerConfig) (*WeKnoraCloudReranker, error) {
if config.AppID == "" {
return nil, fmt.Errorf("WeKnoraCloud reranker: AppID is required")
}
if config.AppSecret == "" {
return nil, fmt.Errorf("WeKnoraCloud reranker: AppSecret is required")
}
baseURL := strings.TrimRight(config.BaseURL, "/")
if err := validateRerankBaseURL(baseURL); err != nil {
return nil, err
}
remoteModelName := ""
if config.ExtraConfig != nil {
remoteModelName = strings.TrimSpace(config.ExtraConfig["remote_model_name"])
}
return &WeKnoraCloudReranker{
modelName: config.ModelName,
remoteModelName: remoteModelName,
modelID: config.ModelID,
appID: config.AppID,
apiKey: config.AppSecret,
baseURL: baseURL,
client: newRerankHTTPClient(60 * time.Second),
}, nil
}
type weKnoraCloudRerankRequest struct {
Model string `json:"model"`
Query string `json:"query"`
Documents []string `json:"documents"`
}
type weKnoraCloudRerankResponse struct {
Results []struct {
Index int `json:"index"`
RelevanceScore float64 `json:"relevance_score"`
Document struct {
Text string `json:"text"`
} `json:"document"`
} `json:"results"`
}
func (r *WeKnoraCloudReranker) Rerank(ctx context.Context, query string, documents []string) ([]RankResult, error) {
reqBody := weKnoraCloudRerankRequest{
Model: r.effectiveModelName(),
Query: query,
Documents: documents,
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("weknoracloud reranker: marshal: %w", err)
}
requestID := uuid.New().String()
headers := utils.Sign(r.appID, r.apiKey, requestID, string(bodyBytes))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.baseURL+weKnoraCloudRerankPath, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("weknoracloud reranker: create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := r.client.Do(req)
if err != nil {
return nil, fmt.Errorf("weknoracloud reranker: do request: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("weknoracloud reranker: read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("weknoracloud reranker: status %d: %s", resp.StatusCode, string(respBytes))
}
var rerankResp weKnoraCloudRerankResponse
if err := json.Unmarshal(respBytes, &rerankResp); err != nil {
return nil, fmt.Errorf("weknoracloud reranker: unmarshal: %w", err)
}
results := make([]RankResult, 0, len(rerankResp.Results))
for _, item := range rerankResp.Results {
results = append(results, RankResult{
Index: item.Index,
RelevanceScore: item.RelevanceScore,
Document: DocumentInfo{Text: item.Document.Text},
})
}
return results, nil
}
func (r *WeKnoraCloudReranker) effectiveModelName() string {
if r.remoteModelName != "" {
return r.remoteModelName
}
return r.modelName
}
func (r *WeKnoraCloudReranker) GetModelName() string { return r.modelName }
func (r *WeKnoraCloudReranker) GetModelID() string { return r.modelID }
+153
View File
@@ -0,0 +1,153 @@
package rerank
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/Tencent/WeKnora/internal/logger"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
// ZhipuReranker implements a reranking system based on Zhipu AI models
type ZhipuReranker struct {
modelName string // Name of the model used for reranking
modelID string // Unique identifier of the model
apiKey string // API key for authentication
baseURL string // Base URL for API requests
client *http.Client // HTTP client for making API requests
customHeaders map[string]string
}
// SetCustomHeaders 设置用户自定义 HTTP 请求头(类似 OpenAI Python SDK 的 extra_headers)。
func (r *ZhipuReranker) SetCustomHeaders(headers map[string]string) {
r.customHeaders = headers
}
// ZhipuRerankRequest represents a request to rerank documents using Zhipu AI API
type ZhipuRerankRequest struct {
Model string `json:"model"` // Model to use for reranking
Query string `json:"query"` // Query text to compare documents against
Documents []string `json:"documents"` // List of document texts to rerank
TopN int `json:"top_n,omitempty"` // Number of top results to return (0 = all)
ReturnDocuments bool `json:"return_documents,omitempty"` // Whether to return documents in response
ReturnRawScores bool `json:"return_raw_scores,omitempty"` // Whether to return raw scores
}
// ZhipuRerankResponse represents the response from Zhipu AI reranking request
type ZhipuRerankResponse struct {
RequestID string `json:"request_id"` // Request ID from client or platform
ID string `json:"id"` // Task order ID from Zhipu platform
Results []ZhipuRankResult `json:"results"` // Ranked results with relevance scores
Usage ZhipuUsage `json:"usage"` // Token usage information
}
// ZhipuRankResult represents a single reranking result from Zhipu AI
type ZhipuRankResult struct {
Index int `json:"index"` // Original index of the document
RelevanceScore float64 `json:"relevance_score"` // Relevance score
Document string `json:"document,omitempty"` // Document text (optional)
}
// ZhipuUsage contains information about token usage in the Zhipu API request
type ZhipuUsage struct {
TotalTokens int `json:"total_tokens"` // Total tokens consumed
PromptTokens int `json:"prompt_tokens"` // Prompt tokens
}
// NewZhipuReranker creates a new instance of Zhipu reranker with the provided configuration
func NewZhipuReranker(config *RerankerConfig) (*ZhipuReranker, error) {
apiKey := config.APIKey
baseURL := "https://open.bigmodel.cn/api/paas/v4/rerank"
if url := config.BaseURL; url != "" {
baseURL = url
}
if err := validateRerankBaseURL(baseURL); err != nil {
return nil, err
}
return &ZhipuReranker{
modelName: config.ModelName,
modelID: config.ModelID,
apiKey: apiKey,
baseURL: baseURL,
client: newRerankHTTPClient(0),
}, nil
}
// Rerank performs document reranking based on relevance to the query using Zhipu AI API
func (r *ZhipuReranker) Rerank(ctx context.Context, query string, documents []string) ([]RankResult, error) {
// Build the request body
requestBody := &ZhipuRerankRequest{
Model: r.modelName,
Query: query,
Documents: documents,
TopN: 0, // Return all documents
ReturnDocuments: true,
ReturnRawScores: false,
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("marshal request body: %w", err)
}
// Send the request
req, err := http.NewRequestWithContext(ctx, "POST", r.baseURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", r.apiKey))
secutils.ApplyCustomHeaders(req, r.customHeaders)
logger.Debugf(ctx, "%s", buildRerankRequestDebug(r.modelName, r.baseURL, query, documents))
resp, err := r.client.Do(req)
if err != nil {
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
// Read the response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("zhipu rerank API error: Http Status: %s, Body: %s", resp.Status, string(body))
}
var response ZhipuRerankResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
// Convert Zhipu results to standard RankResult format
results := make([]RankResult, len(response.Results))
for i, zhipuResult := range response.Results {
results[i] = RankResult{
Index: zhipuResult.Index,
Document: DocumentInfo{
Text: zhipuResult.Document,
},
RelevanceScore: zhipuResult.RelevanceScore,
}
}
return results, nil
}
// GetModelName returns the name of the reranking model
func (r *ZhipuReranker) GetModelName() string {
return r.modelName
}
// GetModelID returns the unique identifier of the reranking model
func (r *ZhipuReranker) GetModelID() string {
return r.modelID
}