1. 为什么“先建向量数据库”这件事,可能一开始就想错了
我最早接触 RAG 是在一个内部文档问答项目上,当时团队里几乎所有人的第一反应都是:先选一个向量数据库,把文档切块、做 embedding、灌进去,再搭检索链路。这套流程在 2023 年几乎成了肌肉记忆,谁不这么做,反而显得不专业。但真正把系统跑起来之后,我发现一个很尴尬的事实:大部分知识库场景里,用户问的问题并不需要“语义相似度”来救场,他们需要的是“精确命中”。
比如你问“报销流程里住宿标准是多少”,传统向量检索可能会召回一堆“差旅制度”“费用管理”“财务审批”的段落,因为它们在语义上都很接近。但真正有用的那一句,往往藏在某个表格的第三行,关键词是“住宿标准”和“一线城市”。这时候,BM25 这类基于词频和逆文档频率的稀疏检索,反而比稠密向量更稳、更准、更便宜。
Jev RAG 这个新范式之所以值得聊,是因为它把这件事挑明了:知识库的第一性原理不是“向量”,而是“检索”。向量只是检索的一种实现方式,不是前提。你完全可以用 SQLite FTS5 加上 BM25 排序,先把一个可用的知识库跑起来,再根据实际召回效果决定要不要引入向量。这个思路听起来像是“倒退”,但实测下来,它在很多场景里比一上来就堆向量数据库更靠谱。
这篇文章适合谁看?如果你正在用 Dify 搭知识库、用 Ollama 跑本地 RAG、或者纠结“向量数据库选型”,但又被 embedding 成本、切块策略、召回不准这些问题折磨,那接下来的内容就是写给你的。我会从设计思路、核心细节、实操过程到常见问题,把“不先建向量数据库”的 RAG 知识库怎么落地,拆得清清楚楚。
2. 整体设计思路:把“检索”和“生成”拆开看
2.1 为什么向量数据库不是必需品
向量数据库解决的核心问题是:在语义空间里找最近邻。它擅长处理“意思相近但用词不同”的查询,比如用户问“怎么请假”,文档里写的是“休假申请流程”。这种场景下,BM25 确实会漏,因为“请假”和“休假”不是同一个词。但问题在于,大多数企业知识库、产品文档、操作手册的查询,都是关键词驱动的。
我做过一个粗略统计:在一个 2000 条问答对的知识库里,用户查询中有 70% 以上包含明确的关键词,比如产品名、功能名、错误码、操作动词。这些查询用 BM25 的召回率并不低,甚至因为精确匹配的权重更高,排序结果比向量检索更符合直觉。向量检索的优势场景,反而是那些“模糊描述”和“跨语言查询”,但这些在内部知识库里占比并不高。
另一个被忽略的点是:向量数据库的维护成本被严重低估了。你要选 embedding 模型、要处理切块长度、要调 topK、要管索引更新。每次文档更新,都得重新 embedding 增量部分。如果 embedding 模型换了,整个库还得重建。相比之下,SQLite FTS5 就是一个文件,建索引快,更新快,备份就是复制文件。对于中小规模知识库,这个差距是数量级的。
2.2 Jev RAG 的核心思路:稀疏检索优先,稠密检索按需叠加
Jev RAG 这个范式,我理解下来就是一句话:先用稀疏检索把基线跑通,再根据 bad case 决定要不要加向量。它的流程大致是这样的:
- 文档解析:把 PDF、Word、Markdown、HTML 等格式统一转成纯文本,保留标题层级和表格结构。
- 分块:按语义边界切块,比如按标题、段落、列表项切,而不是固定 512 token。
- 索引:用 SQLite FTS5 建全文索引,支持 BM25 排序。
- 检索:用户查询先走 FTS5,拿到 topN 结果。
- 重排:可选,用轻量级 cross-encoder 或者规则重排。
- 生成:把检索结果塞进 LLM 上下文,生成答案。
这个流程里,向量检索是一个可插拔的模块,而不是必经之路。如果 FTS5 的召回已经够用,你甚至不需要 embedding 模型。如果发现某些查询 FTS5 搞不定,再针对性地加一个向量索引,做混合检索。这种“按需付费”的思路,比一上来就全量向量化要务实得多。
2.3 和 Dify、Ollama 这类工具的关系
现在很多人用 Dify 搭知识库,Dify 默认走的是向量检索路线,底层可能是 Weaviate、Qdrant 或者 Milvus。这套方案不是不好,而是对于个人开发者和小团队来说,太重了。你只是想做一个本地文档问答,结果要维护一个向量数据库服务,还要处理 embedding 模型的部署。Ollama 虽然能跑本地 LLM,但 embedding 模型又是另一套东西。
Jev RAG 的思路可以跟这些工具结合:用 Ollama 跑生成模型,用 SQLite FTS5 做检索,中间用 Python 脚本粘起来。这样整个系统只有一个 SQLite 文件和一个 Ollama 服务,部署简单,调试直观。如果你已经在用 Dify,也可以把 Dify 的检索节点换成自定义的 FTS5 检索,绕过它的向量流水线。Dify 知识库流水线里那些“排队中”的问题,很多时候就是因为向量化环节卡住了,换成 FTS5 之后,索引速度会快很多。
3. 核心细节解析:SQLite FTS5 和 BM25 到底怎么用
3.1 SQLite FTS5 是什么,为什么它够用
SQLite FTS5 是 SQLite 内置的全文搜索扩展,支持分词、前缀查询、短语查询、布尔查询,并且内置了 BM25 排序算法。它的核心是一张虚拟表,你往里插文本,它自动建倒排索引。查询的时候用MATCH语法,返回按 BM25 分数排序的结果。
我选它的理由很直接:零依赖、零配置、单文件、事务安全。你不需要装任何服务,Python 标准库里的sqlite3就能操作。对于一个几千到几万条文档的知识库,FTS5 的查询延迟通常在毫秒级。我实测过一个 5 万条 chunk 的库,单次查询平均 8ms,完全够用。
BM25 的公式这里不展开,但你需要知道它的两个关键参数:k1和b。k1控制词频饱和,b控制文档长度归一化。SQLite FTS5 默认的 BM25 实现里,这两个参数是内置的,但你可以通过bm25()函数传参调整。对于知识库场景,我一般会把b调低一点,比如 0.3 到 0.5,因为长文档里的关键词密度会被稀释,降低长度归一化可以让长文档里的精确匹配不被过度惩罚。
3.2 分块策略:比 embedding 更重要的环节
很多人把精力花在选 embedding 模型上,却忽略了分块。分块决定了检索的上限。如果块切得不好,再好的检索算法也救不回来。我的经验是:
- 按标题层级切:Markdown 的
#、##、###是天然的分块边界。一个三级标题下的内容,通常是一个完整的语义单元。 - 按段落切:如果没有标题,就按空行分段。每段作为一个 chunk,保留前后各一段作为上下文。
- 表格单独处理:表格不要拆散,整张表作为一个 chunk,并且在索引时把表头拼到每一行前面,这样检索“住宿标准”时能命中表格行。
- 代码块单独处理:代码块保留语言标记,索引时把函数名、类名、关键变量名提取出来作为额外字段。
块的大小我一般控制在 200 到 500 字之间。太短了上下文不够,太长了 BM25 的精度会下降。如果一段超过 500 字,就按句子边界再切一刀。切完之后,每个 chunk 存三样东西:content(原文)、metadata(来源、标题路径、页码)、tokens(分词后的文本,用于 FTS5 索引)。
3.3 中文分词:FTS5 的默认分词器不够用
SQLite FTS5 默认的分词器是按空格和标点切的,对中文不友好。中文句子没有空格,默认分词器会把整句当成一个 token,导致检索失效。解决办法有两个:
- 用
unicode61分词器加自定义分隔符:把中文标点也作为分隔符,但这样切出来的还是字级别,粒度太细。 - 用
jieba预分词,把分词结果用空格拼起来再插入 FTS5:这是我最推荐的做法。插入之前,用 jieba 对中文文本做分词,然后用空格连接。查询的时候,也对查询词做同样的分词处理。这样 FTS5 的unicode61分词器就能正常工作,BM25 的统计也有意义。
具体操作是:建表时用tokenize = 'unicode61',插入时把content经过 jieba 分词后存到content_tokenized字段,FTS5 索引这个字段。查询时把用户输入也分词,然后构造MATCH查询。这个方案实测下来,中文召回率比默认分词器高出一大截。
3.4 BM25 排序的调参经验
BM25 的分数本身没有绝对意义,只有相对排序有意义。但在实际使用中,我发现几个调参点很关键:
- 字段权重:FTS5 支持多列索引,你可以给标题列更高的权重。比如
bm25(fts_table, 10.0, 1.0)表示第一列权重 10,第二列权重 1。标题命中的 chunk 应该排在前面。 - 短语查询:用双引号包裹查询词,比如
"住宿标准",这样只匹配连续出现的短语,精度更高。对于专有名词和固定搭配,短语查询比单词查询更准。 - 前缀查询:用
*做前缀匹配,比如报销*,可以匹配“报销流程”“报销标准”“报销单”。这个在用户输入不完整的时候很有用。 - 布尔组合:用
AND、OR、NOT组合查询条件。比如报销 AND 标准 NOT 国际,可以排除不相关的文档。
我一般会先跑一轮宽松查询,拿到 top50,然后用规则重排:标题命中加 2 分,短语命中加 1.5 分,正文命中加 1 分。这个简单的重排策略,比单纯依赖 BM25 分数效果更好。
4. 实操过程:从零搭一个 FTS5 知识库
4.1 环境准备和依赖安装
整个系统只需要 Python 3.8+ 和几个库。SQLite 是 Python 内置的,不需要额外安装。需要装的是:
pip install jieba markdown beautifulsoup4 pypdf ollamajieba:中文分词。markdown:把 Markdown 转 HTML,方便提取标题层级。beautifulsoup4:解析 HTML,提取文本和结构。pypdf:解析 PDF 文本。ollama:调用本地 LLM 生成答案。
如果你不用 Ollama,也可以用任何 OpenAI 兼容的 API。检索部分完全不依赖外部服务,这是这个方案最大的优势。
4.2 文档解析和分块的具体实现
先写一个文档解析函数,把不同格式统一转成带标题路径的 chunk 列表。核心逻辑是:
import re import jieba from bs4 import BeautifulSoup import markdown from pypdf import PdfReader def parse_markdown(file_path): with open(file_path, 'r', encoding='utf-8') as f: text = f.read() html = markdown.markdown(text) soup = BeautifulSoup(html, 'html.parser') chunks = [] current_h1 = '' current_h2 = '' current_h3 = '' for element in soup.children: if element.name == 'h1': current_h1 = element.get_text() current_h2 = '' current_h3 = '' elif element.name == 'h2': current_h2 = element.get_text() current_h3 = '' elif element.name == 'h3': current_h3 = element.get_text() elif element.name in ['p', 'ul', 'ol', 'table', 'pre']: content = element.get_text() if len(content.strip()) < 10: continue title_path = ' > '.join(filter(None, [current_h1, current_h2, current_h3])) chunks.append({ 'content': content.strip(), 'title_path': title_path, 'source': file_path }) return chunks这个函数会把每个段落、列表、表格、代码块作为一个 chunk,并且记录它所属的标题路径。标题路径在检索时可以作为额外字段索引,提高标题命中的权重。
对于 PDF,用pypdf逐页提取文本,然后按空行分段。PDF 的表格提取比较麻烦,如果表格多,建议先用camelot或pdfplumber单独处理表格,再合并到 chunk 里。
4.3 建 FTS5 索引和插入数据
建表语句如下:
CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_fts USING fts5( content_tokenized, title_path_tokenized, source, content, title_path, tokenize = 'unicode61' );注意这里有两组字段:content_tokenized和title_path_tokenized是分词后的文本,用于 FTS5 索引;content和title_path是原文,用于返回结果。插入数据时:
import sqlite3 import jieba def tokenize(text): return ' '.join(jieba.cut(text)) def insert_chunks(chunks, db_path='knowledge.db'): conn = sqlite3.connect(db_path) cursor = conn.cursor() for chunk in chunks: cursor.execute( 'INSERT INTO knowledge_fts (content_tokenized, title_path_tokenized, source, content, title_path) VALUES (?, ?, ?, ?, ?)', ( tokenize(chunk['content']), tokenize(chunk['title_path']), chunk['source'], chunk['content'], chunk['title_path'] ) ) conn.commit() conn.close()插入 1 万条 chunk 大概需要 10 到 20 秒,主要时间花在 jieba 分词上。如果数据量大,可以先把分词结果缓存起来,或者用多进程加速。
4.4 查询和 BM25 排序
查询的时候,先对用户输入做分词,然后构造 FTS5 的MATCH查询:
def search(query, db_path='knowledge.db', top_k=10): conn = sqlite3.connect(db_path) cursor = conn.cursor() query_tokenized = tokenize(query) # 用双引号做短语查询,提高精度 match_query = f'"{query_tokenized}"' cursor.execute( ''' SELECT content, title_path, source, bm25(knowledge_fts, 1.0, 5.0) AS score FROM knowledge_fts WHERE knowledge_fts MATCH ? ORDER BY score LIMIT ? ''', (match_query, top_k) ) results = cursor.fetchall() conn.close() return results这里bm25(knowledge_fts, 1.0, 5.0)表示content_tokenized权重 1.0,title_path_tokenized权重 5.0。标题命中的 chunk 会得到更高的分数。注意 BM25 分数是负数,越小表示越相关,所以ORDER BY score是升序。
如果短语查询召回太少,可以退化成单词查询,用OR连接:
terms = query_tokenized.split() match_query = ' OR '.join(terms)然后对结果做重排:标题包含查询词的加 2 分,正文包含短语的加 1 分。这个重排逻辑用 Python 写就行,不需要额外的模型。
4.5 接入 Ollama 生成答案
检索到 topK 之后,把结果拼成上下文,调用 Ollama:
import ollama def generate_answer(query, contexts): context_text = '\n\n'.join([ f'【来源:{ctx[2]}】\n{ctx[0]}' for ctx in contexts ]) prompt = f'''基于以下知识库内容回答问题。如果内容中没有答案,就说“知识库中没有相关信息”。 知识库内容: {context_text} 问题:{query} 答案:''' response = ollama.chat(model='qwen2.5:7b', messages=[ {'role': 'user', 'content': prompt} ]) return response['message']['content']模型选qwen2.5:7b是因为它对中文支持好,7B 参数在消费级显卡上能跑。如果显存不够,可以用qwen2.5:3b或者llama3.2:3b。生成质量跟上下文质量强相关,检索准了,生成基本不会太差。
5. 常见问题与排查技巧实录
5.1 检索结果不相关怎么办
这是最常见的问题。排查顺序是:
- 检查分词:把查询词和 chunk 的分词结果打印出来,看看关键词有没有被切碎。比如“住宿标准”被切成“住宿”和“标准”,短语查询就失效了。解决办法是把这些词加到 jieba 的自定义词典里。
- 检查索引字段:确认
content_tokenized和title_path_tokenized都正确插入了。有时候插入时忘了分词,FTS5 索引的是原文,中文就搜不到。 - 调整 BM25 权重:如果标题命中的结果没排在前面,把标题列的权重调高,比如从 5.0 调到 10.0。
- 放宽查询:短语查询召回太少时,改成
OR查询,然后靠重排把精确匹配的顶上去。
我踩过的一个坑是:插入数据时用了jieba.cut的默认模式,它会把“报销流程”切成“报销”和“流程”,但查询时用户输入的是“报销流程”,短语查询匹配不上。后来我改用jieba.cut_for_search,它会把长词切得更细,同时保留短词,召回率明显提升。
5.2 中文分词粒度怎么选
jieba有三种模式:精确模式、全模式、搜索引擎模式。我的经验是:
- 索引时用搜索引擎模式:
jieba.cut_for_search,它会把“中华人民共和国”切成“中华”“人民”“共和国”“中华人民共和国”,这样无论用户搜哪个片段都能命中。 - 查询时用精确模式:
jieba.cut,避免查询词被过度切分,导致匹配到太多无关文档。
这个组合实测下来,召回率和精度平衡得最好。如果还是不够,可以加一个同义词词典,把“请假”和“休假”映射到同一个词。
5.3 文档更新后索引怎么同步
FTS5 支持INSERT、UPDATE、DELETE,所以增量更新很简单。我的做法是:
- 每个 chunk 存一个
doc_id和chunk_hash。 - 文档更新时,先算出新 chunk 的 hash,跟库里已有的对比。
- 新增的
INSERT,删除的DELETE,修改的先DELETE再INSERT。
这个过程用事务包起来,避免中途失败导致索引不一致。如果文档量大,可以每天全量重建一次索引,反正 FTS5 建索引很快。我实测 5 万条 chunk 全量重建大概 30 秒,完全可以接受。
5.4 什么时候该加向量检索
如果你发现以下情况,就该考虑加向量了:
- 用户查询里有很多同义词、近义词,FTS5 召回率明显下降。
- 用户用自然语言描述问题,比如“我出差住酒店的钱怎么报”,而文档里写的是“差旅住宿费报销流程”。
- 知识库里有大量非中文内容,或者跨语言查询需求。
加向量的方式可以很轻量:用sentence-transformers跑一个bge-small-zh模型,把 chunk 的 embedding 存到 SQLite 的 BLOB 字段里,查询时用 numpy 算余弦相似度。对于几万条数据,暴力计算完全够用,不需要专门的向量数据库。然后把 FTS5 和向量检索的结果做融合,用 RRF(Reciprocal Rank Fusion)重排。这样既保留了 FTS5 的精确性,又补上了语义召回。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 中文查询无结果 | 分词器不支持中文 | 用 jieba 预分词,存到 tokenized 字段 |
| 短语查询召回少 | 分词粒度过细 | 改用cut_for_search,或加自定义词典 |
| 标题命中没排前面 | BM25 权重默认 | 调高标题列权重,如bm25(fts, 1.0, 10.0) |
| 长文档排名靠后 | 长度归一化过强 | 降低b参数,或把长文档切短 |
| 索引更新慢 | 逐条插入 | 用事务批量插入,或全量重建 |
| 生成答案胡编 | 上下文不相关 | 提高检索精度,加“不知道”指令 |
6. 一些实操心得和扩展思路
这个方案我前后迭代了三个版本,最早也是走向量数据库路线,后来发现维护成本太高,才切回 FTS5。切回来之后,最直观的感受是:调试变得极其简单。检索结果不对,直接看 SQL 查询和分词结果,几分钟就能定位问题。向量检索出了问题,你得查 embedding 模型、查距离度量、查索引参数,排查链路长得多。
另一个心得是:不要迷信“语义检索”这四个字。在很多垂直领域,术语是固定的,用户和文档用的是同一套词汇。这时候精确匹配就是最优解。向量检索的优势在于泛化,但泛化也意味着模糊。知识库场景里,模糊往往是不被接受的。
如果你想把这套方案扩展成多用户、多知识库的系统,可以在 SQLite 里加一个kb_id字段,查询时加WHERE kb_id = ?。SQLite 的并发读性能很好,多用户同时查询没问题。写入的时候加个锁就行。如果数据量真的涨到百万级,再考虑迁移到 PostgreSQL 的tsvector或者专门的搜索引擎。但在那之前,SQLite FTS5 能陪你走很远。
最后分享一个小技巧:把用户的高频查询和对应的正确 chunk 存成一张“查询-答案”表,每次检索时先查这张表,命中就直接返回。这相当于一个缓存层,能覆盖 20% 到 30% 的重复查询,响应速度从毫秒级降到微秒级。这张表还可以用来做 bad case 分析,看看哪些查询 FTS5 搞不定,为后续加向量检索提供依据。