
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, encodingutf-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_pathknowledge.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_pathknowledge.db, top_k10): 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.0title_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 之后把结果拼成上下文调用 Ollamaimport 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(modelqwen2.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 和向量检索的结果做融合用 RRFReciprocal 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 搞不定为后续加向量检索提供依据。