为什么我没给这个 Agent 接向量数据库
给 Agent 接知识库,行业里的标准答案是向量数据库:把文档切块、每块过一个 embedding 模型变成向量、存进 Milvus 或 FAISS,提问时把问题也变成向量,算余弦相似度取最近的几条。
我没这么做。我用的是关键词匹配——查「曝光」,就找哪段文本里含「曝光」两个字。
先给结论:这个选择对了一半。 关键词法在我的场景里是对的,但代码里有个实现缺陷,把中文文档切成了乱码。下面把两件事都讲清楚。
先厘清一件事:RAG 不是让模型去查资料
很多人以为 RAG 是「模型自己会去知识库里翻」。不是。
实际流程是:提问之前,程序先按关键词去库里翻一遍,把最相关的几段夹在问题里一起递给模型。 模型从头到尾没碰过你的资料柜,它只是收到一个「已经夹好资料的问题包」。
这个区别决定了整个设计的形状——检索是你的代码在干,模型只负责读。
为什么不上向量库
| 维度 | 向量检索(主流做法) | 关键词打分(我的做法) |
|---|---|---|
| 依赖 | 必须装向量库,或调云端 embedding API | 零依赖,纯 std::vector + 字符串查找 |
| 离线部署 | 麻烦,embedding 也得本地跑 | 天生离线 |
| 可解释性 | 差。「为什么这段排第一?」答:余弦相似度 0.83 | 好。「因为里面有『曝光』这个词」 |
| 同义词 | 强,「调亮」和「增大曝光」向量距离很近 | 不认,「调整」和「设置」是两个词 |
| 中文分词 | 不依赖分词 | 致命,中文没空格,整句变一个词 |
决定性的两条是离线和可解释:工业现场经常没网,装不了向量库;出问题时工程师需要知道「为什么检索到了这一段」,而「余弦相似度 0.83」等于没说。
但我敢用「简单但有局限」的方案,真正的原因是换起来不贵:检索层是个纯虚接口 IKnowledgeStore,只有三个方法——塞一条、查一次、看看空不空。将来要换向量库,上层一行不用改。
这是接口设计的价值:允许你选一个不完美但当下够用的方案,因为你知道以后能换。
四个动作
切块:按长度切,带重叠
一篇手册几万字塞不进上下文窗口,得切成小块。两个参数:每块最多多少字符(默认 1200),相邻两块重叠多少(默认 120)。
重叠是为了防止关键句子正好被切在边界上:
不重叠: [....关键句子前半][后半....] ← 两块都匹配不完整
有重叠: [....关键句子前半]
[关键句子前半][后半] ← 后一块完整包含它
切点也不是硬切:先按最大长度划一刀,再回头找最近的换行符改从那儿切,别把一行拦腰截断。
这里有个容易写出死循环的地方:重叠必须小于块大小。如果 overlap >= max_chunk_chars,下一块的起点会跑到这一块起点之前或原地不动,while (start < text.size()) 永远不进步。所以代码里把重叠钳到 max_chunk_chars - 1。
分词:三行,也是死穴
std::istringstream input(LowerAscii(text));
std::vector<std::string> tokens;
std::string token;
while (input >> token) { // >> 自动按空白切
tokens.push_back(token);
}
转小写、按空白切、收进数组。英文好用,中文直接废——中文句子没有空格:
"Exposure Time" → [exposure] [time] 2 个词
"曝光" → [曝光] 1 个词
"图像有点暗" → [图像有点暗] 整句一个词
结果是「图像有点暗」要命中,文档里必须原样出现这五个字。文档写的是「图像偏暗时」,就完全匹配不到。这个是我实测出来的,下面有输出。
打分:命中一个词加一分
for (const auto& token : query_tokens) {
if (!token.empty() && haystack.find(token) != std::string::npos) {
score += 1.0;
}
}
查询「曝光 trigger」,某块两个词都有就是 2 分,只有「曝光」就 1 分。排序依据就是这个。
粗暴,但对术语固定的工业文档够用。
排序取前 K
查询分词 → 加共享锁遍历打分 → 0 分直接丢 → 按分降序 → 截断到 top_k。
两个细节:
打分时拷贝一份再改分。 原始数据是共享状态,检索不该往里写分数(否则第二次检索会看到上次的分)。这和工具注册表那边「先拷贝再释放锁」是同一个思路——不污染共享状态。
0 分的块直接丢,不是照单全收。 不丢的话,一个词都没命中的块也会进候选,然后被 resize(top_k) 按原本的顺序(不是相关性)截断——返回一堆完全无关的内容,比不返回还糟:模型会拿无关内容一本正经地胡说。
实测:跑一遍
用一段 4 行的假手册,max_chunk_chars=160, overlap=40,切出 5 块:
| 块 | 开头 | 说明 |
|---|---|---|
#0 |
1. Exposure control |
完整 |
#1 |
icroseconds. Use the... |
microseconds 被切断了 |
#2 |
.9 means underexposed. |
0.9 被切断 |
#3 |
es extra wiring. |
requires 被切断 |
#4 |
�增大曝光值。 |
乱码 |
检索结果:
查询 "曝光" → #2, #3, #4 各 1 分 命中 3 块,但只有 #4 真相关
查询 "trigger" → #1, #2 各 1 分 重叠把 "2. Trigger mode" 切进了两块
查询 "曝光 trigger" → #2 得 2 分排第一 多词加分,排序正确工作
查询 "图像有点暗" → 一条都没命中 文档写的是「图像偏暗时」
三条结论:重叠会让同一个词重复命中、精度被稀释;多词打分能正确排序;中文口语查询直接废。
然后是真正的 bug
上面那张表最后一行那个 �,我一开始以为是终端显示问题。dump 了原始字节才发现不是:
b6 e5 a2 9e e5 a4 a7 ...
开头那个孤零零的 b6,是「时」的最后一个字节(「时」= e6 97 b6)。也就是说——「时」这个字被从中间切开了,后半截进了下一块。
原因很直白:
chunk.text = text.substr(start, end - start); // start/end 是字节位置
std::string 本来就是字节序列,substr 按字节切天经地义——只要文档是纯 ASCII,这段代码完全正确。但中文一个字占 3 字节,切在中间就产生非法 UTF-8,模型收到的是乱码。
修法不难:切点确定后向前回退到 UTF-8 字符边界(检查字节高位,不是 10xxxxxx 就说明这个字节是字符首字节)。改动很小,但对中文文档是必须修的。
这件事比前面所有设计取舍都值钱——因为它不是「我读了别人的设计然后复述」,是「我读代码时发现它对中文不成立,然后用 od 验了字节」。前者任何人都能讲,后者只有真动过手的人讲得出来。
检索结果怎么进对话
检索到的片段不会直接拼成 user 消息,而是拼成一条独立的 system 消息,插在用户提问之前:
[0] system ← 系统提示词
[1] system ← ★ 知识库检索结果(在 user 之前)
[2] user ← 用户的问题
[3] assistant
三个考虑:
位置在 user 之前——检索发生在问模型之前,这是主循环里就定好的顺序。
角色是 system 而不是 user——等于告诉模型「这是背景资料,不是用户跟我说的话」。
末尾加一句 Ignore it if it does not apply——防检索噪声。检索可能给错(上面「曝光」命中 3 块就是),明确告诉模型可以不理会,否则它会拿无关内容硬答。
取舍汇总
| 设计点 | 主流做法 | 我的做法 | 代价 |
|---|---|---|---|
| 检索方式 | 向量 + embedding | 关键词打分 | 同义词不认、中文分词废 |
| 文档处理 | 整篇塞 / 语义切 | 按长度切 + 120 字符重叠 | 存储翻倍、重复命中 |
| 低相关过滤 | 取前 K 就返回 | 0 分直接丢 | 用词对不上时漏召回 |
| 并发 | mutex 全包 |
shared_mutex 读写分离 |
加锁更慢,只有读远多于写才划算 |
| 结果注入 | 拼进 user 消息 | 独立 system 消息 + 免责句 | 多占 token |
| 可替换性 | 写死实现 | 虚接口 | 一层虚函数开销 |
补一句并发那个选择:同一个项目里,Agent 主循环用的是普通 mutex,知识库用的是 shared_mutex。不是随手写的——主循环全程持锁且每轮都写消息列表,「读多写少」根本不成立,用读写锁纯属白付开销。
同一个项目里两个不同选择,理由各自成立,这比统一用一套更难写对。
会怎么改
- 切点回退到 UTF-8 字符边界——最小改动,收益最大,中文文档不修就是坏的
- 中文检索接分词 + BM25 倒排索引,或直接上向量库(接口已经留好口子)
- 重叠率做成可配,按文档类型调
第一条是 bug,后两条是取舍。区别在于:bug 不会因为你讲得好就消失。