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

发布时间:2026/10/31 12:21:33
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 RAGFTS5 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 和可选的大模型 SDKpip 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, tokenizeunicode61 ) ) 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_k20): 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-208-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 的门槛比想象中低很多。