简介:面向计算机专业毕业设计、课程大作业及项目实战学习者的基于BERT智能问答系统完整项目包,选题聚焦自然语言处理中的问答任务,适合有一定Python基础的读者参考。项目为经导师指导并通过的高分设计,评审98分,源码已在本地编译调试确保可运行。压缩包共57个文件,约1.62MB,涵盖25个Python源码文件、11个Markdown说明文档以及XML配置、Shell脚本、JSON测试数据等类型,其中Python脚本涉及BERT模型调用、命名实体识别、相似度计算、KBQA知识库问答等核心模块,另有配套数据集与README帮助快速上手。目前已有100人学习浏览,项目难度适中,结构按NER、SIM、KBQA等子模块划分,清晰易检索,既能支撑毕业设计文档撰写,也能为智能问答系统的工程实现提供完整参考。
1. 基于Bert的智能问答系统:一份能直接运行的KBQA毕设源码
做NLP毕业设计最怕的不是模型选型,而是代码写了一堆之后根本跑不起来。这份python基于Bert的智能问答系统源码,走的是KBQA-Bert的经典路线:先用Bert加BiLSTM加CRF把问句里的实体抽出来,再用Bert把问句和候选谓词做相似度匹配,两步串起来回答「姚明的妻子是谁」这种知识类问题。评审分98分,说明整个工程的完整度在毕设维度上是够用的。源码里预训练权重、NER和SIM两套训练脚本、知识库数据全都齐了,属于下载下来就能复现的项目。BERT模型实操里最核心的两个任务都落在了具体代码上,适合正在做毕业设计、或者想从零跑通一个NLP项目的学习者。
2. 架构拆解:NER实体识别与谓词匹配双模块的分工
2.1 从文件结构看项目的主干与支线
打开工程根目录,最先看到的是run_similarity.py和kbqa_test.py两个python脚本,这两个就是全部功能的主入口。run_similarity.py负责相似度模块的批量打分,kbqa_test.py负责把实体识别和相似度匹配串成一条完整的问答链路。顺着这两个入口往里看,NER目录下放着run_ner.sh、terminal_predict.py、lstm_crf_layer.py,这是一套从训练到交互式预测的完整管线;Config目录按SIM和NER分成两套配置,说明两个模块各自独立可训练。
Data目录是三块数据各自归位:DB_Data放知识库三元组,NER_Data放BIO标注语料,Sim_Data放句对匹配语料。chinese_L-12-H-768_A-12是Google发布的BERT-Base中文预训练模型,12层Transformer、768维隐藏层、12个注意力头,整个项目的embedding都从它初始化。根目录下的Bert源码是官方仓库结构,run_ner.py和global_config.py是在官方代码基础上做的适配,这种「官方源码做底座、业务逻辑独立成目录」的布局,好处是排查问题时能直接复用官方issue里的经验。
这个结构其实回答了一个很实际的问题:KBQA到底要解决哪几件事。传统知识库问答的流程是把问句拆成实体和意图两个要素,再在知识库里找到对应的三元组返回答案。这套源码把「实体」拆给NER模块,把「意图」拆给SIM模块,两个子问题各自训练、各自验证、各自替换模型,任何一个环节出问题都能单独定位。比起端到端的生成式问答模型,这种两段式的工程落地难度低很多,对毕设来说是最务实的选择。
2.2 NER模块:Bert + BiLSTM + CRF三层结构的分工
NER目录下的lstm_crf_layer.py实现的是模型中段的关键层,整个NER模块的结构是Bert + BiLSTM + CRF三明治。Bert层把每个中文字符编码成768维的上下文向量,这一层解决的是「同一个字在不同语境下语义不同」的问题,比如「行长」里的「行」和「步行」里的「行」,静态词向量根本分不开,Bert可以。BiLSTM在Bert输出之上往后接一层双向LSTM,把每个位置的隐状态再提炼一遍,输出每个字属于B-PER、I-PER、O这些标签的发射分数。CRF层不单独输出标签,它在发射分数上叠加一个转移矩阵,约束标签序列的合法性。
为什么不能省掉CRF直接softmax?因为NER标签之间有强依赖关系,「姚明」被标注成B-PER、I-PER,如果两个位置独立预测,很容易出现B-PER后面接I-ORG这种非法序列。CRF的转移矩阵会把这种序列的全局概率压到很低,解码时用维特比算法在整个序列空间里找最优标签链。我在实际项目里测过,同样的Bert底座,加CRF在实体级别F1上通常能提升两到三个点,主要就是靠这个全局约束。这个三明治结构也是BERT模型实操里做中文NER最常见的配置,代码可复用性很高。
2.3 SIM模块:句对匹配替代模板匹配的选型理由
如果只做实体识别,最多只能告诉用户问句里提到了谁,回答不了「他的妻子是谁」里那个「妻子」。传统KBQA的做法是套模板,比如「XX的妻子是谁」抽成「XX的妻子」,但这种硬匹配对问法变化极其脆弱,同一个问题换成「姚明和叶莉是什么关系」模板就失效了。SIM模块换了一个思路:把问句和知识库里的候选谓词拼成句对,交给Bert判断匹配程度。模型学到的是「姚明的妻子是谁」和「妻子」之间的语义关联,所以「和叶莉是什么关系」同样能匹配到「妻子」这个谓词。
这个设计有一个关键前提:知识库里的谓词集合是有限的,通常只有几百种。问句再多变,意图最终都要落到这几百个谓词上,这就把一个开放域的匹配问题简化成了小候选集上的排序问题。run_similarity.py加载训练好的相似度模型后,对每个候选谓词输出一个分数,取最高分。分数阈值是后文要重点讲的一个参数,它直接决定系统是答错还是答不出。
3. 数据构建:把NLPCC2016KBQA原始三元组变成训练样本
3.1 三元组清洗:triple_clean.py里最容易被跳过的一步
NLPCC2016KBQA的原始数据以三元组为核心,格式大致是「实体、谓词、宾语」三列,但实际从评测包解出来的文件经常带各种噪声:全角空格、HTML实体编码、不可见控制字符、句尾残留的标点。如果不清洗直接喂给模型,BERT学到的词表分布会被这些异常字符干扰,训练出来的NER在遇到正常文本时反而表现不稳定。很多人在这里踩坑,觉得原始数据长得挺整齐,洗不洗无所谓,等到F1上不去才回头查数据。
triple_clean.py做的事情可以归纳成四步:全角转半角、压缩连续空白、还原HTML实体、过滤空行。清洗后把三元组条数和去重后的谓词数量打印出来,和清洗前对比,能明显看出数据规模的变化。我一般会再加一步实体写法归一化,把「姚明 」尾部空格、全角括号这些都统一掉,这一步对后续端到端测试影响非常大,因为NER抽出来的实体如果和知识库里的实体写法差一个空格,三元组就查不到。
代码示意:
import re def clean_triple(text): # 全角转半角:把unicode全角字符映射回ASCII范围 text = "".join( chr(ord(c) - 0xFEE0) if 0xFF01 <= ord(c) <= 0xFF5E else c for c in text ) # 压缩连续空白,顺手去掉行尾的 \r\n 残留 text = re.sub(r"\s+", " ", text).strip() # 还原常见的HTML实体 text = text.replace("&", "&").replace("<", "<") return text def clean_triple_file(src_path, dst_path): cleaned, seen = [], set() with open(src_path, encoding="utf-8") as f: for line in f: line = clean_triple(line) parts = line.split(" ") if len(parts) != 3: # 列数不对的直接丢掉 continue if line in seen: # 去重 continue seen.add(line) cleaned.append(line) with open(dst_path, "w", encoding="utf-8") as f: f.write("\n".join(cleaned)) print(f"清洗后剩余 {len(cleaned)} 条三元组")这段逻辑说明:列数校验用的是空格切分,因为清洗后全角空格已经被转成半角空格,字段分隔符统一了;去重是用set记录整行文本,三元组冗余对训练没有帮助反而会增大数据加载时间。参数说明:src_path和dst_path分别指向原始文件和清洗后的输出文件,输出文件故意不覆盖原文件,这样才能对比清洗前后效果。如果你拿到的是制表符分隔的三元组,把split(" ")改成split("\t")就行,不要照抄分隔符。
3.2 训练样本构造:正负例怎么配才算合理
SIM模块的训练数据是construct_dataset.py生成的核心资产。正例构造没有悬念:一个问句对应它的标准谓词,标签为1。负例的构造方式直接决定模型质量。最省事的做法是从全部谓词里随机采样,但这种负例区分度太低,「姚明的妻子是谁」配一个「出生日期」当负例,词面上几乎没有重叠,模型随便学个表面特征就能分开,真正到了测试阶段,遇到「姚明的身高和体重分别是多少」这种需要区分邻近谓词的问句就废了。
更合理的做法是从同一个实体关联的其它谓词里采样负例。姚明这个实体在知识库里关联着「妻子」「身高」「出生日期」「国籍」等好几个谓词,拿这些做负例,模型被迫去学问句里的细粒度语义,而不是靠词面重叠度蒙混过关。正负例比例建议控制在1:1到1:3之间,负例太多模型会倾向全部预测成负类,阈值怎么调都救不回来。
NER训练数据是BIO标注的字符序列,SIM训练数据是「问句、谓词、标签」三列,两种格式整理如下:
| 数据 | 存放目录 | 每行内容 | 训练脚本 |
|---|---|---|---|
| NER训练集 | Data/NER_Data | 汉字与标签序列,中间用制表符或空格分隔 | run_ner.py |
| SIM训练集 | Data/Sim_Data | 问句 + 制表符 + 谓词 + 制表符 + 0/1标签 | run_similarity.py |
| 知识库 | Data/DB_Data | 实体 + 制表符 + 谓词 + 制表符 + 宾语 | kbqa_test.py 查询 |
注意NER数据里汉字和标签是一一对应的,一个汉字后面跟一个BIO标签,比如「姚 明 B-PER I-PER」。有些原始标注文件里混用了空格和制表符做分隔,脚本加载时容易因为列数不一致报错,统一成一种分隔符是最省事的做法。SIM数据里的问句不要带引号,标签只能是0或1,浮点数标签虽然不报错但会干扰二分类训练。
4. 动手跑通:从run_ner.sh到run_similarity.py的关键参数
4.1 环境准备:版本、依赖与BERT权重放置路径
跑这套工程之前先把两个环境问题定下来:第一是Python版本和依赖,工程里requirements.txt锁的是TensorFlow 1.x系列,千万别拿TensorFlow 2.x直接跑。同一个Bert源码在TF 2下modeling.py里的get_variable、assign_lr这样的API会整片报错,报错信息看起来像是代码坏了,实际是版本不匹配。依赖装完之后用pip list核对一下tensorflow、numpy、six这几个包的版本。
第二是预训练权重的放置位置。chinese_L-12-H-768_A-12目录要放在工程根目录下,目录里的bert_model.ckpt是权重文件、vocab.txt是词表、bert_config.json是模型结构配置。global_config.py里如果写死了权重路径,就要保证路径和实际目录结构一致,这里最容易犯的错是init_checkpoint写成目录名,比如chinese_L-12-H-768_A-12,而不是具体的bert_model.ckpt,加载时模型会提示找不到变量或者变量形状不匹配。
提示:如果机器上没有GPU,NER训练也能在CPU上跑,但max_seq_length和batch_size要调小,否则一个epoch的时间以小时计,毕设节奏会很难受。
4.2 NER训练与预测:run_ner.sh和terminal_predict.py
run_ner.sh是NER训练的标准入口,本质是把run_ner.py的参数封装成了shell命令。常见写法是这样:
export BERT_BASE_DIR=../chinese_L-12-H-768_A-12 export NER_DATA_DIR=../Data/NER_Data export OUTPUT_DIR=../Output/NER python run_ner.py \ --do_train=true \ --do_eval=true \ --do_predict=true \ --vocab_file=$BERT_BASE_DIR/vocab.txt \ --bert_config_file=$BERT_BASE_DIR/bert_config.json \ --init_checkpoint=$BERT_BASE_DIR/bert_model.ckpt \ --max_seq_length=128 \ --train_batch_size=32 \ --eval_batch_size=8 \ --num_train_epochs=3 \ --learning_rate=2e-5 \ --output_dir=$OUTPUT_DIR参数说明:do_train、do_eval、do_predict三个开关可以同时打开,训练完会自动接验证和测试;max_seq_length设128是因为NER数据是单句级别,长度超过128的句子会被截断,设太大不仅浪费显存,还会让训练变慢;learning_rate用2e-5是BERT微调的常见起点,在1e-5到5e-5之间按验证集表现调整。执行方式是在NER目录下跑bash run_ner.sh,训练结束后,Output/NER目录里会生成eval结果和最终训练好的ckpt。
训练完成后用terminal_predict.py做实体预测,它的交互式入口对调试非常有用。核心调用方式:
from terminal_predict import NERPredictor predictor = NERPredictor() text = input("输入一句中文问句:") entities = predictor.predict(text) print("识别到的实体:", entities)代码说明:NERPredictor这里按terminal_predict.py里暴露的类名来改,如果文件里直接暴露的是predict函数,就改成predict(text)调用。predict返回的是实体列表,比如输入「姚明的妻子是谁」,理想输出是['姚明']。第一轮如果识别结果不理想,先看是不是训练epoch太少,NER模型通常要到第三个epoch才稳定。验证指标建议看conlleval脚本输出的实体级F1,而不是准确率,因为O标签占比高的时候准确率虚高。
4.3 相似度模型与端到端测试:run_similarity.py和kbqa_test.py
SIM模块单独跑起来很容易,run_similarity.py加载训练好的相似度模型,对问句和候选谓词逐对打分。打分逻辑里最值钱的参数是阈值:
import run_similarity question = "姚明的妻子是谁" candidates = ["妻子", "身高", "出生日期", "国籍"] scores = run_similarity.score(question, candidates) best_score = max(scores) best_predicate = candidates[scores.index(best_score)] threshold = 0.5 if best_score < threshold: print("没有找到答案") else: print("最佳谓词:", best_predicate, "分数:", best_score)这里threshold取0.5只是起点。SIM模型的分数分布和训练数据配比关系很大,正负例均衡时分数通常会往两端走,0.5附近可以;如果正负例不均衡,分数会整体偏移,这时候拿一批验证问句把阈值从0.3扫到0.7,看哪一档端到端准确率最高,再定最终阈值。
端到端链路是kbqa_test.py负责的,流程可以概括为四步:先调用NER模块把问句里的实体识别出来;再用实体去DB_Data里查所有关联三元组;然后把问句和每个候选谓词拼成句对交给SIM模块打分;最后取最高分并且超过阈值的那个谓词,它的宾语就是答案。跑一句看看效果:
python kbqa_test.py如果输入「姚明的妻子是谁」能输出「叶莉」,说明整条链路通了。到这里你已经具备了把单模块跑通升级为全链路跑通的能力,下一步就是补数据、调阈值、做回归验证。
5. 避坑清单:五个让BERT项目翻车的常见问题
5.1 跑不起来:预训练权重加载失败的几种表现
第一条:init_checkpoint路径写成了目录而不是ckpt文件名,训练一开始就报变量找不到。现象是log里反复出现Variable xxx doesn't exist,或者No variables to save。原因是BERT官方代码要求的init_checkpoint参数要精确到bert_model.ckpt,而不是上级目录。解决方法是把路径写成chinese_L-12-H-768_A-12/bert_model.ckpt,同时确认bert_model.ckpt.index和bert_model.ckpt.data-00000-of-00001两个文件都在。
第二条:TensorFlow版本不对,报Unrecognized op或者placeholder类型错误。现象是import阶段不报错,跑到build graph阶段抛AttributeError。原因是工程基于TF 1.x的API写的,TF 2.x默认eager模式根本不兼容这套代码。解决方法是按requirements.txt安装对应的TF 1.x版本,装完用python -c "import tensorflow as tf; print(tf.version)"确认。
5.2 训练质量差:数据与超参的隐性坑
第三条:SIM模型单独验证F1很高,端到端却几乎答不上来。现象是验证集上准确率超过90%,但随便拿一句测试问句都返回「没有找到答案」。原因是负例全部从随机谓词里采样,模型学到的是词面重叠度,没有学到真正的语义匹配。解决方法是回到construct_dataset.py,把负例采样改成同实体下的其它谓词,重新构造Sim_Data再训练。这是我在相似度模型上踩过最深的坑,代码层面没有任何报错,但效果就是不对。
第四条:max_seq_length设置过大导致显存溢出。现象是训练到第一个step就报ResourceExhaustedError,或者OOM被系统杀掉进程。原因是BERT对输入长度是二次复杂度,128长度的显存占用已经不小,256会让batch_size不得不一路下调。解决方法是NER保持128、SIM保持64到96就够用,中文问句极少超过50个字。如果batch_size调小后还是OOM,检查是不是GPU上同时跑了其它进程,占掉了一部分显存。
5.3 端到端答不上来:模块单独正常但串联失败
第五条:两个模块单独测试都正常,kbqa_test.py却答不出问句。现象是NER能识别出「姚明」,SIM也能给「妻子」打出高分,但端到端输出为空。原因通常出在三个环节:NER识别出的实体和知识库里的实体写法不一致,比如库里的实体文本带空格或带了不常见的别名;SIM阈值太高,把正确候选全部过滤掉;问句分词后实体边界偏移,BERT把「姚明」切成了两个字导致NER标签不连续。解决方法是先给NER输出加一个实体归一化函数,把全角半角、首尾空格、括号类型都统一到知识库的写法;再把SIM阈值先降到0.3,如果还是答不出,就不是阈值问题而是数据问题;最后检查NER输出标签序列里是不是存在B后面的I断了的情况。
这三组问题我每一条都实际遇到过,前两条属于环境问题,看起来吓人但排查成本低;后面三条属于数据与串联问题,表面不报错,最消耗时间。按「环境 → 单模块 → 串联」的顺序排查,能省掉大半的调试时间。
6. 从跑通到答辩:写一个批量回归脚本验证整条链路
6.1 用一组固定问句做回归测试
答辩前最需要的是一个能重复执行的验证脚本,而不是在命令行里一句一句手敲。把测试问句和期望答案写进JSON文件,用脚本批量跑端到端链路,每次改完数据或调完阈值都能重新出一张准确率表。测试集不需要太大,覆盖常用实体和不同类型的问法即可。
import json import kbqa_test with open("test_cases.json", encoding="utf-8") as f: cases = json.load(f) hit = 0 for case in cases: result = kbqa_test.answer(case["question"]) ok = result is not None and case["answer"] in result hit += int(ok) print(case["question"], "->", result, "期望答案:", case["answer"], ok) total = len(cases) print(f"Top-1 准确率: {hit}/{total} = {hit / total:.2%}")这段脚本里的kbqa_test.answer对应工程里的端到端入口函数,如果你的工程里封装成了类,就改成先实例化再调用。代码说明:期望答案用in判断而不是字符串完全相等,因为知识库里的宾语可能带标点或别名,完全匹配会把本来正确的答案误判成失败。
6.2 阈值扫描和预训练模型替换
如果答不上来的问句集中在某几个实体上,优先去看NER输出而不是调SIM阈值。如果SIM分数普遍落在0.4到0.6之间,说明阈值决策风险很高,这时候在回归脚本外面套一层阈值循环,从0.3跑0.7,看哪一档准确率最高。替换预训练模型也值得做,把chinese_L-12-H-768_A-12换成训练更充分的中文RoBERTa权重时,通常只需要改init_checkpoint和vocab_file两个参数,但要注意新权重的词表如果和原版不一样,数据文件要重新过一遍。
从那以后,我每拿到一套新的KBQA数据,都强制先做实体写法对齐和阈值扫描这两件事,再去碰模型训练,这个习惯帮我避开了很多次「看起来跑了很久,一测全是零」的无效工作。需要完整工程的话,可以把这份源码和文档说明一起拿走,Data目录下的原始数据集也省去自己爬语料的时间。希望帮到你。
本文还有配套的精品资源,点击获取