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