徐仁康首页项目文章标签关于

为什么我没给这个 Agent 接向量数据库

2026-09-28 · C++ · LLM · RAG · 架构设计

给 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。不是随手写的——主循环全程持锁且每轮都写消息列表,「读多写少」根本不成立,用读写锁纯属白付开销。

同一个项目里两个不同选择,理由各自成立,这比统一用一套更难写对。

会怎么改

  1. 切点回退到 UTF-8 字符边界——最小改动,收益最大,中文文档不修就是坏的
  2. 中文检索接分词 + BM25 倒排索引,或直接上向量库(接口已经留好口子)
  3. 重叠率做成可配,按文档类型调

第一条是 bug,后两条是取舍。区别在于:bug 不会因为你讲得好就消失。

← 返回文章列表