检索算法改进轨迹实测报告(附代码)
日期:2026-08-10 范围:
SoulMem分支feature/test_framework的 10 个连续 commit(ee2f086→a6e54af) 方法:逐个 checkout HEAD → 清空嵌入缓存 → 运行(1)suite full(soul-tune run retrieve/full fixtures/example_data --batch)与(2)格蕾修 L3 playtest(同一对话geluoxiu_peer_daily.json、同一图、Qwen3.5-4B-Q6_K) 原始日志:%TEMP%\traj\(每 HEAD 的*_suite.log/*_playtest.log/*_trace.txt)
1. 方法与输入控制
ee2f086→d9439dc之间 fixtures 数据零改动(question.json/graph.json/ 对话文件均未变),这 7 个 HEAD 是严格同输入的算法轨迹,suite 均为 693 用例,可直接横向比较。1c32b44(数据重合成)与17819e5(边重生成)属于数据改进,suite 变为 722 用例,单独标注。- 每个 HEAD 运行前删除
*.embcache,避免嵌入/边缓存污染测量(详见 §13 横切发现)。 - playtest 采用同一对话、同一图、同一 LLM 后端(llama-server + BGE),仅 HEAD 代码不同。
2. 总览
| HEAD | 提交说明 | suite 通过/总 | 动作Hit | Playtest 关键观察 |
|---|---|---|---|---|
| ee2f086 | test: retrieve/full 真正执行 DefaultPipeline 并接入动作评测 | 662/693 (95.5%) | 无评测 | embedding 每轮仅合并 2 节点,检索稀疏 |
| d042323 | fix: 修复 Situation 评分尺度并用主 LLM 替代 PAW 管线 | 660/693 (95.2%) | — | embedding 合并 5 节点,召回改善但 suite 微降 |
| a8846f2 | fix: 保留 PAW 管线,主 LLM 仅作临时兜底 | 660/693 (95.2%) | — | 与 d042323 完全一致(隔离出回归来自评分改动) |
| 2f7feb6 | fix: CLS+查询指令与 Situation 专用阈值,修复情境检索 | 669/693 (96.5%) | — | 情境检索修复,embedding 合并 7 节点 |
| eb34772 | fix: top-k+兜底阈值、字符串只加分、权重 0.3/0.7、priority 小偏移 | 683/693 (98.6%) | — | 合并满 10 节点、精确命中分 1.0 |
| 887220e | feat: 查询生成注入记忆锚点 + 生成后校验丢弃与空回退 | 683/693 | — | 查询 grounded、0 丢弃、数量收敛 4–8 |
| d9439dc | feat: 动作指标可见化,为 procedure 检出率量化铺路 | 683/693 | N/A(无真值) | 报告开始输出动作列 |
| 1c32b44 | data: deepseek-v4-flash 重合成 24 图评测数据(带 expected_actions) | 705/722 (97.6%) | 47.4% / R@3 0.375 | 第一次可量化的动作基线 |
| 17819e5 | data: 语义驱动的边重生成 + 动作真值对齐 | 705/722 | 60.9% → 81.5% / 0.584 | 动作触发质量大幅提升 |
| a6e54af | feat: procedure 动作独立 top-k 进入最终结果 | 705/722 | 81.5% | 动作进入上下文,回复引用行为 |
3. ee2f086 — 基线:DefaultPipeline 接入动作评测
问题:此前 suite 的 full 模式并未真正执行完整管线,动作检索(procedure)没有评测路径;纯相似度模式召回稀疏。
改进方向:让 suite 真正执行 RetrDefaultPipeline,并把动作输出纳入评测。
关键代码(crates/soul-tune/src/engine/retrieve/suite.rs):
// full 即 DefaultPipeline:ShortOnly(窗口/摘要) + Similarity + AssociateWithAction
let pipeline_config = DefaultPipelineConfig {
short_mem_with_history: ShortOnlyConfig { clipping_length: None, include_summary: true },
similarity: SimilarityConfig {
similarity_threshold: self.meta.similarity_threshold,
max_results: self.meta.max_results,
},
assoc_with_action: AssociateWithActionConfig {
association: Default::default(),
action_top_k: 3,
},
};
let pipeline_res = RetrDefaultPipeline {}.retrieve(pipeline_request);
all_full_action.extend(
pipeline_res.action.into_iter().map(|(id, score)| (id, score, priority)),
);
all_full_memory.extend(
pipeline_res.association.into_iter().map(|(id, score)| (id, score, priority)),
);
为什么:没有真实执行与动作真值,就无法知道检索差在哪、procedure 是否被检出。
效果:建立 95.5% 基线;playtest embedding 模式每轮只合并出 2 个节点(top-10 大面积为空),确认核心短板是召回不足;失败分散在风堇 5、桑多涅 4、格蕾修 3、琪亚娜 3、绫地宁宁 3。
4. d042323 — Situation 评分尺度修复 + 主 LLM 兜底
问题:Situation 查询的融合分被字符串分量缺失系统性压低(Situation 理论最高仅 0.36),情境检索基本不可用;PAW 服务不可达时管线直接失败。
改进方向:字符串分量缺失时退化为纯 embedding 分;PAW 不可用时主 LLM 兜底。
关键代码(crates/soul-mem-query/src/query/compute.rs):
// 字符串分量仅对精确标识符(Semantic content/aliases、AbstractSituation 结构化字段)生效;
// 对 SpecificSituation 等类型恒为 0。此时若仍按 (1-alpha)×0 混合,
// 会把 embedding 分系统性压缩到 alpha×上限以下(Situation 理论最高仅 0.36)……
// 因此无字符串信号时直接返回纯 embedding 分。
let score = if string_score <= 0.0 {
embedding_score
} else {
string_blend_alpha * embedding_score + (1.0 - string_blend_alpha) * string_score
};
为什么:评分尺度的目标是“同量纲可比较“,这是后续统一阈值的前提;修复链路则保证服务不可用时仍然可用。
效果:playtest embedding 合并 2→5 节点(召回改善)、top 相似度 0.51→0.61;但 suite 662→660 短暂回落(符华 +5、琪亚娜 +4、希儿 +3 失败)。这是尺度变化的短期代价。
5. a8846f2 — 恢复 PAW 优先、主 LLM 仅兜底
问题:把 JSON 修复整体切到主对话 LLM 后,修复质量与成本都不理想。
改进方向:PAW 优先、LLM 兜底。
关键代码(crates/soul-tune/src/engine/playtest/repair.rs):
/// 修复畸形 JSON:PAW 优先(原始管线),不可用或产出无效时用主对话 LLM 顶上。
pub fn repair_json(bad_json: &str, llm: &mut dyn LlmBackend) -> Option<String> {
// ...
if let Some(raw) = run_paw(JSON_REPAIR_SLUG, JSON_REPAIR_SPEC, &prompt, Some(1024)) {
if let Some(j) = try_parse(&raw) {
return Some(j);
}
}
// PAW 不可用或产出无效:暂时用主对话 LLM 顶上
let raw = llm.chat(JSON_REPAIR_SPEC, &prompt, 1024).ok()?;
try_parse(&raw)
}
为什么:修复链路的质量不应由主对话模型承担,同时避免小模型超时;该项不影响检索评分本身。
效果:suite 与 d042323 完全相同(660)——证明上一轮的 -2 回归来自 Situation 评分改动而非 LLM 兜底(变量被隔离);playtest top 相似度升至 0.63。
6. 2f7feb6 — CLS + 查询指令 + Situation 专用阈值
问题:BGE 默认 Mean pooling 与查询侧不带指令,与 passage 侧不对称(BGE v1.5 官方用法是 s2p 加查询指令);Situation 与 Semantic 共用阈值导致情境被过度过滤。
改进方向:Pooling::Cls、查询侧加检索指令、Situation 有效阈值 min(全局, 0.5)。
关键代码(crates/soul-mem-query/src/embedding/embedding_model/bge.rs):
/// BGE v1.5 官方 s2p 检索指令:仅查询侧添加,passage 侧不加。
pub const QUERY_INSTRUCTION: &str = "为这个句子生成表示以用于检索相关文章:";
fn infer_query_batch(&self, input: &[&str]) -> EmbeddingGenResult<Vec<EmbeddingVec>> {
let prefixed = prepend_query_instruction(input);
let refs: Vec<&str> = prefixed.iter().map(|s| s.as_str()).collect();
self.embed_gen_simple_batch(&refs)
}
关键代码(crates/soul-mem-algo/src/algo/retrieve/similarity.rs):
/// Situation 记忆没有字符串通道兜底(SpecificSituation 的 string_score 恒为 0),
/// 融合分尺度天然低于带 string 通道的 Semantic 查询,因此对 Situation 查询
/// 使用更宽松的有效阈值:`min(全局阈值, 该上限)`。
const SITUATION_SIMILARITY_THRESHOLD: f32 = 0.5;
fn effective_similarity_threshold(variant: &MemoryRetrieveQueryVariant, global: f32) -> f32 {
match variant {
MemoryRetrieveQueryVariant::Situation(_) => global.min(SITUATION_SIMILARITY_THRESHOLD),
_ => global,
}
}
为什么:查询侧与 passage 侧必须落在同一语义空间;Situation 分数分布天然偏低,需要自己的阈值而不是被语义阈值饿死。
效果:suite 660→669(+9):风堇 5→1、桑多涅 4→0、格蕾修 3→0、符华 6→3、琪亚娜 7→4;playtest embedding 合并 7 节点。
7. eb34772 — top-k + 兜底阈值 + 字符串只加分 + 权重 0.3/0.7 + priority 小偏移
问题:绝对阈值 0.7/0.35 会饿死相似度不高但合法的查询;字符串分与嵌入分同权混合会拉低纯嵌入命中;tag 通道权重过高(0.4)导致标签共现抬升无关实体;priority 直接乘分(曾达 55 倍)压制低优查询。
改进方向:阈值语义改为“最低兜底分 + 必取 top-k“;字符串只加分;tag/variant 0.3/0.7;priority 仅 ≤0.05 小偏移。
关键代码(similarity.rs):
// similarity_threshold 语义为"最低兜底分":低于该分的节点直接过滤,
// 达到该分的节点按分数取 top-k(max_results),绝对阈值不再饿死查询。
let floor = request.similarity_threshold;
// ... 过滤条件
if res.score < floor { None } else { Some(res) }
// ... 排序后 take(request.max_results)
关键代码(compute.rs,字符串只加分):
// 有字符串信号时取 max:字符串通道只加分、不拉低
// (当 string < embedding 时混合分会低于纯 embedding 分,取 max 保持"兜底加分"语义)。
let score = if string_score <= 0.0 {
embedding_score
} else {
let blended = string_blend_alpha * embedding_score + (1.0 - string_blend_alpha) * string_score;
embedding_score.max(blended)
};
关键代码(blend_weights.rs):
impl Default for BlendWeights {
Self {
tag: 0.3, // 原 0.4:标签在语义不匹配时会拖累 narrative/概念命中,
variant: 0.7, // 且标签共现(如"人物")易抬升无关实体,因此降低其主导作用
// ...
}
}
为什么:兜底分保证“不空手“、top-k 保证“有上限“;字符串加分避免字形近似拖低语义分;priority 小偏移保护重要查询又不破坏分数主导。
效果:suite 669→683(98.6%),失败 24→10(椎叶䌷/绫地宁宁/琪亚娜各 2 + 博丽/桑多涅/雾雨/风堇各 1);playtest 合并满 10 节点、精确命中分达 1.0。代价:suite 耗时 467s→1901s(约 4×,字符串分对每个节点 O(N) 计算),印证大数据量需要索引的预判。
8. 887220e — 查询生成注入记忆锚点 + 生成后校验
问题:查询生成纯靠 LLM 想象编造 5–8 条、无任何校验,幻觉查询直接进入检索。
改进方向:用用户消息做记忆锚点检索(top-5,低于兜底分不注入),生成后逐条校验 top-1 分数(低于 0.35 丢弃并空回退)。
关键代码(runner.rs,hint 选择):
fn select_hints(hits: Vec<HintHit>, k: usize, lookahead: usize, floor: f32) -> Vec<HintHit> {
if hits.first().map(|h| h.score) < Some(floor) {
return Vec::new(); // top-1 低于兜底分不注入(寒暄等无关轮次)
}
let mut out: Vec<HintHit> = hits.iter().take(k).cloned().collect();
// 多样性:top-k 无 Situation 且 top-lookahead 有达标 Situation 时替换第 k 个
if !out.iter().any(|h| h.is_situation) {
if let Some(best) = hits.iter().take(lookahead)
.filter(|h| h.is_situation && h.score >= floor)
.max_by(|a, b| a.score.total_cmp(&b.score)).cloned()
{ /* 替换/追加 */ }
}
out
}
关键代码(runner.rs,生成后校验):
fn validate_query(&self, query: PrioritizedMemoryRetrieveQuery, model: &dyn EmbeddingModel)
-> Option<PreparedQuery> {
let embedded = query.query().embed(model).ok()?;
let sim_req = SimilarityConfig { similarity_threshold: 0.0, max_results: 1 }
.into_request(self.wm.clone(), EmbeddedMemoryRetrieveQuery { embedding: embedded.clone(), query: query.query().clone() });
let top1 = RetrSimilarity {}.retrieve(sim_req).into_iter().next();
if matches!(top1, Some((_, score)) if score >= self.config.similarity_threshold) {
Some(PreparedQuery { query, embedding: Some(embedded), dropped: false })
} else {
None // 低于兜底分 → 丢弃,trace 标记 dropped
}
}
为什么:让 LLM 在生成时就看到真实记忆片段,从源头减少编造;校验作为确定性安全网(嵌入结果缓存进查询对象,检索阶段不二次嵌入)。
效果:suite 不变(683,查询生成不参与 suite);playtest 查询数从 8/8/8 收敛到 6/4/5(4–8 上限)、18 条查询全部 grounded、0 丢弃、无无检索。
9. d9439dc — 动作指标可见化
问题:动作指标(ActionMetrics)计算了但从不输出,procedure 检出率没有任何数字。
改进方向:ActionMetrics 增加 has_expected_actions 区分占位/真实评测,新增汇总函数与报告列。
关键代码(engine/retrieve/data.rs):
pub struct ActionMetrics {
pub action_hit_rate: f64,
pub action_recall_at: Vec<(usize, f64)>,
/// 该用例是否带 expected_actions 真值(False 表示占位指标,不应计入统计)
pub has_expected_actions: bool,
}
关键代码(engine/batch.rs):
pub fn summarize_action_metrics(outcomes: &[TestCaseOutcome]) -> Option<ActionSummary> {
// 只统计 has_expected_actions 为 true 的用例
// 汇总:cases / hit_cases / recall_at3(加权平均)
}
为什么:无法观测就无法改进;占位指标必须与真实评测区分,否则平均值被污染。
效果:行为零变化(suite 683 不变),报告开始输出动作列;当时数据无 expected_actions 故为 N/A——为量化铺路。
10. 1c32b44 — 数据重合成(deepseek-v4-flash)+ expected_actions 真值
问题:旧数据 0 个用例带 expected_actions,动作永远无真值可评;且模型偶发空响应会让修复路径产出缩水的 7–12 用例数据集。
改进方向:questioner 生成 expected_actions(proc_* 节点 id);完整性门 + 最多 3 次重试。
关键代码(soul_scraper data_model/questioner/retrieve.rs):
pub struct PrioritizedRetrieveQuery {
pub priority: u32,
pub tag: Vec<String>,
pub variant: RetrieveQueryVariant,
#[serde(default)]
pub expected: ExpectedResult,
/// 期望触发的程序性记忆节点(proc_* id,1-2 个)……
#[serde(default)]
pub expected_actions: Vec<String>,
}
关键代码(soul_scraper agents/questioner_agent.rs,完整性门):
const MIN_ATOMIC_QUERIES: usize = 15;
const MIN_TOTAL_QUERIES: usize = 45;
const MAX_ATTEMPTS: usize = 3;
for attempt in 0..Self::MAX_ATTEMPTS {
match Self::generate_once(...).await {
Ok(info) => {
let (atomic, total) = Self::query_counts(&info);
if atomic >= Self::MIN_ATOMIC_QUERIES && total >= Self::MIN_TOTAL_QUERIES {
return Ok(info); // 只有查询量达标的生成才被接受
}
// 否则丢弃,重新完整生成
}
// ...
}
}
为什么:检出率必须建立在有真值的数据上;小数据集会让评测失去代表性;空响应是管线故障而非模型能力问题(deepseek-v4-flash 擅长长输出,需让管线正确处理偶发空响应)。
效果:新数据 722 用例(比旧 693 多)、行为可触发用例标注率 89%(156/175)、0 未知 id;第一次拿到可量化的动作基线:Hit 47.4%、R@3 0.375——证实“procedure 检出率低“的判断。
11. 17819e5 — 语义驱动的边重生成 + 动作真值对齐
问题:强制 proc_none 连接(39% 情境的 top-1 是“无动作“)、图指标门槛(聚类/冗余度)逼着乱连、softmax 归一化把触发概率压平(中位数 0.32);纯 Semantic 查询的动作标注在 Sit→Proc 设计下结构性不可达。
改进方向:边生成只按语义——每个非 proc_none 的 proc 必须有 ≥1 条情境触发入边(校验失败即重试);概率保比例归一化;图指标降为仅观察;expected_actions 只保留 Situation 变体查询。
关键代码(soul_scraper graph_quality.rs,保比例归一化):
/// 对每个源节点的所有 Proc 出边做保比例归一化,使 prob 和为 1:
/// 保持 LLM 给出的相对区分度(softmax 会把 0.9/0.1 压成 0.69/0.31,
/// 破坏"主行为 vs 次要行为"的判别性,改用除以和的方式保留比值)。
let sum: f64 = proc_probs.iter().map(|(_, p)| *p).sum();
if (sum - 1.0).abs() > 1e-9 && sum > 0.0 {
for (_, (idx, _)) in proc_probs.iter().enumerate() {
if let MemoryLinkType::Proc(ref mut p) = nodes[i].mem_links[*idx].link_type {
p.prob /= sum;
}
}
}
关键代码(soul_scraper graph_quality.rs,硬校验):
if !proc_without_incoming_proc.is_empty() {
failures.push(format!(
"Procedure 节点缺少触发情境(必须有至少 1 条来自 Situation 的 Proc 入边): {}",
proc_without_incoming_proc.join(", ")
));
}
关键代码(soul_scraper prompt_template/extractor_edge_system,边生成规则):
- 只连接语义上真实存在的关联:不要为了图指标(密度、聚类系数、连通性)强行连边……
- 抽象情境 → proc_none:仅当该情境语义上确实对应"没有特定行动、保持现状"时才连接;
不要为了概率完整性强制连接。
- 概率要有区分度:主行为概率应显著高于次要行为(通常主行为 ≥0.5,次要行为 0.1-0.3),
不要给多个行为分配近乎相等的概率。
- 硬性要求:每个非 proc_none 的 Procedure 节点必须至少有一条来自语义匹配的 Situation
的 Proc 入边——结构验证会检查这一点,不满足会要求重新生成。
为什么:行为指导的质量取决于“情境→行为“边是否语义真实、概率是否有判别性;不可达的真值只会污染指标。
效果:156/156 proc 全覆盖、概率中位数 0.32→0.50、proc_none 边 149→33;动作 Hit 47.4%→60.9%(新边)→81.5%(清掉 73 条不可达真值)、R@3 0.375→0.584;内存检索 705/722 不变。
12. a6e54af — procedure 动作独立 top-k 进入最终结果
问题:动作与记忆节点同池按分合并,Bayes 动作分低,top-10 截断后 LLM 完全看不到行为倾向(playtest 显示动作被检索到但进不了上下文)。
改进方向:动作独立通道——按自身分数单独排 top-k(action_top_k=3),恒进入最终结果;提示词追加「当前行为倾向」小节。
关键代码(runner.rs,独立通道):
// 动作节点独立通道:不参与记忆分数合并,单独排名后进入最终结果
let mut merged_actions: HashMap<MemoryId, (f64, TracedNode)> = HashMap::new();
// 记忆合并只含 sim+ppr(不再 chain action_nodes)
fold_priority_nodes(&mut merged_map, merged, bonus);
fold_priority_nodes(&mut merged_actions, action_nodes, bonus);
// ...
let mut all_nodes = finish_merged(merged_map);
all_nodes.truncate(self.config.merged_top_k);
// 动作独立 top-k:即使分数低于记忆节点也保留进最终结果
let mut action_nodes = finish_merged(merged_actions);
action_nodes.truncate(self.config.action_top_k);
关键代码(runner.rs,提示词注入):
let mut full_prompt = format!("{}\n\n相关记忆:\n{}", chat_prompt, full_context);
if !full_action_text.is_empty() {
full_prompt = format!("{}\n\n当前行为倾向:\n{}", full_prompt, full_action_text);
}
关键代码(trace.rs):
pub struct RetrievalTrace {
pub mode: RetrieveMode,
pub total_elapsed: Duration,
pub merged_nodes: Vec<TracedNode>,
/// 独立于记忆分数的动作节点(procedure top-k):按动作自身分数单独排名,
/// 不参与 merged_nodes 的分数合并与截断,保证"瞬时行为倾向"始终进入最终结果。
pub action_nodes: Vec<TracedNode>,
pub per_query: Vec<QueryTrace>,
}
为什么:行为指导是“瞬时“信号,不该与知识检索抢排位;独立席位保证它一定被 LLM 看到。
效果:playtest 三轮动作稳定进入上下文(proc_silence 0.30 / 收面饼 0.13 / 模仿 0.09 等),FullPipeline 回复开始引用行为(“最近我在看科幻电影,光剑的招式让我觉得很放松”),与 Embedding 对照组出现有意义分化;suite 保持 81.5%。
13. 横切发现
- embcache 不随边失效:嵌入缓存只按嵌入实现版本失效,
graph.json的边(Sit→Proc)变化不会触发重建。本次测量踩过一次坑——旧 HEAD 重建的缓存含旧 proc_none 高分(0.373),当前 HEAD 的 playtest 一度读到旧边;清缓存后正确(真实动作 0.30/0.13/0.09)。建议:给 embcache 增加边哈希失效条件,否则“改边后“的所有测量都可能失真。 - 精度提升有 CPU 代价:eb34772 引入逐节点字符串分后,suite 耗时约 4×(467s→1901s),与“大图需要索引(HNSW)“的预判一致。
- 残留问题:eb34772 的 10 个失败(椎叶䌷/绫地/琪亚娜等)在数据重合成后仍以低命中形态存在(常陆茉子 11%、格蕾修 60%),属于 Bayes 聚合精度的检索侧问题,是下一轮的明确候选。
14. 复现方法
# 逐 HEAD:checkout → 清 embcache → build → suite full → playtest
$env:SOUL_TUNE_CANDLE_MODEL_PATH = 'D:\QwenModel\Qwen3.5-4B-Q6_K.gguf'
$env:SOUL_TUNE_LLAMA_PORT = '8094'
git checkout <head>
Get-ChildItem fixtures\example_data -Recurse -Filter *.embcache | Remove-Item -Force
cargo run -p soul-tune -- run retrieve/full fixtures/example_data --batch
cargo run -p soul-tune -- playtest fixtures/example_data/格蕾修_https_zh_moegirl_org_cn_E6_A0_BC_E8_95_BE_E4_BF_AE fixtures/daily_dialogues/geluoxiu_peer_daily.json
git checkout a6e54af
轨迹覆盖的 HEAD:ee2f086 → d042323 → a8846f2 → 2f7feb6 → eb34772 → 887220e → d9439dc → 1c32b44 → 17819e5 → a6e54af。