企业知识库的检索效果,往往决定了整套系统能不能被真正用起来。上线前想验证"资料这么切、这么搜,能不能找到对的内容",不需要先搭一套向量库,用 Python 自带的 sqlite3 加 FTS5 就能跑出一个可量化评测的原型。本文给出完整实现:建表、中文切分、索引、查询排序,以及一个最小评测集的命中率测算。
一、为什么先做原型而不是直接上向量检索
向量检索的语义泛化能力更强,但它有三个前置条件:一套可用的嵌入模型、稳定的向量存储、以及足够的算力预算。在企业知识库的早期阶段,更常见的问题是结构性的------文档切分是否合理、字段权重是否设置得当、客户口语问题与文档表述之间的距离有多大。这些问题用关键词检索就能暴露,且改造成本极低。
FTS5 是 SQLite 内置的全文检索模块,支持 BM25 排序与字段权重,配合合理的切分策略,足以支撑一个中等规模知识库的原型验证。
二、中文处理的现实选择
FTS5 内置的分词器面向拉丁语系,直接索引中文只能整段命中,效果很差。常见做法有两种:引入第三方分词库,或者自己做字符级二元切分(bigram)。
后者更适合原型阶段:零依赖、行为可预测、对未登录词天然友好。代价是索引体积膨胀,以及部分语义被切碎。下面是实现。

python
import re, sqlite3
def bigrams(text: str) -> str:
"""把中文串切成二元组并用空格连接,英数串保持完整"""
text = re.sub(r'[^\w\u4e00-\u9fa5]+', ' ', text)
tokens = []
for seg in text.split():
if re.search(r'[\u4e00-\u9fa5]', seg):
tokens.extend(seg[i:i + 2] for i in range(max(len(seg) - 1, 1)))
else:
tokens.append(seg.lower())
return ' '.join(tokens)
def init_db(path: str) -> sqlite3.Connection:
conn = sqlite3.connect(path)
conn.execute("""CREATE VIRTUAL TABLE IF NOT EXISTS docs USING fts5(
title, body, tags, doc_id UNINDEXED,
tokenize = 'unicode61 remove_diacritics 2')""")
conn.execute("""CREATE TABLE IF NOT EXISTS docs_meta(
doc_id TEXT PRIMARY KEY, source TEXT, updated_at TEXT)""")
return conn
def add_doc(conn, doc_id, title, body, tags=''):
conn.execute('INSERT INTO docs(title, body, tags, doc_id) VALUES (?,?,?,?)',
(bigrams(title), bigrams(body), bigrams(tags), doc_id))
conn.commit()
注意索引时切分、查询时也要用同一套切分,否则两边 token 对不上,检索结果会大面积为空。
三、查询与排序

FTS5 的 bm25() 函数支持按列加权,把标题权重调高、正文与标签次之,通常能明显改善首条结果的准确率。查询串同样需要切分,再把二元组用 OR 连接------中文短语用 AND 会过于严格。
python
def search(conn, query: str, limit: int = 5):
tokens = bigrams(query).split()
if not tokens:
return []
expr = ' OR '.join(f'"{t}"' for t in tokens)
rows = conn.execute("""
SELECT d.doc_id, d.title,
bm25(docs, 8.0, 1.0, 3.0) AS score,
snippet(docs, 1, '<b>', '</b>', '...', 18) AS snip
FROM docs d JOIN docs_meta m ON m.doc_id = d.doc_id
WHERE docs MATCH ?
ORDER BY score LIMIT ?""", (expr, limit)).fetchall()
return rows
bm25() 返回的是越低越相关的排序分,因此用升序排列。snippet() 顺手把命中的片段摘出来,前端可以直接展示摘要,省掉一次二次处理。
四、用评测集量化效果

原型阶段最有价值的动作,是准备一组真实问题并标注正确答案,跑命中率。评测集不需要大,二三十条就足以看出趋势。
python
def hit_rate(conn, cases, limit: int = 3) -> float:
"""cases: [(问题, 期望命中的 doc_id), ...]"""
hit = 0
for question, expected in cases:
got = [row[0] for row in search(conn, question, limit)]
if expected in got:
hit += 1
else:
print(f'[未命中] {question} -> {got}')
return hit / len(cases)
跑一遍就知道问题出在哪一类:是文档切分把答案切散了,是标题没写清楚,还是客户的口语问法和资料里的书面表述距离太远。这三类问题的修法完全不同,而向量检索会把它们混在一个"效果不好"的结论里。
五、原型的边界
需要说清楚这套方案的适用边界:
· 同义改写仍然吃力。 用户问"设备老是停机",文档里写的是"运行稳定性",二元切分对不上。这类问题需要同义词表或语义模型兜底,属于原型之外的下一步。
· 规模上限。 单机 SQLite 处理几万条文档没有问题,再往上要考虑独立的检索服务。
· 切分策略要与内容形态匹配。 问答型知识库适合整条问答作为一个文档,长文档则需要先按小节拆分,否则一条命中会把整篇正文带出来,摘要失去意义。
把原型跑通的意义,不是省下向量库的钱,而是在动手搭正式系统之前,先弄清楚数据本身的问题在哪。检索效果的上限由内容质量决定,工具只负责把现有的内容能力兑现出来。
(本文由一支长期做企业内容系统的技术团队整理,欢迎同行交流指正。)