mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-24 10:58:13 +00:00
feat(memory): 场景式关联召回——给记忆节点赋场景引用,场面重现即取回
背景(实测):带条件的记忆召不回来。生产库里明明有 「QQ回复禁用Markdown格式 --规定--> 纯文本不用Markdown」「老大 --偏好--> 同左」, 但输入「QQ回复格式」时命中 148 个实体、规则排第 32,注入只取前 5——规则根本没进去; 输入「在吗」这种零内容词的短消息,向量路反而灌进 17 个毫不相关的实体。 根因:词法/向量召回都建立在「字面或语义相似」上,而条件式记忆(在什么场合该怎么做) 约束的是**场面**不是话题。用户措辞不重合时它天然召不回;措辞太宽("QQ")时又被同形 命中淹没。另一处:自动注入只给实体名索引,而规则本体长在关系上(relation_type + object), 即使命中名字也拿不到「纯文本不用Markdown」这句正文。 改动:把「触发条件」升成一等索引维度。 - schema:新增 scenes(key) + scene_refs(scene_id, kind, ref_id, weight), kind ∈ relation|entity。刻意不建外键:节点可能先于引用被清理, 悬空引用由读取侧 JOIN 过滤,级联删除会把清理变成跨表事务。 - 场景键是分层字符串(`/` 分隔,由宽到窄):chan:qq、chan:qq/peer:group_123、 tool:qq_get_message。NormalizeSceneKey 归一(小写、空白/标点→_、按 `/` 分层), 空白不算层级——否则「老大2026-09-04 12:27 QQ私聊图片」这种来源名会被拆成伪层级。 - 写入即挂场景:Triple 新增 Scene 字段,commit() 在同一事务里把「关系 + 两端实体」 挂到场景上(同事务是必须的:关系进库但引用丢了 = 这条记忆永远无声地召不回来)。 - 召回:RecallByScene 前缀匹配(chan:qq 取回 chan:qq 及所有更窄场景;用 `/` 兜底 防止 chan:qq 吞掉 chan:qq2),按 weight(=写入置信度)降序,返回**关系全文 + 原句**。 - 注入:BuildContextInScene 在词法/向量之外叠加场景路,FormatContext 把场景块排在 最前(规则对行为的约束强于话题相关的实体名),上限 8 条 + 原句截断 60 字; 场景实体不在【记忆索引】里重复占位。BuildContext(input) 保持原语义(无场景)。 - 当前场景推导:payload.scene 显式声明 > 通道(chan:qq)> 工具(tool:qq_get_message), 并列命中不取交集。qq 通道本身 RecallPolicy=none(到达的是中断元文本), 真正召回在 qq_get_message 工具上——现在那一步同时带上 chan:qq 与 tool:qq_get_message。 - 写入侧:memory_commit 新增 scene 参数(逐条 triples[].scene 优先,顶层 scene 作批次默认); docToTriples 按文档来源自动带 chan:<source>(QQ 归档的知识天然属于 QQ 场面)。 不做自动猜测:猜错的场景会把无关记忆钉死,之后每次进入该场面都被注入。 - 存量引导:memgc -tag-scene <键> -entity-glob <GLOB>。用 GLOB 而非 LIKE—— LIKE 对 ASCII 不区分大小写,`%QQ%` 会把对象带 /home/newqqagent 的路径类记忆 (生产数据目录、email-mcp、dify-ops 路径…实测 7 条)一起卷进 QQ 场景。 - 清理对齐:PurgeNoise/PurgeOrphans 之后顺带删悬空场景引用,并提供 PurgeStaleSceneRefs;memgc -scene-stats 看场景规模。 验证:go build/vet 干净,go test -count=1 ./... 全绿。 新增用例:场景键归一(含超长/分层/空白)、写入即挂场景(两端实体进、未标的实体不进)、 前缀语义(含 chan:qq2 反例)、weight 排序与 limit、GLOB 存量引导(dry-run 不写库)、 清理后无悬空引用、场景注入面(关系全文+原句+不在索引重复占位)、 agent 侧 sceneKeysFor 优先级(显式声明 > 通道 > 工具、数组形式、nil 安全)。 生产库实测(先 sqlite3 .backup 到 graph.db.bak-20260915-081043 再写): 把 22 条 QQ 相关关系标进 chan:qq(GLOB *QQ* 19 条 + *qq_* 3 条)。同一批输入前后对比: - 「在吗」:改前注入 17 个无关实体;改后场景块直接给出「QQ回复禁用Markdown格式 --规定--> 纯文本不用Markdown」等规则正文(零字面重合也能召回)。 - 「QQ回复格式」:改前规则排第 32 被截掉;改后排在场景块首位。 - 「帮我发个语音」:场景规则置顶,词法路的 qq通道语音输入 等仍在其后。
This commit is contained in:
@ -55,6 +55,10 @@ type Triple struct {
|
||||
// 媒体不再靠正文 marker 反解:结构化字段直接给出归属,
|
||||
// 由调用方(core)把它变成 L3 一等块并与句子建立结构边。
|
||||
MediaDigests []string `json:"media_digests,omitempty"`
|
||||
// Scene 是这条记忆所属的**场景键**(可空)。写完后该三元组的两个实体
|
||||
// 与这条关系都会被挂到这个场景上,场景重现时按场景召回。
|
||||
// 约定见 memory.NormalizeSceneKey:`chan:qq`、`chan:qq/peer:group_123`。
|
||||
Scene string `json:"scene,omitempty"`
|
||||
}
|
||||
|
||||
type GraphDB struct {
|
||||
@ -172,6 +176,32 @@ func (g *GraphDB) initSchema() error {
|
||||
summary TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
// 场景引用:给「记忆节点」再赋一层**触发条件**。
|
||||
//
|
||||
// 为什么需要它:词法/向量召回都靠「字面或语义相似」,而带条件的规则
|
||||
// (「回 QQ 消息不要用 Markdown」「老大消息优先」)在措辞不重合时根本
|
||||
// 召不回来。场景是这类记忆的**索引键**:节点记住自己「属于哪个场面」,
|
||||
// 场面重现(又来一条 QQ 消息)时直接按场景取回,不靠字面命中。
|
||||
`CREATE TABLE IF NOT EXISTS scenes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
// ref_id 的解释由 kind 决定(relation / entity)。这里不用外键:
|
||||
// 节点可能先于引用被清理(PurgeNoise/PurgeOrphans),悬空引用由
|
||||
// 读取侧的 JOIN 自然过滤掉,而级联删除会把清理变成一个跨表事务。
|
||||
`CREATE TABLE IF NOT EXISTS scene_refs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scene_id INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
ref_id INTEGER NOT NULL,
|
||||
weight REAL DEFAULT 1.0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(scene_id, kind, ref_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_scene_refs_scene ON scene_refs(scene_id, kind)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_scene_refs_ref ON scene_refs(kind, ref_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_memory_blocks_modality ON memory_blocks(modality)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_memory_blocks_digest ON memory_blocks(payload_digest)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_memory_block_edges_source ON memory_block_edges(source_kind, source_id)`,
|
||||
@ -365,24 +395,27 @@ func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSe
|
||||
}
|
||||
}
|
||||
|
||||
var existing int
|
||||
var existing int64
|
||||
err = tx.QueryRow(
|
||||
`SELECT 1 FROM relations WHERE source_id = ? AND target_id = ? AND relation_type = ? AND session_id = ?`,
|
||||
`SELECT id FROM relations WHERE source_id = ? AND target_id = ? AND relation_type = ? AND session_id = ?`,
|
||||
sourceID, targetID, t.Relation, sessionID,
|
||||
).Scan(&existing)
|
||||
var relID int64
|
||||
if err == sql.ErrNoRows {
|
||||
_, err = tx.Exec(
|
||||
res, ierr := tx.Exec(
|
||||
`INSERT INTO relations (source_id, target_id, relation_type, confidence, session_id, turn_id, date_bucket, sentence_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
sourceID, targetID, t.Relation, confidence, sessionID, turnID, dateBucket, sentenceID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
if ierr != nil {
|
||||
return nil, 0, 0, ierr
|
||||
}
|
||||
relID, _ = res.LastInsertId()
|
||||
relationsCreated++
|
||||
} else if err != nil {
|
||||
return nil, 0, 0, err
|
||||
} else {
|
||||
relID = existing
|
||||
// 同一(会话内)三元组已存在:仅刷新置信度与时间戳,不重复计数
|
||||
_, err = tx.Exec(
|
||||
`UPDATE relations SET confidence = ?, updated_at = CURRENT_TIMESTAMP
|
||||
@ -393,6 +426,15 @@ func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSe
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
// 场景引用:写完关系立即把「关系 + 两端实体」挂到这个场景上。
|
||||
// 同一事务内完成,避免出现「关系写进去了但场景引用丢了」——
|
||||
// 那会让这条记忆在后来的场景里永远召不回来,且无声无息。
|
||||
if t.Scene != "" && relID != 0 {
|
||||
if err := tagSceneTx(tx, t.Scene, relID, []int64{sourceID, targetID}, confidence); err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user