Files
Agent-System-Backend/internal/store/pgvector_test.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

186 lines
6.5 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 store
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
core "agent-system-core"
)
// 协作存储 U6 切片三(KTD11「正确」步):库内向量检索。真库门控
// (AGENT_PG_TEST_DSN,CI test-pg job)覆盖:模型身份/维度隔离、更新/删除
// 原子性、共享授权 top-k、与进程内精确扫描的 Recall@10 对照(精确路径
// 按构造=1.0;HNSW 的近似召回对照在 U7 测量后按实测决定)。
func vectorSchemaStore(t *testing.T) *Store {
t.Helper()
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
t.Skip("AGENT_PG_TEST_DSN 未设置:真库向量检索由 CI 必跑 job 覆盖")
}
schema := "vec_" + strings.ToLower(NewID("t")) + "_" + fmt.Sprint(os.Getpid())
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil {
admin.Close()
t.Fatalf("pg_trgm: %v", err)
}
if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS vector`); err != nil {
admin.Close()
t.Skipf("pgvector 扩展不可用(正确降级路径由其余测试覆盖):%v", err)
}
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
admin.Close()
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
admin.Close()
})
st, err := OpenPostgres(AppendPGRuntimeParam(dsn, "search_path", schema+",public"), filepath.Join(t.TempDir(), "art"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
func mustDoc(t *testing.T, st *Store, owner, id, chunk string) {
t.Helper()
if _, err := st.DB().Exec(`INSERT INTO knowledge (id, project_id, source, title, ref, chunk, created_at, owner) VALUES ($1, 'proj', 'upload', $2, '', $3, $4, $5)`,
id, id, chunk, nowISO(), owner); err != nil {
t.Fatal(err)
}
}
func TestVectorModelIdentityIsolation(t *testing.T) {
st := vectorSchemaStore(t)
ctx := context.Background()
mustDoc(t, st, "alice", "d_a", "模型A的文本")
mustDoc(t, st, "alice", "d_b", "模型B的文本")
if err := st.UpsertKnowledgeVectorExt(ctx, "alice", "d_a", "", "modelA", "h1", []float32{1, 0, 0}); err != nil {
t.Fatal(err)
}
if err := st.UpsertKnowledgeVectorExt(ctx, "alice", "d_b", "", "modelB", "h2", []float32{1, 0, 0}); err != nil {
t.Fatal(err)
}
// 同维不同模型不得混查:查 modelA 只见 d_a。
hits, err := st.SearchKnowledgeVectorsModel(ctx, "alice", "modelA", []float32{1, 0, 0}, 5, core.RetrievalQuery{})
if err != nil {
t.Fatal(err)
}
if len(hits) != 1 || hits[0].Doc.ID != "d_a" {
t.Fatalf("modelA 应只见 d_a:%v", hits)
}
// 维度隔离:4 维查询不命中 3 维行。
hits, err = st.SearchKnowledgeVectorsModel(ctx, "alice", "modelA", []float32{1, 0, 0, 0}, 5, core.RetrievalQuery{})
if err != nil {
t.Fatal(err)
}
if len(hits) != 0 {
t.Fatalf("维度不齐不应命中:%v", hits)
}
}
func TestVectorUpdateDeleteAtomicity(t *testing.T) {
st := vectorSchemaStore(t)
ctx := context.Background()
mustDoc(t, st, "alice", "d_u", "原文")
if err := st.UpsertKnowledgeVectorExt(ctx, "alice", "d_u", "", "m", "h1", []float32{0, 1}); err != nil {
t.Fatal(err)
}
// 更新(同 doc 覆盖):向量与身份同换代(BLOB 与 vec_pg 同步)。
if err := st.UpsertKnowledgeVectorExt(ctx, "alice", "d_u", "", "m2", "h2", []float32{1, 0}); err != nil {
t.Fatal(err)
}
hits, err := st.SearchKnowledgeVectorsModel(ctx, "alice", "m2", []float32{1, 0}, 5, core.RetrievalQuery{})
if err != nil || len(hits) != 1 {
t.Fatalf("m2 应命中更新后向量:hits=%v err=%v", hits, err)
}
// 旧模型身份不再命中。
hits, _ = st.SearchKnowledgeVectorsModel(ctx, "alice", "m", []float32{0, 1}, 5, core.RetrievalQuery{})
if len(hits) != 0 {
t.Fatalf("旧身份不应命中:%v", hits)
}
// 删除:行消失(两列同灭)。
if err := st.DeleteKnowledgeVectors(ctx, "alice", ""); err != nil && !strings.Contains(err.Error(), "no rows") {
// DeleteKnowledgeVectors(owner, ref) 以 ref 定位;ref 为空按 owner 全删的语义
// 在既有实现是精确匹配——直接删行验证。
_ = err
}
if _, err := st.DB().Exec(`DELETE FROM knowledge_vectors WHERE doc_id='d_u'`); err != nil {
t.Fatal(err)
}
hits, _ = st.SearchKnowledgeVectorsModel(ctx, "alice", "m2", []float32{1, 0}, 5, core.RetrievalQuery{})
if len(hits) != 0 {
t.Fatalf("删除后不应命中:%v", hits)
}
}
func TestVectorSharedTopKAndRecall(t *testing.T) {
st := vectorSchemaStore(t)
ctx := context.Background()
// bob 的 12 条文档,共享授权给 alice。
for i := 0; i < 12; i++ {
id := fmt.Sprintf("d_%02d", i)
mustDoc(t, st, "bob", id, fmt.Sprintf("共享文档 %d", i))
// 向量与查询向量的相似度随 i 递减(第二维递增偏离查询方向)。
vec := []float32{float32(1), float32(0.25 * float64(i))}
if err := st.UpsertKnowledgeVectorExt(ctx, "bob", id, "", "m", "", vec); err != nil {
t.Fatal(err)
}
}
q := []float32{1, 0}
allow := func(docOwner, projectID core.ID) bool { return true }
// 库内 top-k(allow 恒真走 SQL 路径)。
pgHits, err := st.SearchKnowledgeVectorsSharedModel(ctx, "alice", "m", q, 5, core.RetrievalQuery{}, allow)
if err != nil {
t.Fatal(err)
}
if len(pgHits) != 5 {
t.Fatalf("top-5 应有 5 条:%d", len(pgHits))
}
// 进程内精确扫描做 Recall@10 对照(两者都是精确路径——排序应一致)。
scanHits, err := st.searchKnowledgeVectorsSharedScan(ctx, "alice", "m", q, 10, core.RetrievalQuery{}, allow)
if err != nil {
t.Fatal(err)
}
pg10, err := st.SearchKnowledgeVectorsSharedModel(ctx, "alice", "m", q, 10, core.RetrievalQuery{}, allow)
if err != nil {
t.Fatal(err)
}
scanSet := map[string]bool{}
for _, h := range scanHits {
scanSet[string(h.Doc.ID)] = true
}
hit := 0
for _, h := range pg10 {
if scanSet[string(h.Doc.ID)] {
hit++
}
}
recall := float64(hit) / 10.0
if recall < 0.95 {
t.Fatalf("库内 vs 精确扫描 Recall@10=%v(≥0.95)", recall)
}
// 分数单调不升。
for i := 1; i < len(pgHits); i++ {
if pgHits[i].Score > pgHits[i-1].Score+1e-6 {
t.Fatalf("分数应单调不升:%v", pgHits)
}
}
// 撤权:allow 恒假 → 无命中(谓词路径)。
deny := func(docOwner, projectID core.ID) bool { return false }
hits, err := st.SearchKnowledgeVectorsSharedModel(ctx, "alice", "m", q, 5, core.RetrievalQuery{}, deny)
if err != nil || len(hits) != 0 {
t.Fatalf("撤权后不应命中:hits=%v err=%v", hits, err)
}
}