Files
Agent-System-Backend/internal/rag/hybrid.go
T
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

277 lines
8.6 KiB
Go
Raw 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 (
"context"
"crypto/sha256"
"encoding/hex"
"log"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// Pipeline 实现设计 4.12 检索管线:
//
// 索引:文档 → 分块 → 全文入库 →(embedding 配置时)向量入库
// 查询:Embedding → 向量检索 + 全文检索 → RRF 融合 →(rerank 配置时)重排 → TopK
//
// 未配置 embedding 时自动退化为纯全文档(默认行为不变)。
type Pipeline struct {
st *store.Store
set *settings.Service
chunker FixedChunker
embedder core.EmbeddingProvider
reranker core.Reranker
}
func NewPipeline(st *store.Store, set *settings.Service, embedder core.EmbeddingProvider, reranker core.Reranker) *Pipeline {
return &Pipeline{st: st, set: set, chunker: FixedChunker{}, embedder: embedder, reranker: reranker}
}
// SecretLookup 由组合根注入。service 是 models.embedding / models.rerank。
var SecretLookup func(service, fallback string) string
func lookupSecret(service, fallback string) string {
if SecretLookup != nil {
return SecretLookup(service, fallback)
}
return fallback
}
// SetEmbedder/SetReranker 允许组合根后注入(embedder 的路由解析依赖本管线自身)。
func (p *Pipeline) SetEmbedder(e core.EmbeddingProvider) { p.embedder = e }
func (p *Pipeline) SetReranker(r core.Reranker) { p.reranker = r }
// EmbeddingRoute 是 embedding 模型路由解析(供 HTTP 客户端闭包使用)。
func (p *Pipeline) EmbeddingRoute(ctx context.Context) Route { return p.embeddingRoute(ctx) }
// RerankRoute 是 rerank 模型路由解析。
func (p *Pipeline) RerankRoute(ctx context.Context) Route { return p.rerankRoute(ctx) }
func (p *Pipeline) embeddingRoute(ctx context.Context) Route {
eff := p.set.Effective(store.OwnerDefault, "")
return Route{
Base: eff.GetString("models.embedding.baseUrl", ""),
Key: lookupSecret("models.embedding", eff.GetString("models.embedding.apiKey", "")),
Model: eff.GetString("models.embedding.model", ""),
}
}
func (p *Pipeline) rerankRoute(ctx context.Context) Route {
eff := p.set.Effective(store.OwnerDefault, "")
return Route{
Base: eff.GetString("models.rerank.baseUrl", ""),
Key: lookupSecret("models.rerank", eff.GetString("models.rerank.apiKey", "")),
Model: eff.GetString("models.rerank.model", ""),
}
}
// IndexDocument 检索索引入口(core.DocumentIndexer):分块 + 全文 + 可选向量。
// embedding 失败降级为纯全文(记日志,不阻断上传/工件链路)。
func (p *Pipeline) IndexDocument(owner, projectID core.ID, source, title, ref, plainText string) error {
if plainText == "" {
return nil
}
doc := core.ParsedDocument{Blocks: []core.DocBlock{{Type: core.BlockPara, Text: plainText}}}
chunks := p.chunker.Chunk(doc)
if len(chunks) == 0 {
return nil
}
docs := make([]core.KnowledgeDoc, len(chunks))
for i, c := range chunks {
docs[i] = core.KnowledgeDoc{Owner: owner, ProjectID: projectID, Source: source, Title: title, Ref: ref, Chunk: c.Text}
}
return p.IndexChunks(context.Background(), owner, docs)
}
// IndexChunks 写入已构造好的知识分块并建向量(EXT-1 KnowledgeWrite embed=true 的路径)。
// AddKnowledge 原地回填 docs[i].ID;embedding 失败降级为纯全文(记日志,不阻断写入)。
func (p *Pipeline) IndexChunks(ctx context.Context, owner core.ID, docs []core.KnowledgeDoc) error {
if len(docs) == 0 {
return nil
}
if err := p.st.AddKnowledge(docs...); err != nil {
return err
}
if p.embedder == nil {
return nil
}
texts := make([]string, len(docs))
for i, d := range docs {
texts[i] = d.Chunk
}
vecs, err := p.embedder.Embed(ctx, texts)
if err != nil {
log.Printf("[rag] embedding 降级为纯全文(ref=%s): %v", firstRef(docs), err)
return nil
}
model := embedderModel(p.embedder)
for i, v := range vecs {
if len(v) == 0 {
continue
}
if err := p.st.UpsertKnowledgeVectorExt(ctx, owner, docs[i].ID, docs[i].Ref, model, contentHash(docs[i].Chunk), v); err != nil {
return err
}
}
return nil
}
// embedderModel 模型身份(可选接口:EmbeddingProvider 契约未含,实现侧
// 声明;未声明=空=legacy 无身份行,向后兼容)。
func embedderModel(e core.EmbeddingProvider) string {
if m, ok := e.(interface{ Model() string }); ok {
return m.Model()
}
return ""
}
// contentHash 分块内容摘要(写入幂等核对用;sha256 前 16 字节十六进制)。
func contentHash(chunk string) string {
sum := sha256.Sum256([]byte(chunk))
return hex.EncodeToString(sum[:])[:16]
}
func firstRef(docs []core.KnowledgeDoc) string {
for _, d := range docs {
if d.Ref != "" {
return d.Ref
}
}
return ""
}
// RetrieveShared EXT-8 余项:本人两路如常 + 追加共享两路(向量 Shared + 全文
// Shared,allow=适配器判定),RRF 融合后裁剪 topK。embedder 不可用时共享仅全文
// (与切片 2 行为一致)。
func (p *Pipeline) RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) {
own, err := p.Retrieve(ctx, owner, q)
if err != nil {
return nil, err
}
shared, err := p.st.SearchKnowledgeShared(string(owner), q, allow)
if err != nil {
return nil, err
}
// 向量共享路:embedder 可用时并入。
if p.embedder != nil && p.embeddingRoute(ctx).OK() {
if qvecs, e := p.embedder.Embed(ctx, []string{q.Text}); e == nil && len(qvecs) > 0 && len(qvecs[0]) > 0 {
topK := q.TopK
if topK <= 0 {
topK = 5
}
vs, e2 := p.st.SearchKnowledgeVectorsSharedModel(ctx, string(owner), embedderModel(p.embedder), qvecs[0], topK, q, allow)
if e2 == nil {
shared = rrfMerge(vs, shared)
}
}
}
merged := rrfMerge(own, shared)
if limit := q.TopK; limit > 0 && len(merged) > limit {
merged = merged[:limit]
}
return merged, nil
}
// Retrieve 统一检索(core.Retriever)。
func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
topK := q.TopK
if topK <= 0 {
topK = 5
}
if p.embedder == nil {
return p.st.SearchKnowledge(owner, q)
}
route := p.embeddingRoute(ctx)
offline, _ := p.embedder.(interface{ Offline() bool })
if !route.OK() && (offline == nil || !offline.Offline()) {
return p.st.SearchKnowledge(owner, q)
}
qvecs, err := p.embedder.Embed(ctx, []string{q.Text})
if err != nil || len(qvecs) == 0 || len(qvecs[0]) == 0 {
// 查询向量失败:退化为全文(不因可选能力故障阻断主链路)。
if err != nil {
log.Printf("[rag] query embedding fallback to fulltext: %v", err)
}
return p.st.SearchKnowledge(owner, q)
}
q.TopK = topK * 2 // 每路多取,融合后再裁剪
vhits, err := p.st.SearchKnowledgeVectorsModel(ctx, owner, embedderModel(p.embedder), qvecs[0], topK*2, q)
if err != nil {
return nil, err
}
fhits, err := p.st.SearchKnowledge(owner, q)
if err != nil {
return nil, err
}
merged := rrfMerge(vhits, fhits)
if p.reranker != nil && p.rerankRoute(ctx).OK() && len(merged) > 0 {
docs := make([]core.KnowledgeDoc, len(merged))
for i, h := range merged {
docs[i] = h.Doc
}
reordered, err := p.reranker.Rerank(ctx, q.Text, docs, topK)
if err != nil {
log.Printf("[rag] rerank fallback to rrf order: %v", err)
} else {
byID := map[core.ID]core.RetrievalHit{}
for _, h := range merged {
byID[h.Doc.ID] = h
}
out := make([]core.RetrievalHit, 0, len(reordered))
for _, d := range reordered {
if h, ok := byID[d.ID]; ok {
out = append(out, h)
}
}
if len(out) >= topK {
return out[:topK], nil
}
return out, nil
}
}
if len(merged) > topK {
merged = merged[:topK]
}
return merged, nil
}
// rrfMerge 倒数排名融合(k=60):同文档多路命中分数相加;向量路保留 Kind=vector。
func rrfMerge(lists ...[]core.RetrievalHit) []core.RetrievalHit {
const k = 60
type acc struct {
hit core.RetrievalHit
score float64
}
order := []core.ID{}
byID := map[core.ID]*acc{}
for _, list := range lists {
for rank, h := range list {
a, ok := byID[h.Doc.ID]
if !ok {
a = &acc{hit: h}
byID[h.Doc.ID] = a
order = append(order, h.Doc.ID)
}
a.score += 1.0 / float64(k+rank+1)
if h.Kind == "vector" {
a.hit.Kind = "vector" // 向量命中优先标注
}
}
}
out := make([]core.RetrievalHit, 0, len(order))
for _, id := range order {
h := byID[id].hit
h.Score = byID[id].score
out = append(out, h)
}
// 稳定排序(分数降序)。
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j].Score > out[j-1].Score; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out
}