# 03 - 渐进式回忆（Progressive Recall）

## 概述

Progressive Recall 是天工 Memory 的核心检索机制。核心思想：

> 不做一次性全量注入，而是从粗到细、按需展开。

`recall_memory` 只是 Core 向 Memory 提供的一次外部刺激，不等同于深度回忆。Core 不负责判断回忆深度，也不直接构建深度检索策略；Core 只提供当前 prompt、上下文、调用原因和期望信息类型。Memory 系统收到刺激后，先做一次低成本初始回忆，再根据初始回忆的真实结果判断是否需要深入回忆。

### 回忆分层语义

| 层级 | 触发方式 | 目标 | 行为 |
|------|----------|------|------|
| Skip | 初始规划认为不需要历史信息 | 不消耗检索预算 | 不召回，不向 Core 注入内容 |
| Simple | 初始回忆已经足够 | 快速补齐少量历史线索 | 粗召回 + 少量展开 + 直接整理 |
| Normal | 初始回忆命中多条相关记忆 | 整理常规长期记忆 | 混合召回 + Depth2 展开 + 去重压缩 |
| Deep | 初始回忆不足以回答当前刺激 | 深挖来源、决策、产物和关联 Episode | 基于初始结果生成 follow-up queries，并沿 Entity / Decision 的关联 Episode 做关系追溯 |

### 先回忆、再判断是否深挖

深度回忆不是预设策略，也不是一次 `recall_memory` 调用必然执行的动作。Memory 的内部流程是：

```text
Core 主模型判断需要问 Memory
  ↓
recall_memory(stimulus, context)
  ↓
Memory 初始回忆
  - anchor 提取
  - BM25 / vector / hybrid 粗召回
  - 命中节点 Depth2 展开
  ↓
Memory 根据初始结果判断
  - 初始结果是否足够
  - 是否存在信息缺口、冲突、低置信度、缺少来源 Episode
  - 是否需要继续追溯 Entity / Decision / Artifact / Evidence
  ↓
不需要深挖：整理初始回忆并返回
需要深挖：执行 Deep Recall，再统一整理输出
```

深挖判断由 Memory 内部使用专用 Memory LLM 完成，判断依据来自“初始回忆结果”，而不是触发词。典型输入包括：

- Core 提供的外部刺激 prompt。
- 当前对话上下文。
- 初始召回命中的标题、摘要、类型、分数。
- Depth2 展开的完整 Episode / Entity / Decision 内容。
- 当前输出预算和已知上下文，避免重复注入。

判断输出结构：

```rust
pub struct DeepRecallDecision {
    pub need_deep_recall: bool,
    pub reason: String,
    pub followup_queries: Vec<String>,
    pub target_kinds: Vec<MemoryKind>,
    pub max_rounds: usize,
}
```

只有当初始回忆不足以支撑当前回答时，Memory 才进入 deep recall。比如：

- 初始命中为空，但上下文明显依赖历史产物或历史决策。
- 命中的是 Entity / Decision，但缺少来源 Episode。
- 用户需要解释“为什么当时这么选”，而初始命中只给出结论。
- 命中媒体或文件产物，但缺少生成上下文、保存路径或后续使用方式。
- 多条记忆冲突，需要进一步追溯来源和时间顺序。

Deep Recall 包含两类追溯，不互相替代：

- **查询追溯**：使用专用 Memory LLM 给出的 follow-up queries 再次进行 BM25 / vector / hybrid 召回，适合初始命中不足或需要换角度检索的场景。
- **关系追溯**：对初始命中和后续命中的 Entity / Decision 完整内容解析 `related_episodes`、`episode_ids`、`related_episode_ids`，把来源 Episode 补回候选集，适合“只有结论，缺少当时上下文”的场景。

输出给 Core 的始终是整理后的增量记忆，而不是完整检索过程。Memory 必须避免重复当前上下文、重复 URL、重复路径和重复工具结果摘要。

## 工作流（6 阶段）

```
用户输入
  ↓
[Stage 1] Query 分析 ─── 判断偏关键词、偏语义、还是混合
  ↓
[Stage 2] 锚点提取 ─── 从当前 turn 提取关键词和意图
  ↓
[Stage 3] 双引擎召回 ── Tantivy 全文 topK + Qdrant 语义 topK
  ↓
[Stage 4] 融合重排 ──── 分数归一化 + 时间衰减 + 重要度加权 + reranker
  ↓
[Stage 5] 定向展开 ──── 对命中的条目加载 depth-2 结构化数据
  ↓
[Stage 6] 证据回放 ──── 必要时加载 depth-3 原始证据
```

---

## Stage 1: Query 分析

**输入**：当前 turn 的用户消息

**输出**：`QueryIntent`

```rust
#[derive(Debug, Clone)]
pub enum QueryIntent {
    /// 偏关键词：包含代码符号、文件路径、错误码、命令等精确术语
    Keyword,
    /// 偏语义：自然语言描述、模糊意图、"之前那个XXX"
    Semantic,
    /// 混合：既有精确术语又有模糊意图
    Hybrid,
}

pub fn analyze_query(text: &str) -> QueryIntent {
    let has_code_symbols = contains_code_pattern(text);
    let has_paths = contains_file_path(text);
    let has_error_codes = contains_error_pattern(text);
    let has_vague_ref = contains_vague_reference(text); // "之前""那个""上次"

    let precise_signals = has_code_symbols as u8 + has_paths as u8 + has_error_codes as u8;
    let vague_signals = has_vague_ref as u8;

    match (precise_signals > 0, vague_signals > 0) {
        (true, false) => QueryIntent::Keyword,
        (false, true) => QueryIntent::Semantic,
        _ => QueryIntent::Hybrid,
    }
}
```

**Query 分析决定召回策略**：

| Intent | Tantivy 权重 | Qdrant 权重 | 说明 |
|--------|-------------|-------------|------|
| Keyword | 0.7 | 0.3 | 优先精确匹配 |
| Semantic | 0.3 | 0.7 | 优先语义召回 |
| Hybrid | 0.5 | 0.5 | 均衡融合 |

> **降级策略**：当 `LlmConfig.embedding` 未配置时，Qdrant 引擎不可用，所有 Intent 均回退为 Tantivy 单引擎召回（权重 1.0），Query 分析结果保留但仅用于日志记录。

> **Memory LLM 策略**：Query/anchor 规划、Deep Recall 裁决和结果整理必须使用独立 Memory LLM 配置。未配置 Memory LLM 时，这些步骤必须使用规则 fallback，不得自动复用主 `chat` 模型。

---

## Stage 2: 锚点提取

**输入**：当前 turn 的用户消息 + 最近 2 条消息

**输出**：`RecallAnchors`

```rust
#[derive(Debug, Clone)]
pub struct RecallAnchors {
    pub keywords: Vec<String>,
    pub intent: Option<String>,
    pub entity_refs: Vec<String>,   // 引用的实体名
    pub session_id: String,
    pub workspace_id: Option<String>,
}
```

**实现策略（Phase C 初期）**：

- **不使用 LLM 调用**（避免增加延迟），采用规则提取：
  1. 分词 → 提取名词/动词/专有名词
  2. 匹配已知实体名（Entity Memory 中的 `title`）
  3. 提取代码标识符（蛇形命名、驼峰命名等模式）

```rust
pub fn extract_anchors(
    messages: &[Message],
    known_entities: &[String],
    session_id: &str,
    workspace_id: Option<&str>,
) -> RecallAnchors {
    let text = messages.iter()
        .filter(|m| m.role == MessageRole::User)
        .map(|m| m.content.as_str())
        .collect::<Vec<_>>()
        .join(" ");

    let keywords = extract_keywords(&text);
    let entity_refs = known_entities.iter()
        .filter(|e| text.contains(e.as_str()))
        .cloned()
        .collect();

    RecallAnchors {
        keywords,
        intent: None, // Phase C+ 可加 LLM intent 分类
        entity_refs,
        session_id: session_id.to_string(),
        workspace_id: workspace_id.map(String::from),
    }
}
```

---

## Stage 3: 双引擎召回

**输入**：`RecallAnchors` + `QueryIntent`

**输出**：`Vec<RecallHit>`（来自两个引擎的原始候选集）

> **降级模式**：当 `LlmConfig.embedding` 未配置时，Qdrant 引擎不可用，Stage 3 仅执行 Tantivy 全文召回，跳过 3b 语义召回。Stage 4 融合重排退化为 Tantivy 单引擎排序。

```rust
#[derive(Debug, Clone)]
pub struct RecallHit {
    pub node_id: String,
    pub kind: MemoryKind,
    pub title: String,
    pub summary: String,         // depth-1
    pub relevance_score: f32,    // 0.0-1.0（归一化后）
    pub source: RecallSource,    // 来源标记
}

#[derive(Debug, Clone)]
pub enum RecallSource {
    Tantivy { bm25_score: f32 },
    Qdrant { cosine_score: f32 },
    Both { bm25_score: f32, cosine_score: f32 },
}
```

### 3a. Tantivy 全文召回

使用 BM25 多字段查询，适合精确术语命中：

```rust
pub fn tantivy_recall(
    searcher: &tantivy::Searcher,
    schema: &Schema,
    anchors: &RecallAnchors,
    limit: usize,
) -> Result<Vec<RecallHit>> {
    // 构建查询：关键词 + 实体引用
    let query_text = anchors.keywords.join(" ")
        + " "
        + &anchors.entity_refs.join(" ");

    let scope_filter = anchors.workspace_id.as_ref()
        .map(|wid| ("workspace", wid.as_str()));

    let results = fulltext_search(searcher, schema, &query_text, scope_filter, limit)?;

    // 转换为 RecallHit（score 归一化到 0-1）
    let max_score = results.first().map(|(s, _)| *s).unwrap_or(1.0);
    results.into_iter()
        .map(|(score, id)| {
            let normalized = if max_score > 0.0 { score / max_score } else { 0.0 };
            // 从 SQLite 补全 title/summary
            let node = load_node_meta(&id)?;
            Ok(RecallHit {
                node_id: id,
                kind: node.kind,
                title: node.title,
                summary: node.summary,
                relevance_score: normalized,
                source: RecallSource::Tantivy { bm25_score: score },
            })
        })
        .collect()
}
```

### 3b. Qdrant 语义召回

使用 embedding 余弦相似度，适合跨措辞匹配：

```rust
use tiangong_llm::embedding::{EmbeddingProvider, EmbeddingRequest};

pub async fn qdrant_recall(
    client: &QdrantClient,
    embedder: &dyn EmbeddingProvider,
    anchors: &RecallAnchors,
    embedding_model: &str,
    limit: usize,
) -> Result<Vec<RecallHit>> {
    // 将查询文本转为 embedding
    let query_text = if let Some(intent) = &anchors.intent {
        format!("{} {}", intent, anchors.keywords.join(" "))
    } else {
        anchors.keywords.join(" ")
    };
    let resp = embedder.embed(EmbeddingRequest {
        model: embedding_model.to_string(),
        input: vec![query_text],
        dimensions: None,
    }).await?;
    let query_embedding = resp.data.into_iter()
        .next()
        .map(|d| d.embedding)
        .unwrap_or_default();

    let scope_filter = anchors.workspace_id.as_ref()
        .map(|wid| ("workspace", wid.as_str()));

    let results = semantic_search(client, query_embedding, scope_filter, limit as u64).await?;

    results.into_iter()
        .map(|(score, id)| {
            let node = load_node_meta(&id)?;
            Ok(RecallHit {
                node_id: id,
                kind: node.kind,
                title: node.title,
                summary: node.summary,
                relevance_score: score, // cosine similarity 已在 0-1 范围
                source: RecallSource::Qdrant { cosine_score: score },
            })
        })
        .collect()
}
```

---

## Stage 4: 融合重排

将两个引擎的召回结果合并、去重、加权融合、时间衰减，最终排序。

### 融合算法

```rust
pub struct RecallFusionConfig {
    pub tantivy_weight: f32,        // 由 QueryIntent 决定
    pub qdrant_weight: f32,
    pub time_decay_factor: f32,     // 时间衰减系数（默认 0.1）
    pub importance_factor: f32,     // 重要度加权（默认 0.2）
}

pub fn fuse_and_rerank(
    tantivy_hits: Vec<RecallHit>,
    qdrant_hits: Vec<RecallHit>,
    config: &RecallFusionConfig,
    limit: usize,
) -> Vec<RecallHit> {
    let mut merged: HashMap<String, RecallHit> = HashMap::new();

    // 合并 Tantivy 结果
    for hit in tantivy_hits {
        merged.entry(hit.node_id.clone())
            .and_modify(|existing| {
                // 已有 Qdrant 结果，合并为 Both
                if let RecallSource::Qdrant { cosine_score } = &existing.source {
                    if let RecallSource::Tantivy { bm25_score } = &hit.source {
                        existing.source = RecallSource::Both {
                            bm25_score: *bm25_score,
                            cosine_score: *cosine_score,
                        };
                    }
                }
            })
            .or_insert(hit);
    }

    // 合并 Qdrant 结果
    for hit in qdrant_hits {
        merged.entry(hit.node_id.clone())
            .and_modify(|existing| {
                if let RecallSource::Tantivy { bm25_score } = &existing.source {
                    if let RecallSource::Qdrant { cosine_score } = &hit.source {
                        existing.source = RecallSource::Both {
                            bm25_score: *bm25_score,
                            cosine_score: *cosine_score,
                        };
                    }
                }
            })
            .or_insert(hit);
    }

    // 计算融合分数
    let mut results: Vec<RecallHit> = merged.into_values()
        .map(|mut hit| {
            let base_score = match &hit.source {
                RecallSource::Tantivy { bm25_score } => {
                    bm25_score * config.tantivy_weight
                }
                RecallSource::Qdrant { cosine_score } => {
                    cosine_score * config.qdrant_weight
                }
                RecallSource::Both { bm25_score, cosine_score } => {
                    // 双引擎命中：两分数加权 + 双命中奖励
                    bm25_score * config.tantivy_weight
                    + cosine_score * config.qdrant_weight
                    + 0.1 // 双命中奖励
                }
            };

            // 时间衰减
            let recency = recency_boost_from_node(&hit.node_id);
            // 重要度加权
            let importance = get_node_importance(&hit.node_id);

            hit.relevance_score = (
                base_score
                + recency * config.time_decay_factor
                + importance * config.importance_factor
            ).min(1.0);

            hit
        })
        .filter(|hit| hit.relevance_score > 0.05) // 低分过滤
        .collect();

    results.sort_by(|a, b| b.relevance_score.partial_cmp(&a.relevance_score).unwrap());
    results.truncate(limit);
    results
}
```

### Reranker（Phase D 增强）

阶段一使用上述规则融合即可。Phase D 可引入 tiangong-llm 的 `RerankProvider` 进一步提升精度：

```rust
use tiangong_llm::{RerankProvider, RerankRequest};

/// Phase D: 模型 Reranker（通过 tiangong-llm RerankProvider 调用）
pub async fn model_rerank(
    candidates: &[RecallHit],
    query: &str,
    reranker: &dyn RerankProvider,
) -> Result<Vec<RecallHit>> {
    let documents: Vec<String> = candidates.iter()
        .map(|c| format!("{}: {}", c.title, c.summary))
        .collect();

    let resp = reranker.rerank(RerankRequest {
        query: query.to_string(),
        documents,
        top_n: candidates.len().min(20),
    }).await?;

    // 按 rerank 分数重新排序
    let mut reranked: Vec<RecallHit> = resp.results.into_iter()
        .filter_map(|r| {
            candidates.get(r.index).map(|hit| {
                let mut h = hit.clone();
                h.relevance_score = r.relevance_score as f32;
                h
            })
        })
        .collect();
    reranked.sort_by(|a, b| b.relevance_score.partial_cmp(&a.relevance_score).unwrap());
    Ok(reranked)
}
```

> **注意**：`RerankProvider` trait 定义在 `tiangong-llm`，OpenAI 兼容的 rerank 端点（Jina/Cohere 风格 `/rerank`）
> 由 `tiangong-llm` 的 client/provider 层实现。tiangong-memory 从独立 Memory Rerank 配置获取端点，不通过 tiangong-core 的 `LlmConfig.rerank` 读取。

---

## Stage 5: 定向展开

融合重排后，Planner 根据分数决定是否需要展开：

```rust
pub enum RecallDecision {
    /// 粗召回足够，直接用 depth-1 摘要注入 Prompt
    UseSummaries,
    /// 需要展开特定条目到 depth-2
    Expand(Vec<String>),  // node_ids
    /// 不需要历史记忆
    Skip,
}
```

判定规则（不使用额外 LLM 调用）：
- 融合后 0 条 → `Skip`
- 所有命中 `relevance_score > 0.7` → `UseSummaries`
- 存在 `0.3 < relevance_score < 0.7` 的条目 → `Expand` 这些条目

---

## Stage 5（续）: 加载 depth-2

加载 `depth-2` 结构化数据（从 SQLite 的 `full_content` 字段读取）：

```rust
pub fn expand_nodes(
    node_ids: &[String],
    store: &MemoryStore,
) -> Vec<ExpandedMemory> {
    node_ids.iter()
        .filter_map(|id| store.load_depth2(id).ok())
        .collect()
}

pub struct ExpandedMemory {
    pub node_id: String,
    pub full_content: serde_json::Value, // Episode/Decision/Entity 的完整结构
}
```

展开结果注入到 Prompt 的 UserContext 段：

```
[相关记忆]
## Episode: 修复图片重复显示问题
- 结果: 成功
- 关键修改: 将 loop_context 消息角色从 System 改为 User
- 涉及文件: core/mod.rs, model.rs
```

---

## Stage 6: 证据回放

仅在执行阶段工具需要原始内容时触发。

**触发条件**：
- 工具调用涉及历史文件路径，且文件已变更
- 用户提问"当时为什么这样做"类问题

**实现**：
- 从 `evidence/` 目录读取引用的原始日志
- 截取相关片段（限制 1KB 以内）
- 注入到当前 turn 的附加上下文

---

## 性能约束

| 阶段 | 延迟预算 | 实现方式 |
|------|---------|---------|
| Stage 1 锚点提取 | < 5ms | 规则匹配，无 LLM |
| Stage 2 粗召回 | < 10ms | 内存索引扫描 |
| Stage 3 规划评估 | 0ms | 与 Planner 调用合并 |
| Stage 4 定向展开 | < 20ms | 文件 I/O |
| Stage 5 证据回放 | < 50ms | 文件 I/O，按需触发 |

**总延迟**：正常路径（Stage 1-4）< 35ms，不影响用户感知。
