用 SQLite FTS5 给企业知识库做问答检索原型:中文二元切分与 BM25 排序

企业知识库的检索效果,往往决定了整套系统能不能被真正用起来。上线前想验证"资料这么切、这么搜,能不能找到对的内容",不需要先搭一套向量库,用 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 处理几万条文档没有问题,再往上要考虑独立的检索服务。

· 切分策略要与内容形态匹配。 问答型知识库适合整条问答作为一个文档,长文档则需要先按小节拆分,否则一条命中会把整篇正文带出来,摘要失去意义。

把原型跑通的意义,不是省下向量库的钱,而是在动手搭正式系统之前,先弄清楚数据本身的问题在哪。检索效果的上限由内容质量决定,工具只负责把现有的内容能力兑现出来。

(本文由一支长期做企业内容系统的技术团队整理,欢迎同行交流指正。)

相关推荐
承渊政道1 小时前
PR-Agent鸿蒙PC适配全记录:从Python服务端代理到ArkTS原生评审客户端
python·华为·代理模式·agent·harmonyos·pc端
Thomas.Sir1 小时前
第49课:TensorFlow|项目性能调优全方案【训练提速、推理提速、资源占用优化】
人工智能·python·tensorflow
热爱编程的小李2 小时前
安卫士设备数实时监测警报脚本
python
程序员清风2 小时前
Python 操作 MySQL、Redis 与消息队列的完整实践
redis·python·mysql
Q26433650234 小时前
【有源码】基于 Hadoop 生态的化妆品销售数据存储分析与可视化 面向化妆品行业的用户画像构建与销售机会识别研究
大数据·hadoop·python·机器学习·spark·毕业设计·课程设计
考虑考虑10 小时前
cmd局部设置java变量
运维·后端·自动化运维
2601_9620781910 小时前
Python中calendar.weekday用法
python·编程技巧·calendar·日期处理·weekday
2601_9622186110 小时前
万象生鲜系统业财一体化底层打通技术自动生成经营账单
大数据·数据库·人工智能·python·算法
2601_9669496510 小时前
为什么量化策略需要大量历史股票数据?从回测可信度理解数据规模
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash