commit e419c6b387a0307d83eb1b7f0255b33246e27432 Author: shumengya Date: Fri Jun 19 14:09:31 2026 +0800 docs: 添加 mengya-rag 知识库优化方案 diff --git a/RAG优化方案.md b/RAG优化方案.md new file mode 100644 index 0000000..0c24f18 --- /dev/null +++ b/RAG优化方案.md @@ -0,0 +1,262 @@ +# mengya-rag 知识库优化方案 + +> 记录日期:2026-06-19 +> 当前状态:待实施 +> 目标:低成本 · 低幻觉 · 高命中 + +--- + +## 一、现状诊断 + +### 基础信息 + +| 项目 | 当前值 | +|------|--------| +| 笔记数量 | 495 篇 Markdown | +| Embedding 模型 | `BAAI/bge-small-zh-v1.5`(90MB,512 dim) | +| 检索方式 | Hybrid(BM25 + 向量),RRF 融合 | +| LLM | DeepSeek(deepseek-v4-flash) | +| 中文分词 | 自制 N-gram 滑窗 | + +--- + +### 问题 1:中文分词太弱(最核心问题) + +当前 `chinese_tokenizer` 用的是 N-gram 滑窗,对技术笔记效果极差。 + +**示例**:搜索"GMP调度系统" + +``` +N-gram 分词结果:["Go", "Gol", "Gola", "olan", "lang", "中的", "的G", "GMP"] +jieba 分词结果:["Golang", "中", "GMP", "调度", "系统"] +``` + +N-gram 把"GMP"和"调度"拆散,BM25 根本匹配不上完整关键词,召回率大幅下降。495篇涵盖 Golang、Docker、C++ 等技术笔记,这个问题影响所有技术查询。 + +--- + +### 问题 2:文件级强去重损失上下文 + +`_fuse_results` 里每个文件严格只返回 **1 个 chunk**。 + +- "字节Agent中台一面凉经.md" 有 1459 行,答案往往跨多个章节 +- 只拿最高分那一个 chunk,丢失了大量关联内容 +- 对长篇面经、长教程类笔记尤其严重 + +--- + +### 问题 3:Prompt 没有引用约束 + +当前 Prompt 只说"根据以下内容回答",没有强制引用来源编号。DeepSeek 在上下文不完整时会"补充说明"而不是说"笔记中没有",导致幻觉。 + +--- + +### 问题 4:注入上下文无 token 预算 + +所有检索到的 chunk 全量拼入 prompt。若 `top_k=6` 且每块 512 token,注入了 3000+ token,低相关内容稀释关键信息,模型注意力分散,精度反而下降,同时增加 API 费用。 + +--- + +### 问题 5:嵌入模型偏小 + +`BAAI/bge-small-zh-v1.5` 对纯中文 ok,但笔记大量混合英文技术词汇(Docker、Golang、nginx……),小模型语义泛化能力有限,向量召回对中英混合查询效果较差。 + +--- + +## 二、优化方案 + +### 方案总览 + +| # | 改动 | 影响文件 | 预期收益 | 优先级 | +|---|------|----------|----------|--------| +| ① | 中文分词换 jieba | `indexing.py`、`pyproject.toml` | BM25 关键词召回大幅提升 | 🔴 高 | +| ② | 文件去重改为最多 2 chunk/文件 | `retrieval.py` | 长文多节答题完整度提升 | 🟠 中 | +| ③ | QA Prompt 加编号引用 + 严格反幻觉 | `qa.py` | 幻觉率明显下降 | 🟠 中 | +| ④ | 注入上下文加 token 预算(≤ 2000 token) | `qa.py` | 精度提升 + 节省 API 费用 | 🟡 中 | +| ⑤ | 换 `BAAI/bge-m3` 嵌入模型(可选) | `.env` | 中英混合语义理解提升 | 🟢 低 | + +--- + +### ① jieba 中文分词(最高优先级) + +**依赖**:`pyproject.toml` 加入 `jieba>=0.42` + +**改动位置**:`src/mengya_rag/indexing.py` → `chinese_tokenizer()` + +```python +# 旧实现:N-gram 滑窗 +def chinese_tokenizer(text: str) -> list[str]: + for part in re.findall(r"[A-Za-z0-9_#+.-]+|[一-鿿]+", text.lower()): + # N-gram 2~4 滑窗... + +# 新实现:jieba 分词 +import jieba + +STOPWORDS = {"请", "根据", "笔记", "简要", "回答", "哪些", "什么", "内容", "一下", "我的", "有哪些", "是什么"} + +def chinese_tokenizer(text: str) -> list[str]: + tokens = [] + for part in re.findall(r"[A-Za-z0-9_#+.-]+|[一-鿿]+", text.lower()): + if re.fullmatch(r"[A-Za-z0-9_#+.-]+", part): + if part not in {"md", "markdown"} and part not in STOPWORDS: + tokens.append(part) + else: + for word in jieba.cut(part): + if len(word) >= 2 and word not in STOPWORDS: + tokens.append(word) + return tokens +``` + +**可选增强**:在 `data/` 目录放 `userdict.txt`,补充专有名词: + +``` +萌芽笔记 5 +bigmengya 5 +smallmengya 5 +GMP调度 5 +``` + +**注意**:改分词后必须重建索引(`mengya-build-index`)。 + +--- + +### ② 文件级去重放宽(最多 2 chunk/文件) + +**改动位置**:`src/mengya_rag/retrieval.py` → `HybridRetriever._fuse_results()` + +```python +# 旧:严格 1 chunk/文件 +seen_files: set[str] = set() +... +if rel_path in seen_files: + continue +seen_files.add(rel_path) + +# 新:最多 2 chunk/文件 +from collections import Counter +seen_files: Counter[str] = Counter() +... +if seen_files[rel_path] >= 2: + continue +seen_files[rel_path] += 1 +``` + +--- + +### ③ QA Prompt 强化(反幻觉 + 强制引用) + +**改动位置**:`src/mengya_rag/qa.py` → `QA_PROMPT` + `format_context()` + +```python +QA_PROMPT = """你是萌芽 RAG 知识库助手。请严格根据下方标有编号的笔记片段回答问题。 + +规则: +1. 回答中必须用 [来源N] 标注依据,例如"根据 [来源1] 可知……" +2. 若所有片段均无法支撑某个细节,必须明确说"笔记中未找到相关记录",不得凭己见补充 +3. 回答要准确、完整,优先使用中文 + +笔记内容: +--------------------- +{context} +--------------------- + +问题:{question} +回答:""" + +def format_context(items: list[RetrievedNode]) -> str: + parts: list[str] = [] + for index, item in enumerate(items, start=1): + rel_path = item.node.metadata.get("rel_path", "未知来源") + parts.append( + f"[来源{index}]《{rel_path}》\n" + f"{item.node.get_content().strip()}" + ) + return "\n\n".join(parts) +``` + +--- + +### ④ 上下文 Token 预算(硬限 2000 token) + +**改动位置**:`src/mengya_rag/qa.py` → `format_context()` 或 `ask_question()` + +```python +CONTEXT_TOKEN_BUDGET = 2000 # 可通过 env 配置 + +def format_context(items: list[RetrievedNode], budget: int = CONTEXT_TOKEN_BUDGET) -> str: + from .markdown_chunker import estimate_tokens + parts: list[str] = [] + used = 0 + for index, item in enumerate(items, start=1): + rel_path = item.node.metadata.get("rel_path", "未知来源") + content = item.node.get_content().strip() + block = f"[来源{index}]《{rel_path}》\n{content}" + cost = estimate_tokens(block) + if used + cost > budget and parts: + break + parts.append(block) + used += cost + return "\n\n".join(parts) +``` + +--- + +### ⑤ 换更大嵌入模型(可选,需重建索引) + +修改 `.env`: + +```diff +-EMBED_MODEL=BAAI/bge-small-zh-v1.5 ++EMBED_MODEL=BAAI/bge-m3 +``` + +| 对比项 | bge-small-zh | bge-m3 | +|--------|-------------|--------| +| 大小 | ~90 MB | ~570 MB | +| 维度 | 512 | 1024 | +| 语言 | 纯中文 | 中英多语言 | +| 混合技术词召回 | 一般 | 好 | + +改完后执行: + +```bash +mengya-build-index +``` + +--- + +## 三、实施顺序建议 + +``` +第 1 步:换 jieba 分词(改代码 + 重建索引) ← 收益最大 +第 2 步:放宽文件去重 + 加 token 预算 ← 改代码,立即生效 +第 3 步:加强 Prompt ← 改代码,立即生效 +第 4 步(可选):换 bge-m3 嵌入模型 ← 改配置 + 重建索引 +``` + +--- + +## 四、改动文件清单 + +``` +pyproject.toml ← 加 jieba 依赖 +src/mengya_rag/indexing.py ← chinese_tokenizer 换 jieba +src/mengya_rag/retrieval.py ← _fuse_results 去重策略 +src/mengya_rag/qa.py ← QA_PROMPT + format_context token 预算 +data/userdict.txt ← (可选)jieba 自定义词典 +.env / .env.example ← (可选)EMBED_MODEL 升级 +``` + +--- + +## 五、重建索引命令 + +改完代码后执行: + +```bash +# 只重建索引(笔记已在本地) +mengya-build-index + +# 或先同步笔记再重建 +mengya-rag reindex +```