news 2026/10/3 13:14:16

基于BM25的纯Python中文聊天机器人:几百条语料就能训练

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于BM25的纯Python中文聊天机器人:几百条语料就能训练

简介:面向自然语言处理初学者与落地开发者的中文聊天机器人项目,可直接使用自己的语料训练出个性化对话模型,覆盖智能客服、在线问答、智能闲聊等应用场景。压缩包共85个文件,大小约37.94MB,包含18个Python脚本用于模型训练与推理,另有js/css/html等Web展示文件、vocab词表、data数据文件;目录按Seq2seq、SeqGAN、TensorFlow 2.x、PyTorch、分布式版本等模块分区,可按需选用。目前已有1069人学习下载,适合快速开始自己的对话模型实验。项目聚合Seq2seq与SeqGAN两种对话生成策略,同时提供TensorFlow 2.x和PyTorch版本,兼顾不同框架用户;V1.0整合了工程架构,新增Horovod分布式训练,PyTorch版本也增加batch_size训练模式。借助README与清晰的目录结构,可快速替换自己的中文语料完成训练落地;从版本更新规划看,后续还将补充FAQ问答模块和基于Transformer的预训练模型支持,适合需要向智能客服方向深入扩展的开发者。

1. 自己训练一个中文聊天机器人:几百条语料就能跑起来的纯 Python 方案

想给团队内网搭一个“会翻手册的小秘书”,手上有几百条频繁被问的问答,又不想为这些内容去微调大模型、占 GPU 和折腾部署,那么标题里这个方向就是正合适的:用 Python 实现一个可以自己训练的中文聊天机器人,把自己的语料喂进去,训练出来的回答只围绕你给的资料转。这里的“训练”不是深度学习里的反向传播,而是把问答对整理成可检索索引,再用 BM25 相似度和拼音回退做匹配。整套方案对个人开发者、运维和产品同学很友好,成本几乎只有一份代码和每天半小时的语料维护,跑起来之后再谈要不要升级成大模型方案。

2. 先想清楚训练什么:语料格式与检索式机器人的工作原理

2.1 为什么先选检索式而不是生成式

看到“自己训练”四个字,很多人第一反应是 Seq2Seq 或者 Transformer 微调。这类生成式模型确实能做“创作型”回复,但它的代价是被低估的:至少需要几千条高质量问答作为训练集,要准备 GPU 环境,要处理训练不收敛、过拟合、答非所问这一整串问题,而且模型输出不可控,它可能把你没写过的内容当成事实说出来。对绝大多数内部工具型机器人来说,这属于杀鸡用牛刀。

我一般会建议从检索式做起,原因是它的输出完全来自你的语料库,不会“发明”答案。它的思路是:用户输入一句中文问题,先在语料里找出最相似的那条历史问题,然后把对应的答案返回。相似度计算用的是 BM25,一个在信息检索领域被验证过很多年的算法;遇到用户打错字、用同音字,再用拼音匹配做第二层兜底。这套方案的训练过程是建索引和统计词频,不是调神经网络的权重,所以 CPU 上几秒钟就能完成。

2.2 语料格式:问答对是训练的原材料

训练的目标是让机器人“脑子里”只有一个东西:问答对。语料格式不需要复杂,两种最常用,我以 JSON 为主,TXT 为辅。JSON 适合结构化管理,每个问题可以挂多个不同问法;TXT 适合快速录入,两行一组,一问一答。

格式示例适用场景
JSON{"question":"怎么重置密码","answer":"在登录页点忘记密码,按邮件里的链接重置"}结构化语料、需要维护多个问法
TXT每两行一组,第一行问题,第二行答案快速导入、临时测试

不管用哪种格式,有一个关键建议:同一个意图最好写成多条不同的说法。比如“怎么重置密码”和“密码忘了怎么办”如果指向同一个答案,你就写两条不同的 question,指向同一条 answer。这能让 BM25 的匹配空间更大,而不是完全依赖用户用词跟你预料的一样。另外,语料的质量远大于数量。平台里有 500 条互相矛盾的问答,不如整理干净的 100 条。

2.3 “训练”的本质:把中文问题变成可计算的相似度

这一步是理解整个项目的钥匙。所谓训练,其实是对每一句问题做三件事:分词、统计词频计算 IDF、生成拼音缓存。分词是把“怎么重置密码”切成“怎么 / 重置 / 密码”这样的 token,分词用 jieba 基本是事实标准;IDF 统计的是每个词在多少条问题里出现过,出现得越少的词区分度越高,比如“重置”比“怎么”更有代表性;拼音缓存则是为了后续用同音字匹配。

BM25 匹配的过程是一个相对简单的公式套用:对于用户输入的每个词,计算它在候选问题中的词频(TF),再用该词的 IDF 加权,最后按问题长度做归一化,得到这条候选问题的分数。分数越高,说明用户输入与这条语料越接近。代码实现不复杂,但几个关键设计必须在动手前想清楚:分母里那个词频饱和参数 k1,解决的是“一个词出现十次不代表比出现两次好十倍”的问题;长度归一化参数 b,解决的是“长问题天然能匹配更多词”的偏差。这两个参数放在第 5 章详细讲,现在先照默认值用。

3. 写一个可自己训练的聊天机器人:清洗、打分、拼音回退完整代码

3.1 工程结构与依赖安装

整个项目只需要一个主文件加一份语料,目录结构不需要花哨:

文件作用
qa_bot.py主程序,包含清洗、训练、匹配、回复全部逻辑
qa_data.json训练语料,问答对列表
requirements.txt依赖清单
model_cache.json训练产物,由程序自动生成,可以提交到版本库方便别人直接复用

依赖只有两个:jieba负责中文分词,pypinyin负责拼音匹配。安装命令和进入虚拟环境的动作合在一起,避免污染系统 Python。

python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install jieba pypinyin

3.2 数据清洗与分词:不要直接拿原始文本去匹配

用户输入不会规规矩矩,标点、全角半角、Emoji 都会干扰匹配。这一节先把文本标准化,再做分词和去停用词。

import re import string import jieba from pypinyin import lazy_pinyin STOP_WORDS = {"的", "了", "是", "在", "吗", "呢", "吧", "啊", "呀"} def clean_text(text: str) -> str: """统一全角半角,去掉标点和 Emoji,只留下有效字符""" text = text.lower() # 全角转半角,例如把“你好”旁边的全角逗号转成半角 text = "".join( chr(ord(c) - 0xFEE0) if "\uFF01" <= c <= "\uFF5E" else c for c in text ) # 去掉英文标点、中文标点、Emoji text = re.sub(rf"[{re.escape(string.punctuation)}\u3000-\u303F\u4E00-\u9FFF以外的符号]", "", text) # 上面这行只做示意,实际更稳的做法是保留中英文和数字,其余全部移除 text = re.sub(r"[^\u4E00-\u9FA5A-Za-z0-9]", "", text) return text def tokenize(text: str) -> list: """清洗后分词,去掉停用词和空格""" cleaned = clean_text(text) tokens = jieba.lcut(cleaned) return [t for t in tokens if t.strip() and t not in STOP_WORDS]

这个清洗函数有一个关键参数要说明:最后那个正则[^\u4E00-\u9FA5A-Za-z0-9]会丢掉所有不在中英文和数字范围内的字符,包括标点、表情、特殊符号。这意味着用户输入“客服电话是多少😊”,清洗后变成“客服电话是多少”,不会让表情干扰匹配。冒号、括号这种在中文里常见的符号也一并被移除,问题不大,因为去掉它们不影响语义。

3.3 BM25 打分:从问答对里找出最像的那条

核心打分器单独写成一个类,方便后面调 k1 和 b 参数而不动主逻辑。

import math from collections import Counter class BM25Scorer: def __init__(self, corpus_tokens, k1=1.5, b=0.75): self.corpus = corpus_tokens # 每条问题的 token 列表 self.doc_len = [len(doc) for doc in corpus_tokens] self.avg_len = sum(self.doc_len) / max(len(corpus_tokens), 1) self.k1 = k1 # 词频饱和参数 self.b = b # 长度归一化参数 self.idf = self._compute_idf() def _compute_idf(self): df = {} for doc in self.corpus: for term in set(doc): df[term] = df.get(term, 0) + 1 total = len(self.corpus) idf = {} for term, freq in df.items(): # 平滑 IDF,避免 log 里出现 0 或负数 idf[term] = math.log(1 + (total - freq + 0.5) / (freq + 0.5)) return idf def score(self, query_tokens, idx): """返回用户 query 与第 idx 条语料的 BM25 分数""" doc = self.corpus[idx] doc_counter = Counter(doc) dl = self.doc_len[idx] score = 0.0 for term in set(query_tokens): tf = doc_counter.get(term, 0) if tf == 0: continue # 词频部分做饱和处理,适用 k1 tf_part = tf * (self.k1 + 1) / (tf + self.k1 * (1 - self.b + self.b * dl / self.avg_len)) score += self.idf.get(term, 0) * tf_part return score

分数本身的绝对值受语料规模影响很大,不能直接设一个固定阈值。更好的做法是拿“候选答案自己匹配自己”的分数作为上限,把一次查询的分数除以这个上限,得到一个 0 到 1 之间的相对匹配度。这个设计我后面在参数章节还会再提一次,它是避免玄学调参的关键。

3.4 拼音回退与兜底:同音、错字也能接住

用户可能把“重置”打成“充值”,BM25 对这种情况无能为力。拼音回退的逻辑是:当相对匹配度低于阈值时,把输入和每条语料的问题都转成拼音,再做包含匹配。这个回退不能无差别触发,否则“客服电话”和“克服电话”这种同音不同义的句子会被错误连在一起。

def pinyin_fallback(self, query_text, degree): """当 BM25 匹配度低于阈值时,用拼音再做一轮匹配""" if degree >= self.threshold: return None q_py = "".join(lazy_pinyin(query_text)).lower() candidates = [] clean_q = clean_text(query_text) for idx, qa in enumerate(self.pairs): doc = qa["question"] if abs(len(clean_q) - len(clean_text(doc))) > 2: continue # 长度差超过 2 个字,大概率不是同一句话 doc_py = "".join(lazy_pinyin(doc)).lower() if q_py in doc_py or doc_py in q_py: candidates.append((abs(len(clean_q) - len(clean_text(doc))), idx)) if candidates: candidates.sort(key=lambda x: x[0]) return candidates[0][1] return None

这里threshold的取值直接决定回退是否会被触发。设成 0,则所有低分问题都会走拼音;设成 1,则永远不走。我一般默认 0.25,配合长度差限制,既能接住错别字,又不至于把不相关的话硬答。

3.5 训练启动与一次完整问答示例

前面几个片段组装成完整的主类,训练动作在train()里完成,并且把 idf 和语料序列化成缓存文件。下次启动如果发现缓存存在,就不需要重算分词和统计,直接加载。

class QaBot: def __init__(self, data_path="qa_data.json", k1=1.5, b=0.75, threshold=0.25): self.data_path = data_path self.threshold = threshold self.pairs = self.load_pairs(data_path) self.corpus = [tokenize(qa["question"]) for qa in self.pairs] self.scorer = BM25Scorer(self.corpus, k1=k1, b=b) self.cache_path = "model_cache.json" def load_pairs(self, path): if path.endswith(".json"): with open(path, encoding="utf-8") as f: return json.load(f) lines = [line.strip() for line in open(path, encoding="utf-8") if line.strip()] pairs = [] for i in range(0, len(lines), 2): if i + 1 < len(lines): pairs.append({"question": lines[i], "answer": lines[i + 1]}) return pairs def train(self): cache = { "pairs": self.pairs, "corpus": self.corpus, "idf": self.scorer.idf, "doc_len": self.scorer.doc_len, "avg_len": self.scorer.avg_len, } with open(self.cache_path, "w", encoding="utf-8") as f: json.dump(cache, f, ensure_ascii=False) print(f"[train] 完成,共 {len(self.pairs)} 条问答对,索引已保存到 {self.cache_path}") def reply(self, user_input): query_tokens = tokenize(user_input) scores = [self.scorer.score(query_tokens, i) for i in range(len(self.corpus))] best_idx = max(range(len(scores)), key=lambda i: scores[i]) max_possible = self.scorer.score(self.corpus[best_idx], best_idx) degree = scores[best_idx] / max_possible if max_possible > 0 else 0 fallback_idx = self.pinyin_fallback(user_input, degree) if fallback_idx is not None: return self.pairs[fallback_idx]["answer"], degree, fallback_idx if degree < self.threshold: return "这个问题我还没学会,换个说法再试试,或者联系管理员补充语料。", degree, -1 return self.pairs[best_idx]["answer"], degree, best_idx

最后加一个命令行交互入口,训练和问答在同一段里跑起来:

def main(): bot = QaBot("qa_data.json") bot.train() while True: user = input("你> ").strip() if user in ("exit", "quit"): break answer, degree, idx = bot.reply(user) print(f"机器人> {answer}") print(f"[debug] 匹配度={degree:.2f} 命中语料#{idx}") if __name__ == "__main__": main()

训练动作其实就是把json.dump写进缓存的那一段逻辑。第一次跑会慢一两秒,因为 jieba 要加载词典;之后再次启动如果走缓存会明显快很多。这是标题里“训练”的最小落地形态。

4. 自训练聊天机器人避坑记录:5 个让我重写数据的踩坑案例

4.1 分词把专业词切开,匹配率直接腰斩

现象:语料里写的是“公积金提取”,用户问“公积金怎么提取”,无论如何都匹配不到预设答案,反而经常命中断词更碎的无关语料。

原因:jieba 默认词典没有“公积金提取”这个词,分词结果变成“公积金 / 怎么 / 提取”,导致“公积金”和“提取”被当成两个独立词参与 IDF 统计。IDF 高的是“公积金”这种低频词,但它区分不了“公积金提取”和“公积金贷款”两个意图。

解决:在训练前加载自定义词典。把行业专有名词以词表形式喂给 jieba,最稳的方式是单独写一个userdict.txt,每行一个词,可以带词频和词性:

公积金提取 10 n 重置密码 10 n 退款流程 10 n

然后在代码里调用jieba.load_userdict("userdict.txt"),放在模块加载的位置即可。经验值是词频填 10 左右,太小会不生效,太大又会把普通句子强行切成一整块。

4.2 全角半角与 Emoji 干扰,同样的问法匹配不上

现象:用户在手机输入法里打了全角问号和全角冒号,复制进机器人后一句话匹配结果从 0.7 掉到 0.1,完全不是预期答案。

原因:清洗逻辑不彻底。全角字符和半角字符的 Unicode 码点不同,tokenize阶段切出来的词看起来一样,实际字符串并不相等,BM25 的 IDF 表里查不到这个“词”,自然匹配不上。Emoji 更直接,它会让一整个 token 变得不可预期。

解决:清洗函数必须把全角转半角、Emoji 全部移除。前面clean_text里那个正则[^\u4E00-\u9FA5A-Za-z0-9]就是为这事设计的。额外说一句,不要用简单replace(":", ":")这种土办法去处理全角,字符范围太大,一个统一的转换函数才是长期方案。

4.3 低分硬答:用户问没见过的内容,出来牛头不对马嘴

现象:用户问“今天几点下班”,语料里只有“下班时间怎么安排”,虽然语义勉强沾边,但机器人回复了一段跟加班政策完全无关的内容。用户反馈“这机器人答非所问”。

原因:不管分数多低,代码都返回最高分语料的答案。BM25 在语料只有几十条时,任何输入都会有一个最高分,哪怕那个分低得离谱。这是检索式机器人最常见的翻车点。

解决:给degree设一个最低门槛。低于门槛就不答,走兜底话术“这个问题我还没学会”。阈值是经验值,常见做法是先跑一批真实用户问题,统计匹配度的分布,再取低分位点。冷启动时可以先用 0.25 跑一周,看兜底率是否超过 30%,超了就往下调,太低就往上收。

4.4 拼音回退误伤:同音字把两件不相干的事匹配在一起

现象:“退款多久到账”被拼音匹配成“退款多久到账”本身没问题,但更糟的是“客服电话”和“克服电话”这种同音不同义的内容被连在了一起。

原因:把整句话的拼音拼接成一个长字符串,再用in做包含判断,等于允许任意子串交叉匹配。两个长度差不多的句子,只要中间有几个字拼音相同,就可能被判定为同义,实际上语义完全不同。

解决:拼音回退是一个低频兜底,不是主召回路径。我的处理是在第 3 章代码里做的:长度差超过 2 个字直接跳过;只用“完全包含”而不是“片段包含”。这样虽然召回率低了,但误伤率也低了。宁可漏召回,不要让用户被错误答案激怒。

4.5 重复与互斥语料:相似问法被分数更高的错答案抢走

现象:语料里有两条很相似的问题:“密码重置失败怎么办”和“重置密码后无法登录怎么办”。用户问的是前者,但因为后者包含更多重叠词,BM25 给了后者更高分,答案就错了。

原因:检索式模型没有“消歧”能力,它只看词面重合度。两条问题如果共享一半以上的词,分数差距本身就不显著,谁赢取决于词频和长度,而不是具体意图。

解决:录入语料时做两件事。第一,互斥的意图不要写在相邻的相似句式中,稍微改写一下措辞再录入;第二,对已知会混淆的相似问法,额外维护一个“改写映射表”,在reply之前先做一次简单规则替换。比如“重置密码失败”这类输入直接映射到对应答案,绕开 BM25。这个手段看着原始,但在小规模语料下非常可靠。

5. 三个必调参数与增量验证:让自训练机器人越用越准

5.1 三个必调参数:k1、b、匹配度阈值

训练完如果只调三个参数,就从这三个下手。每个参数影响的是完全不同的层面,不要同时改,一次只动一个。

参数默认值作用调参建议
k11.5控制词频的饱和程度语料里问题很短(5 字以内),降到 1.2;长问句多,升到 1.6
b0.75控制长度归一化的强度问题长度差异大(有短问有长问),降到 0.6 以下
threshold0.25控制最低回答门槛兜底率太高就降到 0.15,太低就涨到 0.3

k1 的直觉理解是:答案里的某个关键词出现 5 次,不应该比出现 1 次贡献 5 倍的分数。k1 越大,词频超过 1 之后的增量越低。对中文问答这种短文本场景,词频基本都在 1 附近,k1 对结果的影响其实有限,但调大它能让长答案的分数更稳定。b 则影响长问题是否吃亏:b 越小,长度不同的语料在打分时越公平。

threshold 是这里面最值得花时间调的。我的做法是从 command line 读参数,先跑一周真实用户输入,把不超过 10 条兜底话术的命中情况记下来,再决定调大还是调小。它没有标准答案,完全依赖你的语料覆盖情况。

5.2 先验证再上线:留出集与人工判定

不要拍脑袋觉得“差不多能答了”就发布。一个简单的做法是:从全部语料里随机抽 20% 作为测试集,在训练阶段临时把它们从语料里拿走,再用这 20% 的问题去问机器人,看能不能找到正确的原答案。这一步不写额外代码也能做,手工抽 30 条有代表性的问题跑一遍即可。验证记录用表格维护最直观:

用户问题期望答案来源实际返回判定
重置密码后无法登录怎么办语料#12语料#12通过
密码忘了语料#12语料#8(答发票)失败

验证的目的是找“坏例子”,不是证明机器人多聪明。每轮改完参数,把上一轮失败的例子重新跑一遍,确认它们没有被改挂。

5.3 增量训练:新语料来了要不要推倒重来

产品上线后一定会有新问题进来。增量训练有两种做法:一是把新问答追加到qa_data.json里,重新调用bot.train()重建缓存,代价是几秒钟的分词和统计;二是只在内存里往pairs列表追加数据并重算 idf,不落盘。第一种做法对大多数场景都够用,因为语料规模在千条以内时,重建一次不到十秒,没必要做复杂的在线更新。

真正要注意的反而是数据治理:每次追加前,在加载函数里做一次简单去重,检查question字段是否已经存在。如果直接追加,重复句会抬高语料的长度平均值,扭曲 BM25 的长度归一化系数,导致部分问题的匹配度集体下降。我在本地代码里会加一个不到十行的去重逻辑,遍历一遍pairs,用set把question的清洗结果存起来,出现重复时保留后录入的那条并打一条警告。

这个项目做到这个程度,已经可以服务一个几十人团队的内部问答场景了。常见做法是先人工审核一周的聊天记录,把高频未答问题整理成新语料,再增量训练一轮,如此往复。我现在的新习惯是每加一批语料就顺手写进验证集里,等验证集攒到几十条再统一调一次参数,这样的迭代节奏比较稳。希望这套从零搭起来的方案能帮到你,省下从大模型方案试错的时间,把力气花在整理语料上。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 13:13:33

全开源废品回收系统PHP源码:搭建回收小程序/公众号/App三端平台

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 13:13:20

从Verilog到SystemVerilog:芯片验证工程师的进阶之路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 13:12:42

FPGA工程师必备:Vivado与Vitis实用排错指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 13:12:01

RK3588 HDMI IN热插拔问题剖析:从HPD到UEvent的完整链路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 13:10:45

Scrapy论文爬虫实战:深度学习数据采集与反爬全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 13:10:26

基因家族Motif分析全流程:从序列清洗到可视化及交叉验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华