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

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+281
View File
@@ -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
}
+199
View File
@@ -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)
}
+112
View File
@@ -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")
}
}
+281
View File
@@ -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)
}
}
+248
View File
@@ -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
}
+116
View File
@@ -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)
}
}
+222
View File
@@ -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
}
+93
View File
@@ -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
}
+231
View File
@@ -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
}
+139
View File
@@ -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
}
+277
View File
@@ -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
}
+83
View File
@@ -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")
}
}
+40
View File
@@ -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")
}
}
+282
View File
@@ -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
}
+178
View File
@@ -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)
}
})
}
}
+264
View File
@@ -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
}