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,281 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/models/requestbudget"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
// AliyunMultimodalEmbeddingEndpoint 阿里云 DashScope 多模态 Embedding API 端点
|
||||
AliyunMultimodalEmbeddingEndpoint = "/api/v1/services/embeddings/multimodal-embedding/multimodal-embedding"
|
||||
)
|
||||
|
||||
// AliyunEmbedder implements text vectorization using Aliyun DashScope multimodal embedding API
|
||||
type AliyunEmbedder struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
modelName string
|
||||
truncatePromptTokens int
|
||||
dimensions int
|
||||
modelID string
|
||||
httpClient *http.Client
|
||||
timeout time.Duration
|
||||
maxRetries int
|
||||
customHeaders map[string]string
|
||||
supportsDimensionOverride bool
|
||||
EmbedderPooler
|
||||
}
|
||||
|
||||
// SetCustomHeaders 设置用户自定义 HTTP 请求头(类似 OpenAI Python SDK 的 extra_headers)。
|
||||
func (e *AliyunEmbedder) SetCustomHeaders(headers map[string]string) {
|
||||
e.customHeaders = headers
|
||||
}
|
||||
|
||||
func (e *AliyunEmbedder) SetSupportsDimensionOverride(supported bool) {
|
||||
e.supportsDimensionOverride = supported
|
||||
}
|
||||
|
||||
// AliyunEmbedParameters represents the parameters for Aliyun DashScope multimodal embedding
|
||||
type AliyunEmbedParameters struct {
|
||||
Dimension int `json:"dimension,omitempty"`
|
||||
}
|
||||
|
||||
// AliyunEmbedRequest represents an Aliyun DashScope multimodal embedding request
|
||||
type AliyunEmbedRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input AliyunEmbedInput `json:"input"`
|
||||
Parameters *AliyunEmbedParameters `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
// AliyunEmbedInput represents the input structure for Aliyun embedding
|
||||
type AliyunEmbedInput struct {
|
||||
Contents []AliyunContent `json:"contents"`
|
||||
}
|
||||
|
||||
// AliyunContent represents a single content item in the input
|
||||
type AliyunContent struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// AliyunEmbedResponse represents an Aliyun DashScope embedding response
|
||||
type AliyunEmbedResponse struct {
|
||||
Output struct {
|
||||
Embeddings []struct {
|
||||
Embedding []float32 `json:"embedding"`
|
||||
TextIndex int `json:"text_index"`
|
||||
} `json:"embeddings"`
|
||||
} `json:"output"`
|
||||
Usage struct {
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
|
||||
// AliyunErrorResponse represents an error response from Aliyun DashScope
|
||||
type AliyunErrorResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
|
||||
// NewAliyunEmbedder creates a new Aliyun DashScope embedder
|
||||
func NewAliyunEmbedder(apiKey, baseURL, modelName string,
|
||||
truncatePromptTokens int, dimensions int, modelID string, pooler EmbedderPooler,
|
||||
) (*AliyunEmbedder, error) {
|
||||
if baseURL == "" {
|
||||
baseURL = "https://dashscope.aliyuncs.com"
|
||||
}
|
||||
|
||||
// Remove trailing slash and any existing path suffix
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
// If baseURL contains /compatible-mode/v1, strip it for multimodal API
|
||||
if strings.Contains(baseURL, "/compatible-mode/v1") {
|
||||
baseURL = strings.Replace(baseURL, "/compatible-mode/v1", "", 1)
|
||||
}
|
||||
|
||||
if modelName == "" {
|
||||
return nil, fmt.Errorf("model name is required")
|
||||
}
|
||||
|
||||
if truncatePromptTokens == 0 {
|
||||
truncatePromptTokens = 511
|
||||
}
|
||||
|
||||
timeout := 60 * time.Second
|
||||
|
||||
if err := validateEmbeddingBaseURL(baseURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AliyunEmbedder{
|
||||
apiKey: apiKey,
|
||||
baseURL: baseURL,
|
||||
modelName: modelName,
|
||||
httpClient: newEmbeddingHTTPClient(timeout),
|
||||
truncatePromptTokens: truncatePromptTokens,
|
||||
EmbedderPooler: pooler,
|
||||
dimensions: dimensions,
|
||||
modelID: modelID,
|
||||
timeout: timeout,
|
||||
maxRetries: 3,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Embed converts text to vector
|
||||
func (e *AliyunEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
for range 3 {
|
||||
embeddings, err := e.BatchEmbed(ctx, []string{text})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(embeddings) > 0 {
|
||||
return embeddings[0], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no embedding returned")
|
||||
}
|
||||
|
||||
func (e *AliyunEmbedder) doRequestWithRetry(ctx context.Context, jsonData []byte) (*http.Response, error) {
|
||||
ctx = requestbudget.WithModel(ctx, e.modelID)
|
||||
var resp *http.Response
|
||||
var err error
|
||||
url := e.baseURL + AliyunMultimodalEmbeddingEndpoint
|
||||
|
||||
for i := 0; i <= e.maxRetries; i++ {
|
||||
if err := requestbudget.Check(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if i > 0 {
|
||||
backoffTime := time.Duration(1<<uint(i-1)) * time.Second
|
||||
if backoffTime > 10*time.Second {
|
||||
backoffTime = 10 * time.Second
|
||||
}
|
||||
logger.GetLogger(ctx).
|
||||
Infof("AliyunEmbedder retrying request (%d/%d), waiting %v", i, e.maxRetries, backoffTime)
|
||||
|
||||
select {
|
||||
case <-time.After(backoffTime):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("AliyunEmbedder failed to create request: %v", err)
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+e.apiKey)
|
||||
secutils.ApplyCustomHeaders(req, e.customHeaders)
|
||||
|
||||
resp, err = e.httpClient.Do(req)
|
||||
if stopProviderRetry(ctx, err) {
|
||||
return nil, err
|
||||
}
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
logger.GetLogger(ctx).Errorf("AliyunEmbedder request failed (attempt %d/%d): %v", i+1, e.maxRetries+1, err)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (e *AliyunEmbedder) BatchEmbed(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
// Build contents array from texts
|
||||
contents := make([]AliyunContent, 0, len(texts))
|
||||
for _, text := range texts {
|
||||
contents = append(contents, AliyunContent{Text: text})
|
||||
}
|
||||
|
||||
// Create request body
|
||||
reqBody := AliyunEmbedRequest{
|
||||
Model: e.modelName,
|
||||
Input: AliyunEmbedInput{
|
||||
Contents: contents,
|
||||
},
|
||||
}
|
||||
if e.supportsDimensionsParam() {
|
||||
reqBody.Parameters = &AliyunEmbedParameters{Dimension: e.dimensions}
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("AliyunEmbedder BatchEmbed marshal request error: %v", err)
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := e.doRequestWithRetry(ctx, jsonData)
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("AliyunEmbedder BatchEmbed send request error: %v", err)
|
||||
return nil, fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("AliyunEmbedder BatchEmbed read response error: %v", err)
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Try to parse error response
|
||||
var errResp AliyunErrorResponse
|
||||
if json.Unmarshal(body, &errResp) == nil && errResp.Message != "" {
|
||||
logger.GetLogger(ctx).Errorf("AliyunEmbedder BatchEmbed API error: %s - %s", errResp.Code, errResp.Message)
|
||||
return nil, fmt.Errorf("API error: %s - %s", errResp.Code, errResp.Message)
|
||||
}
|
||||
logger.GetLogger(ctx).Errorf("AliyunEmbedder BatchEmbed API error: Http Status %s", resp.Status)
|
||||
return nil, fmt.Errorf("BatchEmbed API error: Http Status %s", resp.Status)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var response AliyunEmbedResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
logger.GetLogger(ctx).Errorf("AliyunEmbedder BatchEmbed unmarshal response error: %v", err)
|
||||
return nil, fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
// Extract embedding vectors, preserving order by text_index
|
||||
embeddings := make([][]float32, len(texts))
|
||||
for _, emb := range response.Output.Embeddings {
|
||||
if emb.TextIndex >= 0 && emb.TextIndex < len(embeddings) {
|
||||
embeddings[emb.TextIndex] = emb.Embedding
|
||||
}
|
||||
}
|
||||
|
||||
return embeddings, nil
|
||||
}
|
||||
|
||||
// GetModelName returns the model name
|
||||
func (e *AliyunEmbedder) GetModelName() string {
|
||||
return e.modelName
|
||||
}
|
||||
|
||||
func (e *AliyunEmbedder) supportsDimensionsParam() bool {
|
||||
return e.supportsDimensionOverride && e.dimensions > 0
|
||||
}
|
||||
|
||||
// GetDimensions returns the vector dimensions
|
||||
func (e *AliyunEmbedder) GetDimensions() int {
|
||||
return e.dimensions
|
||||
}
|
||||
|
||||
// GetModelID returns the model ID
|
||||
func (e *AliyunEmbedder) GetModelID() string {
|
||||
return e.modelID
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/models/requestbudget"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
// AzureOpenAIEmbedder implements text vectorization using Azure OpenAI API
|
||||
type AzureOpenAIEmbedder struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
modelName string
|
||||
truncatePromptTokens int
|
||||
dimensions int
|
||||
modelID string
|
||||
apiVersion string
|
||||
httpClient *http.Client
|
||||
maxRetries int
|
||||
customHeaders map[string]string
|
||||
supportsDimensionOverride bool
|
||||
EmbedderPooler
|
||||
}
|
||||
|
||||
// SetCustomHeaders 设置用户自定义 HTTP 请求头(类似 OpenAI Python SDK 的 extra_headers)。
|
||||
func (e *AzureOpenAIEmbedder) SetCustomHeaders(headers map[string]string) {
|
||||
e.customHeaders = headers
|
||||
}
|
||||
|
||||
func (e *AzureOpenAIEmbedder) SetSupportsDimensionOverride(supported bool) {
|
||||
e.supportsDimensionOverride = supported
|
||||
}
|
||||
|
||||
type azureOpenAIEmbedRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input []string `json:"input"`
|
||||
EncodingFormat string `json:"encoding_format,omitempty"`
|
||||
Dimensions int `json:"dimensions,omitempty"`
|
||||
}
|
||||
|
||||
// NewAzureOpenAIEmbedder creates a new Azure OpenAI embedder
|
||||
func NewAzureOpenAIEmbedder(apiKey, baseURL, modelName string,
|
||||
truncatePromptTokens int, dimensions int, modelID string,
|
||||
apiVersion string, pooler EmbedderPooler,
|
||||
) (*AzureOpenAIEmbedder, error) {
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("Azure resource endpoint (base URL) is required")
|
||||
}
|
||||
if modelName == "" {
|
||||
return nil, fmt.Errorf("deployment name (model name) is required")
|
||||
}
|
||||
if apiVersion == "" {
|
||||
apiVersion = "2024-10-21"
|
||||
}
|
||||
if truncatePromptTokens == 0 {
|
||||
truncatePromptTokens = 511
|
||||
}
|
||||
|
||||
if err := validateEmbeddingBaseURL(baseURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AzureOpenAIEmbedder{
|
||||
apiKey: apiKey,
|
||||
baseURL: baseURL,
|
||||
modelName: modelName,
|
||||
truncatePromptTokens: truncatePromptTokens,
|
||||
dimensions: dimensions,
|
||||
modelID: modelID,
|
||||
apiVersion: apiVersion,
|
||||
httpClient: newEmbeddingHTTPClient(60 * time.Second),
|
||||
maxRetries: 3,
|
||||
EmbedderPooler: pooler,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *AzureOpenAIEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
for range 3 {
|
||||
embeddings, err := e.BatchEmbed(ctx, []string{text})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(embeddings) > 0 {
|
||||
return embeddings[0], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no embedding returned")
|
||||
}
|
||||
|
||||
func (e *AzureOpenAIEmbedder) BatchEmbed(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
reqBody := azureOpenAIEmbedRequest{
|
||||
Model: e.modelName,
|
||||
Input: texts,
|
||||
EncodingFormat: "float",
|
||||
}
|
||||
if e.supportsDimensionsParam() {
|
||||
reqBody.Dimensions = e.dimensions
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
logger.GetLogger(ctx).Debugf("AzureOpenAIEmbedder BatchEmbed: model=%s, input_count=%d",
|
||||
e.modelName, len(texts))
|
||||
|
||||
resp, err := e.doRequestWithRetry(ctx, jsonData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 1000 {
|
||||
bodyStr = bodyStr[:1000] + "... (truncated)"
|
||||
}
|
||||
return nil, fmt.Errorf("Azure Embedding API error: Http Status %s, Response: %s", resp.Status, bodyStr)
|
||||
}
|
||||
|
||||
var response OpenAIEmbedResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
embeddings := make([][]float32, 0, len(response.Data))
|
||||
for _, data := range response.Data {
|
||||
embeddings = append(embeddings, data.Embedding)
|
||||
}
|
||||
return embeddings, nil
|
||||
}
|
||||
|
||||
func (e *AzureOpenAIEmbedder) doRequestWithRetry(ctx context.Context, jsonData []byte) (*http.Response, error) {
|
||||
ctx = requestbudget.WithModel(ctx, e.modelID)
|
||||
url := fmt.Sprintf("%s/openai/deployments/%s/embeddings?api-version=%s",
|
||||
e.baseURL, e.modelName, e.apiVersion)
|
||||
|
||||
var resp *http.Response
|
||||
var err error
|
||||
|
||||
for i := 0; i <= e.maxRetries; i++ {
|
||||
if err := requestbudget.Check(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if i > 0 {
|
||||
backoffTime := time.Duration(1<<uint(i-1)) * time.Second
|
||||
if backoffTime > 10*time.Second {
|
||||
backoffTime = 10 * time.Second
|
||||
}
|
||||
select {
|
||||
case <-time.After(backoffTime):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
req, reqErr := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData))
|
||||
if reqErr != nil {
|
||||
err = reqErr
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("api-key", e.apiKey)
|
||||
secutils.ApplyCustomHeaders(req, e.customHeaders)
|
||||
|
||||
resp, err = e.httpClient.Do(req)
|
||||
if stopProviderRetry(ctx, err) {
|
||||
return nil, err
|
||||
}
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (e *AzureOpenAIEmbedder) supportsDimensionsParam() bool {
|
||||
return e.supportsDimensionOverride && e.dimensions > 0
|
||||
}
|
||||
|
||||
func (e *AzureOpenAIEmbedder) GetModelName() string { return e.modelName }
|
||||
func (e *AzureOpenAIEmbedder) GetDimensions() int { return e.dimensions }
|
||||
func (e *AzureOpenAIEmbedder) GetModelID() string { return e.modelID }
|
||||
@@ -0,0 +1,156 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAzureOpenAIEmbedderBatchEmbedSendsConfiguredDimensions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var requestBody map[string]any
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST request, got %s", r.Method)
|
||||
}
|
||||
|
||||
if got, want := r.URL.String(),
|
||||
"https://example-resource.openai.azure.com/openai/deployments/text-embedding-3-large-deployment/embeddings?api-version=2024-10-21"; got != want {
|
||||
t.Fatalf("unexpected request path: got %s want %s", got, want)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[{"embedding":[0.1,0.2],"index":0}]}`)),
|
||||
}, nil
|
||||
})
|
||||
|
||||
embedder, err := NewAzureOpenAIEmbedder(
|
||||
"test-key",
|
||||
"https://example-resource.openai.azure.com",
|
||||
"text-embedding-3-large-deployment",
|
||||
511,
|
||||
256,
|
||||
"text-embedding-3-large",
|
||||
"2024-10-21",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create embedder: %v", err)
|
||||
}
|
||||
embedder.SetSupportsDimensionOverride(true)
|
||||
embedder.httpClient = &http.Client{Transport: transport}
|
||||
|
||||
if _, err := embedder.BatchEmbed(context.Background(), []string{"hello"}); err != nil {
|
||||
t.Fatalf("BatchEmbed returned error: %v", err)
|
||||
}
|
||||
|
||||
got, ok := requestBody["dimensions"]
|
||||
if !ok {
|
||||
t.Fatalf("expected request body to include dimensions, got %v", requestBody)
|
||||
}
|
||||
|
||||
if got != float64(256) {
|
||||
t.Fatalf("unexpected dimensions value: got %v want 256", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureOpenAIEmbedderBatchEmbedOmitsDimensionsByDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var requestBody map[string]any
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[{"embedding":[0.1,0.2],"index":0}]}`)),
|
||||
}, nil
|
||||
})
|
||||
|
||||
embedder, err := NewAzureOpenAIEmbedder(
|
||||
"test-key",
|
||||
"https://example-resource.openai.azure.com",
|
||||
"ada-002-deployment",
|
||||
511,
|
||||
1536,
|
||||
"text-embedding-ada-002",
|
||||
"2024-10-21",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create embedder: %v", err)
|
||||
}
|
||||
embedder.httpClient = &http.Client{Transport: transport}
|
||||
|
||||
if _, err := embedder.BatchEmbed(context.Background(), []string{"hello"}); err != nil {
|
||||
t.Fatalf("BatchEmbed returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := requestBody["dimensions"]; ok {
|
||||
t.Fatalf("expected request body to omit dimensions for fixed-size model, got %v", requestBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureOpenAIEmbedderBatchEmbedSendsDimensionsWhenOverrideEnabledRegardlessOfAPIVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var requestBody map[string]any
|
||||
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[{"embedding":[0.1,0.2],"index":0}]}`)),
|
||||
}, nil
|
||||
})
|
||||
|
||||
embedder, err := NewAzureOpenAIEmbedder(
|
||||
"test-key",
|
||||
"https://example-resource.openai.azure.com",
|
||||
"text-embedding-3-large-deployment",
|
||||
511,
|
||||
256,
|
||||
"text-embedding-3-large",
|
||||
"2024-02-15-preview",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create embedder: %v", err)
|
||||
}
|
||||
embedder.SetSupportsDimensionOverride(true)
|
||||
embedder.httpClient = &http.Client{Transport: transport}
|
||||
|
||||
if _, err := embedder.BatchEmbed(context.Background(), []string{"hello"}); err != nil {
|
||||
t.Fatalf("BatchEmbed returned error: %v", err)
|
||||
}
|
||||
|
||||
got, ok := requestBody["dimensions"]
|
||||
if !ok {
|
||||
t.Fatalf("expected request body to include dimensions when override is enabled, got %v", requestBody)
|
||||
}
|
||||
if got != float64(256) {
|
||||
t.Fatalf("unexpected dimensions value: got %v want 256", got)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return f(r)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/models/utils"
|
||||
"github.com/panjf2000/ants/v2"
|
||||
)
|
||||
|
||||
type batchEmbedder struct {
|
||||
pool *ants.Pool
|
||||
}
|
||||
|
||||
func NewBatchEmbedder(pool *ants.Pool) EmbedderPooler {
|
||||
return &batchEmbedder{pool: pool}
|
||||
}
|
||||
|
||||
type textEmbedding struct {
|
||||
text string
|
||||
results []float32
|
||||
}
|
||||
|
||||
func (e *batchEmbedder) BatchEmbedWithPool(ctx context.Context, model Embedder, texts []string) ([][]float32, error) {
|
||||
// Create goroutine pool for concurrent processing of document chunks
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex // For synchronizing access to error
|
||||
var firstErr error // Record the first error that occurs
|
||||
batchSizeStr := os.Getenv("BATCH_EMBED_SIZE")
|
||||
if batchSizeStr == "" {
|
||||
batchSizeStr = "5"
|
||||
}
|
||||
batchSize, err := strconv.Atoi(batchSizeStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if batchSize <= 0 {
|
||||
return nil, fmt.Errorf("embedding batch size must be positive")
|
||||
}
|
||||
textEmbeddings := utils.MapSlice(texts, func(text string) *textEmbedding {
|
||||
return &textEmbedding{text: text}
|
||||
})
|
||||
|
||||
// Function to process each document chunk
|
||||
processChunk := func(texts []*textEmbedding) func() {
|
||||
return func() {
|
||||
defer wg.Done()
|
||||
// If an error has already occurred, don't continue processing
|
||||
mu.Lock()
|
||||
failed := firstErr != nil
|
||||
mu.Unlock()
|
||||
if failed {
|
||||
return
|
||||
}
|
||||
// Embed text
|
||||
embedding, err := model.BatchEmbed(ctx, utils.MapSlice(texts, func(text *textEmbedding) string {
|
||||
return text.text
|
||||
}))
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
if len(embedding) != len(texts) {
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("embedding model returned %d embeddings for %d inputs", len(embedding), len(texts))
|
||||
}
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
for i, text := range texts {
|
||||
if text == nil {
|
||||
continue
|
||||
}
|
||||
text.results = embedding[i]
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Submit all tasks to the goroutine pool
|
||||
for _, texts := range utils.ChunkSlice(textEmbeddings, batchSize) {
|
||||
wg.Add(1)
|
||||
err := e.pool.Submit(processChunk(texts))
|
||||
if err != nil {
|
||||
wg.Done()
|
||||
wg.Wait()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all tasks to complete
|
||||
wg.Wait()
|
||||
|
||||
// Check if any errors occurred
|
||||
if firstErr != nil {
|
||||
return nil, firstErr
|
||||
}
|
||||
|
||||
results := utils.MapSlice(textEmbeddings, func(text *textEmbedding) []float32 {
|
||||
return text.results
|
||||
})
|
||||
return results, nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/models/limiter"
|
||||
)
|
||||
|
||||
// Embedding is the highest-volume background model call: document ingestion
|
||||
// vectorises every chunk, so a single batch upload can burst the whole worker
|
||||
// pool against one embedding provider. Like chat and vlm, embedding is governed
|
||||
// at the client layer via the shared per-model concurrency governor. Only
|
||||
// background (asynq worker) calls are throttled — see limiter.Gate /
|
||||
// types.IsBackgroundTask; interactive query embedding is never gated.
|
||||
//
|
||||
// Placement note: unlike chat/vlm (outermost), this wrapper sits INNERMOST —
|
||||
// directly around the real embedder, BELOW the debug/langfuse decorators.
|
||||
// BatchEmbedWithPool fans a batch out into per-sub-batch BatchEmbed calls
|
||||
// through the pooler, and the pooler invokes BatchEmbed on whichever Embedder
|
||||
// was threaded down as `model`. Sitting innermost is what routes those
|
||||
// per-sub-batch provider round-trips back through Gate, so the semaphore bounds
|
||||
// real concurrent provider calls rather than one coarse per-document unit. The
|
||||
// trade-off is that Gate wait time is included in debug/langfuse timing, which
|
||||
// is acceptable for background ingestion.
|
||||
type concurrencyEmbedder struct {
|
||||
inner Embedder
|
||||
// limit is this model's configured per-model background cap; 0 falls back
|
||||
// to the process-wide default (see limiter.GateN).
|
||||
limit int
|
||||
}
|
||||
|
||||
func (w *concurrencyEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
release := limiter.GateNamedN(ctx, w.inner.GetModelID(), w.inner.GetModelName(), w.limit)
|
||||
defer release()
|
||||
return w.inner.Embed(ctx, text)
|
||||
}
|
||||
|
||||
func (w *concurrencyEmbedder) BatchEmbed(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
release := limiter.GateNamedN(ctx, w.inner.GetModelID(), w.inner.GetModelName(), w.limit)
|
||||
defer release()
|
||||
return w.inner.BatchEmbed(ctx, texts)
|
||||
}
|
||||
|
||||
// BatchEmbedWithPool threads THIS wrapper down as the model so the pooler's
|
||||
// per-sub-batch callbacks land on our gated BatchEmbed above, rather than on
|
||||
// the raw embedder. The wait for each slot is held only around the actual
|
||||
// per-sub-batch provider round-trip.
|
||||
func (w *concurrencyEmbedder) BatchEmbedWithPool(
|
||||
ctx context.Context, model Embedder, texts []string,
|
||||
) ([][]float32, error) {
|
||||
return w.inner.BatchEmbedWithPool(ctx, w, texts)
|
||||
}
|
||||
|
||||
func (w *concurrencyEmbedder) GetModelName() string { return w.inner.GetModelName() }
|
||||
func (w *concurrencyEmbedder) GetDimensions() int { return w.inner.GetDimensions() }
|
||||
func (w *concurrencyEmbedder) GetModelID() string { return w.inner.GetModelID() }
|
||||
|
||||
// wrapEmbeddingConcurrency installs the background concurrency governor directly
|
||||
// around the real embedder. Always applied; a cheap passthrough when no limiter
|
||||
// is installed or the call is interactive.
|
||||
func wrapEmbeddingConcurrency(e Embedder, limit int) Embedder {
|
||||
if e == nil {
|
||||
return e
|
||||
}
|
||||
return &concurrencyEmbedder{inner: e, limit: limit}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/models/limiter"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/panjf2000/ants/v2"
|
||||
)
|
||||
|
||||
// fakeEmbedder blocks every Embed/BatchEmbed call until release is closed and
|
||||
// records the max number of calls it ever saw in flight simultaneously, so
|
||||
// tests can assert the governor's per-model concurrency bound.
|
||||
type fakeEmbedder struct {
|
||||
id string
|
||||
pooler EmbedderPooler // optional: exercises the BatchEmbedWithPool fan-out
|
||||
inFlight int32
|
||||
maxSeen int32
|
||||
enter chan struct{} // one signal per call that reaches the provider
|
||||
release chan struct{} // closed to unblock all (current + future) calls
|
||||
}
|
||||
|
||||
func newFakeEmbedder(id string) *fakeEmbedder {
|
||||
return &fakeEmbedder{
|
||||
id: id,
|
||||
enter: make(chan struct{}, 256),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeEmbedder) track() {
|
||||
n := atomic.AddInt32(&f.inFlight, 1)
|
||||
for {
|
||||
old := atomic.LoadInt32(&f.maxSeen)
|
||||
if n <= old || atomic.CompareAndSwapInt32(&f.maxSeen, old, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
f.enter <- struct{}{}
|
||||
<-f.release
|
||||
atomic.AddInt32(&f.inFlight, -1)
|
||||
}
|
||||
|
||||
func (f *fakeEmbedder) Embed(ctx context.Context, _ string) ([]float32, error) {
|
||||
f.track()
|
||||
return []float32{1}, nil
|
||||
}
|
||||
|
||||
func (f *fakeEmbedder) BatchEmbed(ctx context.Context, _ []string) ([][]float32, error) {
|
||||
f.track()
|
||||
return [][]float32{{1}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeEmbedder) BatchEmbedWithPool(
|
||||
ctx context.Context, model Embedder, texts []string,
|
||||
) ([][]float32, error) {
|
||||
if f.pooler != nil {
|
||||
return f.pooler.BatchEmbedWithPool(ctx, model, texts)
|
||||
}
|
||||
return model.BatchEmbed(ctx, texts)
|
||||
}
|
||||
|
||||
func (f *fakeEmbedder) GetModelName() string { return f.id }
|
||||
func (f *fakeEmbedder) GetDimensions() int { return 1 }
|
||||
func (f *fakeEmbedder) GetModelID() string { return f.id }
|
||||
|
||||
// TestConcurrencyEmbedderBackgroundGated verifies background BatchEmbed calls
|
||||
// are capped at the per-model limit.
|
||||
func TestConcurrencyEmbedderBackgroundGated(t *testing.T) {
|
||||
t.Cleanup(func() { limiter.SetGovernor(nil, 0) })
|
||||
limiter.SetGovernor(limiter.NewLocalLimiter(), 2)
|
||||
|
||||
f := newFakeEmbedder("emb-bg")
|
||||
w := wrapEmbeddingConcurrency(f, 0)
|
||||
|
||||
ctx := types.WithBackgroundTask(context.Background())
|
||||
const n = 5
|
||||
var wg sync.WaitGroup
|
||||
for range n {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = w.BatchEmbed(ctx, []string{"x"})
|
||||
}()
|
||||
}
|
||||
|
||||
// With limit=2 exactly two calls may reach the provider concurrently.
|
||||
for i := range 2 {
|
||||
select {
|
||||
case <-f.enter:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("expected call %d to enter, inFlight=%d", i, atomic.LoadInt32(&f.inFlight))
|
||||
}
|
||||
}
|
||||
// A third must not sneak in while both slots are held.
|
||||
select {
|
||||
case <-f.enter:
|
||||
t.Fatal("a third call entered while limit=2 slots were held")
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(f.release)
|
||||
wg.Wait()
|
||||
if got := atomic.LoadInt32(&f.maxSeen); got > 2 {
|
||||
t.Fatalf("max in-flight %d exceeded limit 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrencyEmbedderPerModelLimitOverridesDefault verifies a model's own
|
||||
// configured limit takes precedence over the process-wide default.
|
||||
func TestConcurrencyEmbedderPerModelLimitOverridesDefault(t *testing.T) {
|
||||
t.Cleanup(func() { limiter.SetGovernor(nil, 0) })
|
||||
// Global default is generous (10), but this model is pinned to 1.
|
||||
limiter.SetGovernor(limiter.NewLocalLimiter(), 10)
|
||||
|
||||
f := newFakeEmbedder("emb-permodel")
|
||||
w := wrapEmbeddingConcurrency(f, 1)
|
||||
|
||||
ctx := types.WithBackgroundTask(context.Background())
|
||||
const n = 3
|
||||
var wg sync.WaitGroup
|
||||
for range n {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = w.BatchEmbed(ctx, []string{"x"})
|
||||
}()
|
||||
}
|
||||
|
||||
// Only one may be in flight because the per-model limit is 1.
|
||||
select {
|
||||
case <-f.enter:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("expected one call to enter, inFlight=%d", atomic.LoadInt32(&f.inFlight))
|
||||
}
|
||||
select {
|
||||
case <-f.enter:
|
||||
t.Fatal("a second call entered while per-model limit=1 slot was held")
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(f.release)
|
||||
wg.Wait()
|
||||
if got := atomic.LoadInt32(&f.maxSeen); got > 1 {
|
||||
t.Fatalf("max in-flight %d exceeded per-model limit 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrencyEmbedderInteractiveNotGated verifies interactive calls bypass
|
||||
// the governor entirely, even at limit 1.
|
||||
func TestConcurrencyEmbedderInteractiveNotGated(t *testing.T) {
|
||||
t.Cleanup(func() { limiter.SetGovernor(nil, 0) })
|
||||
limiter.SetGovernor(limiter.NewLocalLimiter(), 1)
|
||||
|
||||
f := newFakeEmbedder("emb-interactive")
|
||||
w := wrapEmbeddingConcurrency(f, 0)
|
||||
|
||||
ctx := context.Background() // no background marker
|
||||
const n = 3
|
||||
var wg sync.WaitGroup
|
||||
for range n {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = w.Embed(ctx, "x")
|
||||
}()
|
||||
}
|
||||
// All three must be able to run at once despite limit=1.
|
||||
for i := range n {
|
||||
select {
|
||||
case <-f.enter:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("interactive call %d did not enter (should be ungated), inFlight=%d",
|
||||
i, atomic.LoadInt32(&f.inFlight))
|
||||
}
|
||||
}
|
||||
close(f.release)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestConcurrencyEmbedderPoolFanOutGated verifies that BatchEmbedWithPool's
|
||||
// per-sub-batch provider round-trips are individually gated — the reason the
|
||||
// wrapper sits innermost.
|
||||
func TestConcurrencyEmbedderPoolFanOutGated(t *testing.T) {
|
||||
t.Setenv("BATCH_EMBED_SIZE", "1") // one provider round-trip per text
|
||||
t.Cleanup(func() { limiter.SetGovernor(nil, 0) })
|
||||
limiter.SetGovernor(limiter.NewLocalLimiter(), 2)
|
||||
|
||||
pool, err := ants.NewPool(16)
|
||||
if err != nil {
|
||||
t.Fatalf("new pool: %v", err)
|
||||
}
|
||||
defer pool.Release()
|
||||
|
||||
f := newFakeEmbedder("emb-pool")
|
||||
f.pooler = NewBatchEmbedder(pool)
|
||||
w := wrapEmbeddingConcurrency(f, 0)
|
||||
|
||||
ctx := types.WithBackgroundTask(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
_, _ = w.BatchEmbedWithPool(ctx, w, []string{"a", "b", "c", "d", "e"})
|
||||
}()
|
||||
|
||||
for i := range 2 {
|
||||
select {
|
||||
case <-f.enter:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("expected sub-batch %d to enter, inFlight=%d", i, atomic.LoadInt32(&f.inFlight))
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-f.enter:
|
||||
t.Fatal("a third sub-batch entered while limit=2 slots were held")
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(f.release)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("BatchEmbedWithPool did not complete after release")
|
||||
}
|
||||
if got := atomic.LoadInt32(&f.maxSeen); got > 2 {
|
||||
t.Fatalf("max in-flight sub-batches %d exceeded limit 2", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
func TestConfigFromModel(t *testing.T) {
|
||||
m := &types.Model{
|
||||
ID: "emb-1",
|
||||
Name: "text-embedding-3-small",
|
||||
Source: types.ModelSourceRemote,
|
||||
Parameters: types.ModelParameters{
|
||||
BaseURL: "https://api.example.com/v1",
|
||||
APIKey: "sk-xxx",
|
||||
Provider: "openai",
|
||||
EmbeddingParameters: types.EmbeddingParameters{
|
||||
Dimension: 1536,
|
||||
TruncatePromptTokens: 512,
|
||||
SupportsDimensionOverride: true,
|
||||
},
|
||||
ExtraConfig: map[string]string{"region": "us-east"},
|
||||
CustomHeaders: map[string]string{"X-Gateway": "g1"},
|
||||
},
|
||||
}
|
||||
|
||||
cfg := ConfigFromModel(m, "app", "secret")
|
||||
if cfg.ModelID != "emb-1" || cfg.ModelName != "text-embedding-3-small" {
|
||||
t.Errorf("identity mismatch: %+v", cfg)
|
||||
}
|
||||
if cfg.Dimensions != 1536 || cfg.TruncatePromptTokens != 512 {
|
||||
t.Errorf("embedding params mismatch: %+v", cfg)
|
||||
}
|
||||
if !cfg.SupportsDimensionOverride {
|
||||
t.Errorf("SupportsDimensionOverride not propagated: %+v", cfg)
|
||||
}
|
||||
if cfg.CustomHeaders["X-Gateway"] != "g1" {
|
||||
t.Errorf("CustomHeaders not propagated: %+v", cfg.CustomHeaders)
|
||||
}
|
||||
if cfg.ExtraConfig["region"] != "us-east" {
|
||||
t.Errorf("ExtraConfig not propagated: %+v", cfg.ExtraConfig)
|
||||
}
|
||||
if cfg.AppID != "app" || cfg.AppSecret != "secret" {
|
||||
t.Errorf("cloud creds mismatch: %+v", cfg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package embedding
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEmbeddingDimensionsParamRequiresExplicitOverride(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
supports func(bool) bool
|
||||
}{
|
||||
{
|
||||
name: "aliyun",
|
||||
supports: func(enabled bool) bool {
|
||||
e := &AliyunEmbedder{dimensions: 1024}
|
||||
e.SetSupportsDimensionOverride(enabled)
|
||||
return e.supportsDimensionsParam()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nvidia",
|
||||
supports: func(enabled bool) bool {
|
||||
e := &NvidiaEmbedder{dimensions: 1024}
|
||||
e.SetSupportsDimensionOverride(enabled)
|
||||
return e.supportsDimensionsParam()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "volcengine",
|
||||
supports: func(enabled bool) bool {
|
||||
e := &VolcengineEmbedder{dimensions: 1024}
|
||||
e.SetSupportsDimensionOverride(enabled)
|
||||
return e.supportsDimensionsParam()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zhipu",
|
||||
supports: func(enabled bool) bool {
|
||||
e := &ZhipuEmbedder{dimensions: 1024}
|
||||
e.SetSupportsDimensionOverride(enabled)
|
||||
return e.supportsDimensionsParam()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "azure_openai",
|
||||
supports: func(enabled bool) bool {
|
||||
e := &AzureOpenAIEmbedder{dimensions: 1024}
|
||||
e.SetSupportsDimensionOverride(enabled)
|
||||
return e.supportsDimensionsParam()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.supports(false); got {
|
||||
t.Fatalf("supportsDimensionsParam() = true with override disabled")
|
||||
}
|
||||
if got := tt.supports(true); !got {
|
||||
t.Fatalf("supportsDimensionsParam() = false with override enabled")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddingDimensionsParamRequiresPositiveDimension(t *testing.T) {
|
||||
e := &OpenAIEmbedder{dimensions: 0}
|
||||
e.SetSupportsDimensionOverride(true)
|
||||
if e.supportsDimensionsParam() {
|
||||
t.Fatal("expected dimensions param to be omitted when configured dimension is zero")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/models/provider"
|
||||
"github.com/Tencent/WeKnora/internal/models/utils/ollama"
|
||||
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// Embedder defines the interface for text vectorization
|
||||
type Embedder interface {
|
||||
// Embed converts text to vector
|
||||
Embed(ctx context.Context, text string) ([]float32, error)
|
||||
|
||||
// BatchEmbed converts multiple texts to vectors in batch
|
||||
BatchEmbed(ctx context.Context, texts []string) ([][]float32, error)
|
||||
|
||||
// GetModelName returns the model name
|
||||
GetModelName() string
|
||||
|
||||
// GetDimensions returns the vector dimensions
|
||||
GetDimensions() int
|
||||
|
||||
// GetModelID returns the model ID
|
||||
GetModelID() string
|
||||
|
||||
EmbedderPooler
|
||||
}
|
||||
|
||||
type EmbedderPooler interface {
|
||||
BatchEmbedWithPool(ctx context.Context, model Embedder, texts []string) ([][]float32, error)
|
||||
}
|
||||
|
||||
// EmbedderType represents the embedder type
|
||||
type EmbedderType string
|
||||
|
||||
// Config represents the embedder configuration
|
||||
type Config struct {
|
||||
Source types.ModelSource `json:"source"`
|
||||
BaseURL string `json:"base_url"`
|
||||
ModelName string `json:"model_name"`
|
||||
APIKey string `json:"api_key"`
|
||||
TruncatePromptTokens int `json:"truncate_prompt_tokens"`
|
||||
Dimensions int `json:"dimensions"`
|
||||
SupportsDimensionOverride bool `json:"supports_dimension_override"`
|
||||
ModelID string `json:"model_id"`
|
||||
Provider string `json:"provider"`
|
||||
// MaxConcurrency caps concurrent background calls to this model; 0 falls
|
||||
// back to the process-wide default (see limiter.GateN).
|
||||
MaxConcurrency int `json:"max_concurrency"`
|
||||
ExtraConfig map[string]string `json:"extra_config"`
|
||||
// CustomHeaders 允许在调用远程 API 时附加自定义 HTTP 请求头(类似 OpenAI Python SDK 的 extra_headers)。
|
||||
CustomHeaders map[string]string `json:"custom_headers"`
|
||||
AppID string
|
||||
AppSecret string // 加密值,工厂函数调用方传入,使用前已解密
|
||||
}
|
||||
|
||||
// ConfigFromModel 根据 types.Model 构造 embedding.Config。
|
||||
// 生产路径(从 DB 拉起)和测试连接路径(临时表单)共享这份映射。
|
||||
// appID / appSecret 是已解密的 WeKnoraCloud 凭证,调用方负责传入。
|
||||
func ConfigFromModel(m *types.Model, appID, appSecret string) Config {
|
||||
if m == nil {
|
||||
return Config{}
|
||||
}
|
||||
return Config{
|
||||
Source: m.Source,
|
||||
BaseURL: m.Parameters.BaseURL,
|
||||
APIKey: m.Parameters.APIKey,
|
||||
ModelID: m.ID,
|
||||
ModelName: m.Name,
|
||||
Dimensions: m.Parameters.EmbeddingParameters.Dimension,
|
||||
SupportsDimensionOverride: m.Parameters.EmbeddingParameters.SupportsDimensionOverride,
|
||||
TruncatePromptTokens: m.Parameters.EmbeddingParameters.TruncatePromptTokens,
|
||||
Provider: m.Parameters.Provider,
|
||||
MaxConcurrency: m.Parameters.MaxConcurrency,
|
||||
ExtraConfig: m.Parameters.ExtraConfig,
|
||||
CustomHeaders: m.Parameters.CustomHeaders,
|
||||
AppID: appID,
|
||||
AppSecret: appSecret,
|
||||
}
|
||||
}
|
||||
|
||||
// NewEmbedder creates an embedder based on the configuration
|
||||
func NewEmbedder(config Config, pooler EmbedderPooler, ollamaService *ollama.OllamaService) (Embedder, error) {
|
||||
e, err := newEmbedder(config, pooler, ollamaService)
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
if setter, ok := e.(interface{ SetSupportsDimensionOverride(bool) }); ok {
|
||||
setter.SetSupportsDimensionOverride(config.SupportsDimensionOverride)
|
||||
}
|
||||
// Innermost: gate the real provider round-trips (including the per-sub-batch
|
||||
// pool callbacks) before debug/langfuse wrap for logging/tracing. See
|
||||
// concurrencyEmbedder for why this sits below the observability decorators.
|
||||
e = wrapEmbeddingConcurrency(wrapEmbeddingRequestBudget(e), config.MaxConcurrency)
|
||||
if logger.LLMDebugEnabled() {
|
||||
e = &debugEmbedder{inner: e}
|
||||
}
|
||||
if langfuse.GetManager().Enabled() {
|
||||
e = &langfuseEmbedder{inner: e}
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func newEmbedder(config Config, pooler EmbedderPooler, ollamaService *ollama.OllamaService) (Embedder, error) {
|
||||
var embedder Embedder
|
||||
var err error
|
||||
switch strings.ToLower(string(config.Source)) {
|
||||
case string(types.ModelSourceLocal):
|
||||
embedder, err = NewOllamaEmbedder(config.BaseURL,
|
||||
config.ModelName, config.TruncatePromptTokens, config.Dimensions, config.ModelID, pooler, ollamaService)
|
||||
return embedder, err
|
||||
case string(types.ModelSourceRemote):
|
||||
// Detect or use configured provider for routing
|
||||
providerName := provider.ProviderName(config.Provider)
|
||||
if providerName == "" {
|
||||
providerName = provider.DetectProvider(config.BaseURL)
|
||||
}
|
||||
|
||||
// Route to provider-specific embedders
|
||||
switch providerName {
|
||||
case provider.ProviderAliyun:
|
||||
// 检查是否是多模态嵌入模型
|
||||
// 多模态模型: tongyi-embedding-vision-*, multimodal-embedding-*
|
||||
// tex-only模型: text-embedding-v1/v2/v3/v4 应该使用 OpenAI 兼容接口,否则响应格式不匹配、embedding 返回空数组
|
||||
isMultimodalModel := strings.Contains(strings.ToLower(config.ModelName), "vision") ||
|
||||
strings.Contains(strings.ToLower(config.ModelName), "multimodal")
|
||||
|
||||
if isMultimodalModel {
|
||||
// 多模态模型需要使用DashScope专用 API 端点
|
||||
// 如果用户填写了 OpenAI 兼容模式的 URL,自动修正为多模态 API 的baseURL
|
||||
baseURL := config.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://dashscope.aliyuncs.com"
|
||||
} else if strings.Contains(baseURL, "/compatible-mode/") {
|
||||
// 移除 compatible-mode 路径,AliyunEmbedder 会自动添加多模态端点
|
||||
baseURL = strings.Replace(baseURL, "/compatible-mode/v1", "", 1)
|
||||
baseURL = strings.Replace(baseURL, "/compatible-mode", "", 1)
|
||||
}
|
||||
aliyunEmb, aErr := NewAliyunEmbedder(config.APIKey,
|
||||
baseURL,
|
||||
config.ModelName,
|
||||
config.TruncatePromptTokens,
|
||||
config.Dimensions,
|
||||
config.ModelID,
|
||||
pooler)
|
||||
if aliyunEmb != nil {
|
||||
aliyunEmb.SetCustomHeaders(config.CustomHeaders)
|
||||
}
|
||||
embedder, err = aliyunEmb, aErr
|
||||
} else {
|
||||
baseURL := config.BaseURL
|
||||
if baseURL == "" || !strings.Contains(baseURL, "/compatible-mode/") {
|
||||
baseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
}
|
||||
openaiEmb, oErr := NewOpenAIEmbedder(config.APIKey,
|
||||
baseURL,
|
||||
config.ModelName,
|
||||
config.TruncatePromptTokens,
|
||||
config.Dimensions,
|
||||
config.ModelID,
|
||||
pooler)
|
||||
if openaiEmb != nil {
|
||||
openaiEmb.SetCustomHeaders(config.CustomHeaders)
|
||||
}
|
||||
embedder, err = openaiEmb, oErr
|
||||
}
|
||||
return embedder, err
|
||||
case provider.ProviderVolcengine:
|
||||
// Volcengine Ark uses multimodal embedding API
|
||||
volcEmb, vErr := NewVolcengineEmbedder(config.APIKey,
|
||||
config.BaseURL,
|
||||
config.ModelName,
|
||||
config.TruncatePromptTokens,
|
||||
config.Dimensions,
|
||||
config.ModelID,
|
||||
pooler)
|
||||
if volcEmb != nil {
|
||||
volcEmb.SetCustomHeaders(config.CustomHeaders)
|
||||
}
|
||||
embedder, err = volcEmb, vErr
|
||||
return embedder, err
|
||||
case provider.ProviderJina:
|
||||
// Jina AI uses different API format (truncate instead of truncate_prompt_tokens)
|
||||
jinaEmb, jErr := NewJinaEmbedder(config.APIKey,
|
||||
config.BaseURL,
|
||||
config.ModelName,
|
||||
config.TruncatePromptTokens,
|
||||
config.Dimensions,
|
||||
config.ModelID,
|
||||
pooler)
|
||||
if jinaEmb != nil {
|
||||
jinaEmb.SetCustomHeaders(config.CustomHeaders)
|
||||
}
|
||||
embedder, err = jinaEmb, jErr
|
||||
return embedder, err
|
||||
case provider.ProviderAzureOpenAI:
|
||||
apiVersion := "2024-10-21"
|
||||
if config.ExtraConfig != nil {
|
||||
if v, ok := config.ExtraConfig["api_version"]; ok {
|
||||
apiVersion = v
|
||||
}
|
||||
}
|
||||
azureEmb, azErr := NewAzureOpenAIEmbedder(config.APIKey,
|
||||
config.BaseURL,
|
||||
config.ModelName,
|
||||
config.TruncatePromptTokens,
|
||||
config.Dimensions,
|
||||
config.ModelID,
|
||||
apiVersion,
|
||||
pooler)
|
||||
if azureEmb != nil {
|
||||
azureEmb.SetCustomHeaders(config.CustomHeaders)
|
||||
}
|
||||
embedder, err = azureEmb, azErr
|
||||
return embedder, err
|
||||
case provider.ProviderNvidia:
|
||||
nvEmb, nErr := NewNvidiaEmbedder(config.APIKey,
|
||||
config.BaseURL,
|
||||
config.ModelName,
|
||||
config.Dimensions,
|
||||
config.ModelID,
|
||||
pooler)
|
||||
if nvEmb != nil {
|
||||
nvEmb.SetCustomHeaders(config.CustomHeaders)
|
||||
}
|
||||
embedder, err = nvEmb, nErr
|
||||
return embedder, err
|
||||
case provider.ProviderGemini:
|
||||
geminiEmb, gErr := NewGeminiEmbedder(config.APIKey,
|
||||
config.BaseURL,
|
||||
config.ModelName,
|
||||
config.TruncatePromptTokens,
|
||||
config.Dimensions,
|
||||
config.ModelID,
|
||||
pooler)
|
||||
if geminiEmb != nil {
|
||||
geminiEmb.SetCustomHeaders(config.CustomHeaders)
|
||||
}
|
||||
embedder, err = geminiEmb, gErr
|
||||
return embedder, err
|
||||
case provider.ProviderZhipu:
|
||||
zhipuEmb, zErr := NewZhipuEmbedder(config.APIKey,
|
||||
config.BaseURL,
|
||||
config.ModelName,
|
||||
config.TruncatePromptTokens,
|
||||
config.Dimensions,
|
||||
config.ModelID,
|
||||
pooler)
|
||||
if zhipuEmb != nil {
|
||||
zhipuEmb.SetCustomHeaders(config.CustomHeaders)
|
||||
}
|
||||
embedder, err = zhipuEmb, zErr
|
||||
return embedder, err
|
||||
case provider.ProviderWeKnoraCloud:
|
||||
embedder, err = NewWeKnoraCloudEmbedder(config)
|
||||
return embedder, err
|
||||
default:
|
||||
// Use OpenAI-compatible embedder for other providers
|
||||
openaiEmb, oErr := NewOpenAIEmbedder(config.APIKey,
|
||||
config.BaseURL,
|
||||
config.ModelName,
|
||||
config.TruncatePromptTokens,
|
||||
config.Dimensions,
|
||||
config.ModelID,
|
||||
pooler)
|
||||
if openaiEmb != nil {
|
||||
openaiEmb.SetCustomHeaders(config.CustomHeaders)
|
||||
}
|
||||
embedder, err = openaiEmb, oErr
|
||||
return embedder, err
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported embedder source: %s", config.Source)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/models/requestbudget"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
const geminiEmbeddingBaseURL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
||||
// GeminiEmbedder implements text vectorization using the native Gemini
|
||||
// embedContent / batchEmbedContents REST API.
|
||||
type GeminiEmbedder struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
modelName string
|
||||
truncatePromptTokens int
|
||||
dimensions int
|
||||
modelID string
|
||||
httpClient *http.Client
|
||||
timeout time.Duration
|
||||
maxRetries int
|
||||
customHeaders map[string]string
|
||||
supportsDimensionOverride bool
|
||||
EmbedderPooler
|
||||
}
|
||||
|
||||
type geminiBatchEmbedRequest struct {
|
||||
Requests []geminiEmbedRequest `json:"requests"`
|
||||
}
|
||||
|
||||
type geminiEmbedRequest struct {
|
||||
Model string `json:"model"`
|
||||
Content geminiContent `json:"content"`
|
||||
TaskType string `json:"taskType,omitempty"`
|
||||
OutputDimensionality int `json:"output_dimensionality,omitempty"`
|
||||
}
|
||||
|
||||
type geminiContent struct {
|
||||
Parts []geminiPart `json:"parts"`
|
||||
}
|
||||
|
||||
type geminiPart struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type geminiBatchEmbedResponse struct {
|
||||
Embeddings []geminiEmbedding `json:"embeddings"`
|
||||
}
|
||||
|
||||
type geminiEmbedding struct {
|
||||
Values []float32 `json:"values"`
|
||||
}
|
||||
|
||||
func NewGeminiEmbedder(apiKey, baseURL, modelName string,
|
||||
truncatePromptTokens int, dimensions int, modelID string, pooler EmbedderPooler,
|
||||
) (*GeminiEmbedder, error) {
|
||||
if modelName == "" {
|
||||
return nil, fmt.Errorf("model name is required")
|
||||
}
|
||||
if truncatePromptTokens == 0 {
|
||||
truncatePromptTokens = 511
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = geminiEmbeddingBaseURL
|
||||
}
|
||||
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
if strings.HasSuffix(baseURL, "/openai") {
|
||||
baseURL = strings.TrimSuffix(baseURL, "/openai")
|
||||
}
|
||||
|
||||
timeout := 60 * time.Second
|
||||
|
||||
if err := validateEmbeddingBaseURL(baseURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GeminiEmbedder{
|
||||
apiKey: apiKey,
|
||||
baseURL: baseURL,
|
||||
modelName: strings.TrimPrefix(modelName, "models/"),
|
||||
truncatePromptTokens: truncatePromptTokens,
|
||||
dimensions: dimensions,
|
||||
modelID: modelID,
|
||||
httpClient: newEmbeddingHTTPClient(timeout),
|
||||
timeout: timeout,
|
||||
maxRetries: 3,
|
||||
EmbedderPooler: pooler,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *GeminiEmbedder) SetCustomHeaders(headers map[string]string) {
|
||||
e.customHeaders = headers
|
||||
}
|
||||
|
||||
func (e *GeminiEmbedder) SetSupportsDimensionOverride(supported bool) {
|
||||
e.supportsDimensionOverride = supported
|
||||
}
|
||||
|
||||
func (e *GeminiEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
embeddings, err := e.BatchEmbed(ctx, []string{text})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(embeddings) == 0 {
|
||||
return nil, fmt.Errorf("no embedding returned")
|
||||
}
|
||||
return embeddings[0], nil
|
||||
}
|
||||
|
||||
func (e *GeminiEmbedder) BatchEmbed(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
if len(texts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
requests := make([]geminiEmbedRequest, 0, len(texts))
|
||||
for _, text := range texts {
|
||||
req := geminiEmbedRequest{
|
||||
Model: "models/" + e.modelName,
|
||||
Content: geminiContent{Parts: []geminiPart{
|
||||
{Text: text},
|
||||
}},
|
||||
}
|
||||
if e.supportsDimensionOverride && e.dimensions > 0 {
|
||||
req.OutputDimensionality = e.dimensions
|
||||
}
|
||||
requests = append(requests, req)
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(geminiBatchEmbedRequest{Requests: requests})
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("GeminiEmbedder BatchEmbed marshal request error: %v", err)
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
logger.GetLogger(ctx).Debugf("GeminiEmbedder BatchEmbed: model=%s, input_count=%d",
|
||||
e.modelName, len(texts))
|
||||
|
||||
resp, err := e.doRequestWithRetry(ctx, jsonData)
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("GeminiEmbedder BatchEmbed send request error: %v", err)
|
||||
return nil, fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("GeminiEmbedder BatchEmbed read response error: %v", err)
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 1000 {
|
||||
bodyStr = bodyStr[:1000] + "... (truncated)"
|
||||
}
|
||||
logger.GetLogger(ctx).Errorf("GeminiEmbedder BatchEmbed API error: Http Status %s, Response Body: %s", resp.Status, bodyStr)
|
||||
return nil, fmt.Errorf("Gemini BatchEmbed API error: Http Status %s, Response: %s", resp.Status, bodyStr)
|
||||
}
|
||||
|
||||
var response geminiBatchEmbedResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
logger.GetLogger(ctx).Errorf("GeminiEmbedder BatchEmbed unmarshal response error: %v", err)
|
||||
return nil, fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
if len(response.Embeddings) != len(texts) {
|
||||
return nil, fmt.Errorf("Gemini BatchEmbed returned %d embeddings for %d inputs", len(response.Embeddings), len(texts))
|
||||
}
|
||||
|
||||
embeddings := make([][]float32, 0, len(response.Embeddings))
|
||||
for _, embedding := range response.Embeddings {
|
||||
embeddings = append(embeddings, embedding.Values)
|
||||
}
|
||||
return embeddings, nil
|
||||
}
|
||||
|
||||
func (e *GeminiEmbedder) doRequestWithRetry(ctx context.Context, jsonData []byte) (*http.Response, error) {
|
||||
ctx = requestbudget.WithModel(ctx, e.modelID)
|
||||
var resp *http.Response
|
||||
var err error
|
||||
url := fmt.Sprintf("%s/models/%s:batchEmbedContents", e.baseURL, e.modelName)
|
||||
|
||||
for i := 0; i <= e.maxRetries; i++ {
|
||||
if err := requestbudget.Check(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if i > 0 {
|
||||
backoffTime := time.Duration(1<<uint(i-1)) * time.Second
|
||||
if backoffTime > 10*time.Second {
|
||||
backoffTime = 10 * time.Second
|
||||
}
|
||||
logger.GetLogger(ctx).
|
||||
Infof("GeminiEmbedder retrying request (%d/%d), waiting %v", i, e.maxRetries, backoffTime)
|
||||
|
||||
select {
|
||||
case <-time.After(backoffTime):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
var req *http.Request
|
||||
req, err = http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("GeminiEmbedder failed to create request: %v", err)
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-goog-api-key", e.apiKey)
|
||||
secutils.ApplyCustomHeaders(req, e.customHeaders)
|
||||
|
||||
resp, err = e.httpClient.Do(req)
|
||||
if stopProviderRetry(ctx, err) {
|
||||
return nil, err
|
||||
}
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
logger.GetLogger(ctx).Errorf("GeminiEmbedder request failed (attempt %d/%d): %v", i+1, e.maxRetries+1, err)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (e *GeminiEmbedder) GetModelName() string {
|
||||
return e.modelName
|
||||
}
|
||||
|
||||
func (e *GeminiEmbedder) GetDimensions() int {
|
||||
return e.dimensions
|
||||
}
|
||||
|
||||
func (e *GeminiEmbedder) GetModelID() string {
|
||||
return e.modelID
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGeminiEmbedderBatchEmbedUsesNativeAPI(t *testing.T) {
|
||||
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
|
||||
|
||||
var gotPath string
|
||||
var gotAPIKey string
|
||||
var gotReq geminiBatchEmbedRequest
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotAPIKey = r.Header.Get("x-goog-api-key")
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"embeddings": [
|
||||
{"values": [0.1, 0.2]},
|
||||
{"values": [0.3, 0.4]}
|
||||
]
|
||||
}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
embedder, err := NewGeminiEmbedder("test-key", server.URL+"/openai", "gemini-embedding-2",
|
||||
0, 768, "model-id", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewGeminiEmbedder: %v", err)
|
||||
}
|
||||
|
||||
embeddings, err := embedder.BatchEmbed(context.Background(), []string{"hello", "world"})
|
||||
if err != nil {
|
||||
t.Fatalf("BatchEmbed: %v", err)
|
||||
}
|
||||
|
||||
if gotPath != "/models/gemini-embedding-2:batchEmbedContents" {
|
||||
t.Fatalf("path = %q, want native batchEmbedContents path", gotPath)
|
||||
}
|
||||
if gotAPIKey != "test-key" {
|
||||
t.Fatalf("x-goog-api-key = %q", gotAPIKey)
|
||||
}
|
||||
if len(gotReq.Requests) != 2 {
|
||||
t.Fatalf("requests len = %d", len(gotReq.Requests))
|
||||
}
|
||||
if gotReq.Requests[0].Model != "models/gemini-embedding-2" {
|
||||
t.Fatalf("request model = %q", gotReq.Requests[0].Model)
|
||||
}
|
||||
if gotReq.Requests[0].OutputDimensionality != 0 {
|
||||
t.Fatalf("output_dimensionality = %d, want omitted by default", gotReq.Requests[0].OutputDimensionality)
|
||||
}
|
||||
if gotReq.Requests[0].Content.Parts[0].Text != "hello" {
|
||||
t.Fatalf("first text = %q", gotReq.Requests[0].Content.Parts[0].Text)
|
||||
}
|
||||
if len(embeddings) != 2 || len(embeddings[0]) != 2 || embeddings[1][1] != 0.4 {
|
||||
t.Fatalf("unexpected embeddings: %#v", embeddings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiEmbedderBatchEmbedSendsOutputDimensionalityWhenOverrideEnabled(t *testing.T) {
|
||||
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
|
||||
|
||||
var gotReq geminiBatchEmbedRequest
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"embeddings":[{"values":[0.1,0.2]}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
embedder, err := NewGeminiEmbedder("test-key", server.URL, "gemini-embedding-2",
|
||||
0, 768, "model-id", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewGeminiEmbedder: %v", err)
|
||||
}
|
||||
embedder.SetSupportsDimensionOverride(true)
|
||||
|
||||
if _, err := embedder.BatchEmbed(context.Background(), []string{"hello"}); err != nil {
|
||||
t.Fatalf("BatchEmbed: %v", err)
|
||||
}
|
||||
if gotReq.Requests[0].OutputDimensionality != 768 {
|
||||
t.Fatalf("output_dimensionality = %d, want 768", gotReq.Requests[0].OutputDimensionality)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiEmbedderReturnsAPIErrorBody(t *testing.T) {
|
||||
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
embedder, err := NewGeminiEmbedder("test-key", server.URL, "gemini-embedding-2",
|
||||
0, 0, "model-id", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewGeminiEmbedder: %v", err)
|
||||
}
|
||||
|
||||
_, err = embedder.BatchEmbed(context.Background(), []string{"hello"})
|
||||
if err == nil || !strings.Contains(err.Error(), "404") {
|
||||
t.Fatalf("expected 404 error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/models/requestbudget"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
// JinaEmbedder implements text vectorization functionality using Jina AI API
|
||||
// Jina API is mostly OpenAI-compatible but does NOT support truncate_prompt_tokens
|
||||
type JinaEmbedder struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
modelName string
|
||||
dimensions int
|
||||
modelID string
|
||||
httpClient *http.Client
|
||||
timeout time.Duration
|
||||
maxRetries int
|
||||
customHeaders map[string]string
|
||||
supportsDimensionOverride bool
|
||||
EmbedderPooler
|
||||
}
|
||||
|
||||
// SetCustomHeaders 设置用户自定义 HTTP 请求头(类似 OpenAI Python SDK 的 extra_headers)。
|
||||
func (e *JinaEmbedder) SetCustomHeaders(headers map[string]string) {
|
||||
e.customHeaders = headers
|
||||
}
|
||||
|
||||
func (e *JinaEmbedder) SetSupportsDimensionOverride(supported bool) {
|
||||
e.supportsDimensionOverride = supported
|
||||
}
|
||||
|
||||
// JinaEmbedRequest represents a Jina embedding request
|
||||
// Note: Jina uses 'truncate' (boolean) instead of 'truncate_prompt_tokens' (integer)
|
||||
type JinaEmbedRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input []string `json:"input"`
|
||||
Truncate bool `json:"truncate,omitempty"` // Whether to truncate text exceeding max token length
|
||||
Dimensions int `json:"dimensions,omitempty"` // Output embedding dimensions (for models that support it)
|
||||
}
|
||||
|
||||
// JinaEmbedResponse represents a Jina embedding response
|
||||
type JinaEmbedResponse struct {
|
||||
Data []struct {
|
||||
Embedding []float32 `json:"embedding"`
|
||||
Index int `json:"index"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// NewJinaEmbedder creates a new Jina embedder
|
||||
func NewJinaEmbedder(apiKey, baseURL, modelName string,
|
||||
truncatePromptTokens int, dimensions int, modelID string, pooler EmbedderPooler,
|
||||
) (*JinaEmbedder, error) {
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.jina.ai/v1"
|
||||
}
|
||||
|
||||
if modelName == "" {
|
||||
return nil, fmt.Errorf("model name is required")
|
||||
}
|
||||
|
||||
timeout := 60 * time.Second
|
||||
|
||||
if err := validateEmbeddingBaseURL(baseURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &JinaEmbedder{
|
||||
apiKey: apiKey,
|
||||
baseURL: baseURL,
|
||||
modelName: modelName,
|
||||
httpClient: newEmbeddingHTTPClient(timeout),
|
||||
EmbedderPooler: pooler,
|
||||
dimensions: dimensions,
|
||||
modelID: modelID,
|
||||
timeout: timeout,
|
||||
maxRetries: 3,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Embed converts text to vector
|
||||
func (e *JinaEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
for range 3 {
|
||||
embeddings, err := e.BatchEmbed(ctx, []string{text})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(embeddings) > 0 {
|
||||
return embeddings[0], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no embedding returned")
|
||||
}
|
||||
|
||||
func (e *JinaEmbedder) doRequestWithRetry(ctx context.Context, jsonData []byte) (*http.Response, error) {
|
||||
ctx = requestbudget.WithModel(ctx, e.modelID)
|
||||
var resp *http.Response
|
||||
var err error
|
||||
url := e.baseURL + "/embeddings"
|
||||
|
||||
for i := 0; i <= e.maxRetries; i++ {
|
||||
if err := requestbudget.Check(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if i > 0 {
|
||||
backoffTime := time.Duration(1<<uint(i-1)) * time.Second
|
||||
if backoffTime > 10*time.Second {
|
||||
backoffTime = 10 * time.Second
|
||||
}
|
||||
logger.GetLogger(ctx).
|
||||
Infof("JinaEmbedder retrying request (%d/%d), waiting %v", i, e.maxRetries, backoffTime)
|
||||
|
||||
select {
|
||||
case <-time.After(backoffTime):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild request each time to ensure Body is valid
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("JinaEmbedder failed to create request: %v", err)
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+e.apiKey)
|
||||
secutils.ApplyCustomHeaders(req, e.customHeaders)
|
||||
|
||||
resp, err = e.httpClient.Do(req)
|
||||
if stopProviderRetry(ctx, err) {
|
||||
return nil, err
|
||||
}
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
logger.GetLogger(ctx).Errorf("JinaEmbedder request failed (attempt %d/%d): %v", i+1, e.maxRetries+1, err)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (e *JinaEmbedder) BatchEmbed(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
// Create request body - Jina uses 'truncate' boolean instead of 'truncate_prompt_tokens'
|
||||
reqBody := JinaEmbedRequest{
|
||||
Model: e.modelName,
|
||||
Input: texts,
|
||||
Truncate: true, // Enable truncation for long texts
|
||||
}
|
||||
|
||||
if e.supportsDimensionOverride && e.dimensions > 0 {
|
||||
reqBody.Dimensions = e.dimensions
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("JinaEmbedder EmbedBatch marshal request error: %v", err)
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Send request
|
||||
resp, err := e.doRequestWithRetry(ctx, jsonData)
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("JinaEmbedder EmbedBatch send request error: %v", err)
|
||||
return nil, fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
// Read response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("JinaEmbedder EmbedBatch read response error: %v", err)
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.GetLogger(ctx).Errorf("JinaEmbedder EmbedBatch API error: Http Status %s, Body: %s", resp.Status, string(body))
|
||||
return nil, fmt.Errorf("EmbedBatch API error: Http Status %s", resp.Status)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var response JinaEmbedResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
logger.GetLogger(ctx).Errorf("JinaEmbedder EmbedBatch unmarshal response error: %v", err)
|
||||
return nil, fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
// Extract embedding vectors
|
||||
embeddings := make([][]float32, 0, len(response.Data))
|
||||
for _, data := range response.Data {
|
||||
embeddings = append(embeddings, data.Embedding)
|
||||
}
|
||||
|
||||
return embeddings, nil
|
||||
}
|
||||
|
||||
// GetModelName returns the model name
|
||||
func (e *JinaEmbedder) GetModelName() string {
|
||||
return e.modelName
|
||||
}
|
||||
|
||||
// GetDimensions returns the vector dimensions
|
||||
func (e *JinaEmbedder) GetDimensions() int {
|
||||
return e.dimensions
|
||||
}
|
||||
|
||||
// GetModelID returns the model ID
|
||||
func (e *JinaEmbedder) GetModelID() string {
|
||||
return e.modelID
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/tracing/langfuse"
|
||||
)
|
||||
|
||||
// langfuseEmbedder wraps an Embedder and reports each call as a Langfuse
|
||||
// generation observation. Input token counts are approximated from the text
|
||||
// lengths when the underlying provider doesn't return usage data, because
|
||||
// Langfuse's cost reports require non-zero input tokens.
|
||||
type langfuseEmbedder struct {
|
||||
inner Embedder
|
||||
}
|
||||
|
||||
func (l *langfuseEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
mgr := langfuse.GetManager()
|
||||
if !mgr.Enabled() {
|
||||
return l.inner.Embed(ctx, text)
|
||||
}
|
||||
genCtx, gen := mgr.StartGeneration(ctx, langfuse.GenerationOptions{
|
||||
Name: "embedding.embed",
|
||||
Model: l.inner.GetModelName(),
|
||||
Input: text,
|
||||
Metadata: map[string]interface{}{
|
||||
"model_id": l.inner.GetModelID(),
|
||||
"dimensions": l.inner.GetDimensions(),
|
||||
},
|
||||
})
|
||||
result, err := l.inner.Embed(genCtx, text)
|
||||
usage := approxEmbeddingUsage([]string{text})
|
||||
var out interface{}
|
||||
if len(result) > 0 {
|
||||
out = map[string]interface{}{
|
||||
"dimensions": len(result),
|
||||
"vector_preview": result[:min(3, len(result))],
|
||||
}
|
||||
}
|
||||
gen.Finish(out, usage, err)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (l *langfuseEmbedder) BatchEmbed(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
mgr := langfuse.GetManager()
|
||||
if !mgr.Enabled() {
|
||||
return l.inner.BatchEmbed(ctx, texts)
|
||||
}
|
||||
genCtx, gen := mgr.StartGeneration(ctx, langfuse.GenerationOptions{
|
||||
Name: "embedding.batch_embed",
|
||||
Model: l.inner.GetModelName(),
|
||||
Input: map[string]interface{}{
|
||||
"count": len(texts),
|
||||
// Avoid sending megabytes of full text — Langfuse truncates but
|
||||
// the network cost is still real. Keep a short preview instead.
|
||||
"preview": previewTexts(texts, 5),
|
||||
},
|
||||
Metadata: map[string]interface{}{
|
||||
"model_id": l.inner.GetModelID(),
|
||||
"dimensions": l.inner.GetDimensions(),
|
||||
"batch_size": len(texts),
|
||||
},
|
||||
})
|
||||
result, err := l.inner.BatchEmbed(genCtx, texts)
|
||||
usage := approxEmbeddingUsage(texts)
|
||||
var out interface{}
|
||||
if len(result) > 0 {
|
||||
out = map[string]interface{}{
|
||||
"count": len(result),
|
||||
"dimensions": len(result[0]),
|
||||
}
|
||||
}
|
||||
gen.Finish(out, usage, err)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (l *langfuseEmbedder) BatchEmbedWithPool(ctx context.Context, model Embedder, texts []string) ([][]float32, error) {
|
||||
return l.inner.BatchEmbedWithPool(ctx, l, texts)
|
||||
}
|
||||
|
||||
func (l *langfuseEmbedder) GetModelName() string { return l.inner.GetModelName() }
|
||||
func (l *langfuseEmbedder) GetDimensions() int { return l.inner.GetDimensions() }
|
||||
func (l *langfuseEmbedder) GetModelID() string { return l.inner.GetModelID() }
|
||||
|
||||
// approxEmbeddingUsage estimates input tokens as ~rune_count / 4, matching the
|
||||
// rule of thumb OpenAI uses in their tokenizer docs. This is purely for cost
|
||||
// reporting — Langfuse lets users define per-model cost multipliers, so the
|
||||
// approximation need only be proportional to length.
|
||||
func approxEmbeddingUsage(texts []string) *langfuse.TokenUsage {
|
||||
total := 0
|
||||
for _, t := range texts {
|
||||
runes := len([]rune(t))
|
||||
if runes == 0 {
|
||||
continue
|
||||
}
|
||||
total += runes/4 + 1
|
||||
}
|
||||
if total == 0 {
|
||||
return nil
|
||||
}
|
||||
return &langfuse.TokenUsage{
|
||||
Input: total,
|
||||
Total: total,
|
||||
Unit: "TOKENS",
|
||||
}
|
||||
}
|
||||
|
||||
func previewTexts(texts []string, n int) []string {
|
||||
if len(texts) <= n {
|
||||
out := make([]string, len(texts))
|
||||
for i, t := range texts {
|
||||
out[i] = truncateRunes(t, 120)
|
||||
}
|
||||
return out
|
||||
}
|
||||
out := make([]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = truncateRunes(texts[i], 120)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func truncateRunes(s string, maxRunes int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
return string(r[:maxRunes]) + "..."
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
)
|
||||
|
||||
// debugEmbedder wraps an Embedder with LLM debug logging.
|
||||
type debugEmbedder struct {
|
||||
inner Embedder
|
||||
}
|
||||
|
||||
func (d *debugEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
start := time.Now()
|
||||
result, err := d.inner.Embed(ctx, text)
|
||||
logEmbeddingDebug(ctx, d.inner.GetModelName(), []string{text}, singleToDouble(result), err, time.Since(start))
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (d *debugEmbedder) BatchEmbed(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
start := time.Now()
|
||||
result, err := d.inner.BatchEmbed(ctx, texts)
|
||||
logEmbeddingDebug(ctx, d.inner.GetModelName(), texts, result, err, time.Since(start))
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (d *debugEmbedder) BatchEmbedWithPool(ctx context.Context, model Embedder, texts []string) ([][]float32, error) {
|
||||
return d.inner.BatchEmbedWithPool(ctx, d, texts)
|
||||
}
|
||||
|
||||
func (d *debugEmbedder) GetModelName() string { return d.inner.GetModelName() }
|
||||
func (d *debugEmbedder) GetDimensions() int { return d.inner.GetDimensions() }
|
||||
func (d *debugEmbedder) GetModelID() string { return d.inner.GetModelID() }
|
||||
|
||||
func singleToDouble(v []float32) [][]float32 {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return [][]float32{v}
|
||||
}
|
||||
|
||||
func logEmbeddingDebug(ctx context.Context, model string, inputs []string, outputs [][]float32, callErr error, dur time.Duration) {
|
||||
if !logger.LLMDebugEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
record := &logger.LLMCallRecord{
|
||||
CallType: "Embedding",
|
||||
Model: model,
|
||||
Duration: dur,
|
||||
}
|
||||
|
||||
// Input section: show each text with a preview
|
||||
var inputBuf strings.Builder
|
||||
inputBuf.WriteString(fmt.Sprintf("count=%d\n", len(inputs)))
|
||||
for i, t := range inputs {
|
||||
preview := strings.ReplaceAll(t, "\n", "\\n")
|
||||
preview = logger.TruncateRunes(preview, 200)
|
||||
inputBuf.WriteString(fmt.Sprintf("[%d] (len=%d) %s\n", i, len([]rune(t)), preview))
|
||||
}
|
||||
record.Sections = append(record.Sections, logger.RecordSection{Title: "Input", Content: inputBuf.String()})
|
||||
|
||||
// Output section
|
||||
if outputs != nil {
|
||||
var outBuf strings.Builder
|
||||
outBuf.WriteString(fmt.Sprintf("count=%d\n", len(outputs)))
|
||||
for i, vec := range outputs {
|
||||
if len(vec) > 0 {
|
||||
outBuf.WriteString(fmt.Sprintf("[%d] dims=%d, first_3=[%.6f, %.6f, %.6f]\n", i, len(vec),
|
||||
safeIdx(vec, 0), safeIdx(vec, 1), safeIdx(vec, 2)))
|
||||
} else {
|
||||
outBuf.WriteString(fmt.Sprintf("[%d] empty\n", i))
|
||||
}
|
||||
}
|
||||
record.Sections = append(record.Sections, logger.RecordSection{Title: "Output", Content: outBuf.String()})
|
||||
}
|
||||
|
||||
if callErr != nil {
|
||||
record.Error = callErr.Error()
|
||||
}
|
||||
logger.LLMDebugLog(ctx, record)
|
||||
}
|
||||
|
||||
func safeIdx(v []float32, i int) float32 {
|
||||
if i < len(v) {
|
||||
return v[i]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/models/requestbudget"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
// NvidiaEmbedder implements text vectorization functionality using NVIDIA API
|
||||
type NvidiaEmbedder struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
modelName string
|
||||
dimensions int
|
||||
modelID string
|
||||
httpClient *http.Client
|
||||
timeout time.Duration
|
||||
maxRetries int
|
||||
customHeaders map[string]string
|
||||
supportsDimensionOverride bool
|
||||
EmbedderPooler
|
||||
}
|
||||
|
||||
// SetCustomHeaders 设置用户自定义 HTTP 请求头(类似 OpenAI Python SDK 的 extra_headers)。
|
||||
func (e *NvidiaEmbedder) SetCustomHeaders(headers map[string]string) {
|
||||
e.customHeaders = headers
|
||||
}
|
||||
|
||||
func (e *NvidiaEmbedder) SetSupportsDimensionOverride(supported bool) {
|
||||
e.supportsDimensionOverride = supported
|
||||
}
|
||||
|
||||
// NvidiaEmbedRequest represents an NVIDIA embedding request
|
||||
type NvidiaEmbedRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input []string `json:"input"`
|
||||
EncodingFormat string `json:"encoding_format,omitempty"`
|
||||
Dimensions int `json:"dimensions,omitempty"`
|
||||
TruncatePromptTokens int `json:"truncate_prompt_tokens,omitempty"`
|
||||
InputType string `json:"input_type,omitempty"`
|
||||
}
|
||||
|
||||
// NvidiaEmbedResponse represents an NVIDIA embedding response
|
||||
type NvidiaEmbedResponse struct {
|
||||
Data []struct {
|
||||
Embedding []float32 `json:"embedding"`
|
||||
Index int `json:"index"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// NewNvidiaEmbedder creates a new NVIDIA embedder
|
||||
func NewNvidiaEmbedder(apiKey, baseURL, modelName string,
|
||||
dimensions int, modelID string, pooler EmbedderPooler,
|
||||
) (*NvidiaEmbedder, error) {
|
||||
if baseURL == "" {
|
||||
baseURL = "https://integrate.api.nvidia.com/v1"
|
||||
}
|
||||
|
||||
if modelName == "" {
|
||||
return nil, fmt.Errorf("model name is required")
|
||||
}
|
||||
|
||||
timeout := 60 * time.Second
|
||||
|
||||
if err := validateEmbeddingBaseURL(baseURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &NvidiaEmbedder{
|
||||
apiKey: apiKey,
|
||||
baseURL: baseURL,
|
||||
modelName: modelName,
|
||||
httpClient: newEmbeddingHTTPClient(timeout),
|
||||
EmbedderPooler: pooler,
|
||||
dimensions: dimensions,
|
||||
modelID: modelID,
|
||||
timeout: timeout,
|
||||
maxRetries: 3, // Maximum retry count
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Embed converts text to vector
|
||||
func (e *NvidiaEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
for range 3 {
|
||||
embeddings, err := e.BatchEmbed(ctx, []string{text})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(embeddings) > 0 {
|
||||
return embeddings[0], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no embedding returned")
|
||||
}
|
||||
|
||||
func (e *NvidiaEmbedder) doRequestWithRetry(ctx context.Context, jsonData []byte) (*http.Response, error) {
|
||||
ctx = requestbudget.WithModel(ctx, e.modelID)
|
||||
var resp *http.Response
|
||||
var err error
|
||||
url := e.baseURL + "/embeddings"
|
||||
|
||||
for i := 0; i <= e.maxRetries; i++ {
|
||||
if err := requestbudget.Check(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if i > 0 {
|
||||
backoffTime := time.Duration(1<<uint(i-1)) * time.Second
|
||||
if backoffTime > 10*time.Second {
|
||||
backoffTime = 10 * time.Second
|
||||
}
|
||||
logger.GetLogger(ctx).
|
||||
Infof("NvidiaEmbedder retrying request (%d/%d), waiting %v", i, e.maxRetries, backoffTime)
|
||||
|
||||
select {
|
||||
case <-time.After(backoffTime):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild request each time to ensure Body is valid
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("NvidiaEmbedder failed to create request: %v", err)
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+e.apiKey)
|
||||
secutils.ApplyCustomHeaders(req, e.customHeaders)
|
||||
|
||||
resp, err = e.httpClient.Do(req)
|
||||
if stopProviderRetry(ctx, err) {
|
||||
return nil, err
|
||||
}
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
logger.GetLogger(ctx).Errorf("NvidiaEmbedder request failed (attempt %d/%d): %v", i+1, e.maxRetries+1, err)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (e *NvidiaEmbedder) BatchEmbed(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
// Create request body
|
||||
reqBody := NvidiaEmbedRequest{
|
||||
Model: e.modelName,
|
||||
Input: texts,
|
||||
EncodingFormat: "float",
|
||||
InputType: "passage",
|
||||
}
|
||||
if e.supportsDimensionsParam() {
|
||||
reqBody.Dimensions = e.dimensions
|
||||
}
|
||||
isQuery, _ := ctx.Value(types.EmbedQueryContextKey).(bool)
|
||||
if isQuery {
|
||||
reqBody.InputType = "query"
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("NvidiaEmbedder EmbedBatch marshal request error: %v", err)
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Send request (passing jsonData instead of constructing http.Request)
|
||||
resp, err := e.doRequestWithRetry(ctx, jsonData)
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("NvidiaEmbedder EmbedBatch send request error: %v", err)
|
||||
return nil, fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
// Read response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("NvidiaEmbedder EmbedBatch read response error: %v", err)
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.GetLogger(ctx).Errorf("NvidiaEmbedder EmbedBatch API error: Http Status %s", resp.Status)
|
||||
return nil, fmt.Errorf("EmbedBatch API error: Http Status %s", resp.Status)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var response NvidiaEmbedResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
logger.GetLogger(ctx).Errorf("NvidiaEmbedder EmbedBatch unmarshal response error: %v", err)
|
||||
return nil, fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
// Extract embedding vectors
|
||||
embeddings := make([][]float32, 0, len(response.Data))
|
||||
for _, data := range response.Data {
|
||||
embeddings = append(embeddings, data.Embedding)
|
||||
}
|
||||
|
||||
return embeddings, nil
|
||||
}
|
||||
|
||||
// GetModelName returns the model name
|
||||
func (e *NvidiaEmbedder) GetModelName() string {
|
||||
return e.modelName
|
||||
}
|
||||
|
||||
func (e *NvidiaEmbedder) supportsDimensionsParam() bool {
|
||||
return e.supportsDimensionOverride && e.dimensions > 0
|
||||
}
|
||||
|
||||
// GetDimensions returns the vector dimensions
|
||||
func (e *NvidiaEmbedder) GetDimensions() int {
|
||||
return e.dimensions
|
||||
}
|
||||
|
||||
// GetModelID returns the model ID
|
||||
func (e *NvidiaEmbedder) GetModelID() string {
|
||||
return e.modelID
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/models/requestbudget"
|
||||
"github.com/Tencent/WeKnora/internal/models/utils/ollama"
|
||||
ollamaapi "github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
// OllamaEmbedder implements text vectorization functionality using Ollama
|
||||
type OllamaEmbedder struct {
|
||||
modelName string
|
||||
truncatePromptTokens int
|
||||
ollamaService *ollama.OllamaService
|
||||
dimensions int
|
||||
modelID string
|
||||
supportsDimensionOverride bool
|
||||
EmbedderPooler
|
||||
}
|
||||
|
||||
// OllamaEmbedRequest represents an Ollama embedding request
|
||||
type OllamaEmbedRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
TruncatePromptTokens int `json:"truncate_prompt_tokens"`
|
||||
}
|
||||
|
||||
// OllamaEmbedResponse represents an Ollama embedding response
|
||||
type OllamaEmbedResponse struct {
|
||||
Embedding []float32 `json:"embedding"`
|
||||
}
|
||||
|
||||
// NewOllamaEmbedder creates a new Ollama embedder
|
||||
func NewOllamaEmbedder(baseURL,
|
||||
modelName string,
|
||||
truncatePromptTokens int,
|
||||
dimensions int,
|
||||
modelID string,
|
||||
pooler EmbedderPooler,
|
||||
ollamaService *ollama.OllamaService,
|
||||
) (*OllamaEmbedder, error) {
|
||||
if modelName == "" {
|
||||
modelName = "nomic-embed-text"
|
||||
}
|
||||
|
||||
if truncatePromptTokens == 0 {
|
||||
truncatePromptTokens = 511
|
||||
}
|
||||
|
||||
return &OllamaEmbedder{
|
||||
modelName: modelName,
|
||||
truncatePromptTokens: truncatePromptTokens,
|
||||
ollamaService: ollamaService,
|
||||
EmbedderPooler: pooler,
|
||||
dimensions: dimensions,
|
||||
modelID: modelID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensureModelAvailable ensures that the model is available
|
||||
func (e *OllamaEmbedder) ensureModelAvailable(ctx context.Context) error {
|
||||
logger.GetLogger(ctx).Infof("Ensuring model %s is available", e.modelName)
|
||||
return e.ollamaService.EnsureModelAvailable(ctx, e.modelName)
|
||||
}
|
||||
|
||||
func (e *OllamaEmbedder) SetSupportsDimensionOverride(supported bool) {
|
||||
e.supportsDimensionOverride = supported
|
||||
}
|
||||
|
||||
// Embed converts text to vector
|
||||
func (e *OllamaEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
embedding, err := e.BatchEmbed(ctx, []string{text})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to embed text: %w", err)
|
||||
}
|
||||
|
||||
if len(embedding) == 0 {
|
||||
return nil, fmt.Errorf("failed to embed text: %w", err)
|
||||
}
|
||||
|
||||
return embedding[0], nil
|
||||
}
|
||||
|
||||
// BatchEmbed converts multiple texts to vectors in batch
|
||||
func (e *OllamaEmbedder) BatchEmbed(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
if err := requestbudget.RequireSupported(ctx, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Ensure model is available
|
||||
if err := e.ensureModelAvailable(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create request
|
||||
req := &ollamaapi.EmbedRequest{
|
||||
Model: e.modelName,
|
||||
Input: texts,
|
||||
Options: make(map[string]interface{}),
|
||||
}
|
||||
if e.supportsDimensionOverride && e.dimensions > 0 {
|
||||
req.Dimensions = e.dimensions
|
||||
}
|
||||
|
||||
// Set truncation parameters
|
||||
if e.truncatePromptTokens > 0 {
|
||||
req.Options["num_ctx"] = e.truncatePromptTokens
|
||||
truncate := true
|
||||
req.Truncate = &truncate
|
||||
}
|
||||
|
||||
// Send request
|
||||
startTime := time.Now()
|
||||
resp, err := e.ollamaService.Embeddings(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get embedding vectors: %w", err)
|
||||
}
|
||||
|
||||
logger.GetLogger(ctx).Debugf("Embedding vector retrieval took: %v", time.Since(startTime))
|
||||
return resp.Embeddings, nil
|
||||
}
|
||||
|
||||
// GetModelName returns the model name
|
||||
func (e *OllamaEmbedder) GetModelName() string {
|
||||
return e.modelName
|
||||
}
|
||||
|
||||
// GetDimensions returns the vector dimensions
|
||||
func (e *OllamaEmbedder) GetDimensions() int {
|
||||
return e.dimensions
|
||||
}
|
||||
|
||||
// GetModelID returns the model ID
|
||||
func (e *OllamaEmbedder) GetModelID() string {
|
||||
return e.modelID
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/models/requestbudget"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
// OpenAIEmbedder implements text vectorization functionality using OpenAI API
|
||||
type OpenAIEmbedder struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
modelName string
|
||||
truncatePromptTokens int
|
||||
dimensions int
|
||||
modelID string
|
||||
httpClient *http.Client
|
||||
timeout time.Duration
|
||||
maxRetries int
|
||||
customHeaders map[string]string
|
||||
supportsDimensionOverride bool
|
||||
EmbedderPooler
|
||||
}
|
||||
|
||||
// OpenAIEmbedRequest represents an OpenAI embedding request
|
||||
type OpenAIEmbedRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input []string `json:"input"`
|
||||
EncodingFormat string `json:"encoding_format,omitempty"`
|
||||
Dimensions int `json:"dimensions,omitempty"`
|
||||
TruncatePromptTokens int `json:"truncate_prompt_tokens,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIEmbedResponse represents an OpenAI embedding response
|
||||
type OpenAIEmbedResponse struct {
|
||||
Data []struct {
|
||||
Embedding []float32 `json:"embedding"`
|
||||
Index int `json:"index"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// NewOpenAIEmbedder creates a new OpenAI embedder
|
||||
func NewOpenAIEmbedder(apiKey, baseURL, modelName string,
|
||||
truncatePromptTokens int, dimensions int, modelID string, pooler EmbedderPooler,
|
||||
) (*OpenAIEmbedder, error) {
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
}
|
||||
|
||||
if modelName == "" {
|
||||
return nil, fmt.Errorf("model name is required")
|
||||
}
|
||||
|
||||
if truncatePromptTokens == 0 {
|
||||
truncatePromptTokens = 511
|
||||
}
|
||||
|
||||
timeout := 60 * time.Second
|
||||
|
||||
if err := validateEmbeddingBaseURL(baseURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &OpenAIEmbedder{
|
||||
apiKey: apiKey,
|
||||
baseURL: baseURL,
|
||||
modelName: modelName,
|
||||
httpClient: newEmbeddingHTTPClient(timeout),
|
||||
truncatePromptTokens: truncatePromptTokens,
|
||||
EmbedderPooler: pooler,
|
||||
dimensions: dimensions,
|
||||
modelID: modelID,
|
||||
timeout: timeout,
|
||||
maxRetries: 3, // Maximum retry count
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetCustomHeaders 设置用户自定义 HTTP 请求头(类似 OpenAI Python SDK 的 extra_headers)。
|
||||
// 保留头(Authorization、Content-Type 等)会在发送时被自动跳过。
|
||||
func (e *OpenAIEmbedder) SetCustomHeaders(headers map[string]string) {
|
||||
e.customHeaders = headers
|
||||
}
|
||||
|
||||
func (e *OpenAIEmbedder) SetSupportsDimensionOverride(supported bool) {
|
||||
e.supportsDimensionOverride = supported
|
||||
}
|
||||
|
||||
// Embed converts text to vector
|
||||
func (e *OpenAIEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
for range 3 {
|
||||
embeddings, err := e.BatchEmbed(ctx, []string{text})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(embeddings) > 0 {
|
||||
return embeddings[0], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no embedding returned")
|
||||
}
|
||||
|
||||
func (e *OpenAIEmbedder) doRequestWithRetry(ctx context.Context, jsonData []byte) (*http.Response, error) {
|
||||
ctx = requestbudget.WithModel(ctx, e.modelID)
|
||||
var resp *http.Response
|
||||
var err error
|
||||
url := e.baseURL + "/embeddings"
|
||||
|
||||
for i := 0; i <= e.maxRetries; i++ {
|
||||
if err := requestbudget.Check(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if i > 0 {
|
||||
backoffTime := time.Duration(1<<uint(i-1)) * time.Second
|
||||
if backoffTime > 10*time.Second {
|
||||
backoffTime = 10 * time.Second
|
||||
}
|
||||
logger.GetLogger(ctx).
|
||||
Infof("OpenAIEmbedder retrying request (%d/%d), waiting %v", i, e.maxRetries, backoffTime)
|
||||
|
||||
select {
|
||||
case <-time.After(backoffTime):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild request each time to ensure Body is valid.
|
||||
// IMPORTANT: declare `req` separately (var) so the assignment to `err`
|
||||
// below uses the outer-scope variable, not a fresh loop-local one.
|
||||
// Previously this read `req, err := http.NewRequestWithContext(...)`,
|
||||
// where `:=` introduced a new `err` shadowing the outer one. The
|
||||
// `resp, err = httpClient.Do(req)` line then wrote to the shadowed
|
||||
// `err` only, so when all retries failed with connection errors the
|
||||
// outer `err` stayed nil. The function returned `(nil, nil)`, and
|
||||
// callers (BatchEmbed line 195) blindly dereferenced `resp.Body` →
|
||||
// SIGSEGV nil-pointer panic that took down the whole process.
|
||||
// Reproduce: stop the embedding upstream (e.g. localhost:3130), make
|
||||
// any RAG query → backend SIGSEGV instead of returning HTTP 500.
|
||||
var req *http.Request
|
||||
req, err = http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("OpenAIEmbedder failed to create request: %v", err)
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+e.apiKey)
|
||||
secutils.ApplyCustomHeaders(req, e.customHeaders)
|
||||
|
||||
resp, err = e.httpClient.Do(req)
|
||||
if stopProviderRetry(ctx, err) {
|
||||
return nil, err
|
||||
}
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
logger.GetLogger(ctx).Errorf("OpenAIEmbedder request failed (attempt %d/%d): %v", i+1, e.maxRetries+1, err)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (e *OpenAIEmbedder) BatchEmbed(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
// Create request body
|
||||
reqBody := OpenAIEmbedRequest{
|
||||
Model: e.modelName,
|
||||
Input: texts,
|
||||
EncodingFormat: "float",
|
||||
TruncatePromptTokens: e.truncatePromptTokens,
|
||||
}
|
||||
if e.supportsDimensionsParam() {
|
||||
reqBody.Dimensions = e.dimensions
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("OpenAIEmbedder EmbedBatch marshal request error: %v", err)
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Log request details for debugging
|
||||
logger.GetLogger(ctx).Debugf("OpenAIEmbedder BatchEmbed: model=%s, input_count=%d, truncate_tokens=%d",
|
||||
e.modelName, len(texts), e.truncatePromptTokens)
|
||||
|
||||
// Check for invalid input lengths and log details
|
||||
hasInvalidLength := false
|
||||
for i, text := range texts {
|
||||
textLen := len(text)
|
||||
textPreview := text
|
||||
if len(textPreview) > 200 {
|
||||
textPreview = textPreview[:200] + "..."
|
||||
}
|
||||
|
||||
// Log warning if length is outside valid range [1, 8192]
|
||||
if textLen == 0 || textLen > 8192 {
|
||||
hasInvalidLength = true
|
||||
logger.GetLogger(ctx).Errorf("OpenAIEmbedder BatchEmbed input[%d]: INVALID length=%d (must be [1, 8192]), preview=%s",
|
||||
i, textLen, textPreview)
|
||||
} else {
|
||||
logger.GetLogger(ctx).Debugf("OpenAIEmbedder BatchEmbed input[%d]: length=%d, preview=%s",
|
||||
i, textLen, textPreview)
|
||||
}
|
||||
}
|
||||
|
||||
if hasInvalidLength {
|
||||
logger.GetLogger(ctx).Errorf("OpenAIEmbedder BatchEmbed: Found invalid input lengths, this will likely cause API error")
|
||||
}
|
||||
|
||||
// Send request (passing jsonData instead of constructing http.Request)
|
||||
resp, err := e.doRequestWithRetry(ctx, jsonData)
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("OpenAIEmbedder EmbedBatch send request error: %v", err)
|
||||
return nil, fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
// Read response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("OpenAIEmbedder EmbedBatch read response error: %v", err)
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Log detailed error response from OpenAI API
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 1000 {
|
||||
bodyStr = bodyStr[:1000] + "... (truncated)"
|
||||
}
|
||||
logger.GetLogger(ctx).Errorf("OpenAIEmbedder EmbedBatch API error: Http Status %s, Response Body: %s", resp.Status, bodyStr)
|
||||
return nil, fmt.Errorf("EmbedBatch API error: Http Status %s, Response: %s", resp.Status, bodyStr)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var response OpenAIEmbedResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
logger.GetLogger(ctx).Errorf("OpenAIEmbedder EmbedBatch unmarshal response error: %v", err)
|
||||
return nil, fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
// Extract embedding vectors
|
||||
embeddings := make([][]float32, 0, len(response.Data))
|
||||
for _, data := range response.Data {
|
||||
embeddings = append(embeddings, data.Embedding)
|
||||
}
|
||||
|
||||
return embeddings, nil
|
||||
}
|
||||
|
||||
// GetModelName returns the model name
|
||||
func (e *OpenAIEmbedder) GetModelName() string {
|
||||
return e.modelName
|
||||
}
|
||||
|
||||
func (e *OpenAIEmbedder) supportsDimensionsParam() bool {
|
||||
return e.supportsDimensionOverride && e.dimensions > 0
|
||||
}
|
||||
|
||||
// GetDimensions returns the vector dimensions
|
||||
func (e *OpenAIEmbedder) GetDimensions() int {
|
||||
return e.dimensions
|
||||
}
|
||||
|
||||
// GetModelID returns the model ID
|
||||
func (e *OpenAIEmbedder) GetModelID() string {
|
||||
return e.modelID
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenAIEmbedderBatchEmbedOmitsDimensionsByDefault(t *testing.T) {
|
||||
requestBody := captureOpenAIEmbeddingRequest(t, "text-embedding-3-small", 256, false)
|
||||
|
||||
if _, ok := requestBody["dimensions"]; ok {
|
||||
t.Fatalf("expected request body to omit dimensions by default, got %v", requestBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIEmbedderBatchEmbedSendsDimensionsWhenOverrideEnabled(t *testing.T) {
|
||||
requestBody := captureOpenAIEmbeddingRequest(t, "text-embedding-3-small", 256, true)
|
||||
|
||||
got, ok := requestBody["dimensions"]
|
||||
if !ok {
|
||||
t.Fatalf("expected request body to include dimensions, got %v", requestBody)
|
||||
}
|
||||
if got != float64(256) {
|
||||
t.Fatalf("unexpected dimensions value: got %v want 256", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIEmbedderBatchEmbedOmitsDimensionsForOpenAICompatibleModels(t *testing.T) {
|
||||
requestBody := captureOpenAIEmbeddingRequest(t, "text-embedding-v3", 1024, false)
|
||||
|
||||
if _, ok := requestBody["dimensions"]; ok {
|
||||
t.Fatalf("expected request body to omit dimensions for OpenAI-compatible model, got %v", requestBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIEmbedderBatchEmbedOmitsDimensionsForFixedSizeModels(t *testing.T) {
|
||||
requestBody := captureOpenAIEmbeddingRequest(t, "text-embedding-ada-002", 1536, false)
|
||||
|
||||
if _, ok := requestBody["dimensions"]; ok {
|
||||
t.Fatalf("expected request body to omit dimensions for fixed-size model, got %v", requestBody)
|
||||
}
|
||||
}
|
||||
|
||||
func captureOpenAIEmbeddingRequest(t *testing.T, modelName string, dimensions int, supportsDimensionOverride bool) map[string]any {
|
||||
t.Helper()
|
||||
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
|
||||
|
||||
requestBody := map[string]any{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/embeddings" {
|
||||
t.Fatalf("unexpected request path: %s", r.URL.Path)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"embedding":[0.1,0.2],"index":0}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
embedder, err := NewOpenAIEmbedder(
|
||||
"test-key",
|
||||
server.URL,
|
||||
modelName,
|
||||
511,
|
||||
dimensions,
|
||||
"8f7d6082-5a15-4f84-ae55-88b2bdac4ba0",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOpenAIEmbedder: %v", err)
|
||||
}
|
||||
embedder.SetSupportsDimensionOverride(supportsDimensionOverride)
|
||||
|
||||
if _, err := embedder.BatchEmbed(context.Background(), []string{"hello"}); err != nil {
|
||||
t.Fatalf("BatchEmbed: %v", err)
|
||||
}
|
||||
|
||||
return requestBody
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/models/requestbudget"
|
||||
)
|
||||
|
||||
func stopProviderRetry(ctx context.Context, err error) bool {
|
||||
return err != nil && (ctx.Err() != nil || errors.Is(err, requestbudget.ErrStopped))
|
||||
}
|
||||
|
||||
type requestBudgetEmbedder struct {
|
||||
Embedder
|
||||
supported bool
|
||||
}
|
||||
|
||||
func wrapEmbeddingRequestBudget(inner Embedder) Embedder {
|
||||
supported := false
|
||||
switch inner.(type) {
|
||||
case *OpenAIEmbedder, *AzureOpenAIEmbedder, *AliyunEmbedder, *VolcengineEmbedder,
|
||||
*JinaEmbedder, *NvidiaEmbedder, *GeminiEmbedder, *ZhipuEmbedder, *WeKnoraCloudEmbedder:
|
||||
supported = true
|
||||
}
|
||||
return &requestBudgetEmbedder{Embedder: inner, supported: supported}
|
||||
}
|
||||
|
||||
func (w *requestBudgetEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
if err := requestbudget.RequireSupported(ctx, w.supported); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return w.Embedder.Embed(ctx, text)
|
||||
}
|
||||
|
||||
func (w *requestBudgetEmbedder) BatchEmbed(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
if err := requestbudget.RequireSupported(ctx, w.supported); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return w.Embedder.BatchEmbed(ctx, texts)
|
||||
}
|
||||
|
||||
func (w *requestBudgetEmbedder) BatchEmbedWithPool(ctx context.Context, model Embedder, texts []string) ([][]float32, error) {
|
||||
if err := requestbudget.RequireSupported(ctx, w.supported); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Preserve the concurrency wrapper supplied by the factory for each batch.
|
||||
return w.Embedder.BatchEmbedWithPool(ctx, model, texts)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/models/requestbudget"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/panjf2000/ants/v2"
|
||||
)
|
||||
|
||||
type embeddingBudgetGuard struct {
|
||||
mu sync.Mutex
|
||||
limit int
|
||||
models, phases []string
|
||||
}
|
||||
|
||||
func (g *embeddingBudgetGuard) Key() string { return "local-embedding-test" }
|
||||
func (g *embeddingBudgetGuard) Check(ctx context.Context) error { return ctx.Err() }
|
||||
func (g *embeddingBudgetGuard) Reserve(ctx context.Context, model, phase string) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(g.models) >= g.limit {
|
||||
return requestbudget.ErrStopped
|
||||
}
|
||||
g.models, g.phases = append(g.models, model), append(g.phases, phase)
|
||||
return nil
|
||||
}
|
||||
func embeddingBudgetContext(g *embeddingBudgetGuard) context.Context {
|
||||
return requestbudget.WithModel(requestbudget.WithPhase(requestbudget.WithGuard(context.Background(), g), "wiki_embedding"), "caller-forged-model")
|
||||
}
|
||||
func assertEmbeddingBudget(t *testing.T, g *embeddingBudgetGuard, sends int) {
|
||||
t.Helper()
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if len(g.models) != sends {
|
||||
t.Fatalf("sends=%d reservations=%d", sends, len(g.models))
|
||||
}
|
||||
for i := range g.models {
|
||||
if g.models[i] != "concrete-embedding" || g.phases[i] != "wiki_embedding" {
|
||||
t.Fatalf("lost adapter identity/context: %v %v", g.models, g.phases)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestBudgetEmbeddingProviderTransports(t *testing.T) {
|
||||
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
|
||||
var sends atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sends.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"data":[{"index":0,"embedding":[0.1]}]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newEmbeddingHTTPClient(3 * time.Second)
|
||||
calls := map[string]func(context.Context, []byte) (*http.Response, error){
|
||||
"openai": (&OpenAIEmbedder{baseURL: server.URL, modelID: "concrete-embedding", httpClient: client}).doRequestWithRetry,
|
||||
"azure": (&AzureOpenAIEmbedder{baseURL: server.URL, modelID: "concrete-embedding", httpClient: client}).doRequestWithRetry,
|
||||
"aliyun": (&AliyunEmbedder{baseURL: server.URL, modelID: "concrete-embedding", httpClient: client}).doRequestWithRetry,
|
||||
"volcengine": (&VolcengineEmbedder{baseURL: server.URL, modelID: "concrete-embedding", httpClient: client}).doRequestWithRetry,
|
||||
"jina": (&JinaEmbedder{baseURL: server.URL, modelID: "concrete-embedding", httpClient: client}).doRequestWithRetry,
|
||||
"nvidia": (&NvidiaEmbedder{baseURL: server.URL, modelID: "concrete-embedding", httpClient: client}).doRequestWithRetry,
|
||||
"gemini": (&GeminiEmbedder{baseURL: server.URL, modelID: "concrete-embedding", httpClient: client}).doRequestWithRetry,
|
||||
"zhipu": (&ZhipuEmbedder{baseURL: server.URL, modelID: "concrete-embedding", httpClient: client}).doRequestWithRetry,
|
||||
}
|
||||
for name, call := range calls {
|
||||
for _, limit := range []int{0, 1} {
|
||||
t.Run(fmt.Sprintf("%s/limit=%d", name, limit), func(t *testing.T) {
|
||||
sends.Store(0)
|
||||
g := &embeddingBudgetGuard{limit: limit}
|
||||
resp, err := call(embeddingBudgetContext(g), []byte(`{}`))
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if limit == 0 && !errors.Is(err, requestbudget.ErrStopped) {
|
||||
t.Fatalf("guard not applied: %v", err)
|
||||
}
|
||||
if limit == 1 && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if int(sends.Load()) != limit {
|
||||
t.Fatalf("sends=%d want=%d", sends.Load(), limit)
|
||||
}
|
||||
assertEmbeddingBudget(t, g, limit)
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, limit := range []int{0, 1} {
|
||||
t.Run(fmt.Sprintf("weknoracloud/limit=%d", limit), func(t *testing.T) {
|
||||
sends.Store(0)
|
||||
e := &WeKnoraCloudEmbedder{baseURL: server.URL, modelName: "local-test", modelID: "concrete-embedding", client: client}
|
||||
g := &embeddingBudgetGuard{limit: limit}
|
||||
_, err := e.BatchEmbed(embeddingBudgetContext(g), []string{"local test"})
|
||||
if limit == 0 && !errors.Is(err, requestbudget.ErrStopped) {
|
||||
t.Fatalf("guard not applied: %v", err)
|
||||
}
|
||||
if limit == 1 && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if int(sends.Load()) != limit {
|
||||
t.Fatalf("sends=%d want=%d", sends.Load(), limit)
|
||||
}
|
||||
assertEmbeddingBudget(t, g, limit)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestBudgetEmbeddingPoolCountsEachBatch(t *testing.T) {
|
||||
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
|
||||
t.Setenv("BATCH_EMBED_SIZE", "2")
|
||||
for _, limit := range []int{0, 2, 4} {
|
||||
t.Run(fmt.Sprint(limit), func(t *testing.T) {
|
||||
var sends atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sends.Add(1)
|
||||
var body struct {
|
||||
Input []string `json:"input"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
data := make([]map[string]any, len(body.Input))
|
||||
for i := range body.Input {
|
||||
data[i] = map[string]any{"index": i, "embedding": []float32{0.1}}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"data": data})
|
||||
}))
|
||||
defer server.Close()
|
||||
pool, err := ants.NewPool(3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Release()
|
||||
e, err := NewEmbedder(Config{Source: types.ModelSourceRemote, Provider: "openai", BaseURL: server.URL, APIKey: "local-only", ModelID: "concrete-embedding", ModelName: "local-test"}, NewBatchEmbedder(pool), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := &embeddingBudgetGuard{limit: limit}
|
||||
ctx, cancel := context.WithTimeout(embeddingBudgetContext(g), 3*time.Second)
|
||||
defer cancel()
|
||||
vectors, err := e.BatchEmbedWithPool(ctx, e, []string{"a", "b", "c", "d", "e", "f", "g"})
|
||||
if limit < 4 && !errors.Is(err, requestbudget.ErrStopped) {
|
||||
t.Fatalf("batch escaped budget: %v", err)
|
||||
}
|
||||
if limit == 4 && (err != nil || len(vectors) != 7) {
|
||||
t.Fatalf("batch failed: %d %v", len(vectors), err)
|
||||
}
|
||||
if int(sends.Load()) != limit {
|
||||
t.Fatalf("batch sends=%d want=%d", sends.Load(), limit)
|
||||
}
|
||||
assertEmbeddingBudget(t, g, limit)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestBudgetEmbeddingRetriesConsumeReservations(t *testing.T) {
|
||||
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
|
||||
for _, limit := range []int{1, 2} {
|
||||
t.Run(fmt.Sprint(limit), func(t *testing.T) {
|
||||
var sends atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if sends.Add(1) == 1 {
|
||||
connection, _, err := w.(http.Hijacker).Hijack()
|
||||
if err == nil {
|
||||
connection.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"data":[{"index":0,"embedding":[0.1]}]}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
e, err := NewOpenAIEmbedder("local-only", server.URL, "local-test", 0, 0, "concrete-embedding", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.maxRetries = 1
|
||||
g := &embeddingBudgetGuard{limit: limit}
|
||||
_, err = e.BatchEmbed(embeddingBudgetContext(g), []string{"test"})
|
||||
if limit == 1 && !errors.Is(err, requestbudget.ErrStopped) {
|
||||
t.Fatalf("retry escaped budget: %v", err)
|
||||
}
|
||||
if limit == 2 && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if int(sends.Load()) != limit {
|
||||
t.Fatalf("retry sends=%d want=%d", sends.Load(), limit)
|
||||
}
|
||||
assertEmbeddingBudget(t, g, limit)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestBudgetVolcengineCountsEachText(t *testing.T) {
|
||||
t.Setenv("SSRF_WHITELIST", "127.0.0.1")
|
||||
var sends atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sends.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"data":{"embedding":[0.1]}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
e := &VolcengineEmbedder{baseURL: server.URL, modelID: "concrete-embedding", modelName: "local-test", httpClient: newEmbeddingHTTPClient(time.Second)}
|
||||
g := &embeddingBudgetGuard{limit: 2}
|
||||
_, err := e.BatchEmbed(embeddingBudgetContext(g), []string{"a", "b", "c"})
|
||||
if !errors.Is(err, requestbudget.ErrStopped) || sends.Load() != 2 {
|
||||
t.Fatalf("per-text budget not enforced: sends=%d err=%v", sends.Load(), err)
|
||||
}
|
||||
assertEmbeddingBudget(t, g, 2)
|
||||
}
|
||||
|
||||
func TestRequestBudgetEmbeddingFactoryFailsClosed(t *testing.T) {
|
||||
g := &embeddingBudgetGuard{limit: 1}
|
||||
ctx := embeddingBudgetContext(g)
|
||||
e, err := NewEmbedder(Config{Source: types.ModelSourceLocal, ModelID: "local-model"}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.Embed(ctx, "test"); !errors.Is(err, requestbudget.ErrStopped) {
|
||||
t.Fatalf("local embed invoked: %v", err)
|
||||
}
|
||||
if _, err := e.BatchEmbed(ctx, []string{"test"}); !errors.Is(err, requestbudget.ErrStopped) {
|
||||
t.Fatalf("local batch invoked: %v", err)
|
||||
}
|
||||
if _, err := e.BatchEmbedWithPool(ctx, e, []string{"test"}); !errors.Is(err, requestbudget.ErrStopped) {
|
||||
t.Fatalf("local pool invoked: %v", err)
|
||||
}
|
||||
assertEmbeddingBudget(t, g, 0)
|
||||
fake := newFakeEmbedder("unknown")
|
||||
close(fake.release)
|
||||
unknown := wrapEmbeddingRequestBudget(fake)
|
||||
if _, err := unknown.Embed(ctx, "test"); !errors.Is(err, requestbudget.ErrStopped) {
|
||||
t.Fatalf("unknown embed adapter allowed: %v", err)
|
||||
}
|
||||
if _, err := unknown.BatchEmbed(ctx, []string{"test"}); !errors.Is(err, requestbudget.ErrStopped) {
|
||||
t.Fatalf("unknown batch adapter allowed: %v", err)
|
||||
}
|
||||
if _, err := unknown.BatchEmbedWithPool(ctx, unknown, []string{"test"}); !errors.Is(err, requestbudget.ErrStopped) {
|
||||
t.Fatalf("unknown pool adapter allowed: %v", err)
|
||||
}
|
||||
if len(fake.enter) != 0 {
|
||||
t.Fatal("unsupported adapter was invoked")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/models/requestbudget"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
// sharedEmbeddingHTTPTransport keeps a single SSRF-safe connection pool for
|
||||
// all embedding clients. Embedders are recreated as model configuration changes,
|
||||
// but their outbound connections can be safely reused across client instances,
|
||||
// so the transport (and its keep-alive pool) is built once at package load.
|
||||
var sharedEmbeddingHTTPTransport = requestbudget.Transport{Base: secutils.NewSSRFSafeTransport(
|
||||
secutils.DefaultSSRFSafeHTTPClientConfig(),
|
||||
)}
|
||||
|
||||
// validateEmbeddingBaseURL checks that a resolved embedding API base URL is safe
|
||||
// for outbound requests. Empty URLs are allowed (callers apply provider defaults).
|
||||
func validateEmbeddingBaseURL(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
|
||||
}
|
||||
|
||||
// newEmbeddingHTTPClient returns an HTTP client with connection-level SSRF
|
||||
// protection and redirect validation, aligned with internal/models/chat/transport.go.
|
||||
// All clients share sharedEmbeddingHTTPTransport so keep-alive connections are
|
||||
// pooled globally, while each keeps its own timeout.
|
||||
func newEmbeddingHTTPClient(timeout time.Duration) *http.Client {
|
||||
cfg := secutils.DefaultSSRFSafeHTTPClientConfig()
|
||||
cfg.Timeout = timeout
|
||||
return secutils.NewSSRFSafeHTTPClientWithTransport(cfg, sharedEmbeddingHTTPTransport)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
func TestNewEmbeddingHTTPClient_ReusesTransport(t *testing.T) {
|
||||
firstTimeout := 15 * time.Second
|
||||
secondTimeout := 45 * time.Second
|
||||
first := newEmbeddingHTTPClient(firstTimeout)
|
||||
second := newEmbeddingHTTPClient(secondTimeout)
|
||||
|
||||
if first == second {
|
||||
t.Fatal("expected distinct HTTP clients")
|
||||
}
|
||||
firstGuard, ok := first.Transport.(*secutils.SSRFValidatingRoundTripper)
|
||||
if !ok {
|
||||
t.Fatalf("expected SSRF-validating transport, got %T", first.Transport)
|
||||
}
|
||||
secondGuard, ok := second.Transport.(*secutils.SSRFValidatingRoundTripper)
|
||||
if !ok {
|
||||
t.Fatalf("expected SSRF-validating transport, got %T", second.Transport)
|
||||
}
|
||||
if firstGuard.Base != secondGuard.Base {
|
||||
t.Fatal("expected embedding HTTP clients to share a base transport")
|
||||
}
|
||||
if firstGuard.Base != http.RoundTripper(sharedEmbeddingHTTPTransport) {
|
||||
t.Fatal("expected embedding HTTP client to use the shared transport")
|
||||
}
|
||||
if first.Timeout != firstTimeout {
|
||||
t.Fatalf("unexpected first client timeout: got %v, want %v", first.Timeout, firstTimeout)
|
||||
}
|
||||
if second.Timeout != secondTimeout {
|
||||
t.Fatalf("unexpected second client timeout: got %v, want %v", second.Timeout, secondTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEmbeddingBaseURL_RejectsLoopback(t *testing.T) {
|
||||
err := validateEmbeddingBaseURL("http://169.254.169.254/latest/meta-data")
|
||||
if err == nil {
|
||||
t.Fatal("expected SSRF error for link-local metadata URL")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "SSRF") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEmbeddingBaseURL_AllowsEmpty(t *testing.T) {
|
||||
if err := validateEmbeddingBaseURL(""); err != nil {
|
||||
t.Fatalf("empty base URL should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewOpenAIEmbedder_RejectsPrivateBaseURL(t *testing.T) {
|
||||
_, err := NewOpenAIEmbedder(
|
||||
"test-key",
|
||||
"http://169.254.169.254/latest/meta-data",
|
||||
"text-embedding-3-small",
|
||||
511,
|
||||
256,
|
||||
"model-id",
|
||||
nil,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected SSRF rejection for link-local metadata URL")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/models/requestbudget"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
// VolcengineMultimodalEmbeddingPath 火山引擎 Ark 多模态 Embedding API 路径
|
||||
VolcengineMultimodalEmbeddingPath = "/api/v3/embeddings/multimodal"
|
||||
)
|
||||
|
||||
// VolcengineEmbedder implements text vectorization using Volcengine Ark multimodal embedding API
|
||||
type VolcengineEmbedder struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
modelName string
|
||||
truncatePromptTokens int
|
||||
dimensions int
|
||||
modelID string
|
||||
httpClient *http.Client
|
||||
timeout time.Duration
|
||||
maxRetries int
|
||||
customHeaders map[string]string
|
||||
supportsDimensionOverride bool
|
||||
EmbedderPooler
|
||||
}
|
||||
|
||||
// SetCustomHeaders 设置用户自定义 HTTP 请求头(类似 OpenAI Python SDK 的 extra_headers)。
|
||||
func (e *VolcengineEmbedder) SetCustomHeaders(headers map[string]string) {
|
||||
e.customHeaders = headers
|
||||
}
|
||||
|
||||
func (e *VolcengineEmbedder) SetSupportsDimensionOverride(supported bool) {
|
||||
e.supportsDimensionOverride = supported
|
||||
}
|
||||
|
||||
// VolcengineEmbedRequest represents a Volcengine Ark multimodal embedding request
|
||||
type VolcengineEmbedRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input []VolcengineInputContent `json:"input"`
|
||||
Dimensions int `json:"dimensions,omitempty"`
|
||||
}
|
||||
|
||||
// VolcengineInputContent represents a single input item for Volcengine
|
||||
type VolcengineInputContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ImageURL *VolcengineImageURL `json:"image_url,omitempty"`
|
||||
}
|
||||
|
||||
// VolcengineImageURL represents the image URL structure for Volcengine
|
||||
type VolcengineImageURL struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// VolcengineEmbedResponse represents a Volcengine Ark multimodal embedding response
|
||||
// Multimodal API returns data as an object with embedding array directly
|
||||
type VolcengineEmbedResponse struct {
|
||||
Object string `json:"object"`
|
||||
Data struct {
|
||||
Embedding []float32 `json:"embedding"`
|
||||
} `json:"data"`
|
||||
Model string `json:"model"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
// VolcengineErrorResponse represents an error response from Volcengine
|
||||
type VolcengineErrorResponse struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// NewVolcengineEmbedder creates a new Volcengine Ark embedder
|
||||
func NewVolcengineEmbedder(apiKey, baseURL, modelName string,
|
||||
truncatePromptTokens int, dimensions int, modelID string, pooler EmbedderPooler,
|
||||
) (*VolcengineEmbedder, error) {
|
||||
if baseURL == "" {
|
||||
baseURL = "https://ark.cn-beijing.volces.com"
|
||||
}
|
||||
|
||||
// Remove trailing slash
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
|
||||
// Extract base host if URL contains the full multimodal path
|
||||
if strings.Contains(baseURL, "/embeddings/multimodal") {
|
||||
// Strip the path to get base URL
|
||||
if idx := strings.Index(baseURL, "/api/"); idx != -1 {
|
||||
baseURL = baseURL[:idx]
|
||||
}
|
||||
} else if strings.HasSuffix(baseURL, "/api/v3") {
|
||||
// If it ends with /api/v3, keep just the host
|
||||
baseURL = strings.TrimSuffix(baseURL, "/api/v3")
|
||||
}
|
||||
|
||||
if modelName == "" {
|
||||
return nil, fmt.Errorf("model name is required")
|
||||
}
|
||||
|
||||
if truncatePromptTokens == 0 {
|
||||
truncatePromptTokens = 511
|
||||
}
|
||||
|
||||
timeout := 60 * time.Second
|
||||
|
||||
if err := validateEmbeddingBaseURL(baseURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &VolcengineEmbedder{
|
||||
apiKey: apiKey,
|
||||
baseURL: baseURL,
|
||||
modelName: modelName,
|
||||
httpClient: newEmbeddingHTTPClient(timeout),
|
||||
truncatePromptTokens: truncatePromptTokens,
|
||||
EmbedderPooler: pooler,
|
||||
dimensions: dimensions,
|
||||
modelID: modelID,
|
||||
timeout: timeout,
|
||||
maxRetries: 3,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Embed converts text to vector
|
||||
func (e *VolcengineEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
for range 3 {
|
||||
embeddings, err := e.BatchEmbed(ctx, []string{text})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(embeddings) > 0 {
|
||||
return embeddings[0], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no embedding returned")
|
||||
}
|
||||
|
||||
func (e *VolcengineEmbedder) doRequestWithRetry(ctx context.Context, jsonData []byte) (*http.Response, error) {
|
||||
ctx = requestbudget.WithModel(ctx, e.modelID)
|
||||
var resp *http.Response
|
||||
var err error
|
||||
url := e.baseURL + VolcengineMultimodalEmbeddingPath
|
||||
|
||||
for i := 0; i <= e.maxRetries; i++ {
|
||||
if err := requestbudget.Check(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if i > 0 {
|
||||
backoffTime := time.Duration(1<<uint(i-1)) * time.Second
|
||||
if backoffTime > 10*time.Second {
|
||||
backoffTime = 10 * time.Second
|
||||
}
|
||||
logger.GetLogger(ctx).
|
||||
Infof("VolcengineEmbedder retrying request (%d/%d), waiting %v", i, e.maxRetries, backoffTime)
|
||||
|
||||
select {
|
||||
case <-time.After(backoffTime):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("VolcengineEmbedder failed to create request: %v", err)
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+e.apiKey)
|
||||
secutils.ApplyCustomHeaders(req, e.customHeaders)
|
||||
|
||||
resp, err = e.httpClient.Do(req)
|
||||
if stopProviderRetry(ctx, err) {
|
||||
return nil, err
|
||||
}
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
logger.GetLogger(ctx).Errorf("VolcengineEmbedder request failed (attempt %d/%d): %v", i+1, e.maxRetries+1, err)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (e *VolcengineEmbedder) BatchEmbed(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
embeddings := make([][]float32, len(texts))
|
||||
|
||||
// Volcengine multimodal API returns a single combined embedding for all inputs,
|
||||
// so we need to call the API once per text for proper batch embedding
|
||||
for i, text := range texts {
|
||||
input := []VolcengineInputContent{
|
||||
{
|
||||
Type: "text",
|
||||
Text: text,
|
||||
},
|
||||
}
|
||||
|
||||
reqBody := VolcengineEmbedRequest{
|
||||
Model: e.modelName,
|
||||
Input: input,
|
||||
}
|
||||
if e.supportsDimensionsParam() {
|
||||
reqBody.Dimensions = e.dimensions
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("VolcengineEmbedder BatchEmbed marshal request error: %v", err)
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := e.doRequestWithRetry(ctx, jsonData)
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("VolcengineEmbedder BatchEmbed send request error: %v", err)
|
||||
return nil, fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("VolcengineEmbedder BatchEmbed read response error: %v", err)
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errResp VolcengineErrorResponse
|
||||
if json.Unmarshal(body, &errResp) == nil && errResp.Error.Message != "" {
|
||||
logger.GetLogger(ctx).Errorf("VolcengineEmbedder BatchEmbed API error: %s - %s", errResp.Error.Code, errResp.Error.Message)
|
||||
return nil, fmt.Errorf("API error: %s - %s", errResp.Error.Code, errResp.Error.Message)
|
||||
}
|
||||
logger.GetLogger(ctx).Errorf("VolcengineEmbedder BatchEmbed API error: Http Status %s", resp.Status)
|
||||
return nil, fmt.Errorf("BatchEmbed API error: Http Status %s", resp.Status)
|
||||
}
|
||||
|
||||
var response VolcengineEmbedResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
logger.GetLogger(ctx).Errorf("VolcengineEmbedder BatchEmbed unmarshal response error: %v", err)
|
||||
return nil, fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
embeddings[i] = response.Data.Embedding
|
||||
}
|
||||
|
||||
return embeddings, nil
|
||||
|
||||
}
|
||||
|
||||
// GetModelName returns the model name
|
||||
func (e *VolcengineEmbedder) GetModelName() string {
|
||||
return e.modelName
|
||||
}
|
||||
|
||||
func (e *VolcengineEmbedder) supportsDimensionsParam() bool {
|
||||
return e.supportsDimensionOverride && e.dimensions > 0
|
||||
}
|
||||
|
||||
// GetDimensions returns the vector dimensions
|
||||
func (e *VolcengineEmbedder) GetDimensions() int {
|
||||
return e.dimensions
|
||||
}
|
||||
|
||||
// GetModelID returns the model ID
|
||||
func (e *VolcengineEmbedder) GetModelID() string {
|
||||
return e.modelID
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/models/provider"
|
||||
"github.com/Tencent/WeKnora/internal/models/requestbudget"
|
||||
"github.com/Tencent/WeKnora/internal/models/utils"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const weKnoraCloudEmbedPath = "/api/v1/embeddings"
|
||||
|
||||
// WeKnoraCloudEmbedder 实现 embedding.Embedder 接口,对接 WeKnoraCloud /api/v1/embeddings
|
||||
type WeKnoraCloudEmbedder struct {
|
||||
modelName string
|
||||
remoteModelName string
|
||||
modelID string
|
||||
appID string
|
||||
apiKey string
|
||||
baseURL string
|
||||
dimensions int
|
||||
supportsDimensionOverride bool
|
||||
client *http.Client
|
||||
EmbedderPooler
|
||||
}
|
||||
|
||||
// NewWeKnoraCloudEmbedder 构造 WeKnoraCloudEmbedder
|
||||
func NewWeKnoraCloudEmbedder(config Config) (*WeKnoraCloudEmbedder, error) {
|
||||
if config.AppID == "" {
|
||||
return nil, fmt.Errorf("WeKnoraCloud embedder: AppID is required")
|
||||
}
|
||||
if config.AppSecret == "" {
|
||||
return nil, fmt.Errorf("WeKnoraCloud embedder: AppSecret is required")
|
||||
}
|
||||
remoteModelName := ""
|
||||
if config.ExtraConfig != nil {
|
||||
remoteModelName = strings.TrimSpace(config.ExtraConfig["remote_model_name"])
|
||||
}
|
||||
baseURL := strings.TrimRight(config.BaseURL, "/")
|
||||
if baseURL == "" {
|
||||
baseURL = provider.WeKnoraCloudBaseURL
|
||||
}
|
||||
if err := validateEmbeddingBaseURL(baseURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &WeKnoraCloudEmbedder{
|
||||
modelName: config.ModelName,
|
||||
remoteModelName: remoteModelName,
|
||||
modelID: config.ModelID,
|
||||
appID: config.AppID,
|
||||
apiKey: config.AppSecret,
|
||||
baseURL: baseURL,
|
||||
dimensions: config.Dimensions,
|
||||
supportsDimensionOverride: config.SupportsDimensionOverride,
|
||||
client: newEmbeddingHTTPClient(60 * time.Second),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type weKnoraCloudEmbedRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input []string `json:"input"`
|
||||
Dimensions int `json:"dimensions,omitempty"`
|
||||
TruncatePromptTokens int `json:"truncate_prompt_tokens,omitempty"`
|
||||
}
|
||||
|
||||
type weKnoraCloudEmbedResponse struct {
|
||||
Data []struct {
|
||||
Index int `json:"index"`
|
||||
Embedding []float32 `json:"embedding"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (e *WeKnoraCloudEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
results, err := e.BatchEmbed(ctx, []string{text})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return nil, fmt.Errorf("weknoracloud embedder: empty response")
|
||||
}
|
||||
return results[0], nil
|
||||
}
|
||||
|
||||
func (e *WeKnoraCloudEmbedder) BatchEmbed(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
ctx = requestbudget.WithModel(ctx, e.modelID)
|
||||
if err := requestbudget.Check(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody := weKnoraCloudEmbedRequest{Model: e.effectiveModelName(), Input: texts}
|
||||
if e.supportsDimensionOverride && e.dimensions > 0 {
|
||||
reqBody.Dimensions = e.dimensions
|
||||
}
|
||||
bodyBytes, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weknoracloud embedder: marshal: %w", err)
|
||||
}
|
||||
|
||||
requestID := uuid.New().String()
|
||||
headers := utils.Sign(e.appID, e.apiKey, requestID, string(bodyBytes))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.baseURL+weKnoraCloudEmbedPath, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weknoracloud embedder: create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := e.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weknoracloud embedder: do request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weknoracloud embedder: read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("weknoracloud embedder: status %d: %s", resp.StatusCode, string(respBytes))
|
||||
}
|
||||
|
||||
var embedResp weKnoraCloudEmbedResponse
|
||||
if err := json.Unmarshal(respBytes, &embedResp); err != nil {
|
||||
return nil, fmt.Errorf("weknoracloud embedder: unmarshal: %w", err)
|
||||
}
|
||||
|
||||
result := make([][]float32, len(texts))
|
||||
seen := make([]bool, len(texts))
|
||||
for _, item := range embedResp.Data {
|
||||
if item.Index < 0 || item.Index >= len(result) {
|
||||
return nil, fmt.Errorf(
|
||||
"weknoracloud embedder: response index %d out of range for %d inputs",
|
||||
item.Index,
|
||||
len(texts),
|
||||
)
|
||||
}
|
||||
if seen[item.Index] {
|
||||
return nil, fmt.Errorf("weknoracloud embedder: duplicate response index %d", item.Index)
|
||||
}
|
||||
result[item.Index] = item.Embedding
|
||||
seen[item.Index] = true
|
||||
}
|
||||
for index, found := range seen {
|
||||
if !found {
|
||||
return nil, fmt.Errorf("weknoracloud embedder: missing embedding for input index %d", index)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *WeKnoraCloudEmbedder) BatchEmbedWithPool(ctx context.Context, model Embedder, texts []string) ([][]float32, error) {
|
||||
return e.BatchEmbed(ctx, texts)
|
||||
}
|
||||
|
||||
func (e *WeKnoraCloudEmbedder) SetSupportsDimensionOverride(supported bool) {
|
||||
e.supportsDimensionOverride = supported
|
||||
}
|
||||
|
||||
func (e *WeKnoraCloudEmbedder) effectiveModelName() string {
|
||||
if e.remoteModelName != "" {
|
||||
return e.remoteModelName
|
||||
}
|
||||
return e.modelName
|
||||
}
|
||||
|
||||
func (e *WeKnoraCloudEmbedder) GetModelName() string { return e.modelName }
|
||||
func (e *WeKnoraCloudEmbedder) GetModelID() string { return e.modelID }
|
||||
func (e *WeKnoraCloudEmbedder) GetDimensions() int { return e.dimensions }
|
||||
@@ -0,0 +1,100 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newWeKnoraCloudEmbedderTestServer(t *testing.T, response string) *WeKnoraCloudEmbedder {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != weKnoraCloudEmbedPath {
|
||||
http.Error(w, fmt.Sprintf("request path = %q, want %q", r.URL.Path, weKnoraCloudEmbedPath), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, response)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
return &WeKnoraCloudEmbedder{
|
||||
modelName: "test-embedding",
|
||||
modelID: "test-model-id",
|
||||
appID: "test-app-id",
|
||||
apiKey: "test-api-key",
|
||||
baseURL: server.URL,
|
||||
client: server.Client(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeKnoraCloudBatchEmbedPreservesInputOrder(t *testing.T) {
|
||||
embedder := newWeKnoraCloudEmbedderTestServer(t, `{
|
||||
"data": [
|
||||
{"index": 1, "embedding": [0.3, 0.4]},
|
||||
{"index": 0, "embedding": [0.1, 0.2]}
|
||||
]
|
||||
}`)
|
||||
|
||||
got, err := embedder.BatchEmbed(context.Background(), []string{"first", "second"})
|
||||
if err != nil {
|
||||
t.Fatalf("BatchEmbed returned error: %v", err)
|
||||
}
|
||||
want := [][]float32{{0.1, 0.2}, {0.3, 0.4}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("BatchEmbed result = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeKnoraCloudBatchEmbedRejectsMalformedResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
texts []string
|
||||
wantErrMsg string
|
||||
}{
|
||||
{
|
||||
name: "negative index",
|
||||
response: `{"data": [{"index": -1, "embedding": [0.1, 0.2]}]}`,
|
||||
texts: []string{"first"},
|
||||
wantErrMsg: "response index -1 out of range",
|
||||
},
|
||||
{
|
||||
name: "index above input range",
|
||||
response: `{"data": [{"index": 1, "embedding": [0.1, 0.2]}]}`,
|
||||
texts: []string{"first"},
|
||||
wantErrMsg: "response index 1 out of range",
|
||||
},
|
||||
{
|
||||
name: "duplicate index",
|
||||
response: `{"data": [{"index": 0, "embedding": [0.1]}, {"index": 0, "embedding": [0.2]}]}`,
|
||||
texts: []string{"first"},
|
||||
wantErrMsg: "duplicate response index 0",
|
||||
},
|
||||
{
|
||||
name: "missing result",
|
||||
response: `{"data": [{"index": 0, "embedding": [0.1, 0.2]}]}`,
|
||||
texts: []string{"first", "second"},
|
||||
wantErrMsg: "missing embedding for input index 1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
embedder := newWeKnoraCloudEmbedderTestServer(t, tt.response)
|
||||
|
||||
_, err := embedder.BatchEmbed(context.Background(), tt.texts)
|
||||
if err == nil {
|
||||
t.Fatalf("BatchEmbed returned nil error, want error containing %q", tt.wantErrMsg)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErrMsg) {
|
||||
t.Fatalf("BatchEmbed error = %q, want it to contain %q", err, tt.wantErrMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package embedding
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/models/provider"
|
||||
"github.com/Tencent/WeKnora/internal/models/requestbudget"
|
||||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
// ZhipuEmbedder implements text vectorization functionality using Zhipu AI API
|
||||
type ZhipuEmbedder struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
modelName string
|
||||
truncatePromptTokens int
|
||||
dimensions int
|
||||
modelID string
|
||||
httpClient *http.Client
|
||||
timeout time.Duration
|
||||
maxRetries int
|
||||
customHeaders map[string]string
|
||||
supportsDimensionOverride bool
|
||||
EmbedderPooler
|
||||
}
|
||||
|
||||
// ZhipuEmbedRequest represents a Zhipu embedding request
|
||||
type ZhipuEmbedRequest struct {
|
||||
Model string `json:"model"`
|
||||
Input []string `json:"input"`
|
||||
Dimensions int `json:"dimensions,omitempty"`
|
||||
TruncatePromptTokens int `json:"truncate_prompt_tokens,omitempty"`
|
||||
}
|
||||
|
||||
// ZhipuEmbedResponse represents a Zhipu embedding response
|
||||
type ZhipuEmbedResponse struct {
|
||||
Data []struct {
|
||||
Embedding []float32 `json:"embedding"`
|
||||
Index int `json:"index"`
|
||||
} `json:"data"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// NewZhipuEmbedder creates a new Zhipu embedder
|
||||
func NewZhipuEmbedder(apiKey, baseURL, modelName string,
|
||||
truncatePromptTokens int, dimensions int, modelID string, pooler EmbedderPooler,
|
||||
) (*ZhipuEmbedder, error) {
|
||||
if baseURL == "" {
|
||||
baseURL = provider.ZhipuEmbeddingBaseURL
|
||||
}
|
||||
|
||||
if modelName == "" {
|
||||
return nil, fmt.Errorf("model name is required")
|
||||
}
|
||||
|
||||
if truncatePromptTokens == 0 {
|
||||
truncatePromptTokens = 511
|
||||
}
|
||||
|
||||
timeout := 60 * time.Second
|
||||
|
||||
if err := validateEmbeddingBaseURL(baseURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ZhipuEmbedder{
|
||||
apiKey: apiKey,
|
||||
baseURL: baseURL,
|
||||
modelName: modelName,
|
||||
httpClient: newEmbeddingHTTPClient(timeout),
|
||||
truncatePromptTokens: truncatePromptTokens,
|
||||
EmbedderPooler: pooler,
|
||||
dimensions: dimensions,
|
||||
modelID: modelID,
|
||||
timeout: timeout,
|
||||
maxRetries: 3, // Maximum retry count
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetCustomHeaders sets custom HTTP headers for the embedder
|
||||
func (e *ZhipuEmbedder) SetCustomHeaders(headers map[string]string) {
|
||||
e.customHeaders = headers
|
||||
}
|
||||
|
||||
func (e *ZhipuEmbedder) SetSupportsDimensionOverride(supported bool) {
|
||||
e.supportsDimensionOverride = supported
|
||||
}
|
||||
|
||||
// Embed converts text to vector
|
||||
func (e *ZhipuEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
for range 3 {
|
||||
embeddings, err := e.BatchEmbed(ctx, []string{text})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(embeddings) > 0 {
|
||||
return embeddings[0], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no embedding returned")
|
||||
}
|
||||
|
||||
func (e *ZhipuEmbedder) doRequestWithRetry(ctx context.Context, jsonData []byte) (*http.Response, error) {
|
||||
ctx = requestbudget.WithModel(ctx, e.modelID)
|
||||
var resp *http.Response
|
||||
var err error
|
||||
url := e.baseURL + "/embeddings"
|
||||
|
||||
for i := 0; i <= e.maxRetries; i++ {
|
||||
if err := requestbudget.Check(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if i > 0 {
|
||||
backoffTime := time.Duration(1<<uint(i-1)) * time.Second
|
||||
if backoffTime > 10*time.Second {
|
||||
backoffTime = 10 * time.Second
|
||||
}
|
||||
logger.GetLogger(ctx).
|
||||
Infof("ZhipuEmbedder retrying request (%d/%d), waiting %v", i, e.maxRetries, backoffTime)
|
||||
|
||||
select {
|
||||
case <-time.After(backoffTime):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
var req *http.Request
|
||||
req, err = http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("ZhipuEmbedder failed to create request: %v", err)
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+e.apiKey)
|
||||
secutils.ApplyCustomHeaders(req, e.customHeaders)
|
||||
|
||||
resp, err = e.httpClient.Do(req)
|
||||
if stopProviderRetry(ctx, err) {
|
||||
return nil, err
|
||||
}
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
logger.GetLogger(ctx).Errorf("ZhipuEmbedder request failed (attempt %d/%d): %v", i+1, e.maxRetries+1, err)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (e *ZhipuEmbedder) BatchEmbed(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
// Create request body
|
||||
reqBody := ZhipuEmbedRequest{
|
||||
Model: e.modelName,
|
||||
Input: texts,
|
||||
TruncatePromptTokens: e.truncatePromptTokens,
|
||||
}
|
||||
if e.supportsDimensionsParam() {
|
||||
reqBody.Dimensions = e.dimensions
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("ZhipuEmbedder BatchEmbed marshal request error: %v", err)
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Log request details for debugging
|
||||
logger.GetLogger(ctx).Debugf("ZhipuEmbedder BatchEmbed: model=%s, input_count=%d, truncate_tokens=%d",
|
||||
e.modelName, len(texts), e.truncatePromptTokens)
|
||||
|
||||
// Check for invalid input lengths and log details
|
||||
hasInvalidLength := false
|
||||
for i, text := range texts {
|
||||
textLen := len(text)
|
||||
textPreview := text
|
||||
if len(textPreview) > 200 {
|
||||
textPreview = textPreview[:200] + "..."
|
||||
}
|
||||
|
||||
// Log warning if length is outside valid range [1, 8192]
|
||||
if textLen == 0 || textLen > 8192 {
|
||||
hasInvalidLength = true
|
||||
logger.GetLogger(ctx).Errorf("ZhipuEmbedder BatchEmbed input[%d]: INVALID length=%d (must be [1, 8192]), preview=%s",
|
||||
i, textLen, textPreview)
|
||||
} else {
|
||||
logger.GetLogger(ctx).Debugf("ZhipuEmbedder BatchEmbed input[%d]: length=%d, preview=%s",
|
||||
i, textLen, textPreview)
|
||||
}
|
||||
}
|
||||
|
||||
if hasInvalidLength {
|
||||
logger.GetLogger(ctx).Errorf("ZhipuEmbedder BatchEmbed: Found invalid input lengths, this will likely cause API error")
|
||||
}
|
||||
|
||||
// Send request (passing jsonData instead of constructing http.Request)
|
||||
resp, err := e.doRequestWithRetry(ctx, jsonData)
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("ZhipuEmbedder BatchEmbed send request error: %v", err)
|
||||
return nil, fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
// Read response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
logger.GetLogger(ctx).Errorf("ZhipuEmbedder BatchEmbed read response error: %v", err)
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Log detailed error response from OpenAI API
|
||||
bodyStr := string(body)
|
||||
if len(bodyStr) > 1000 {
|
||||
bodyStr = bodyStr[:1000] + "... (truncated)"
|
||||
}
|
||||
logger.GetLogger(ctx).Errorf("ZhipuEmbedder BatchEmbed API error: Http Status %s, Response Body: %s", resp.Status, bodyStr)
|
||||
return nil, fmt.Errorf("BatchEmbed API error: Http Status %s, Response: %s", resp.Status, bodyStr)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var response ZhipuEmbedResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
logger.GetLogger(ctx).Errorf("ZhipuEmbedder BatchEmbed unmarshal response error: %v", err)
|
||||
return nil, fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
// Extract embedding vectors
|
||||
embeddings := make([][]float32, 0, len(response.Data))
|
||||
for _, data := range response.Data {
|
||||
embeddings = append(embeddings, data.Embedding)
|
||||
}
|
||||
|
||||
return embeddings, nil
|
||||
}
|
||||
|
||||
// GetModelName returns the model name
|
||||
func (e *ZhipuEmbedder) GetModelName() string {
|
||||
return e.modelName
|
||||
}
|
||||
|
||||
func (e *ZhipuEmbedder) supportsDimensionsParam() bool {
|
||||
return e.supportsDimensionOverride && e.dimensions > 0
|
||||
}
|
||||
|
||||
// GetDimensions returns the vector dimensions
|
||||
func (e *ZhipuEmbedder) GetDimensions() int {
|
||||
return e.dimensions
|
||||
}
|
||||
|
||||
// GetModelID returns the model ID
|
||||
func (e *ZhipuEmbedder) GetModelID() string {
|
||||
return e.modelID
|
||||
}
|
||||
Reference in New Issue
Block a user