ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

基于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 pypinyin3.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, k11.5, b0.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(keylambda x: x[0]) return candidates[0][1] return None这里threshold的取值直接决定回退是否会被触发。设成 0则所有低分问题都会走拼音设成 1则永远不走。我一般默认 0.25配合长度差限制既能接住错别字又不至于把不相关的话硬答。3.5 训练启动与一次完整问答示例前面几个片段组装成完整的主类训练动作在train()里完成并且把 idf 和语料序列化成缓存文件。下次启动如果发现缓存存在就不需要重算分词和统计直接加载。class QaBot: def __init__(self, data_pathqa_data.json, k11.5, b0.75, threshold0.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, k1k1, bb) self.cache_path model_cache.json def load_pairs(self, path): if path.endswith(.json): with open(path, encodingutf-8) as f: return json.load(f) lines [line.strip() for line in open(path, encodingutf-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, encodingutf-8) as f: json.dump(cache, f, ensure_asciiFalse) 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)), keylambda 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.6b0.75控制长度归一化的强度问题长度差异大有短问有长问降到 0.6 以下threshold0.25控制最低回答门槛兜底率太高就降到 0.15太低就涨到 0.3k1 的直觉理解是答案里的某个关键词出现 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的清洗结果存起来出现重复时保留后录入的那条并打一条警告。这个项目做到这个程度已经可以服务一个几十人团队的内部问答场景了。常见做法是先人工审核一周的聊天记录把高频未答问题整理成新语料再增量训练一轮如此往复。我现在的新习惯是每加一批语料就顺手写进验证集里等验证集攒到几十条再统一调一次参数这样的迭代节奏比较稳。希望这套从零搭起来的方案能帮到你省下从大模型方案试错的时间把力气花在整理语料上。本文还有配套的精品资源点击获取
返回列表