mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
fix(memory): 场景召回按块向量余弦排序 + 召回 query 可见性 + 对话回执放宽
① 场景召回是每轮常驻注入,此前按「场景权重→时间倒序」排,与 query 无关: 老图库的 rt/rtchild/inputch 等历史事实常年霸榜,实测 1833 字符里九成是它们, 刚聊的群聊内容一句没有 —— 这就是"忘事"的机制。 块里本来就存着向量,相关性就是一次余弦比较:RecallByScene 带出端点/原句块 向量,Indexer 按 max(cos) 排序并设 0.20 下限,取前 8。 无稠密空间时保持原顺序(不能用字面匹配冒充语义 —— 既有判据要求 「措辞与记忆零字面重合时场景路必须兜住」)。 ② recall query 可见性:cleaner 缺失/生效/回空三种情况都打日志。此前静默回退 原文,"召回 query 其实是工具原始回执"这件事在日志里完全看不到。 实测 qq_get_message: 199 -> 27 bytes(cleaner 生效)。 ③ 对话正文类工具(get_message/get_history/list_chats)时间线渲染放宽到 400 字 (160 字会把群聊上下文切成碎片)。 判据:internal/memory/scene_relevance_test.go
This commit is contained in:
@ -383,6 +383,11 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
// 未配置时退化到 TF-IDF/fastText 稀疏检索,保持既有行为。
|
// 未配置时退化到 TF-IDF/fastText 稀疏检索,保持既有行为。
|
||||||
if cfg.MultimodalSpace != nil && cfg.MultimodalSpace.Loaded() {
|
if cfg.MultimodalSpace != nil && cfg.MultimodalSpace.Loaded() {
|
||||||
rc.SetDenseSpace(cfg.MultimodalSpace)
|
rc.SetDenseSpace(cfg.MultimodalSpace)
|
||||||
|
// 场景召回的相关性判定用同一空间:块里存的就是这个空间的向量,
|
||||||
|
// 所以"相关"只是一次余弦比较(缺了它就只能按时间倒序灌旧事实)。
|
||||||
|
if cfg.Indexer != nil {
|
||||||
|
cfg.Indexer.SetDenseSpace(cfg.MultimodalSpace)
|
||||||
|
}
|
||||||
if cfg.DocStore != nil {
|
if cfg.DocStore != nil {
|
||||||
cfg.DocStore.SetDenseSpace(cfg.MultimodalSpace)
|
cfg.DocStore.SetDenseSpace(cfg.MultimodalSpace)
|
||||||
cfg.DocStore.BuildDenseIndex(cfg.MultimodalSpace)
|
cfg.DocStore.BuildDenseIndex(cfg.MultimodalSpace)
|
||||||
|
|||||||
@ -75,11 +75,22 @@ func (a *Agent) toolOutputForQuery(toolName, raw string) string {
|
|||||||
}
|
}
|
||||||
cleaner := a.stageHost.ToolDefCleaner(toolName)
|
cleaner := a.stageHost.ToolDefCleaner(toolName)
|
||||||
if cleaner == nil {
|
if cleaner == nil {
|
||||||
|
// ★ 可见性(2026-10-27):cleaner 缺失时必须**说出来**。
|
||||||
|
// 此前这里静默回退原文,于是"召回 query 是 tool 原始 map[...] 字符串"
|
||||||
|
// 这件事在日志里完全看不到 —— 只能靠读代码猜。
|
||||||
|
if len(raw) > 0 {
|
||||||
|
log.Printf("[agent] recall query for %s has NO cleaner, using raw output (%d bytes, head=%q)",
|
||||||
|
toolName, len(raw), headRunes(raw, 40))
|
||||||
|
}
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
if cleaned := cleaner(raw); cleaned != "" {
|
cleaned := cleaner(raw)
|
||||||
|
if cleaned != "" {
|
||||||
|
log.Printf("[agent] recall query for %s cleaned: %d -> %d bytes (head=%q)",
|
||||||
|
toolName, len(raw), len(cleaned), headRunes(cleaned, 40))
|
||||||
return cleaned
|
return cleaned
|
||||||
}
|
}
|
||||||
|
log.Printf("[agent] recall query for %s cleaner returned empty, falling back to raw (%d bytes)", toolName, len(raw))
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -588,10 +599,27 @@ const (
|
|||||||
// 时间线是"给下一轮看的历史",不是当轮工作区 —— 够让模型认出
|
// 时间线是"给下一轮看的历史",不是当轮工作区 —— 够让模型认出
|
||||||
// "我查到了什么",细节仍可用 coldcontext_query / 工具重取。
|
// "我查到了什么",细节仍可用 coldcontext_query / 工具重取。
|
||||||
toolResultTimelineChars = 160
|
toolResultTimelineChars = 160
|
||||||
|
// toolResultTimelineCharsChat 是**对话正文类**工具(qq_get_message /
|
||||||
|
// qq_get_history)的放宽值(2026-10-27):群聊上下文被切到 160 字后只剩碎片,
|
||||||
|
// 模型看得到"有人说过平板",看不到"平板→promax→机型→随机发货"这条链。
|
||||||
|
toolResultTimelineCharsChat = 400
|
||||||
// toolResultTimelineMax 单条事件最多渲染几条工具回执。
|
// toolResultTimelineMax 单条事件最多渲染几条工具回执。
|
||||||
toolResultTimelineMax = 3
|
toolResultTimelineMax = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// isChatCorpusTool 报告该工具的输出是否属于"对话正文"(需要更长的渲染)。
|
||||||
|
//
|
||||||
|
// 判定用后缀而不是全名:工具名带插件前缀(qq_get_message / telegram_get_message…),
|
||||||
|
// 而语义是"取聊天正文"这一族。
|
||||||
|
func isChatCorpusTool(name string) bool {
|
||||||
|
for _, suffix := range []string{"_get_message", "_get_history", "_list_chats", "_search_messages"} {
|
||||||
|
if strings.HasSuffix(name, suffix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// oneLine 压成单行(时间线是一行一条事件,换行会破坏结构)。
|
// oneLine 压成单行(时间线是一行一条事件,换行会破坏结构)。
|
||||||
func oneLine(s string) string {
|
func oneLine(s string) string {
|
||||||
s = strings.ReplaceAll(s, "\r", " ")
|
s = strings.ReplaceAll(s, "\r", " ")
|
||||||
@ -625,7 +653,11 @@ func (a *Agent) renderedToolResultsOf(e *ContextEvent) []string {
|
|||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, fmt.Sprintf("%s: %s", tr.Name, truncateStr(txt, toolResultTimelineChars)))
|
limit := toolResultTimelineChars
|
||||||
|
if isChatCorpusTool(tr.Name) {
|
||||||
|
limit = toolResultTimelineCharsChat
|
||||||
|
}
|
||||||
|
out = append(out, fmt.Sprintf("%s: %s", tr.Name, truncateStr(txt, limit)))
|
||||||
}
|
}
|
||||||
if skipped > 0 {
|
if skipped > 0 {
|
||||||
out = append(out, fmt.Sprintf("+%d more tool results", skipped))
|
out = append(out, fmt.Sprintf("+%d more tool results", skipped))
|
||||||
@ -643,3 +675,12 @@ func (a *Agent) buildMessages(sysPrompt, input string, ctxTokens int) []agentAPI
|
|||||||
msgs = append(msgs, agentAPI.Message{Role: "user", Content: input})
|
msgs = append(msgs, agentAPI.Message{Role: "user", Content: input})
|
||||||
return msgs
|
return msgs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// headRunes 取前 n 个 rune 用于日志(避免把整段回执打进 journal)。
|
||||||
|
func headRunes(s string, n int) string {
|
||||||
|
r := []rune(strings.TrimSpace(s))
|
||||||
|
if len(r) <= n {
|
||||||
|
return string(r)
|
||||||
|
}
|
||||||
|
return string(r[:n]) + "…"
|
||||||
|
}
|
||||||
|
|||||||
@ -115,6 +115,17 @@ type Relation struct {
|
|||||||
SentenceText string `json:"sentence_text,omitempty"` // 原句全文(走原句块回溯)
|
SentenceText string `json:"sentence_text,omitempty"` // 原句全文(走原句块回溯)
|
||||||
// SentenceBlockID 是原句块的 ID(blk_src_<hash>),供场景引用与去重。
|
// SentenceBlockID 是原句块的 ID(blk_src_<hash>),供场景引用与去重。
|
||||||
SentenceBlockID string `json:"sentence_block_id,omitempty"`
|
SentenceBlockID string `json:"sentence_block_id,omitempty"`
|
||||||
|
// SourceVec / TargetVec / SentenceVec 是端点块与原句块的**稠密向量**。
|
||||||
|
//
|
||||||
|
// 为什么带在关系上:场景召回是"每轮都要跑的常驻注入",排序必须与本轮
|
||||||
|
// query 相关(块里本来就存着向量,相关性就是一次余弦比较)。
|
||||||
|
// 此前 RecallByScene 只按「场景权重 → 时间倒序」排,老图库里的历史高频
|
||||||
|
// 三元组常年霸榜,每轮灌同一批无关内容(实测 90% token 是这些)。
|
||||||
|
//
|
||||||
|
// 不落 JSON / 不入库:它们只用于本次比较,块向量本身仍在 memory_blocks 里。
|
||||||
|
SourceVec []float64 `json:"-"`
|
||||||
|
TargetVec []float64 `json:"-"`
|
||||||
|
SentenceVec []float64 `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Triple struct {
|
type Triple struct {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package memory
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@ -27,6 +28,21 @@ type Indexer struct {
|
|||||||
// 起来"就是这么来的。这里记下上次同步的实体数,召回前发现数量变了就补一次。
|
// 起来"就是这么来的。这里记下上次同步的实体数,召回前发现数量变了就补一次。
|
||||||
lastSyncCount int
|
lastSyncCount int
|
||||||
lastSyncAt time.Time
|
lastSyncAt time.Time
|
||||||
|
|
||||||
|
// dense 是多模态稠密空间(可选)。场景召回的相关性用它算余弦 ——
|
||||||
|
// 块里本来就有向量(memory_blocks.vector),所以"相关"就是一次向量比较,
|
||||||
|
// 不需要另建索引,也不该退化成字符串匹配。
|
||||||
|
dense vector.MultimodalEmbedder
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDenseSpace 注入稠密多模态空间(由内核装配时传入)。
|
||||||
|
func (idx *Indexer) SetDenseSpace(ds vector.MultimodalEmbedder) {
|
||||||
|
if idx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx.mu.Lock()
|
||||||
|
idx.dense = ds
|
||||||
|
idx.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// retrainInterval 是增量重训的最小间隔。
|
// retrainInterval 是增量重训的最小间隔。
|
||||||
@ -253,8 +269,18 @@ func (idx *Indexer) BuildContextInScene(userInput string, scenes []string) *Inje
|
|||||||
Relations: nil,
|
Relations: nil,
|
||||||
}
|
}
|
||||||
if sceneRecall != nil {
|
if sceneRecall != nil {
|
||||||
|
// ★ 场景召回按**块向量余弦**排序并设下限(2026-10-27 现场)。
|
||||||
|
//
|
||||||
|
// RecallByScene 的排序是「场景权重 → 时间倒序」,与本轮 query 无关:
|
||||||
|
// 老图库里 `rt 标签 / rtchild / inputch` 这类历史事实常年霸榜,
|
||||||
|
// 每轮都灌同一批无关三元组(实测 1833 字符里九成是它们),
|
||||||
|
// 真正刚聊的内容一句没有 —— 模型"忘事"就是这样产生的。
|
||||||
|
//
|
||||||
|
// 块里本就存着向量,所以相关性只是一次余弦比较(不建额外索引)。
|
||||||
|
// 措辞零字面重合但语义相近的场景记忆仍然会被召回(既有判据要求)。
|
||||||
|
ranked := idx.rankSceneRelationsByVector(input, sceneRecall.Relations)
|
||||||
ctx.Scenes = sceneRecall.Scenes
|
ctx.Scenes = sceneRecall.Scenes
|
||||||
ctx.SceneRelations = sceneRecall.Relations
|
ctx.SceneRelations = ranked
|
||||||
ctx.SceneBlocks = sceneRecall.Blocks
|
ctx.SceneBlocks = sceneRecall.Blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -408,6 +434,82 @@ func (idx *Indexer) FormatContext(ctx *InjectedContext) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sceneRelevanceFloor 是场景关系的余弦下限。
|
||||||
|
//
|
||||||
|
// 0.20 的依据:多模态空间下同一段对话内的话题相关短语通常 ≥0.35,
|
||||||
|
// 而跨主题(rt 设计 vs 平板闲聊)实测多在 0.1 以下;0.20 夹在中间,
|
||||||
|
// 既不会把"换了个说法"的真记忆挡在门外,也不会让长期霸榜的旧事实混进来。
|
||||||
|
const sceneRelevanceFloor = 0.20
|
||||||
|
|
||||||
|
// rankSceneRelationsByVector 用 query 向量给场景关系打分并截断。
|
||||||
|
//
|
||||||
|
// 无稠密空间(未启用多模态)时退回原顺序:此时没有可用向量,
|
||||||
|
// 宁可少一层排序,也不能用字符串匹配冒充语义(既有判据明确要求
|
||||||
|
// 「措辞与记忆零字面重合时场景路必须兜住」)。
|
||||||
|
func (idx *Indexer) rankSceneRelationsByVector(query string, rels []Relation) []Relation {
|
||||||
|
if len(rels) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
idx.mu.RLock()
|
||||||
|
ds := idx.dense
|
||||||
|
idx.mu.RUnlock()
|
||||||
|
if ds == nil || !ds.Loaded() || strings.TrimSpace(query) == "" {
|
||||||
|
if len(rels) > maxSceneRecallRelations {
|
||||||
|
return rels[:maxSceneRecallRelations]
|
||||||
|
}
|
||||||
|
return rels
|
||||||
|
}
|
||||||
|
qv, err := ds.VectorizeDense(query)
|
||||||
|
if err != nil || len(qv) == 0 {
|
||||||
|
if len(rels) > maxSceneRecallRelations {
|
||||||
|
return rels[:maxSceneRecallRelations]
|
||||||
|
}
|
||||||
|
return rels
|
||||||
|
}
|
||||||
|
type scored struct {
|
||||||
|
rel Relation
|
||||||
|
sim float64
|
||||||
|
}
|
||||||
|
out := make([]scored, 0, len(rels))
|
||||||
|
for _, rel := range rels {
|
||||||
|
best := 0.0
|
||||||
|
for _, v := range [][]float64{rel.SentenceVec, rel.SourceVec, rel.TargetVec} {
|
||||||
|
if len(v) != len(qv) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s := denseCosine(qv, v); s > best {
|
||||||
|
best = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best >= sceneRelevanceFloor {
|
||||||
|
out = append(out, scored{rel: rel, sim: best})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool { return out[i].sim > out[j].sim })
|
||||||
|
if len(out) > maxSceneRecallRelations {
|
||||||
|
out = out[:maxSceneRecallRelations]
|
||||||
|
}
|
||||||
|
kept := make([]Relation, 0, len(out))
|
||||||
|
for _, e := range out {
|
||||||
|
kept = append(kept, e.rel)
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
// denseCosine 计算两个等长向量的余弦相似度(零向量返回 0)。
|
||||||
|
func denseCosine(a, b []float64) float64 {
|
||||||
|
var dot, na, nb float64
|
||||||
|
for i := range a {
|
||||||
|
dot += a[i] * b[i]
|
||||||
|
na += a[i] * a[i]
|
||||||
|
nb += b[i] * b[i]
|
||||||
|
}
|
||||||
|
if na == 0 || nb == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return dot / (math.Sqrt(na) * math.Sqrt(nb))
|
||||||
|
}
|
||||||
|
|
||||||
// maxSceneRecallRelations 是单次场景召回的关系上限。
|
// maxSceneRecallRelations 是单次场景召回的关系上限。
|
||||||
//
|
//
|
||||||
// 场景是**每轮都要注入**的常驻内容:不封顶时一个宽场景(如 chan:qq)
|
// 场景是**每轮都要注入**的常驻内容:不封顶时一个宽场景(如 chan:qq)
|
||||||
|
|||||||
@ -391,6 +391,7 @@ func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error
|
|||||||
relQuery := `SELECT e.id, sb.id, tb.id, sb.text_content, tb.text_content,
|
relQuery := `SELECT e.id, sb.id, tb.id, sb.text_content, tb.text_content,
|
||||||
e.edge_type, e.confidence, COALESCE(e.status, ''), e.session_id,
|
e.edge_type, e.confidence, COALESCE(e.status, ''), e.session_id,
|
||||||
e.turn_id, e.created_at, COALESCE(srt.text, ''),
|
e.turn_id, e.created_at, COALESCE(srt.text, ''),
|
||||||
|
COALESCE(sb.vector, ''), COALESCE(tb.vector, ''), COALESCE(srt.vector, ''),
|
||||||
MAX(sr.weight) AS w
|
MAX(sr.weight) AS w
|
||||||
FROM scene_refs sr
|
FROM scene_refs sr
|
||||||
JOIN scenes s ON sr.scene_id = s.id
|
JOIN scenes s ON sr.scene_id = s.id
|
||||||
@ -398,7 +399,7 @@ func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error
|
|||||||
JOIN memory_blocks sb ON sb.id = e.source_id
|
JOIN memory_blocks sb ON sb.id = e.source_id
|
||||||
JOIN memory_blocks tb ON tb.id = e.target_id
|
JOIN memory_blocks tb ON tb.id = e.target_id
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT c.target_id AS edge_id, MIN(b.text_content) AS text
|
SELECT c.target_id AS edge_id, MIN(b.text_content) AS text, MIN(b.vector) AS vector
|
||||||
FROM memory_block_edges c
|
FROM memory_block_edges c
|
||||||
JOIN memory_blocks b ON b.id = c.source_id
|
JOIN memory_blocks b ON b.id = c.source_id
|
||||||
WHERE c.edge_type = 'contains' AND c.target_kind = 'edge'
|
WHERE c.edge_type = 'contains' AND c.target_kind = 'edge'
|
||||||
@ -420,13 +421,25 @@ func (g *GraphDB) RecallByScene(scenes []string, limit int) (*SceneRecall, error
|
|||||||
// relations 行号,块体系下无意义)。
|
// relations 行号,块体系下无意义)。
|
||||||
// ★ DateBucket / SentenceID 不再有来源:旧表退场后边表不存这两列,
|
// ★ DateBucket / SentenceID 不再有来源:旧表退场后边表不存这两列,
|
||||||
// 日期可从 created_at 推,原句 ID 由 contains 边回溯得到。
|
// 日期可从 created_at 推,原句 ID 由 contains 边回溯得到。
|
||||||
|
var srcVecRaw, tgtVecRaw, senVecRaw string
|
||||||
if err := rows.Scan(&rel.ID, &rel.SourceBlockID, &rel.TargetBlockID,
|
if err := rows.Scan(&rel.ID, &rel.SourceBlockID, &rel.TargetBlockID,
|
||||||
&rel.SourceName, &rel.TargetName,
|
&rel.SourceName, &rel.TargetName,
|
||||||
&rel.RelationType, &rel.Confidence, &rel.Status, &rel.SessionID,
|
&rel.RelationType, &rel.Confidence, &rel.Status, &rel.SessionID,
|
||||||
&rel.TurnID, &rel.CreatedAt, &rel.SentenceText, &w); err != nil {
|
&rel.TurnID, &rel.CreatedAt, &rel.SentenceText,
|
||||||
|
&srcVecRaw, &tgtVecRaw, &senVecRaw, &w); err != nil {
|
||||||
rows.Close()
|
rows.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 块向量(算场景相关度用):解码失败就当没有,不影响召回本身。
|
||||||
|
if v, err := decodeVector(srcVecRaw); err == nil {
|
||||||
|
rel.SourceVec = v
|
||||||
|
}
|
||||||
|
if v, err := decodeVector(tgtVecRaw); err == nil {
|
||||||
|
rel.TargetVec = v
|
||||||
|
}
|
||||||
|
if v, err := decodeVector(senVecRaw); err == nil {
|
||||||
|
rel.SentenceVec = v
|
||||||
|
}
|
||||||
// 旧表退场后边表不存 date_bucket,从 created_at 推日期桶。
|
// 旧表退场后边表不存 date_bucket,从 created_at 推日期桶。
|
||||||
if rel.DateBucket == "" && !rel.CreatedAt.IsZero() {
|
if rel.DateBucket == "" && !rel.CreatedAt.IsZero() {
|
||||||
rel.DateBucket = rel.CreatedAt.Format("2006-01-02")
|
rel.DateBucket = rel.CreatedAt.Format("2006-01-02")
|
||||||
|
|||||||
72
internal/memory/scene_relevance_test.go
Normal file
72
internal/memory/scene_relevance_test.go
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
// 场景召回必须按**块向量余弦**与当前 query 相关,而不是按历史热度/时间。
|
||||||
|
//
|
||||||
|
// 现场(2026-10-27):群里聊平板/机型,注入的 1833 字记忆里九成是
|
||||||
|
// `rt 标签 / rtchild / inputch` 这类老图库高频三元组 —— 模型"忘事"就是
|
||||||
|
// 这么来的。块里本来就存着向量,所以相关性只是一次余弦比较。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDenseCosineBasics(t *testing.T) {
|
||||||
|
if got := denseCosine([]float64{1, 0}, []float64{1, 0}); math.Abs(got-1) > 1e-9 {
|
||||||
|
t.Fatalf("同向余弦应为 1,实际 %v", got)
|
||||||
|
}
|
||||||
|
if got := denseCosine([]float64{1, 0}, []float64{0, 1}); math.Abs(got) > 1e-9 {
|
||||||
|
t.Fatalf("正交余弦应为 0,实际 %v", got)
|
||||||
|
}
|
||||||
|
if got := denseCosine([]float64{0, 0}, []float64{1, 0}); got != 0 {
|
||||||
|
t.Fatalf("零向量余弦应为 0,实际 %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeEmbedder 把文本映射成固定向量:包含 "平板" ⇒ 与 query 同向;
|
||||||
|
// "rt" ⇒ 正交。用来验证"闸门按语义而不是字面"。
|
||||||
|
type fakeEmbedder struct{}
|
||||||
|
|
||||||
|
func (fakeEmbedder) Loaded() bool { return true }
|
||||||
|
func (fakeEmbedder) Dim() int { return 2 }
|
||||||
|
func (fakeEmbedder) Fingerprint() string { return "fake" }
|
||||||
|
func (fakeEmbedder) VectorizeDense(text string) ([]float64, error) {
|
||||||
|
if hasSub(text, "平板") || hasSub(text, "机型") {
|
||||||
|
return []float64{1, 0}, nil
|
||||||
|
}
|
||||||
|
return []float64{0, 1}, nil
|
||||||
|
}
|
||||||
|
func (fakeEmbedder) Vectorize([]string) [][]float64 { return nil }
|
||||||
|
|
||||||
|
// EmbedImageDense / Close 满足 vector.MultimodalEmbedder 的完整接口
|
||||||
|
// (测试只用文本路;图片与生命周期在真空间里才有意义)。
|
||||||
|
func (fakeEmbedder) EmbedImageDense([]byte, string) ([]float64, error) { return nil, nil }
|
||||||
|
|
||||||
|
func (fakeEmbedder) Close() {}
|
||||||
|
|
||||||
|
func hasSub(s, sub string) bool {
|
||||||
|
return strings.Contains(s, sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRankSceneRelationsByVectorDropsUnrelated(t *testing.T) {
|
||||||
|
idx := NewIndexer(nil)
|
||||||
|
idx.SetDenseSpace(fakeEmbedder{})
|
||||||
|
rels := []Relation{
|
||||||
|
{SourceName: "rt标签", TargetName: "内核", SourceVec: []float64{0, 1}, TargetVec: []float64{0, 1}},
|
||||||
|
{SourceName: "平板", TargetName: "promax", SourceVec: []float64{1, 0}, TargetVec: []float64{0.9, 0.1}},
|
||||||
|
}
|
||||||
|
kept := idx.rankSceneRelationsByVector("群友在聊平板机型", rels)
|
||||||
|
if len(kept) != 1 || kept[0].SourceName != "平板" {
|
||||||
|
t.Fatalf("只应保留与 query 语义相关的三元组,实际 %+v", kept)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRankSceneRelationsByVectorNoEmbedderKeepsOrder(t *testing.T) {
|
||||||
|
idx := NewIndexer(nil) // 未注入稠密空间
|
||||||
|
rels := []Relation{{SourceName: "a"}, {SourceName: "b"}}
|
||||||
|
kept := idx.rankSceneRelationsByVector("随便问问", rels)
|
||||||
|
if len(kept) != 2 || kept[0].SourceName != "a" {
|
||||||
|
t.Fatalf("无稠密空间时应保留原顺序(不能用字面匹配冒充语义),实际 %+v", kept)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user