简介:这是一份基于深度学习的地铁智能问答系统Python源码包,聚焦智能交通场景,面向计算机、人工智能、数据科学等相关专业在校生与从业者,可用于毕业设计、课程设计、期末大作业或创新项目起步。资源共17个文件,以15个Python脚本为核心,另有项目说明txt和介绍md文档,压缩包大小7KB;源码采用Django框架组织,包含subway_robot主工程、backend业务模块、数据库迁移文件以及ChineseNER命名实体识别相关代码,目录划分清晰,可快速理解从交互接口到业务处理的完整链路。目前已有126人学习使用,且经过本地运行验证,功能稳定可直接复现。既能直接体验完整问答流程,也便于按需替换语料或扩展地铁知识库,对希望快速上手智能问答开发的学习者具有较高参考价值。
1. 智能交通里的地铁问答,为什么深度学习的路子能落地
乘客问「末班车是几点」,另外一个人问「最后一趟车到几点」,还有老人直接说「最晚那班车啥时候走」。关键词检索系统在「末班车」「最后」「最晚」面前经常翻车,这类问题每天在地铁客服里被问上百次。基于深度学习的地铁智能问答系统要解决的正是这种同义多变的问法:用深度模型把用户问题映射到标准问,再从知识库里取回答案。这套方案用 Python 实现,主线是 BERT 微调加向量检索,搭配 Faiss 做候选召回,适合智能交通场景里的客服机器人、智能语音助手,也适合想入门深度学习和 NLP 问答的人照着源码复现并投入。
2. 问答系统的整体架构与选型:为什么检索式撑不住地铁口语
2.1 封闭域问答的需求拆解:地铁问法到底有多「散」
地铁客服是典型的封闭域问答:问题范围有限,答案基本固定。我把地铁站被问得最多的问题归成六类:运营时间、票价、换乘、车站设施、失物招领、突发运营调整。每一类下面,用户的表达方式差异非常大。比如「运营时间」,有人问「末班车几点」,有人问「最后一趟车到几点」,还有人问「晚上最晚一班是几点的」。如果系统只做关键词匹配,「最后一趟」「最晚一班」和「末班车」之间没有公共关键词,传统倒排索引会直接算成低相关。
| 意图类别 | 标准问示例 | 口语变体示例 | 关键词命中难点 |
|---|---|---|---|
| 运营时间 | 末班车几点发 | 最后一趟车几点 | 末班 / 最后一趟无共词 |
| 票价 | 到浦东机场多少钱 | 去机场坐地铁要几块钱 | 问句里没有「票价」 |
| 换乘 | 到虹桥火车站怎么换乘 | 去虹桥站倒几号线 | 换乘 / 倒 / 转 说法不一 |
| 失物招领 | 失物招领处电话 | 东西落车上了怎么办 | 没有「失物」字样 |
更麻烦的是,地铁问答经常夹带站名。「从人民广场到虹桥火车站怎么换乘」和「人民广场站可以换几号线」都包含「人民广场」和「换乘」,但意图完全不同:前者要路径规划,后者要换乘线路查询。这类区分不能靠关键词命中,要靠对整句语义的理解。这也是标题里强调「基于深度学习」的原因:深度学习模型学的是句子里词与词之间的关系,而不是简单有没有某几个词。
我在整理真实客服日志时还发现,同一个问题会被写成「虹桥火车站怎么走」「去虹桥火车站坐几号线」「到虹桥站怎么倒车」,站名也经常用简称。如果做纯关键词系统,必须手动维护一套庞大的同义词库,还要处理简称、错别字、语气词。基于深度学习的方案把这些都丢给语义模型,代价是训练数据和算力,收益是维护成本大幅下降。
2.2 检索式、生成式与混合式:为什么最终选「分类 + 向量召回」
地铁问答系统可选的技术路线有三类。第一类是传统检索式,用 BM25 或编辑距离召回 FAQ。优点是快、可解释、好维护,缺点是前面说的词汇鸿沟,同义改写能力差。第二类是端到端生成式,用大模型或 Seq2Seq 直接生成答案,优点是灵活,缺点是地铁信息不允许编造,模型一旦记混站点,会给乘客指错路,风险不可接受。第三类是混合式,用深度模型做意图分类和语义召回,用知识库存放可控答案,再用置信度决定是否交给人工。
| 方案 | 准确率天花板 | 答案可控性 | 开发成本 | 维护成本 |
|---|---|---|---|---|
| 传统检索式 | 中等,受限于关键词 | 高 | 低 | 高,同义词库要一直补 |
| 生成式 | 高但不稳定 | 低 | 高 | 高,要持续收集语料防幻觉 |
| 分类 + 向量召回 | 高 | 高 | 中 | 中,主要维护FAQ数据 |
从标题「基于深度学习的地铁智能问答系统」来看,绝大多数可运行的 Python 源码实现的是第三类。这一类方案训练成本可控,单卡就能跑;答案可控,知识库不撒谎;效果能解释,相似度分数可以追溯。相比生成式,它不需要动辄几十 G 的模型和昂贵的推理资源,很适合地铁站内的客服机器人或语音助手后台。
我一般还会在混合式后面加一层规则兜底。比如「今天末班车提前」这种临时运营调整,很难靠静态知识库里的问答覆盖。做法是在意图识别之前先用正则匹配「提前」「临时」「停运」这类触发词,命中后直接返回公告,不让模型抢答。深度学习负责语义泛化,规则负责硬约束,两者不冲突。
2.3 系统数据流:从query到answer的五个环节
我习惯把系统拆成五个环节:预处理、意图识别、候选召回、兜底判断、答案返回。预处理主要处理全角半角、大小写、多余空格,以及把「地铁站」这类明显语气词清理掉;意图识别用 BERT 微调的多分类模型,把问题分到运营时间、票价、换乘、失物等桶里;候选召回在对应桶里用 Faiss 做向量相似度检索,取 Top 5;兜底判断看最高相似度分数有没有过阈值,没过就不硬答;答案返回直接取知识库里的标准答案,不经过模型生成。
这五个环节的顺序有讲究。先做意图识别再召回,比全量检索要稳。因为「票价」和「换乘」两个问题在向量空间里可能离得很近,如果直接在整个 FAQ 上做相似度,用户问「到机场多少钱」,系统可能召回「到机场坐几号线」,因为句子里的共同词是「机场」。先分桶,再在桶内做向量检索,可以从源头减少这类跨意图误召回。
每条用户问题还应该带一个会话ID和时间戳,哪怕当前版本不做多轮对话。原因很简单:日志回流要做,线上问题要复盘。没有会话ID,你很难知道同一个用户连续问了两遍是不是因为第一遍答错了。源码里如果看到 API 直接打印结果而不写日志,我会先补上raw_query、intent、score、answer四个字段再上线。
3. 数据清洗与知识库构建:把地铁FAQ变成BERT能学的样本
3.1 语料来源与标注格式:如何从官网文本整理出JSON和TSV
不管源码包里训练脚本写得多完整,最花时间的永远是数据。地铁 FAQ 语料常见来源有三个:地铁官网「乘客服务」页面、站内公告的 PDF 与 Excel、客服对话历史。前两种是标准问和答案,但口语变体少;第三种是真实用户问题,语法乱、有错别字、有语气词,但价值最高。我建议以官网 FAQ 为主,客服日志做补充,两边合并。
标准的数据格式我会用 JSON 保存完整知识库,字段包含standard_question、answer、intent、similar_questions。同时生成一个训练意图分类用的 TSV,每行是一个自然语言问题和对应的意图编号。意图编号先按业务定好一张表,不要随手编。
| intent_id | intent 名称 | 标准问示例 |
|---|---|---|
| 0 | 运营时间 | 地铁首末班车时间 |
| 1 | 票价 | 地铁票价怎么计算 |
| 2 | 换乘 | 到虹桥火车站怎么换乘 |
| 3 | 车站设施 | 站内有洗手间吗 |
| 4 | 失物招领 | 失物招领处在哪里 |
| 5 | 运营调整 | 今天末班车提前吗 |
注意意图类别数量不要拍脑袋。我见过有人把「票价」拆成「单程票价」「交通卡票价」「二维码票价」三个意图,结果训练数据严重不平衡,小类几乎学不动。地铁是封闭域,维持 5 到 10 个粗粒度意图最合适,细粒度差异交给相似问匹配去解决。
下面给出一个从 Excel 清洗到生成 JSON 的脚本:
import json import pandas as pd df = pd.read_excel("raw_qa.xlsx", engine="openpyxl") df = df.dropna(subset=["问题", "答案"]) # 去掉只有一两个字的无效问句 df = df[df["问题"].str.strip().str.len() >= 4] # 合并相同答案的问题,作为相似问 merged = {} for _, row in df.iterrows(): key = row["答案"].strip() if key not in merged: merged[key] = { "answer": key, "intent": row["所属类别"], "similar_questions": [], } merged[key]["similar_questions"].append(row["问题"].strip()) records = [] for m in merged.values(): standard_question = m["similar_questions"][0] records.append({ "standard_question": standard_question, "answer": m["answer"], "intent": m["intent"], "similar_questions": m["similar_questions"][1:], }) with open("data/faq.json", "w", encoding="utf-8") as f: json.dump(records, f, ensure_ascii=False, indent=2)逻辑说明:先按答案文本合并,因为同一个答案对应的不同问法可以互为相似问;然后取第一条问法作为标准问,其余放进similar_questions。这个脚本只做了最基础的清洗,真实场景还要打掉答案里的换行、统一站名写法,例如「上海站」和「上海火车站」要归一化,否则向量检索会把它们当成两个独立实体。
参数说明:str.len() >= 4是最小问句长度。地铁场景里「几点」「票价」这类极短问题确实存在,但训练分类器时太短的句子信息量不足。我一般会保留在原始表里单独做规则处理,而不会直接丢。如果你手里的数据量够大,可以把阈值改成 2,后面靠意图分类的置信度去过滤。
3.2 口语变体扩充:用同义词替换和客服日志做数据增强
假设官网整理出的标准问只有几百条,直接拿去训练 BERT 意图分类,很容易过拟合。我一般会做两层数据增强。第一层是规则同义词替换,针对地铁领域高频词写一个替换表;第二层是把客服日志里真实的 query 按意图抽出来,直接加入训练集。这里给出一个可配置的替换生成器:
import re SYNONYMS = { "末班车": ["最后一趟车", "最晚一班车", "最后一班车"], "换乘": ["换线", "倒车", "转乘", "转线"], "怎么走": ["怎么去", "怎么到", "路线是"], } def expand_with_synonyms(text, max_candidates=20): results = [text] for key, values in SYNONYMS.items(): new_results = [] for item in results: for value in values: new_results.append(item.replace(key, value)) results.extend(new_results) results = list(dict.fromkeys(results)) # 去重 if len(results) > max_candidates: break return results逻辑说明:每一轮替换生成的新句子会再进入下一轮,所以如果一句话同时包含「末班车」和「换乘」,就能组合出更多变体。max_candidates是上限,防止组合爆炸。地铁问答的意图模型不需要海量生成,每个标准问最多 20 个变体就够了,再多会出现「末班车末班车」这类无意义拼接。
如果你手头有真实客服日志,数据增强的正确姿势是先把日志按归属意图分类,再和同义词生成结果合并。真实问法里有语气词「呗」「哈」「呢」,比如「末班车几点呢」,这些是同义词表生成不出来的。我一般会再写一个简单的口语模板套用,把「末班车几点呢」这种句子人为复制几份,本质上是在教模型把语气词当作无关信息。
数据增强之后要做一次去重和长度过滤。同一个标准问生成出 20 条变体,其中 3 条可能在清洗后完全一样。dict.fromkeys只对单轮生成有效,多轮之后还是会有重复,所以建议在合并完所有来源的样本后,用drop_duplicates(subset=["text"])再清一次。重复样本会造成验证集虚高,线上真实效果反而差,这个现象后面踩坑章节还会提到。
3.3 训练集校验与意图映射:用python脚本排查脏数据
数据扩充之后还要做一次校验。最常见的脏数据是两条几乎一样的问句被标成不同意图,这样的样本会把 BERT 的 loss 直接带偏。我一般会写一个简单的冲突检查:
import pandas as pd df = pd.read_csv("data/train_intent.tsv", sep="\t", encoding="utf-8") # 检查同一文本是否对应多个标签 dups = df[df.duplicated(subset=["text"], keep=False)] conflict = dups[dups["label"] != dups.groupby("text")["label"].transform("first")] print("冲突样本数:", len(conflict)) print(conflict.head())这段代码会把同一个问题对应多个意图的行打出来。遇到这种情况,我的处理原则是:如果一条问法真的会落到两个意图,比如「票价和末班车时间」,那就把这条样本拆成两个训练样本,分别保留上下文;或者把它归到更细的子类里。不要硬保留一个错误标签,否则模型会在两类之间反复震荡。
TSV 里的label必须是从 0 开始的连续整数。BERT 分类模型的输出层num_labels按df["label"].nunique()自动算,如果发现类别编号是从 1 开始,模型的输出维度会多一个,训练时 loss 计算会报错或者把 0 类永远学成空。这种问题在源码排错时很常见,看起来像模型代码错了,实际上是数据文件里多了一列行号被读进来了。
到这一步,data/faq.json给向量检索用,data/train_intent.tsv给意图分类微调用。两个文件可以共用同一个标准问集合,但训练 TSV 里会加入大量口语变体,FAQ JSON 里保持少量标准问就可以。这样的结构在后面增量更新时也方便。
4. 模型训练与部署:从PyTorch到Flask API的最小可跑实现
4.1 项目结构:源码zip里该有的目录长什么样
一个规范的「地铁智能问答」Python 源码包,文件结构一般是这样:
subway_qa/ ├── data/ │ ├── faq.json │ └── train_intent.tsv ├── models/ │ ├── intent_model/ │ └── faq.index ├── src/ │ ├── train_intent.py │ ├── build_index.py │ └── api_server.py └── requirements.txtdata/faq.json是知识库,负责提供答案;data/train_intent.tsv是意图分类训练集;models/intent_model/是微调后的 BERT 分类模型;models/faq.index是 Faiss 向量索引;src/里三个脚本分别完成训练、索引构建和 API 服务。拿到源码后先按这个结构把目录补全,再看requirements.txt安装依赖。
依赖一般包含 PyTorch、transformers、datasets、faiss-cpu、flask。这里不写死版本号,是因为版本匹配本身很容易踩坑,后面第 5 章会专门讲。安装命令通常是这样:
pip install torch transformers datasets faiss-cpu flask pandas scikit-learn如果你是 python 入门阶段,建议先用python -m venv venv建一个虚拟环境再装,避免把系统 Python 环境搞乱。深度学习训练时经常要装新包,虚拟环境是最基本的后悔药。装好后先用一行命令验证:
python -c "import transformers, torch; print(transformers.__version__, torch.__version__)"能打印出版本就说明环境通了。如果报ModuleNotFoundError,不要继续往下跑训练脚本,先解决依赖,否则后面每一步都会失败。
4.2 训练意图分类模型:精简版train_intent.py
意图分类模型我选bert-base-chinese,因为地铁问答的句子短、领域词集中,中文 BERT 预训练模型已经能覆盖大部分语义,而且模型不大,单卡就能微调。训练脚本核心代码如下:
import pandas as pd from sklearn.model_selection import train_test_split from datasets import Dataset from transformers import ( AutoTokenizer, AutoModelForSequenceClassification, TrainingArguments, Trainer, ) df = pd.read_csv("data/train_intent.tsv", sep="\t", encoding="utf-8") df["label"] = df["label"].astype(int) train_texts, eval_texts, train_labels, eval_labels = train_test_split( df["text"], df["label"], test_size=0.15, random_state=42, stratify=df["label"] ) model_name = "bert-base-chinese" tokenizer = AutoTokenizer.from_pretrained(model_name) def make_dataset(texts, labels): enc = tokenizer( texts.tolist(), truncation=True, padding=True, max_length=64 ) enc["labels"] = labels.tolist() return Dataset.from_dict(enc) train_dataset = make_dataset(train_texts, train_labels) eval_dataset = make_dataset(eval_texts, eval_labels) model = AutoModelForSequenceClassification.from_pretrained( model_name, num_labels=df["label"].nunique() ) training_args = TrainingArguments( output_dir="models/intent_model", num_train_epochs=5, per_device_train_batch_size=32, per_device_eval_batch_size=64, learning_rate=2e-5, weight_decay=0.01, evaluation_strategy="epoch", save_strategy="epoch", logging_steps=20, report_to=None, seed=42, ) trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=eval_dataset, ) trainer.train()逻辑说明:train_test_split用stratify=df["label"]是为了按意图类别分层划分,避免「票价」这类大类全部掉进训练集或验证集。tokenizer统一做截断和 padding,max_length=64对地铁问题足够,因为最长的问句一般不超过 30 个中文字。num_labels由df["label"].nunique()自动计算,不用在代码里写死。
参数说明:per_device_train_batch_size=32在有 12G 显存时能跑,如果只有 8G 或更小,改成 8,同时给TrainingArguments加gradient_accumulation_steps=4,实际效果接近 32 的 batch size。learning_rate=2e-5是 BERT 微调的常见起步值,不要一上来就用1e-3。evaluation_strategy="epoch"每个 epoch 结束后跑一次验证,5 个 epoch 大概就能看到验证集 loss 不再降。
训练结束后,要把模型和分词器保存下来,供向量检索和 API 加载使用:
model.save_pretrained("models/intent_model") tokenizer.save_pretrained("models/intent_model")预测时用pipeline或者AutoModelForSequenceClassification.from_pretrained加载。注意一点:保存目录里如果没有config.json,大概率是save_pretrained没执行成功,后面加载会直接报目录不存在。新手上路很容易漏掉这一步。
4.3 构建向量索引与应用接口:从embedding到Faiss检索
意图分类只是把问题分桶,真正要答到哪条 FAQ,靠的是语义向量检索。这里用 BERT 的last_hidden_state[:, 0, :]即 CLS 向量做句向量。对地铁这种一二十个字的短文本,CLS 向量作为 baseline 足够,后续要更准可以换成双塔模型。索引构建脚本:
import json import numpy as np import torch import faiss from transformers import AutoTokenizer, AutoModel model_name = "bert-base-chinese" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModel.from_pretrained(model_name) model.eval() faq = json.load(open("data/faq.json", encoding="utf-8")) questions = [item["standard_question"] for item in faq] def embed(texts): inputs = tokenizer( texts, padding=True, truncation=True, max_length=64, return_tensors="pt" ) with torch.no_grad(): outputs = model(**inputs) vec = outputs.last_hidden_state[:, 0, :].numpy().astype("float32") norm = np.linalg.norm(vec, axis=1, keepdims=True) return vec / norm vectors = embed(questions) index = faiss.IndexFlatIP(vectors.shape[1]) index.add(vectors) faiss.write_index(index, "models/faq.index") print("向量维度:", vectors.shape[1], "FAQ数量:", len(questions))逻辑说明:IndexFlatIP是内积索引,因为向量已经做 L2 归一化,内积等价于余弦相似度,分数范围在 -1 到 1。astype("float32")是 Faiss 的硬性要求,它不支持 float64。每个向量对应faq.json里同一下标的标准问,这个顺序关系在增量更新时要特别注意。
API 服务用 Flask 封装,对外暴露一个/qa接口:
from flask import Flask, request, jsonify import json import faiss app = Flask(__name__) faq = json.load(open("data/faq.json", encoding="utf-8")) index = faiss.read_index("models/faq.index") def embed(texts): # 与 build_index.py 中的 embed 实现保持一致 ... @app.route("/qa", methods=["POST"]) def qa(): query = request.get_json().get("query", "").strip() if not query: return jsonify({"error": "query不能为空"}), 400 vec = embed([query]) scores, idxs = index.search(vec, k=5) best_score = float(scores[0][0]) best_idx = int(idxs[0][0]) if best_score < 0.72: return jsonify({ "query": query, "answer": "这个问题我还没学会,建议转人工客服。", "score": best_score, }) return jsonify({ "query": query, "answer": faq[best_idx]["answer"], "score": best_score, }) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)参数说明:k=5表示召回 5 条候选,但最终只取第一条;如果你后续要做重排,可以先把 Top 5 输出。0.72是兜底阈值,它不是拍脑袋定的,我会用一批真实 query 跑一遍,把每条 query 的 top1 分数画出来,选一个能滤掉 80% 错误答问的分数。阈值设太高会让很多问题转人工,设太低会硬答错。
启动后可以用下面这条命令做冒烟测试:
curl -X POST http://localhost:5000/qa \ -H "Content-Type: application/json" \ -d '{"query":"最后一趟地铁几点"}'如果返回的score在 0.85 以上,说明索引和 API 链路基本通。如果返回转人工,多半是faq.json里没有覆盖这个问题,或者向量索引没有包含新知识,先去排查数据,而不是调阈值。
5. 地铁智能问答系统的常见坑与排查:从中文编码到显存溢出
5.1 现象:训练时中文全部变成乱码,loss曲线直接崩掉
我在跑第一个版本时就吃过这个亏。.tsv文件是从 Excel 导出的,默认编码是 GBK,而 Python 脚本里用utf-8读取,读出来的中文变成「鍦扮牬」之类的乱码。BERT 分词器拿到乱码后,词表里找不到对应 token,模型学不到任何语义信息,loss 怎么降都降不到合理范围。
原因很简单:文件编码与读取编码不一致。解决方法是统一用 UTF-8,并在读取时指定编码,涉及open的代码全部加上encoding="utf-8";如果文件带 BOM,则用utf-8-sig。一个排查命令:
with open("data/train_intent.tsv", "rb") as f: raw = f.read(10) print(raw)正常 UTF-8 中文应该是一串连续字节,不是转义成\x的乱码。看到开头有b'\xef\xbb\xbf'就是 UTF-8 BOM,读取时用utf-8-sig即可。这个问题在 Linux 服务器上特别常见,因为 Windows 下创建的文本文件经常是 GBK,用pandas.read_csv时不指定编码就全乱了。
5.2 现象:验证集准确率有93%,真实乘客问法却答非所问
这是新手最容易困惑的。验证集是官网 FAQ 扩出来的干净样本,真实用户的问题往往带着语气词、错别字和口语省略,例如「人工客服怎么找」「虹桥能退卡不」。模型在花式口语上表现不好,本质是训练分布和线上分布不一致。
解决分三步。第一,从客服日志里抽真实 query,人工标注后加入训练集,哪怕只有几百条,效果也远好过盲目调参。第二,增加规则增强,把「能不能」「为啥」「咋」「呢」这类口语词套到标准问上。第三,把评估指标从整体准确率换成每类意图的召回率,重点看「失物招领」这种低频高价值类别,别被「票价」类别的高占比带偏。整体指标好看不等于线上好用,这是我反复踩出来的经验。
5.3 现象:batch_size设成32,8G显存直接OOM
BERT 中文模型参数量是 1 亿多,训练时加上 Adam 优化器状态和梯度,显存占用远大于模型文件本身。per_device_train_batch_size=32在常见 8G 显卡上大概率会报CUDA out of memory。
解决方法是把 batch size 降到 8,然后用gradient_accumulation_steps=4累积梯度,让实际更新步接近 32 的 batch。在TrainingArguments里两个参数配合使用:
training_args = TrainingArguments( per_device_train_batch_size=8, gradient_accumulation_steps=4, per_device_eval_batch_size=16, fp16=True, )参数说明:gradient_accumulation_steps=4表示 4 个小 batch 后做一次优化器更新,不影响评估逻辑。fp16=True在支持半精度加速的显卡上能把显存占用再压一大截,但注意如果训练 loss 出现 NaN,优先关掉它。没有 GPU 的时候,可以把训练规模压到最低试跑一次代码,确认数据没问题再去租卡,比一次直接上大 batch 省钱。
5.4 现象:FAQ.json里新加了一条知识,Faiss检索结果却全部错位
这个坑非常隐蔽。向量索引是按照faq.json中questions列表的下标顺序构建的,如果你在faq.json中间插入一条新数据,没有重新运行build_index.py,API 返回的faq[best_idx]就会对应到旧索引的位置,答案串位。我之前在「末班车时间」和「首班车时间」之间插了一条数据,结果用户问末班车,系统答了首班车,还认为置信度很高。
解决方法是给每条 FAQ 记录一个稳定的id,API 检索时通过id_map映射到当前记录,而不是依赖列表下标。更省事的办法是每次改完faq.json后无条件重建整个索引。如果知识库规模超过几万条,再考虑只增量添加新向量,但删除和修改还是全量重建最稳。Faiss 的index.add只会追加,不会自动感知你删了哪条知识。
5.5 现象:问题里同时带起终点,「到虹桥火车站怎么走」把起点忽略了
这是单轮问答模型的常见边界:意图分类能识别「换乘」,但分不清「从哪到哪」。用户说「从静安寺到虹桥火车站要多久」,如果你只按标准问「虹桥火车站怎么走」匹配,就会丢掉起点静安寺,返回一个不完整答案。
解决思路是做轻量槽位提取,不需要引入复杂 NER。先用正则把「从X到Y」或「从X出发到Y」的 X 和 Y 抽出来,再查站名表确认,然后直接用规则拼接换乘方案。深度模型负责意图和语义,站名槽位用规则负责,这个组合在地铁场景里最不容易出错。如果只靠 Faiss 相似度,模型很容易被「虹桥火车站」这个显眼站名带偏,因为训练集里大量问法都是围绕终点站展开的。
6. 进阶技巧:置信度阈值、增量更新与召回评估
6.1 冷启动阶段的置信度阈值与兜底话术
阈值不要拍脑袋。冷启动时我会准备一批真实用户问题,自己先跑一遍,把每条 query 的 Top1 相似度记录下来,按分数从高到低排列。阈值定在「错答率可以接受」的位置,一般在 0.65 到 0.75 之间。如果答错比拒答后果严重,阈值就往高调。兜底话术也要写清楚,让乘客知道这个系统不是死机了,而是真的需要人工介入。
6.2 增量更新:新FAQ入库的「后悔药」
地铁线路经常调整,FAQ 随时要变。新知识入库不需要重训意图分类模型,只要更新faq.json并重建 Faiss 索引。重建脚本一跑,老模型照常服务,几乎没有成本。但意图分类模型每两周到一个月用合并后的新语料重训一次,避免新意图被旧模型错分。如果你不想全量重建索引,可以把新问题单独 embed 后index.add,前提是别删除旧数据。
6.3 日志回流:让系统越用越准
我一般每周五跑一次未命中日志,把「相似度低于阈值」的 query 按频次排序,人工标注出 Top 30,合并进训练集。这个动作比调任何参数都有效。拿到的源码如果只跑通一个 demo,真正能上线维持的是这个回流闭环。我的习惯是先在日志里加一个raw_query字段,别等到要复盘时才发现日志里只有最终答案。系统上线只是开始,不是结束,希望帮到你。
本文还有配套的精品资源,点击获取