MapAI:RAG 1.0 系统架构与痛点复盘
这篇笔记整理我在 MapAI 早期版本里实现的一套 RAG 1.0 架构。它已经具备了完整的上传、清洗、切片、向量化、检索和问答闭环,但从今天回头看,很多设计还是明显带着“先跑起来”的 1.0 痕迹。
这份复盘的重点不是讲一个完美方案,而是把老版本真实的构造流程、运行方式和后续暴露出来的问题讲清楚,方便后面继续演进到更稳的 RAG 2.0。
RAG 1.0 总览
整个系统可以分成两条主链路:
- 数据入库链路:负责把用户上传的原始文件清洗、切片并写入向量库。
- 检索推理链路:负责在用户提问时召回相关 Chunk,并交给大模型做最终回答。
对应到接口层,分别是:
POST /api/rag/uploadPOST /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。
当时支持的主要格式包括:
PDFDOCXMDTXTCSVXLSXJSON
这个设计的好处是前面统一输入,后面的切片和 Embedding 都只面对字符串,不需要对每种文档格式分别写检索逻辑。
代价也很明显:一旦进入纯文本阶段,很多原始结构信息就丢了,比如表格、标题层级、字段列关系和单元格边界。
动态语义分片
RAG 1.0 的切片策略已经不是最原始的固定长度截断,但本质仍然属于“规则驱动的半语义切片”。
核心参数:
minSize = 180targetSize = 700maxSize = 1000
切分优先级:
- 优先按双换行拆段落:
\n\s*\n+ - 如果段落仍然过长,再按结束标点拆句子:
。!?;;.!? - 如果还拆不开,就按
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 条结果,而是先放大召回范围:
也就是:
- 最少取
60 - 最多取
200 - 中间按
topK * 30放大
这个思路本身是对的,它相当于承认“第一次粗召回不够准”,所以先多捞一点数据回来再做过滤。
多租户隔离过滤
RAG 1.0 的多知识库隔离不是在向量搜索阶段完成的,而是在向量搜索之后做“后置过滤”。
流程大致是:
- 先从 Redis
Set里取当前 KB 的合法allowedIds - 再执行向量库
similaritySearch - 最后对结果做双重保险过滤,并截断到
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@10、MRR等指标
从今天回看 RAG 1.0
如果用一句话概括这套老版架构,我会说:
它已经完成了“能上传、能检索、能回答”的最小闭环,但很多关键模块仍停留在规则驱动、后置修补和弱评估阶段。
RAG 1.0 最宝贵的价值,不是它有多先进,而是它把问题暴露得足够具体:
- 切片为什么会影响召回
- 全局搜索为什么会造成稀疏化
- 为什么不能只靠向量检索兜底一切
- 为什么 LLM 不能既做检索判断又做最终回答
- 为什么没有评估体系的优化几乎等于盲调
这些坑踩出来之后,后面的 RAG 2.0 才会更有方向。
小结
MapAI 的 RAG 1.0 已经具备了完整的数据入库和问答链路:上传文件后完成去重、解析、切片、向量化和 Redis 持久化;用户提问时完成召回、过滤、上下文组装和 JSON 化回答。
但它的核心问题也很清楚:切片不够语义化、检索过滤顺序不合理、兜底检索太弱、缺少重排、LLM 职责过重、效果无法量化。对一个 1.0 系统来说,这其实很正常。真正重要的是把问题讲清楚,然后让下一版架构不再重复这些老坑。