Files
Agent-System-Backend/internal/rag/embed.go
weichen 5d5cef9fcd
ci / vet-and-test (pull_request) Successful in 56s
Add model identity and in-database vector top-k via pgvector (U6 slice 3)
Why: plan U6 vector slice (KTD11 correctness step) - same-dimension vectors from different models must never mix, and PG tiers should run filtered exact top-k in the database instead of scanning every row in-process.

Evidence: full suite green on SQLite and real PostgreSQL 18.6 with pgvector (35 packages each tier) - pgvector_test covers model-identity and dimension isolation, update/delete atomicity (BLOB and vec_pg swap together), shared top-5 with score monotonicity, Recall@10 = exact-scan parity, deny-all predicate leakage; storage-migrate tests green after excluding the derived vec_pg column.

Impact: v25 migration adds model/content_hash columns and (PG, best-effort DO block) the pgvector extension plus vec_pg column - absence degrades to in-process scan; UpsertKnowledgeVectorExt dual-writes BLOB (cross-tier authority) and vec_pg; SearchKnowledgeVectorsModel/SharedModel filter by model identity and use SQL exact top-k (ORDER BY vec_pg <=> q) with automatic degradation; embedders expose Model() and the rag pipeline threads it through indexing and both retrieve paths; storage-migrate excludes vec_pg as a derived column.

Refs: proposals/2026-10-11-collaboration-storage-and-distribution.md U6 slice 3, KTD11; R9.
2026-10-11 16:31:12 +00:00

187 lines
5.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package rag
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
core "agent-system-core"
)
// Route 解析一次模型路由(设置层;组合根/管线调用方负责取有效设置)。
type Route struct {
Base string
Key string
Model string
}
// OK 表示该 kind 已配置可用。
func (r Route) OK() bool { return r.Base != "" && r.Model != "" }
// HTTPEmbedder 是 OpenAI 兼容 /embeddings 客户端(core.EmbeddingProvider)。
// resolve 每次调用解析当前路由(设置可热更)。
type HTTPEmbedder struct {
resolve func(ctx context.Context) Route
client *http.Client
}
func NewHTTPEmbedder(resolve func(ctx context.Context) Route) *HTTPEmbedder {
return &HTTPEmbedder{resolve: resolve, client: &http.Client{Timeout: 60 * time.Second}}
}
type embeddingsRequest struct {
Model string `json:"model"`
Input []string `json:"input"`
}
type embeddingsResponse struct {
Data []struct {
Index int `json:"index"`
Embedding []float32 `json:"embedding"`
} `json:"data"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
// Model 当前 embedding 模型身份(U6-3:写入与检索同源;未配置返回空)。
func (e *HTTPEmbedder) Model() string {
route := e.resolve(context.Background())
return route.Model
}
func (e *HTTPEmbedder) Embed(ctx context.Context, texts []string) ([][]float32, error) {
if len(texts) == 0 {
return nil, nil
}
route := e.resolve(ctx)
if !route.OK() {
return nil, fmt.Errorf("rag: embedding 模型未配置(settings.models.embedding)")
}
body, err := json.Marshal(embeddingsRequest{Model: route.Model, Input: texts})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, route.Base+"/embeddings", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if route.Key != "" {
req.Header.Set("Authorization", "Bearer "+route.Key)
}
resp, err := e.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("rag: embeddings %d: %s", resp.StatusCode, truncateStr(string(raw), 200))
}
var out embeddingsResponse
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
if out.Error != nil {
return nil, fmt.Errorf("rag: embeddings: %s", out.Error.Message)
}
if len(out.Data) != len(texts) {
return nil, fmt.Errorf("rag: embeddings 返回 %d 条(期望 %d)", len(out.Data), len(texts))
}
vecs := make([][]float32, len(texts))
for _, d := range out.Data {
if d.Index < 0 || d.Index >= len(vecs) {
return nil, fmt.Errorf("rag: embeddings index 越界 %d", d.Index)
}
vecs[d.Index] = d.Embedding
}
return vecs, nil
}
// HTTPReranker 是通用 /rerank 客户端(Cohere/Jina 兼容形状;core.Reranker)。
type HTTPReranker struct {
resolve func(ctx context.Context) Route
client *http.Client
}
func NewHTTPReranker(resolve func(ctx context.Context) Route) *HTTPReranker {
return &HTTPReranker{resolve: resolve, client: &http.Client{Timeout: 30 * time.Second}}
}
type rerankRequest struct {
Model string `json:"model"`
Query string `json:"query"`
Documents []struct {
Text string `json:"text"`
} `json:"documents"`
TopN int `json:"top_n"`
}
type rerankResponse struct {
Results []struct {
Index int `json:"index"`
Relevance float64 `json:"relevance_score"`
} `json:"results"`
}
func (r *HTTPReranker) Rerank(ctx context.Context, query string, docs []core.KnowledgeDoc, topK int) ([]core.KnowledgeDoc, error) {
route := r.resolve(ctx)
if !route.OK() || len(docs) == 0 {
return docs, nil // 未配置 = 显式跳过(排序退化为混合分)
}
reqBody := rerankRequest{Model: route.Model, Query: query, TopN: topK}
for _, d := range docs {
reqBody.Documents = append(reqBody.Documents, struct {
Text string `json:"text"`
}{Text: d.Chunk})
}
body, err := json.Marshal(reqBody)
if err != nil {
return docs, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, route.Base+"/rerank", bytes.NewReader(body))
if err != nil {
return docs, err
}
req.Header.Set("Content-Type", "application/json")
if route.Key != "" {
req.Header.Set("Authorization", "Bearer "+route.Key)
}
resp, err := r.client.Do(req)
if err != nil {
return docs, err // rerank 失败不阻断检索:返回原序
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if resp.StatusCode != http.StatusOK {
return docs, fmt.Errorf("rag: rerank %d: %s", resp.StatusCode, truncateStr(string(raw), 200))
}
var out rerankResponse
if err := json.Unmarshal(raw, &out); err != nil {
return docs, err
}
sorted := make([]core.KnowledgeDoc, 0, len(out.Results))
for _, res := range out.Results {
if res.Index >= 0 && res.Index < len(docs) {
sorted = append(sorted, docs[res.Index])
}
}
if len(sorted) == 0 {
return docs, nil
}
return sorted, nil
}
func truncateStr(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}