Elaina 9e44748f91 fix: anchor stopwords - remove generic question patterns causing cross-topic contamination
- Add ANCHOR_STOPWORDS set in anchor.py (真正通用的疑问pattern)
- Filter Chinese n-grams against stopwords in extract()
- Update sparse.py content_words extraction to use stopword-filtered query
- Diagnosis: 'Git rebase vs merge' query now correctly excludes Redis/asyncio blocks
- Phase1 results: Full CGK 42.6 tokens avg, 0% contamination (vs Last-5 67.6 tokens, 100%)
- Phase2 ablation: Gate-only accounts for most of the benefit
- Phase3 sensitivity: OVERLAP/NEW_RATIO thresholds insensitive on clean data;
  RECENT_WINDOW is the primary token budget control

Known honest limitations:
- Test set is clean 4-topic synthetic data (no real dirty dialogue)
- No strong baselines (BM25 ablation incomplete)
- No answer-level evaluation (only retrieval blocks measured)
- No parameter sensitivity on noisy real-world data
- Zero contamination on 5 queries is not generalizable
2026-04-22 22:30:18 +08:00
2026-04-22 01:12:03 +08:00

上下文门控器 (Context Gatekeeper)

轻量级上下文选择器,在同一会话中自动从历史对话里选出最小且相关的片段,减少话题污染和控制上下文长度。

特性

  • 🚀 纯 Python,无需向量化模型依赖(无 embedding、reranker、分类器
  • 💻 资源消耗极低,依赖极少,普通的私有部署环境都能跑
  • 🔍 话题门控,通过锚点 overlap + new_ratio 判断继续/切换,含指代词强制继承
  • 📦 稀疏召回BM25/IDF-overlap 评分,用户侧权重高于助手侧
  • 🎯 最小覆盖,基于 IDF 加权集合覆盖的贪心选择
  • ⚙️ 稳定约束区,持久化用户偏好(语言/风格/禁用项)

核心流程

用户查询 q
    ↓
① 锚点提取(中文 2/3-gram、英文单词、代码标识符、版本号、引号短语
    ↓
② 话题门控overlap > 0.45 → 继续overlap < 0.20 且 new_ratio > 0.70 → 切换;
            有指代词 → 强制继续;中间地带默认继续)
    ↓
③ 稀疏召回top-20BM25/IDF-overlap + exact match + 新鲜度奖励)
    ↓ 话题切换时:内容词过滤(只保留包含 query 内容词的块)
④ 最小覆盖选择gain = ΣIDF(t) / cost^α,贪心选择达到 85% 覆盖停止)

安装

pip install -e .

快速开始

from src.gatekeeper import ContextGatekeeper

# 初始化token 预算 4000
gate = ContextGatekeeper(token_budget=4000)

# 添加多轮对话
gate.add_turn("如何设计一个 Redis 分布式锁?",
              "分布式锁需要满足互斥性、死锁避免、性能要求。")
gate.add_turn("锁的 TTL 设置多少合适?",
              "TTL 取决于业务耗时,建议 3-5 倍 buffer同时要续期机制。")

# 为当前查询选择上下文
selected = gate.select("锁的 TTL 设置多少合适?")

for item in selected:
    print(f"轮次 {item['turn_id']}: {item['user']}")
    print(f"助手: {item['assistant']}\n")

# 构建完整 prompt可直接发给 LLM
prompt = gate.build_prompt("锁的 TTL 设置多少合适?")
print(prompt)

项目结构

context-gatekeeper/
├── src/
│   ├── anchor.py       # 锚点提取2/3-gram + IDF
│   ├── block.py        # Block 数据结构
│   ├── topic_gate.py   # 话题门控overlap + new_ratio + 指代词)
│   ├── sparse.py       # 稀疏召回BM25/IDF + exact + recency
│   ├── selector.py     # 最小覆盖选择IDF加权贪心
│   └── gatekeeper.py   # 主模块(组合各子模块)+ 句级裁剪
├── test_100rounds_v2.py   # 100轮4话题完整对照实验
└── README.md

运行测试

# 100轮4话题对照实验
python test_100rounds_v2.py

算法细节

锚点提取

从文本中提取有检索价值的关键词单元,支持:

  • 中文2-gram 和 3-gram如"分布式锁"、"跨进程通信"
  • 英文:单词形态
  • 代码:标识符、版本号(如 v1.2.3
  • 引号短语:完整的技术术语

规则驱动,无需分词库,响应速度极快。

话题门控判断

overlap = Σ IDF(t) for t ∈ A(q)∩A(T) / Σ IDF(t) for t ∈ A(q)
new_ratio = Σ IDF(t) for t ∈ A(q)\A(T) / Σ IDF(t) for t ∈ A(q)

if overlap > 0.45:           # 重叠度高,继续当前话题
    continue
elif overlap < 0.20 and new_ratio > 0.70:  # 新词占比高,切换话题
    switch
elif has_deictic:            # 有指代词,强制继承
    continue
else:
    continue                  # 中间地带默认继续,避免切断正在发展的思路

话题切换时的内容词过滤

话题切换时,用内容词(英文术语/代码标识符/长中文词)对候选块做硬过滤:

# 从 query 中提取内容词(区分于通用 n-gram
content_words = {
    'redis',              # 英文术语
    'postgresql', 'explain',  # 英文术语
    'asyncio', 'gather',  # 英文术语
    'git', 'rebase', 'merge',  # 英文术语
    'v1.2.3',             # 版本号
    '惰性删除',            # 长中文术语(>=4字符
}

# 块必须包含至少一个内容词,否则被过滤掉
if topic_switched and content_words:
    block_text = block.user_text + ' ' + block.assistant_text
    if not any(cw in block_text.lower() for cw in content_words):
        score = 0.0  # 硬过滤

为什么用内容词而不是 IDF 阈值:因为 IDF > 2.0 筛选出来的是稀有字符 n-gram如"看执"、"行计"),这些是通用词,反而会桥接不同话题。内容词是显式的 topic 标识符,区分度高。

稀疏召回评分

score = 1.5·lex(u_b,q) + 0.7·lex(a_b,q) + 1.0·exact(b,q) + 0.2·recency(b)
  • lex(u_b,q):用户轮次与 Query 的 BM25/IDF 重叠(权重 1.5
  • lex(a_b,q):助手轮次与 Query 的 BM25/IDF 重叠(权重 0.7,助手侧信息量通常更小)
  • exact(b,q):完全匹配奖励(精确命中关键词)
  • recency(b):新鲜度奖励,越近的轮次权重越高

取 top-20 进入下一步。

最小覆盖 gain

gain(b|S) = Σ IDF(t) for t ∈ cov(b)\covered(S) / cost(b)^α,  α=0.8

覆盖率达到 85% 或 token 预算耗尽时停止。

为什么用 IDF 加权:高频词(如"数据"、"系统")区分度低,低频词(如"GeoHash"、"分布式锁")才是真正的语义锚点。用 IDF 加权确保选择的是真正有信息量的片段,而不是反复覆盖高频通用词。

句级裁剪

选中的 block 不一定整块都塞给 LLM。按句子分割后只保留包含 query 锚点的句子,最多保留 3 句。助手侧即使不含锚点,也保留第一句作为上下文衔接。

100 轮 4 话题对照实验

设置4 个话题Redis、Python asyncio、PostgreSQL、Git每话题 25 轮交替,总计 100 轮。Token 预算 4000。

验证结果

验证 指标 结果
话题隔离 100轮后问GitT1/T2/T3不应出现 无污染
召回完整性 Git锚点覆盖 100%
Token节省 无门控 vs 有门控 97.7% 节省
交替无污染 5次交替查询每次无跨话题召回 全部通过
完整召回 Git/Redis窗口内召回率 100%

交替话题验证详情(每轮问完立即动态跟踪 active_topic

查询 目标话题 召回轮次 跨话题污染 结果
EXPLAIN ANALYZE怎么看 T3(PG) [99]
Git rebase/merge区别 T4(Git) [88,100,96,92]
Redis惰性删除区别 T1(Redis) [89,93,97]
asyncio.Task cancel T2(asyncio) [90,94,98]
Git reset/revert场景 T4(Git) [88,100,96,92]

结论:在纯规则、轻量、资源受限约束下,上下文门控器实现了零跨话题污染,同时节省 97.7% token。

局限性与适用场景

局限性:

  • 稀疏检索依赖词形匹配,语义相近但词形不同的情况容易漏召
  • Token 估算为粗略估算字符数×1.5),与实际有 2-3 倍误差
  • "完整召回"与"最小覆盖"存在权衡:窗口内只选最相关的块,而非全部块
  • 没有在 QuAC 这类标准学术数据集上做对照实验,无法跟 Attentive History 这类基于注意力机制的方法直接对比

适用场景:

  • 资源受限的生产环境(边缘设备、私有部署)
  • 对延迟敏感的实时对话
  • 中等复杂度对话10-100轮

不适用:

  • 需要精确语义匹配的场景(建议用向量检索)
  • 极长对话(>100轮IDF 全量更新有偏)
Description
轻量级上下文选择器 - 话题门控 + 稀疏召回 + 最小覆盖
Readme 170 KiB
Languages
Python 100%