ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Jev RAG新范式:用SQLite FTS5与BM25替代向量数据库的轻量级RAG方案

Jev RAG新范式:用SQLite FTS5与BM25替代向量数据库的轻量级RAG方案

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 字符类别切分,对英文和数字友好,但中文会被当成一整个字符串,导致检索失效。解决办法有两个:

  1. 用trigram分词器,它按三字符滑窗切分,对中文支持好,但索引会变大。
  2. 自己在入库前做中文分词,把词用空格连起来,再用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,但直接把用户问题丢进去会出问题,因为问题里可能有标点、停用词。我的做法是:

  1. 去掉标点和停用词。
  2. 用 jieba 分词。
  3. 用 OR 连接,让包含任意关键词的段落都能被召回。
  4. 如果结果太多,再用 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 openai

4.2 文档入库:切块、分词、写 FTS 表

文档切块是 RAG 的老话题。我的经验是,知识库场景下按语义段落切比按固定字数切更好。具体做法是:

  1. 按标题层级切大块。
  2. 大块超过 500 字再按句号切。
  3. 每块保留 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 查询流程:从问题到答案

完整查询流程分四步:

  1. 对问题分词,构造 FTS5 查询。
  2. 执行 MATCH,拿 Top-20 候选。
  3. 如果 Top-1 分数足够高,直接取 Top-3 拼 prompt。
  4. 否则把 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 的门槛比想象中低很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进