Skip to content

RAG 系统痛点分析与优化路径

本文档对 DocMind 当前 RAG 系统做逐层痛点审计,每个痛点都从源码出发定位根因,给出优化方案和面试表述策略。

目标读者:面试前自查用。面试官问"你的 RAG 系统有什么不足""你下一步打算优化什么"时,这份文档就是答案底稿。

前提:迭代 #1–#8 已完成(向量化 fail-fast、Langfuse 追踪、置信度标记、Query Decomposition、自适应检索、双路径合并、成本优化、MinerU 全格式入库)。本文聚焦已完成优化之后仍然存在的结构性问题


一、痛点总览

按 RAG 数据流方向分五层,每层标注影响等级(🔴 高 / 🟡 中 / 🟢 低)和面试区分度(⭐~⭐⭐⭐)。

痛点影响面试区分度
入库层P1. TextChunker 不感知 Markdown 结构 ✅ 已解决(迭代 #9)🔴⭐⭐
P2. 无增量索引,文档更新只能全量重建 ✅ 已解决(迭代 #9)🟡⭐⭐
P3. Chunk 之间无结构化关系(扁平存储)🟡⭐⭐⭐
检索层P4. RRF 去重粒度粗(50 字前缀)🟡
P5. Milvus HNSW ef 硬编码,无召回率/延迟可调🟢
P6. BM25 候选上限硬编码(topK×10 或 400)🟢
Agent/生成层P7. 会话历史原样拼接,长对话 token 膨胀🔴⭐⭐
P8. MemoryTool 非原子读写,并发丢失更新🟡
P9. 无工具级熔断,失败工具持续浪费延迟🟡⭐⭐
评估层P10. 无离线评估体系 ✅ 已解决(迭代 #10,52 条 × 4 档对照)🔴⭐⭐⭐
P11. 无 A/B 实验框架,参数调整全局生效🟡⭐⭐
工程层P12. Token 成本无追踪 部分解决(Phase 6 Langfuse OTel 可追踪 LLM token 用量)🟡 🟢⭐⭐
P13. Prompt 模板无版本管理🟢
P14. CRAG 灰区阈值需按领域自适应🟡⭐⭐
P15. Agent trace 存储量增长(qa_message JSON 字段越来越大)🟡

二、逐层深度分析

入库层

P1. TextChunker 不感知 Markdown 结构 🔴

根因

迭代 #8 把文档解析切到 MinerU 后,所有格式(PDF / PPT / 图片 / 网页)统一输出结构化 Markdown——带 #/##/### 标题层级、Markdown 表格、代码块、图片引用。但 TextChunkerservice/knowledge/TextChunker.java)仍然是 #8 之前的逻辑:按 \n\n 分段 → 短段合并到 400 字 → 长段按句号切分 + 50 字重叠。

这意味着:

  • 标题层级信息被丢弃## 第三章 权限管理 和后续正文被切到同一个 chunk 里,chunk 的 chapter metadata 没有利用 heading 层级,检索时无法按章节过滤或加权
  • Markdown 表格被切碎:一张 20 行的表格超过 400 字阈值时会被从中间切断,前半段缺尾、后半段缺头,LLM 无法还原完整表结构
  • 代码块被句号分割破坏:代码中的 . 被当作句子分隔符,System.out.println("hello"); 可能被从 . 处切断

影响量化

MinerU 输出中约 15-20% 的内容是表格和代码块(企业文档尤其如此)。这部分内容被切碎后,向量化的语义质量下降,检索召回时即使命中了正确 chunk,内容也是残缺的——LLM 基于残缺表格生成的答案准确度会明显下降。

优化方案:Markdown-Aware Chunker

MinerU Markdown 输出


Markdown 结构解析
  ├─ heading (#/##/###) → 作为 chapter 边界 + 写入 chunk metadata
  ├─ 表格(| xxx | xxx |)→ 整表作为单个 chunk,不切碎
  ├─ 代码块(```...```)→ 整块保留
  ├─ 图片引用 + 前后段落 → caption-aware chunk
  └─ 普通段落 → 沿用现有合并/切分逻辑

核心原则:结构化元素的完整性优先于 chunk 大小均匀性。一张 800 字的表格作为单 chunk(超过 400 字目标值)远好于切成两个 400 字的残表。

面试话术

"迭代 #8 把文档解析切到 MinerU 后,上游输出已经是结构化 Markdown,但 TextChunker 还在用'按空行分段'的朴素策略——表格超 400 字会被切碎、heading 层级信息被丢弃。这是典型的上游升级倒逼下游适配:解析质量提升了但切片策略没跟上,导致检索阶段拿到的 chunk 质量反而受限于切片而非解析。下一步做 Markdown-Aware Chunker,核心原则是结构化元素的完整性优先于 chunk 大小均匀性。"


P2. 无增量索引,文档更新只能全量重建 🟡

根因

KnowledgeBaseServiceImpl.processDocument() 的流程是:删除旧 chunk → 重新抽取全文 → 重新切分 → 重新 embedding → 重新写入 Milvus + MySQL。一份 100 页的 PDF 改了一个错别字,也要重走全量流程(embedding 是最昂贵的步骤,100 页约产生 200-400 个 chunk,每个要调一次 embedding API)。

影响

  • 知识库维护者不敢频繁更新文档,导致知识库内容陈旧
  • 大文档更新耗时长(embedding + Milvus 写入),影响用户体验
  • 不必要的 embedding API 调用浪费成本

优化方案:Chunk Fingerprint + 增量更新

文档更新时:
1. 重新解析 + 切分(这步成本低,毫秒级)
2. 对每个新 chunk 计算 content hash(SHA-256 前 16 字节)
3. 与 kb_chunk 表中已存在的 chunk 按 hash 比对:
   ├─ hash 匹配 → 跳过 embedding,保留原向量
   ├─ hash 不匹配 → 重新 embedding + 更新 Milvus
   └─ 新增/删除的 chunk → 对应插入/删除
4. kb_chunk 表增加 content_hash 列

面试话术

"生产环境的知识库不是入一次就完事——文档会频繁修订。当前全量重建的方式在文档量上来后成本不可接受。增量索引的核心是 chunk 级 fingerprint:内容 hash 匹配就复用已有向量,只对变更部分重新 embedding。这把更新成本从 O(所有 chunk) 降到 O(变更 chunk)。"


P3. Chunk 之间无结构化关系 🟡

根因

当前所有 chunk 在 Milvus 中是扁平存储——每个 chunk 独立存在,chunk 之间没有引用、因果、层级等关系。检索只能按语义相似度或关键词匹配独立召回,无法做"找到 A chunk 后沿关系链找到 B chunk"这种图谱式检索。

影响场景

  • 跨章节推理:"第三章定义的术语在第七章是怎么使用的?"——两个 chunk 语义不相似但有引用关系,向量检索和 BM25 都难以同时召回
  • 因果链推理:"这个配置项的默认值是什么?改了会影响哪些功能?"——需要沿因果关系展开
  • 层级推理:"总结本章的核心观点"——需要知道哪些 chunk 属于同一个 chapter

优化方向:Chunk-Level Knowledge Graph(GraphRAG 轻量版)

入库时:
1. LLM 抽取 chunk 间关系(引用、因果、同章节、前后文)
2. 存入关系表 kb_chunk_relation(source_chunk_id, target_chunk_id, relation_type)

检索时:
1. 正常向量 + BM25 召回 Top-K
2. 对 Top-K 中的每个 chunk,沿关系图谱展开 1 跳邻居
3. 邻居 chunk 作为"上下文补充"加入候选集
4. 统一进入 reranker 精排

这不是完整的 Neo4j GraphRAG——是用 MySQL 关系表实现的轻量版,足以解决上述场景,且不引入新的基础设施依赖。

面试话术

"当前 chunk 是扁平存储,检索只能靠相似度独立召回。但文档本身是有结构的——章节层级、术语引用、因果链。我计划做一个轻量 GraphRAG:入库时用 LLM 抽取 chunk 间关系存入 MySQL,检索时对命中 chunk 做 1 跳图谱展开,把关联 chunk 作为上下文补充。核心思想是检索不止是'找最像的',还要'找有关系的'。"


检索层

P4. RRF 去重粒度粗 🟡

根因

RRFFusion.java:55 用 chunk 内容前 50 字作为去重 key。两个 chunk 如果前 50 字相同就视为重复,保留 RRF 分数更高的。

问题在于:

  • 假阴性(漏去重):两个 chunk 语义几乎相同但开头措辞不同("配置步骤如下:..." vs "以下是配置步骤..."),不会被去重
  • 假阳性(误去重):文档中的列表项可能前 50 字相同但后半段完全不同,会被错误去重

优化方案

将去重升级为两级:

  1. 精确去重(现有逻辑):前 50 字 prefix 匹配,快速过滤完全重复
  2. 语义去重(新增):对精确去重后的候选集,计算相邻 chunk 的 embedding 余弦相似度,≥0.95 的视为语义重复

第二级只在 RRF 融合后的 Top-N 候选上执行(通常 12-20 条),不会增加显著计算开销——embedding 已经在向量检索阶段产生过了,可以从 Milvus 中回取。

面试话术

"RRF 去重用前 50 字前缀匹配,这是一个工程折中——快但粗。'以下是配置步骤'和'配置步骤如下'语义相同但不会被去重,会浪费 reranker 的名额。升级方向是在精确去重后加一层语义去重:对 Top-N 候选计算 embedding 余弦,≥0.95 合并。候选量小(十几条),开销可忽略。"


P5/P6. Milvus ef 和 BM25 候选上限硬编码 🟢

根因

  • VectorRetriever.java:56:HNSW 搜索参数 ef=64 硬编码,ef 越大召回率越高但延迟越大
  • BM25Retriever.java:65:候选上限 topK * 10 或 400(取大值),大语料库下 400 可能不够,小语料库下浪费

优化方案

两者都移入 sys_ai_config 数据库热配:rag.milvus_efrag.bm25_candidate_limit。简单但实用——不同部署环境的数据规模差异大,硬编码无法适配。


Agent/生成层

P7. 会话历史原样拼接,长对话 token 膨胀 🔴

根因

DocMindAgentbuildConversationHistory() 取最近 6 条消息原文拼接进 prompt。单条消息可能有 500-1000 字(特别是 LLM 的长回答),6 条就是 3000-6000 字——与检索上下文(3000-5000 token)体量相当。长对话场景下,历史消息会挤占检索上下文的 token 预算,导致主回答质量下降。

影响量化

  • 对话前 3 轮:历史 ~1000 字,检索上下文 ~5000 字,比例健康
  • 对话第 10 轮:历史 ~5000 字,检索上下文被压缩到 ~2000 字,答案质量明显下降
  • 这也是为什么用户经常"开新对话"——不是不想续聊,是续聊效果差

优化方案:分层历史压缩

近 2 轮:保留原文(上下文衔接需要精确措辞)
第 3-6 轮:用小模型摘要为 1 段 (~200 字)
第 7 轮以上:丢弃(或进一步压缩为一句话主题)

总历史 token 预算:固定上限 1500 字,不再随轮次线性增长

aiConfigHolder.callSmallModel() 做摘要,成本极低(qwen-turbo 处理 3000 字约 $0.0003)。摘要可以缓存在 qa_message 表的新字段里,避免每轮重复摘要。

面试话术

"长对话的一个隐性问题是历史消息膨胀。最近 6 条原文可能有 5000 字,和检索上下文抢 token 预算。用户觉得'对话越长回答越差'不是幻觉——是真的。我的方案是分层压缩:近 2 轮保原文,3-6 轮小模型摘要为 200 字,更早的丢弃。总历史固定 1500 字上限,不随轮次膨胀。"


P8. MemoryTool 非原子读写 🟡

根因

MemoryTool.store_memory() 的流程是:GET user:memory:{userId} → JSON 反序列化 → 合并新 key → JSON 序列化 → SET user:memory:{userId}。读和写之间没有任何锁,两个并发请求可能同时读到旧值,各自合并后写回,后写的覆盖先写的——丢失更新(lost update)

优化方案

用 Redis WATCH + MULTI/EXEC 实现乐观锁:

WATCH user:memory:{userId}
val = GET user:memory:{userId}
merged = merge(val, newData)
MULTI
SET user:memory:{userId} merged
EXEC  // 如果 key 被其他客户端修改过,EXEC 返回 nil,重试

或者更简单:用 Redis Hash(HSET user:memory:{userId} key value)替代 JSON 字符串,HSET 是原子操作,单 key 粒度更新天然不丢失。


P9. 无工具级熔断 🟡

根因

DocMindAgent 的 LLM 驱动工具选择中,如果某个工具(比如 web_search 的 Tavily API)持续超时,LLM 仍然会在每次查询中尝试调用它——浪费 3-5s 延迟后再走降级路径mcp_tool_registryavgLatencyMs 统计但没有反馈闭环。

优化方案:轻量熔断器

每个工具维护:
- 连续失败计数 consecutiveFailures (AtomicInteger)
- 熔断状态 CircuitState (CLOSED / OPEN / HALF_OPEN)
- 上次熔断时间 lastOpenTime

状态转换:
CLOSED → 连续失败 ≥ 3 → OPEN(直接返回空结果,跳过调用)
OPEN → 30s 后 → HALF_OPEN(放一个请求试探)
HALF_OPEN → 成功 → CLOSED / 失败 → OPEN

不需要引入 Resilience4j——4 个工具的熔断逻辑用一个 ToolCircuitBreaker 类(~50 行)就够了。熔断状态写入 mcp_tool_registry 表,MCP Console 面板可展示。

面试话术

"微服务里熔断是标配,AI Agent 的工具调用也需要。当前 web_search 如果 Tavily 宕了,每个查询都要等 3-5s 超时再降级——体验很差。我做了一个轻量熔断:连续 3 次失败自动断开,30s 后半开试探。不需要 Resilience4j——4 个工具用一个 50 行的类就够了。核心是把微服务治理的思维迁移到 Agent 工具管理。"


评估层

P10. 无离线评估体系 🔴 ⭐⭐⭐

根因

这是当前系统最大的结构性缺陷。迭代 #1-#8 的每次优化,"效果变好了"都是主观判断——没有量化指标、没有对照组、没有回归测试。具体表现:

  • 不知道检索召回率(Recall@K)是多少——改了 QueryProfiler 的参数矩阵,召回率是升了还是降了?
  • 不知道答案忠实度(Faithfulness)——LLM 幻觉率是多少?Self-Reflection 拦截了多少?漏了多少?
  • 不知道端到端效果——用户满意度只有"前端看起来不错"这种感性判断
  • 无法做 regression testing——新迭代可能破坏旧场景但无法发现

面试官问"你怎么衡量 RAG 效果"时,如果只能说"看日志 / 人工测试",说服力会大打折扣。

优化方案:RAGAS 风格的离线评估 Pipeline

Step 1:Golden Set 构建
  ├─ 从知识库中抽取 100 个 chunk
  ├─ 用 LLM 自动生成 QA pair(question + ground_truth_answer + source_chunks)
  └─ 人工审核 + 修正(保证 golden set 质量)

Step 2:评估指标(参考 RAGAS 框架)
  ├─ Retrieval Recall@K:ground_truth chunk 是否在检索 Top-K 中
  ├─ Context Precision:Top-K 中真正相关的 chunk 占比
  ├─ Answer Faithfulness:答案中的每个 claim 是否能在检索上下文中找到依据
  ├─ Answer Relevance:答案是否回答了问题(而非答非所问)
  └─ MRR(Mean Reciprocal Rank):ground_truth chunk 排在第几位

Step 3:自动化执行
  ├─ Java 测试类 or 独立 Spring Batch Job
  ├─ 遍历 golden set → 走完整 RAG 链路 → 计算指标
  ├─ 结果写入 eval_result 表(run_id, timestamp, metrics_json)
  └─ Admin Dashboard 展示趋势图(每次参数调整前后的指标对比)

Step 4:回归防护
  ├─ CI 中集成轻量版 eval(50 条 golden set,阈值检查)
  └─ Recall@5 < 0.6 或 Faithfulness < 0.7 → 构建失败

面试话术

"没有评估体系的 RAG 优化是在盲人摸象——你改了参数,召回率是升了还是降了?不知道。迭代 #1-#8 的效果判断都是主观的,这是最大的工程债。

我计划建一套 RAGAS 风格的离线评估 Pipeline:用 LLM 自动生成 QA golden set,评估 5 个指标——Retrieval Recall@K、Context Precision、Answer Faithfulness、Answer Relevance、MRR。每次参数调整前后跑一遍,结果存库展示趋势图。这样每次迭代都有数据支撑,不再靠感觉。

更进一步,可以在 CI 中集成轻量版 eval 做 regression guard——Recall@5 低于阈值就阻断构建,防止新迭代悄悄破坏旧场景。"


P11. 无 A/B 实验框架 🟡

根因

sys_ai_config 支持热更新,但只有全局一套配置。改了 reflection.skip_threshold 从 0.85 到 0.9,全量用户立刻生效——无法做灰度验证。

优化方案

在 AiConfigHolder 中增加实验分流层:

1. experiment 表:experiment_id, config_overrides (JSON), traffic_percent, status
2. 请求入口读取 userId → hash(userId) % 100 → 落入哪个实验组
3. AiConfigHolder.get(key) 先查实验覆盖,无覆盖走全局
4. 评估指标按实验组分桶统计

依赖 P10 评估体系先建立。

工程层

P12. Token 成本无追踪 🟡

根因

Langfuse(迭代 #2)通过 OTel span 记录了各步骤延迟和 LLM 的 prompt/completion,但 Spring AI 的 ChatResponse 中的 Usage(promptTokens / completionTokens)没有被主动采集和聚合。想回答"单查询平均成本多少""本月 LLM 花了多少钱"——答不上来。

优化方案

DocMindAgent 流式回调中提取 Usage 信息,写入 qa_message.token_usage (JSON 字段) + Redis 滑动窗口聚合(每小时 / 每天 / 每月),Admin Dashboard 展示成本趋势。


P13. Prompt 模板无版本管理 🟢

根因

8 个 prompt 模板文件(src/main/resources/prompts/)是纯文本,修改后没有版本追踪。想知道"上周的 prompt 和这周的区别是什么"只能查 git log——但 prompt 修改通常不会单独提交,容易混在其他 commit 里。

优化方案

Prompt 模板内容写入 sys_prompt_template 表(template_name, version, content, created_at),每次修改自动递增 version。PromptAssembler 优先从数据库读,fallback 到文件。配合 P10 评估体系,可以追踪"哪个 prompt 版本的 Faithfulness 最高"。


三、优化路线图

ROI(投入产出比)× 面试区分度 排优先级:

Phase 1:评估基础设施(1-2 周)

序号优化项预期收益依赖
1P10 离线评估 Pipeline所有后续优化有数据支撑;面试核心答案
2P12 Token 成本追踪成本可量化;配合评估体系做成本-质量 trade-off

为什么评估先行:没有评估体系,后续所有优化都是"改了之后感觉变好了"——这在面试中毫无说服力。建立评估体系本身就是一个高区分度的面试答案。

Phase 2:检索质量提升(1-2 周)

序号优化项预期收益依赖
3P1 Markdown-Aware Chunker表格/代码块完整性提升 → Recall@K 提升
4P7 会话历史压缩长对话答案质量止跌
5P9 工具熔断故障场景延迟降低 3-5s

Phase 3:架构级演进(2-4 周)

序号优化项预期收益依赖
6P3 Chunk 知识图谱(轻量 GraphRAG)跨章节推理能力P1 提供 heading 层级
7P2 增量索引文档更新成本从 O(N) 降到 O(ΔN)
8P11 A/B 实验框架参数调优有灰度验证P10 提供指标

四、面试应对策略

面试官问法 → 应答映射

"你的 RAG 系统有什么不足?"

"最大的结构性问题是没有离线评估体系。迭代 #1-#8 做了很多优化——自适应检索、Query Decomposition、成本压缩——但'效果变好了'都是主观判断。没有 Recall@K、没有 Faithfulness 指标、没有 regression guard。这是我下一步最优先做的事。

具体到检索层,有两个痛点:第一,MinerU 输出结构化 Markdown 后 TextChunker 没有适配——表格会被切碎、heading 层级被丢弃;第二,chunk 之间是扁平存储,跨章节推理只能靠向量相似度碰运气。"

"你怎么衡量 RAG 效果?"

"目前主要靠 Langfuse 看链路延迟和 token 消耗,加上 Self-Reflection 的置信度标记做实时质量感知。但这只是在线的——没有离线评估基线

我计划建一套 RAGAS 风格的评估 Pipeline:LLM 自动生成 QA golden set,评估 5 个指标(Retrieval Recall@K、Context Precision、Answer Faithfulness、Answer Relevance、MRR),结果存库展示趋势图。每次参数调整前后跑一遍,做到数据驱动的迭代而不是靠感觉调参。"

"你下一步打算优化什么?"

"三个层面,按优先级排:

  1. 评估先行——建离线评估 Pipeline + token 成本追踪,让所有后续优化有数据支撑
  2. 检索质量——Markdown-Aware Chunker 解决表格切碎问题 + 会话历史压缩解决长对话 token 膨胀
  3. 架构演进——轻量 GraphRAG 让检索'不止找最像的,还找有关系的'

这个排序的逻辑是:先有度量,再做优化,最后做架构级变更。没有评估体系的优化是盲人摸象。"

"你的项目有什么工程上的遗憾?"

"最大的遗憾是评估体系应该在第一次迭代就建,而不是留到最后。没有评估基线意味着前 8 次迭代的'效果提升'无法量化——面试时说'主观感觉好了'是减分项。

第二个遗憾是 MemoryTool 的并发安全——Redis 的 read-modify-write 没有加锁,用的是非原子操作。虽然当前用户量下并发冲突概率极低,但这种'靠运气正确'的代码在工程上是不合格的。改成 Redis Hash 原子操作只需要半天,应该早做。"


五、痛点与已完成迭代的关系

痛点与哪次迭代相关关系
P1 Markdown-Aware Chunker#8 MinerU 全格式入库上游升级倒逼下游适配——MinerU 输出已是结构化 Markdown,但 TextChunker 没跟上
P3 GraphRAG#4 Query Decomposition互补关系——Decomposition 解决"多焦点",GraphRAG 解决"跨章节关系"
P7 历史压缩#7 成本优化同一条成本优化主线的延伸——#7 压缩了 LLM 调用成本,P7 压缩 prompt 输入成本
P9 工具熔断#1 向量化 fail-fast同一个"fail-fast 不 fail-silent"原则的延伸——#1 修了单点降级,P9 做系统级降级
P10 评估体系所有迭代底层缺失——所有迭代的效果判断都缺乏量化支撑

这种关联性在面试中非常有价值——它展示的不是一堆零散的优化,而是一条连贯的工程思考主线