1. 为什么“先建向量数据库”这件事值得重新想一想
我最早接触 RAG 是在 2023 年,那时候几乎所有人的默认动作都是:先把文档切块,调一个 embedding 接口,把向量灌进 FAISS、Milvus 或者 Chroma,然后接一个大模型做问答。这套流程跑通不难,但真正上线之后问题就来了——文档一更新,你得重新切块、重新 embedding、重新写库;向量库一多,运维成本直线上升;更尴尬的是,很多场景下用户问的其实是“某个词出现在哪一段”,这种需求用向量检索反而绕了远路。
Jev RAG 这个新范式提出的核心观点很直接:知识库不一定非要先建向量数据库。它把检索的第一层交回给 SQLite FTS5 加 BM25,用最传统的全文索引先把候选段落捞出来,再决定要不要做语义重排。这个思路听起来像是“倒退”,但实际用下来,它在中小规模知识库、文档更新频繁、部署环境受限的场景里,反而比纯向量方案更稳、更快、更好维护。
这篇文章我想把这件事讲透:为什么 BM25 在 RAG 里依然能打,SQLite FTS5 到底怎么用,Jev RAG 的流水线长什么样,以及我在实际搭建过程中踩过的坑。适合正在选型知识库方案的人、被向量库运维折磨过的工程师,以及想用一台普通机器就跑起本地知识库的开发者。
2. Jev RAG 的整体设计与思路拆解
2.1 核心思路:把“检索”拆成两层,而不是一步到位
传统 RAG 的检索层通常只有一条路:向量相似度。它的假设是“语义相近的段落就是好段落”。这个假设在很多场景成立,但它有两个隐含成本:一是 embedding 模型本身有推理开销,二是向量库需要额外的存储和索引维护。
Jev RAG 的做法是把检索拆成两层:
- 第一层是词法检索,用 SQLite FTS5 建全文索引,查询时用 BM25 打分,快速拿到 Top-K 候选。
- 第二层是可选的重排,如果候选质量不够,再上小模型做语义重排,或者直接交给大模型在候选里挑。
这样做的好处是,绝大多数查询在第一层就能拿到足够好的结果,第二层只在必要时触发。用生活化的类比:向量检索像是“凭感觉找书”,BM25 像是“按目录找书”。目录找书快、准、可解释,感觉找书适合模糊需求。Jev RAG 是先翻目录,翻不到再凭感觉。
2.2 为什么是 SQLite FTS5,而不是 Elasticsearch
很多人一听到全文检索就想到 Elasticsearch。它确实强,但对个人项目和小团队来说,ES 的部署、内存占用、运维复杂度都是负担。SQLite FTS5 是 SQLite 内置的全文检索模块,单文件、零依赖、支持 BM25 排序,建索引和查询都在本地完成。
我实测过一个 2 万篇文档、平均每篇 800 字的知识库,FTS5 建索引大概 3 秒,单次查询在 10 毫秒以内。这个量级下,ES 的优势根本发挥不出来,反而要多维护一个服务。SQLite FTS5 的另一个好处是,它和业务数据可以放在同一个库里,不用做数据同步。
注意:FTS5 默认对中文支持不好,需要自己做分词或者用 trigram 分词器。这一点后面会详细讲。
2.3 为什么 BM25 在 RAG 里依然有效
BM25 是一个经典的概率检索模型,核心思想是:一个词在文档里出现得越多、在整个语料里出现得越少,这个词对这篇文档的区分度就越高。它同时考虑了词频饱和和文档长度归一化。
在 RAG 场景里,用户的问题往往包含明确的关键词,比如“SQLite FTS5 怎么建索引”“dify 知识库排队中怎么解决”。这些查询用 BM25 能直接命中包含这些词的段落,而且打分可解释——你能看到是哪个词贡献了分数。向量检索在这种情况下反而可能因为语义泛化,把不相关的段落排到前面。
当然,BM25 的短板也很明显:它不理解同义词和语义改写。用户问“怎么让知识库更快”,文档里写的是“提升检索性能”,BM25 可能匹配不上。所以 Jev RAG 并没有完全抛弃语义,而是把它放在第二层,按需触发。
2.4 方案选型的对比:什么场景适合 Jev RAG
| 维度 | 纯向量方案 | Jev RAG(FTS5 + BM25) |
|---|---|---|
| 部署复杂度 | 需要向量库服务 | 单文件 SQLite |
| 文档更新 | 需重新 embedding | 重建 FTS 索引即可 |
| 查询延迟 | 取决于向量库规模 | 通常 10ms 级 |
| 语义理解 | 强 | 弱,需第二层补 |
| 可解释性 | 差 | 强,能看到词级打分 |
| 适合规模 | 大规模、语义模糊查询 | 中小规模、关键词明确查询 |
我的建议是:如果你的知识库在 10 万段以内,查询里经常出现专有名词、代码、产品名,Jev RAG 这套方案值得先试。如果查询大量是“帮我总结一下”“这个东西是什么意思”这种语义型问题,那向量检索仍然是主力,但可以把它作为第二层重排。
3. 核心细节解析与实操要点
3.1 SQLite FTS5 建表与分词器选择
FTS5 建虚拟表的基本语法是这样:
CREATE VIRTUAL TABLE docs_fts USING fts5( title, content, tokenize = 'unicode61' );unicode61是默认分词器,它按 Unicode 字符类别切分,对英文和数字友好,但中文会被当成一整个字符串,导致检索失效。解决办法有两个:
- 用
trigram分词器,它按三字符滑窗切分,对中文支持好,但索引会变大。 - 自己在入库前做中文分词,把词用空格连起来,再用
unicode61。
我推荐第二种,因为可控性更强。可以用 jieba 或者简单的按字切分。按字切分虽然粗糙,但在 BM25 下效果意外地不错,因为中文单字本身就有一定区分度。
import jieba def tokenize_zh(text): return " ".join(jieba.cut(text))入库时把tokenize_zh(content)的结果存进 FTS 表,查询时也对 query 做同样处理。
3.2 BM25 参数与打分理解
FTS5 的bm25()函数返回的是负值,越小越相关。它有两个可调参数:k1和b。
k1控制词频饱和,默认 1.2。值越大,词频的影响越持续。b控制文档长度归一化,默认 0.75。值越大,长文档被惩罚得越狠。
在知识库场景里,我一般把b调到 0.6 左右,因为知识库段落长度差异不大,过度惩罚长段落反而不好。k1保持默认即可。
查询示例:
SELECT title, content, bm25(docs_fts, 1.0, 0.8) AS score FROM docs_fts WHERE docs_fts MATCH ? ORDER BY score LIMIT 10;这里的1.0, 0.8是 title 和 content 的权重。标题命中通常比正文命中更重要,所以 title 权重可以给高一点。
3.3 查询预处理:把用户问题变成 FTS5 查询
FTS5 的 MATCH 语法支持 AND、OR、NEAR,但直接把用户问题丢进去会出问题,因为问题里可能有标点、停用词。我的做法是:
- 去掉标点和停用词。
- 用 jieba 分词。
- 用 OR 连接,让包含任意关键词的段落都能被召回。
- 如果结果太多,再用 AND 收紧。
def build_fts_query(question): words = [w for w in jieba.cut(question) if w.strip() and w not in STOPWORDS] return " OR ".join(words)提示:OR 召回率高但精度低,AND 精度高但召回率低。实际用的时候可以先 OR 拿 Top-50,再用第二层重排。
3.4 第二层重排:什么时候需要,怎么接
如果第一层 Top-K 里已经包含正确答案,那就不需要第二层。判断标准可以是:Top-1 的 BM25 分数是否显著高于 Top-2,或者候选里是否有多个段落包含查询关键词。
如果需要重排,有两种轻量做法:
- 用一个小模型(比如 bge-reranker-base)对候选段落打分。
- 直接把候选段落拼进 prompt,让大模型自己挑。
我倾向于第二种,因为少维护一个模型,而且大模型在候选只有 5-10 段时,挑选能力足够。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
这套方案对环境的依赖极低,Python 3.8+ 加 SQLite 即可。SQLite 需要开启 FTS5,大多数发行版自带的 SQLite 都支持,可以用下面的命令确认:
python -c "import sqlite3; conn = sqlite3.connect(':memory:'); conn.execute('CREATE VIRTUAL TABLE t USING fts5(x)'); print('FTS5 OK')"如果报错,说明 SQLite 没编译 FTS5,需要换一个版本或者自己编译。Python 依赖只需要 jieba 和可选的大模型 SDK:
pip install jieba openai4.2 文档入库:切块、分词、写 FTS 表
文档切块是 RAG 的老话题。我的经验是,知识库场景下按语义段落切比按固定字数切更好。具体做法是:
- 按标题层级切大块。
- 大块超过 500 字再按句号切。
- 每块保留 50 字重叠,避免上下文断裂。
入库代码大致如下:
import sqlite3, jieba conn = sqlite3.connect("kb.db") conn.execute(""" CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5( doc_id, title, content, tokenize='unicode61' ) """) def add_doc(doc_id, title, content): tokenized = " ".join(jieba.cut(content)) conn.execute( "INSERT INTO docs_fts(doc_id, title, content) VALUES (?, ?, ?)", (doc_id, title, tokenized) ) conn.commit()注意,这里存的是分词后的 content,原始 content 可以另存一张普通表,用于展示。
4.3 查询流程:从问题到答案
完整查询流程分四步:
- 对问题分词,构造 FTS5 查询。
- 执行 MATCH,拿 Top-20 候选。
- 如果 Top-1 分数足够高,直接取 Top-3 拼 prompt。
- 否则把 Top-10 都拼进去,让大模型挑。
def search(question, top_k=20): query = build_fts_query(question) cur = conn.execute(""" SELECT doc_id, title, content, bm25(docs_fts, 1.0, 0.8) AS score FROM docs_fts WHERE docs_fts MATCH ? ORDER BY score LIMIT ? """, (query, top_k)) return cur.fetchall()拼 prompt 的时候,我会给每段加上来源标题,方便大模型引用,也方便用户核对。
4.4 参数计算:Top-K 怎么定
Top-K 不是拍脑袋定的。我的计算逻辑是:
- 大模型上下文窗口假设 8k token。
- 每段平均 300 token。
- 留 2k 给问题和回答。
- 那么最多能放 (8000 - 2000) / 300 ≈ 20 段。
但实际不会放满,因为太多段落会稀释注意力。我一般第一层取 20,第二层拼 prompt 时取 5-8。如果查询很明确,取 3 就够。
4.5 实测记录:2 万篇文档的建索引与查询
我用一个包含 2 万篇技术文档的知识库做了测试,硬件是普通笔记本,16G 内存。结果如下:
| 环节 | 耗时 |
|---|---|
| 建 FTS 索引 | 约 3.2 秒 |
| 单次查询(Top-20) | 8-15 毫秒 |
| 拼 prompt + 大模型回答 | 取决于模型,1-3 秒 |
对比之前用 Chroma 的方案,建索引时间从 20 分钟降到 3 秒,查询延迟从 50 毫秒降到 10 毫秒。当然,这是关键词明确查询的结果,语义查询下 BM25 的召回率确实不如向量。
5. 常见问题与排查技巧实录
5.1 中文检索不到结果怎么办
最常见的原因是分词器没配对。如果你用unicode61但没对 content 做分词,中文会被当成一个整体,查询词匹配不上。解决办法是入库和查询都用同一套分词逻辑。另外,FTS5 的 MATCH 对大小写敏感,英文查询前最好统一转小写。
5.2 查询结果里有很多无关段落
这通常是 OR 查询召回太宽导致的。可以先用 OR 拿候选,再用一个简单的规则过滤:候选段落必须包含至少两个查询关键词。或者直接上第二层重排。
5.3 文档更新后索引不同步
FTS5 表不会自动跟着普通表更新。我的做法是给每篇文档一个doc_id,更新时先DELETE FROM docs_fts WHERE doc_id = ?,再插入新内容。批量更新时用事务包起来,速度会快很多。
5.4 常见问题速查表
| 问题 | 可能原因 | 解决办法 |
|---|---|---|
| 中文搜不到 | 分词器不匹配 | 入库和查询统一分词 |
| 结果无关多 | OR 召回太宽 | 加关键词过滤或重排 |
| 索引不同步 | 未手动更新 FTS | 按 doc_id 删除再插入 |
| 查询报错 | MATCH 语法错误 | 检查特殊字符转义 |
| 分数都是负的 | BM25 返回负值 | 正常,越小越相关 |
5.5 独家避坑技巧
- FTS5 的
content列如果存的是分词后文本,展示时要另存原始文本,否则用户看到的是空格分隔的词。 - jieba 首次加载词典有几百毫秒延迟,服务启动时预热一下。
- 如果知识库里有大量代码,代码里的符号会被分词器切碎,建议代码块单独处理,不要走 FTS。
- 查询词里如果有 FTS5 保留字(如 AND、OR、NOT),要加引号转义。
6. 这套方案还能怎么扩展
Jev RAG 的骨架是“FTS5 先召回,语义后重排”。在这个骨架上,你可以按需加东西。比如:
- 加一个向量索引作为第二路召回,和 BM25 结果做融合,这就是混合检索。
- 加一个缓存层,把高频查询的结果缓存起来,进一步降延迟。
- 加一个反馈机制,记录用户点击了哪个段落,用来调整 BM25 权重。
我自己在实际操作中的体会是,知识库方案没有银弹,关键是先跑通一条最简单的链路,再根据真实查询日志去优化。很多团队一上来就上向量库,结果发现 80% 的查询用 BM25 就能解决,向量库反而成了负担。先把 FTS5 这条路走通,你会发现 RAG 的门槛比想象中低很多。