MapAI:RAG 1.0 系统架构与痛点复盘

整理 MapAI 老版 RAG 的数据管道、检索推理链路,以及 RAG 1.0 阶段暴露出的核心问题与优化方向

MapAI:RAG 1.0 系统架构与痛点复盘

这篇笔记整理我在 MapAI 早期版本里实现的一套 RAG 1.0 架构。它已经具备了完整的上传、清洗、切片、向量化、检索和问答闭环,但从今天回头看,很多设计还是明显带着“先跑起来”的 1.0 痕迹。

这份复盘的重点不是讲一个完美方案,而是把老版本真实的构造流程、运行方式和后续暴露出来的问题讲清楚,方便后面继续演进到更稳的 RAG 2.0

RAG 1.0 总览

整个系统可以分成两条主链路:

  • 数据入库链路:负责把用户上传的原始文件清洗、切片并写入向量库。
  • 检索推理链路:负责在用户提问时召回相关 Chunk,并交给大模型做最终回答。

对应到接口层,分别是:

  • POST /api/rag/upload
  • POST /api/rag/chat

数据流管道

当用户调用 POST /api/rag/upload 上传文件时,系统会依次经过下面 5 步完成处理:

[文件上传]
  -> 1. SHA-256 去重
  -> 2. Apache Tika 文本提取
  -> 3. 动态语义分片
  -> 4. DashScope 向量化
  -> 5. Redis 持久化

SHA-256 去重

系统会先计算文件哈希值,在同一个知识库 KB 内实现秒传与去重。

这样做的目标很直接:

  • 避免同一份文件被重复写入。
  • 节省 Redis 存储空间和向量化成本。
  • 降低重复 Chunk 对检索排序造成的噪声。

这一步本质上是“知识库内去重”,而不是全局跨库去重,所以它能保证当前 KB 里的数据相对干净,但也意味着多个 KB 之间仍可能保留相同内容。

文本提取

原始文件上传后,系统使用 Apache Tika 做统一文本解析,把不同格式的文件都转成纯文本 String

当时支持的主要格式包括:

  • PDF
  • DOCX
  • MD
  • TXT
  • CSV
  • XLSX
  • JSON

这个设计的好处是前面统一输入,后面的切片和 Embedding 都只面对字符串,不需要对每种文档格式分别写检索逻辑。

代价也很明显:一旦进入纯文本阶段,很多原始结构信息就丢了,比如表格、标题层级、字段列关系和单元格边界。

动态语义分片

RAG 1.0 的切片策略已经不是最原始的固定长度截断,但本质仍然属于“规则驱动的半语义切片”。

核心参数:

  • minSize = 180
  • targetSize = 700
  • maxSize = 1000

切分优先级:

  1. 优先按双换行拆段落:\n\s*\n+
  2. 如果段落仍然过长,再按结束标点拆句子:。!?;;.!?
  3. 如果还拆不开,就按 maxSize 硬切

组装逻辑:

  • 累积文本达到 targetSize 后,输出为一个独立 Chunk。
  • 如果尾部碎片小于 minSize,则强行并到上一个 Chunk,尽量保证语义完整。

这套方案在 1.0 阶段已经比“纯 500 字一刀切”强不少,因为它至少知道先看段落和句子边界,但它仍然不是严格意义上的语义切片。

向量化与写入

每个切好的文本块最终会被封装为统一的 Document 结构:

Document {
  id(UUID),
  text,
  metadata: {
    kb_name,
    file_name,
    chunk_index
  }
}

Embedding 模型使用的是阿里云 DashScope,把文本转换成稠密向量后写入 Redis 向量索引:

rag_vector_index_v1

这一层本质是一次 Vector Upsert,完成后系统就具备了按向量相似度检索 Chunk 的能力。

Redis 持久化设计

除了向量索引本身,RAG 1.0 还在 Redis 中额外保留了一套辅助元数据,方便文件管理和关键词兜底。

文件列表:

rag:kb:{kbName}:files

这里通常用 Hash 结构,保存文件名和基础属性映射。

原始全文:

rag:kb:{kbName}:file:{base64}:text

这里通常用 String 结构,保留该文件的完整纯文本内容,用于后续兜底搜索。

Chunk 索引:

rag:kb:{kbName}:file:{base64}:chunks

这里通常用 Set 结构,保存该文件下所有 Chunk ID。

这种设计的好处是简单、直接、落地快。缺点是索引和元数据结构开始变多以后,后期维护成本会上升,而且 Redis 同时承担“向量库 + 文件目录 + 原文仓库”三种角色,边界不够清晰。

检索与推理管道

当用户调用 POST /api/rag/chat 进行对话时,请求会先经过检索层,再进入生成层。

可以粗略拆成两段:

  • 阶段 A:检索与过滤,主要由 KnowledgeBaseService 负责
  • 阶段 B:上下文组装与推理,主要由 RagChatService 负责

阶段 A:检索与过滤

过采样计算

为了降低多知识库混查时目标数据被稀释的风险,系统不会直接只取 topK 条结果,而是先放大召回范围:

$$ \text{fetchK} = \text{clamp}(\text{topK} \times 30, 60, 200) $$

也就是:

  • 最少取 60
  • 最多取 200
  • 中间按 topK * 30 放大

这个思路本身是对的,它相当于承认“第一次粗召回不够准”,所以先多捞一点数据回来再做过滤。

多租户隔离过滤

RAG 1.0 的多知识库隔离不是在向量搜索阶段完成的,而是在向量搜索之后做“后置过滤”。

流程大致是:

  1. 先从 Redis Set 里取当前 KB 的合法 allowedIds
  2. 再执行向量库 similaritySearch
  3. 最后对结果做双重保险过滤,并截断到 topK

过滤逻辑可以写成:

$$ \text{Filter: } \text{id} \in \text{allowedIds} \lor \text{meta.kb\_name} == \text{kbName} $$

最后再执行:

limit(topK)

这一步保证了逻辑上的多租户隔离,但因为它是“检索后过滤”,所以当全局数据规模变大时,会出现明显的稀疏化问题。

关键词兜底

如果向量检索结果为空,系统不会直接返回“查不到”,而是退化成原文扫描。

具体做法是:

  • 从 Redis 里的原始全文读取文本
  • 做大小写不敏感的子串匹配
  • 命中后返回相关内容作为兜底结果

这一步的价值主要在于:

  • 防止冷启动时 Embedding 质量不稳定
  • 防止专有名词、缩写或新词没有被向量很好表示
  • 至少让系统在“完全搜不到”的情况下还能给一个兜底结果

但它本质上还是非常弱的字符串匹配,能力上和真正的信息检索差得很远。

阶段 B:上下文组装与推理

上下文拼接

在检索阶段拿到 docs[] 后,系统会把这些召回文本拼接成 context,再注入最终提示词。

这一步是典型的 RAG prompt 组装逻辑:

  • 检索层负责找到候选证据
  • Prompt 层负责把证据组织给大模型
  • 大模型在此基础上完成回答

LLM 裁判任务

RAG 1.0 的提示词里,大模型不只是回答问题,还被要求扮演一个“情报查询问答节点”。

它需要同时承担三件事:

  • 做相关性校验
  • 基于已给出的知识库内容作答
  • 拦截幻觉和无依据扩写

这意味着同一次调用里,LLM 既是生成器,又承担了一部分检索判断职责。

结构化输出

为了让后端更稳定地消费结果,系统会约束大模型输出固定 JSON,再由后端解析后返回给用户:

{
  "related": true,
  "reply": "大模型基于知识库给出的回答...",
  "reason": "判断相关或不相关的逻辑解释"
}

这个设计在工程上很实用,因为后端不需要再从一大段自然语言中猜测模型到底是“拒答”还是“回答成功”。

RAG 1.0 的核心痛点

RAG 1.0 最大的问题不是“完全不能用”,而是它虽然把流程跑通了,但很多关键环节都还是 1.0 的折中方案。一旦文档规模、知识库数量或问题复杂度上去,问题就会集中暴露。

痛点 1:分片策略仍然机械化

现状:

  • 虽然切片时已经考虑段落和标点
  • 但本质仍然是围绕 700 / 1000 字目标做规则凑字数
  • Chunk 之间没有 Overlap

后果:

  • 物理边界上的上下句容易被切断
  • 参数表、说明表、列表型内容可能被一分为二
  • 上下文连续性不够,影响后续召回和回答质量

后续方向:

  • 改成真正的 Semantic Chunking
  • 或至少引入 10% ~ 15% 的滚动窗口重叠区

痛点 2:全局向量搜索 + 后置过滤

现状:

  • 系统先在全量索引里搜索
  • 再用当前 KB 的合法 ID 集合做后置过滤

后果:

  • 当总知识库规模变大时,fetchK 即使放大到 200
  • 也可能先被其他知识库的高相似内容占满
  • 最后过滤完之后,当前 KB 的有效结果几乎没有,甚至直接搜空

这本质上就是“目标知识被全局噪声稀释”。

后续方向:

  • 改成 Pre-Filtering
  • 在向量检索阶段就基于 kb_name 或其他元数据做过滤
  • 把相似度计算范围直接收缩到当前知识库内部

痛点 3:关键词兜底过弱

现状:

  • 向量检索失败后,只能退化成大小写不敏感的原文子串匹配

后果:

  • 不能处理同义词
  • 不能处理缩写
  • 不能处理近义表达
  • 专有术语换一种说法后就直接未命中

比如:

  • 文档里写的是 DDG
  • 用户问的是“驱逐舰”

这类场景下,子串匹配基本没有任何容错能力。

后续方向:

  • 引入 BM25
  • 或构建倒排索引 Inverted Index
  • 配合中文分词器,做真正的 Hybrid Search

痛点 4:缺少重排机制

现状:

  • 向量库返回结果后,只依据 Embedding 相似度粗排
  • 粗排结果直接塞给 LLM

后果:

  • 前几个 Chunk 可能带有较多低相关噪声
  • 真正包含标准答案的 Chunk 可能被挤到后面
  • 一旦上下文窗口有限,LLM 很容易“迷失在中间”

后续方向:

  • 在检索后增加 Reranker
  • 比如 BGE-Reranker
  • 对候选结果做二次精排
  • 再结合分数阈值做动态截断

痛点 5:LLM 既当运动员又当裁判

现状:

  • 单次调用里,LLM 同时做“相关性判断”和“答案生成”

后果:

  • 只要检索结果“不够完美但有点相关”
  • LLM 就可能直接判定 related: false
  • 结果是部分可用信息被浪费,系统容错率很低

后续方向:

  • 把相关性判断前置到检索阶段
  • Reranker 或轻量分类模型做相关性过滤
  • 让大模型专注于信息整合和自然语言生成

痛点 6:固定切片参数 + 缺乏评估体系

现状:

  • 不区分文档类型,统一用一套切片参数
  • 参数密集的表格、叙事文档、结构化 JSON 被同样处理
  • 同时没有标准召回评估

后果:

  • 短文档或高密度文档容易被切坏
  • 系统效果无法量化
  • 后续优化变成“靠感觉调参”

后续方向:

  • 针对不同文件类型使用差异化切片策略
  • 表格类文档采用结构化切片
  • 建立标准问题对 Ground Truth
  • 评估 Recall@10MRR 等指标

从今天回看 RAG 1.0

如果用一句话概括这套老版架构,我会说:

它已经完成了“能上传、能检索、能回答”的最小闭环,但很多关键模块仍停留在规则驱动、后置修补和弱评估阶段。

RAG 1.0 最宝贵的价值,不是它有多先进,而是它把问题暴露得足够具体:

  • 切片为什么会影响召回
  • 全局搜索为什么会造成稀疏化
  • 为什么不能只靠向量检索兜底一切
  • 为什么 LLM 不能既做检索判断又做最终回答
  • 为什么没有评估体系的优化几乎等于盲调

这些坑踩出来之后,后面的 RAG 2.0 才会更有方向。

小结

MapAIRAG 1.0 已经具备了完整的数据入库和问答链路:上传文件后完成去重、解析、切片、向量化和 Redis 持久化;用户提问时完成召回、过滤、上下文组装和 JSON 化回答。

但它的核心问题也很清楚:切片不够语义化、检索过滤顺序不合理、兜底检索太弱、缺少重排、LLM 职责过重、效果无法量化。对一个 1.0 系统来说,这其实很正常。真正重要的是把问题讲清楚,然后让下一版架构不再重复这些老坑。