说到 RAG(检索增强生成),业界最惯常的思维往往是:上向量数据库、调 Embedding 模型、再写召回逻辑。
然而,在面对中小规模的内部知识库,这条路径往往伴随着较高的成本和复杂的运维。
今天,我们将反其道而行之:不上向量库、不调 Embedding,纯靠标准库的 SQLite FTS5,配合轻量的 jieba 中文分词,来实现一套毫秒级响应的文档检索方案。
这套方案的实现关键,是一个轻量级的文档索引器。
索引器要做到什么?
存储交给免管理后台、易协同的 Markdown,检索不借助外部 Embedding 模型,那么这个索引器唯一要攻克的难关,就是提供不亚于模糊语义的“检索精确度”。
具体而言,它必须满足以下三项核心指标:
- 响应快:用户提问后,毫秒级快速返回候选章节;
- 排序稳:利用 FTS5 内置的 BM25 算法,把最相关的章节推到顶部;
- 定位准:避开“粗暴按字数硬切片”的局限,精准定位并提取具体章节(Anchor)。
为了达成这些指标,我们需要文档切片、双层分词、虚拟表存储以及密度滑窗高亮等核心技术。在逐个拆解这些技术细节之前,我们先看一下索引器的工作流。
索引器的整体架构
整个设计围绕唯一的核心类 DocIndex 展开。它分为两部分:build() 负责离线建库,而 search() / list() / read() 负责在线查询。
graph TD
%% Base Flow
subgraph BuildPipeline [Build Pipeline 离线/初始化]
A[Markdown 文件] -->|rglob 遍历| B[split_markdown 切块]
B -->|生成 Anchor 锚点| C[Chunk 原文]
C --> D[jieba 空格 Token 流]
C -->|"作为 raw_content (不索引)"| E[(SQLite FTS5 虚拟表)]
D -->|"作为 content (索引列)"| E
end
subgraph SearchPipeline [Search Pipeline 在线检索]
F[用户 Query] -->|jieba / Tokens 提取| G[OR 拼接 Match 语句]
G -->|MATCH 查询| E
E -->|bm25 排序评分/负数越小越相关| H[命中 Chunk]
H -->|密度滑窗 & 倒序高亮算法| I[SearchHit 片段]
I -->|回显前端/大模型上下文| J[用户/大模型]
end
数据模型:FTS5 虚拟表与“双列”设计
建索引的第一步,是定义 FTS5 虚拟表的 schema。
CREATE VIRTUAL TABLE docs USING fts5(
path UNINDEXED,
anchor UNINDEXED,
category UNINDEXED,
title UNINDEXED,
heading_path UNINDEXED,
raw_content UNINDEXED,
content, -- 唯一被索引的列
tokenize='unicode61 remove_diacritics 2'
);
注意哪些列是 UNINDEXED:除了 content,全部都是。也就是说 FTS5 只对 content 这一列建倒排索引。
真正的设计巧思,藏在 content 和 raw_content 这对双列里:
| 列 | 是否索引 | 存什么 |
|---|---|---|
content | ✅ 索引 | jieba 分词后、用空格连接的 token 流 |
raw_content | ❌ 不索引 | 章节的原始 Markdown |
为什么要分两列存几乎“同一份”内容?因为它们服务于两个互斥的目的:
- 机器要匹配的,是分好词的 token 流(
content)——这样 FTS5 才能正确切中文(下一节细说); - 人要看的,是没被分词破坏的原文(
raw_content)——用来生成片段、做高亮、整段回灌给大模型。
另外两个细节:
- 建表用
DROP TABLE IF EXISTS docs+ 重建——build()是全量重建,不做增量。对几百个文档的内部库,简单可靠胜过增量的复杂度。 - 默认
db_path=":memory:",索引整个跑在内存里;也支持落盘到文件。配合check_same_thread=False,让 ASGI 多线程能共享同一连接。
双层分词:为什么中文要切两次
第一层,索引时的 jieba 分词。 灌数据前,每个 chunk 的正文都先过一遍 _tokenize:
def _tokenize(text: str) -> str:
return " ".join(t for t in jieba.cut_for_search(text) if t.strip())
jieba.cut_for_search 是搜索引擎模式,它会把“长方体”同时切出“长方”和“长方体”这种更细粒度、更利于召回的结果,然后用空格把这些 token 拼成字符串,存进 content 列。
第二层,FTS5 自己的 unicode61 tokenizer。 表定义里的 tokenize='unicode61 remove_diacritics 2',会在索引和查询时按 Unicode 规则(空格、标点)再切一次,并做大小写折叠和重音归一(remove_diacritics 2)。
为什么非要两层? 因为 unicode61 没有中文分词能力,它会把一整段连续的中文当成一个超长 token,就失去了检索的意义。所以必须先用 jieba 把中文切成带空格的词,再交给 unicode61 按空格切。
查询侧也必须对称地走同样的 jieba 分词,否则索引和查询的 token 对不上,什么都搜不到:
tokens = [t for t in jieba.cut_for_search(query) if t.strip()]
索引怎么切,查询就得怎么切。
切片:把一篇长文档拆成可定位的“章节”
build() 遍历 docs_root.rglob("*.md")(排序以保证确定性),每个文件交给配套的 split_markdown 按标题切块。切块的质量,直接决定检索命中的颗粒度——我们希望命中的是“某一节”,而不是“整个文件”或“半句话”。
切块逻辑的几个关键设计:
① 按标题边界切,每块自带标题行。 用一个栈维护标题层级,生成可读的 heading_path,比如 "Scripting > Simple examples > Creating a part"。
while stack and stack[-1][0] >= level:
stack.pop()
stack.append((level, heading))
heading_path = " > ".join(h for _, h in stack)
② anchor 是章节的唯一定位符,且保证不重复。 用 slugify 把标题转成锚点(中文也保留,靠正则 [\w\u4e00-\u9fff]+),再用 reserve 给重名标题自动加 -2、-3 后缀:
def reserve(base_slug: str) -> str:
n = used.get(base_slug, 0)
used[base_slug] = n + 1
return base_slug if n == 0 else f"{base_slug}-{n + 1}"
于是 (path, anchor) 成了全库每个章节的主键——搜索返回它,read(path, anchor) 就能精确取回这一节。
③ 超长章节再按段落二次切。 一节太长(超过 max_chars,默认 2000)会撑爆上下文,也稀释相关度。_split_oversized 按空行(段落边界)把它打包成多块,子块 anchor 加 ~2、~3 后缀;实在有超长单段才硬切。
④ 边界情况兜底。 第一个标题之前的内容进 _preamble 块;整篇没有标题的文件,整体进一个 _root 块。这样确保没有内容会“漏索引”。
每个 chunk 最终摊平成一行 (path, anchor, category, title, heading_path, raw_content, content) 灌进表。注意 content 存的是 _tokenize(chunk.content),raw_content 存的是原文——又一次双列分离。category 取自顶层目录名,用来做分类过滤。
检索:bm25 排序
查询的核心就十几行:
seen = set()
distinct = [t for t in tokens if not (t in seen or seen.add(t))]
match_expr = " OR ".join(f'"{t}"' for t in distinct)
sql = ("SELECT path, anchor, title, heading_path, raw_content, "
"bm25(docs) AS score FROM docs WHERE docs MATCH ? "
"ORDER BY score LIMIT ?")
FTS5 内置 bm25 打分,但有个反直觉的点——它返回的是负数,越相关越小(越负)。所以 ORDER BY score(默认升序)恰好把最相关的排在最前。实测中,一个精确命中的章节 score 可能是 -7.35,而一个只蹭中了单个词的弱命中接近 -0.0。
片段:密度滑窗 + 高亮算法
前面说过,因为索引列是 token 流,FTS5 自带的片段函数用不了,所以片段是基于 raw_content 手写的。
它要解决的问题是:在一段可能上千字的原文里,截出一小段“命中词最密集”的窗口,并把命中词高亮。
第一步,找出所有命中词的位置:
for tl in distinct: # 去重、转小写的查询词
i = 0
while (p := lower.find(tl, i)) != -1:
positions.append((p, tl))
i = p + len(tl)
第二步,密度滑窗——找命中词种类最多的中心。 以每个命中位置为圆心,数一数前后 window(150 字符)内出现了几种不同的查询词,种类最多的那个中心胜出:
for pi, _ in positions:
seen = {tj for pj, tj in positions if abs(pj - pi) <= window}
if len(seen) > best_count:
best_count, best_center = len(seen), pi
注意它优化的是命中词的“种类数”而非“次数”——一段同时出现“创建 + 长方体 + 拉伸”三个不同词的文字,比一段把“创建”重复五遍的文字更值得展示。代价是 O(n²) 双重循环,但 n 是单段命中数,规模很小,可接受。
第三步,开窗 + 高亮。以最佳中心开一个 ±150 的窗口,截断到 max_len,然后把最多 5 个命中词(长词优先、不重叠)用 【】 包起来。
这个高亮逻辑不仅要切合上下文,在具体代码实现时,还巧妙地利用了长词优先和倒序替换的原则:
# 核心高亮逻辑截取自 indexer.py
# 按匹配词本身的长度降序排列(长词优先),防止如“长方体”被分拆成的“长方”抢先匹配
for tl in sorted(set(distinct), key=lambda s: -len(s))[:5]:
idx = lower_snip.find(tl)
if idx == -1:
continue
end_idx = idx + len(tl)
if any(not (end_idx <= s or idx >= e) for s, e in spans):
continue
spans.append((idx, end_idx))
# 从后往前(倒序)插入高亮标记 【】,避免先插入的前缀导致后面字符的索引偏移
if spans:
for s, e in sorted(spans, reverse=True):
snippet = snippet[:s] + "【" + snippet[s:e] + "】" + snippet[e:]
长词优先避免“长方体”被先匹配的“长方”截断;从后往前插入高亮标记,避免前面插入的字符让后面的下标全部错位。再加上头尾的 … 省略号,一个干净、聚焦、带高亮的片段就出来了。
一个兜底:若一个命中词都找不到(纯 bm25 排序命中但字面不重叠的边缘情况),就退化成取开头 max_len 个字符。
read:两条取数路径,和一道安全闸
最后是取全文 / 取章节的 read,它有两条路径,差别很大:
def read(self, path, anchor=None):
if anchor is not None: # 路径一:取单个章节
cur.execute("SELECT raw_content FROM docs WHERE path=? AND anchor=?", ...)
return row["raw_content"] # 直接查 DB,不碰文件系统
candidate = (self.docs_root / path).resolve() # 路径二:取整篇文件
try:
candidate.relative_to(self.docs_root) # 路径穿越防御
except ValueError:
raise PermissionError(f"path escapes docs_root: {path}")
...
return candidate.read_text(encoding="utf-8")
- 带 anchor:直接从数据库取那一节的
raw_content,根本不碰文件系统——又快又安全,且和搜索结果天然一致。 - 不带 anchor:要读整个文件,于是会触碰文件系统,这就有了攻击面。
path来自(可能不可信的)调用方,一个../../etc/passwd就能逃出文档目录。这里的防御是把路径resolve()成绝对路径后,用relative_to(self.docs_root)校验它仍在文档根目录之内,否则抛PermissionError。
这是 OWASP 中经典的 Path Traversal 防御。 任何“按用户给的路径读文件”的接口,都必须有这道闸。
RAG 的关键不在于一定要上向量库,而在于用合适的索引、切片和排序,把知识库里真正有用的内容稳定、快速地送到模型面前。