ARTICLE DETAIL

资讯详情

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

自然语言问答系统实战:从语料切分到检索抽取部署全解

自然语言问答系统实战:从语料切分到检索抽取部署全解 简介这是一套面向自然语言处理与知识图谱问答研究者的自然语言问答系统实现聚焦将自然语言问题解析为语义查询图并转换为SPARQL语句、在图数据库中执行检索的完整流程同时基于数据驱动的消歧策略处理实体与谓词链接中的歧义问题。资源共78个文件压缩包3.43MB核心代码以62个Java源文件为主覆盖查询图生成、实体/谓词消歧、图数据库连接等模块8个Python脚本用于三元组预处理与片段生成另有4个PDF文档和3个Markdown说明辅助理解系统原理与部署步骤。当前已有334人学习下载。资源提供完整的项目源码、gAnswer相关文档及部署指导适合NLP爱好者、研究生及工程师深入研究问答系统查询图构建、SPARQL转换与图数据库集成等关键技术。1. 领包先想清楚自然语言问答系统到底要做成什么样拿到一个名为“NLP自然语言问答系统.zip”的项目包很多人第一反应是解压、“跑起来”、看选项。但自然语言问答系统在工程上的定义比表面宽得多——它既可以是一条命令把PDF丢进去然后对着终端问“摘要里提到的阈值是多少”也可以是一套对接企业知识库的Web服务按用户问法返回带出处的高亮片段。前者是检索式问答后者是抽取式问答再往上还有生成式问答。三者处理的数据、依赖的算力和对外表现完全不同。这个ZIP里的NLP问答系统最常见的形态是“离线预处理 检索召回 答案抽取 接口暴露”这条成熟路径它不依赖GPU也能跑出可用效果。所以读这篇之前先界定你要什么如果语料是几千条结构化FAQ直接把问题-答案对建索引就够了如果是几百篇长文档则需要先切片再建库再在检索结果上做答案抽取如果期望它能像大模型那样编出上下文里没有的内容那不现实。后面所有章节围绕“解压后如何把系统理解透、改得动、跑得稳”来展开重点落在预处理、检索、抽取和接口这四个能动手的环节上。2. 拆开ZIP先看懂数据流语料、切分与向量化的依赖关系2.1 从requirements.txt判断技术栈选型解压后先看依赖清单。自然语言问答系统最普遍的技术栈是中文分词用jieba数值计算用numpy基础检索用sklearn的TfidfVectorizer或CountVectorizer如果包里有faiss或hnswlib说明作者在向量检索上下了功夫。从依赖清单能反向推断系统瓶颈在哪只有sklearn没有faiss说明语料规模预期在十万条以内检索方式是暴力余弦相似度出现transformers则代表答案抽取阶段接了BERT类模型这个目录下通常还会有models/或bert_weights/。有一个常见误区是直接忽略版本号真实踩坑案例里sklearn与scikit-learn混装、或numpy版本超过faiss编译上限都会导致导入期报错。稳妥做法是建独立虚拟环境后逐行安装见下面命令。bash python -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate pip install --upgrade pip pip install -r requirements.txt # 若失败逐条 pip install 看具体报错依赖安装失败时不要盲改版本先执行pip list确认是否已有同名包冲突再用pip index versions 包名查看可用版本。提示若requirements.txt缺失绝大多数此类自然语言问答系统只需jieba、sklearn、flask、pandas四件套即可启动。2.2 语料目录的三种常见组织方式打开数据目录先看文件后缀和存放层级。本人经手过的自然语言问答系统语料无非三种组织方式。第一种是各一个txt文件一行一条样本适合FAQ型问答第二种是csv或json字段通常包含question、answer、category最易处理第三种是长文档按章节切分每个章节存成一个txt文件名即章节标题这类语料必须做二次切分。用下面脚本快速预览语料规模与字段python import pandas as pd import json尝试多种常见格式按实际路径调整try: df pd.read_csv(data/qa_pairs.csv) print(csv 格式字段, df.columns.tolist()) except Exception: with open(data/qa_pairs.json, r, encodingutf-8) as f: data json.load(f) df pd.DataFrame(data) print(json 格式字段, df.columns.tolist())print(样本总数, len(df)) print(df.head())如果语料是长文档且每篇超过500字必须切片。切片粒度直接影响检索精度切得太大命中片段中噪声多抽取答案时定位困难切得太小语义单元不完整召回率下降。经验值是中文按300到500字重叠50字切分重叠的目的是避免答案正好落在切缝上。句子级切分用re.split(r[。\n])段落级则按连续两个换行符切。切分之后要为每条片段生成唯一的doc_id和原始出处这一步决定答案能否带引用返回不能省。2.3 清洗规则里最容易忽略的三个细节自然语言问答系统对语料质量的敏感度远高于普通文本分类。第一个细节是全角半角混排——中文语料里常混入全角逗号、括号和数字检索时TfidfVectorizer按空格和标点切词全角字符不会被jieba分词器正确处理所以先统一做unicodedata.normalize并手工映射全角符号。第二个细节是无效空白字符包括不间断空格\u00a0和零宽字符\u200b需要正则清洗。第三个细节是简繁混排混合使用简繁会造成同一概念被拆成两个词召回率下降明显提前用opencc统一为简体。清洗逻辑整理成函数便于复用python import re import unicodedatadef clean_text(text: str) - str: # 规范化 unicode全角转半角依赖 NFKC 即可覆盖大部分 text unicodedata.normalize(NFKC, text) # 去除零宽字符与不可见控制符 text re.sub(r[\u200b-\u200f\u2060\u202a-\u202e], , text) # 合并多余空白 text re.sub(r\s, , text).strip() return textdef split_paragraphs(text: str, max_len: int 400, overlap: int 50): paras re.split(r\n\s*\n, text) chunks [] for p in paras: p clean_text(p) if len(p) max_len: chunks.append(p) continue # 超出长度时按句子边界滑窗切分 sentences re.split(r(?[。]), p) buf for sent in sentences: if len(buf) len(sent) max_len: chunks.append(buf) buf buf[-overlap:] sent # 重叠保留前一段尾部 else: buf sent if buf: chunks.append(buf) return chunks代码逻辑说明先做全角转半角这一步把英文引号、逗号及数字统一成Python更易处理的半角形态然后正则去掉零宽字符最后按段落滑窗切分。overlap取50字是折中值太短失去衔接语义太长造成检索冗余。参数max_len应按“平均答案长度 × 3”来设FAQ场景常常100字内就能覆盖完整问答对长文档场景才需要加大。2.4 分词与停用词配置词级检索是自然语言问答系统的地基jieba分词质量直接决定后续TF-IDF的特征表达。系统默认词典对行业术语和专有名词覆盖不足必须在加载后补充自定义词典否则“BERT”会被切成B、ER、T“Transformer”被切成Trans、former。自定义词典格式为“词语 词频 词性”逐行写入custom_dict.txt高频词不标词频也可以具体如下plaintext BERT 1000 nz Transformer 800 nz 检索式问答 100 nz 向量召回 100 nz加载方式是在代码入口调用jieba.load_userdict(custom_dict.txt)。停用词表则应反向谨慎处理自然语言问答系统里疑问词“什么、怎么、为什么”对FAQ匹配有一定作用不能像文本分类那样一律删掉。比较合理的做法是保留疑问词只删语气助词和虚词并用HMM开关做二次判别。分词后应做一次统计查看词频分布是否出现大量单字碎片若有则说明自定义词典不够或清洗环节遗漏了特殊符号。3. 检索层实现倒排索引、BM25分数与向量召回的取舍3.1 三种召回方式在同一自然语言问答系统中的分工检索层的任务不是“找出正确答案”而是“找出一小批候选”。自然语言问答系统里最经典的组合是稀疏检索先召回、向量检索做重排。稀疏检索指BM25或TF-IDF这类基于词项匹配的算法它的优势是可解释、快、不依赖额外模型文件缺点是遇到同义词和句式改写就失效。向量检索则把句子编码为稠密向量按余弦相似度召回能处理“语义相近但用词完全不同”的情况但需要一个不错的编码模型CPU上推理会慢。ZIP包里如果只有sklearn你面对的就是纯稀疏方案若有faiss加sentence-transformers目录就能走双路召回。实际落地上本人习惯把两者做成管道第一路用BM25取Top50第二路用向量余弦取Top50两路取并集后再统一算综合分。这个设计能让系统在FAQ场景下捕获到“我要退钱”和“如何申请退款”这类同义问法。下面给出不依赖外部模型的最小BM25实现可在纯sklearn环境下直接运行python from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity import numpy as npdef build_index(chunks): vectorizer TfidfVectorizer( tokenizerjieba.lcut, ngram_range(1, 2), # 加入二元词组缓解同义改写 min_df1, max_df0.9, # 过滤几乎出现在所有文本中的词 sublinear_tfTrue # 对长文档做 tf 压缩 ) matrix vectorizer.fit_transform(chunks) return vectorizer, matrixdef retrieve(query, top_k20): q_vec vectorizer.transform([query]) scores cosine_similarity(q_vec, matrix).flatten() top_idx np.argsort(scores)[::-1][:top_k] return [(i, scores[i]) for i in top_idx if scores[i] 0.1]参数说明ngram_range(1,2)让“自然语言”和“语言处理”这类相邻词组合能同时出现避免单纯字面匹配漏召回sublinear_tfTrue将词频取对数防止长文档因词频高而占便宜相似度低于0.1的候选直接丢弃这个阈值要按语料调试FAQ型语料可以提到0.25长文档型语料则降到0.05。提示如果系统已有whoosh或elasticsearch应优先用原生BM25实现而不是TF-IDF近似两者在停用词处理和词频归一化上都有细节差异。3.2 向量召回的关键把拼接字段和段落编号一起编码向量召回部分核心参数不在模型而在文本组织方式。FAQ场景中如果单独编码question用户换个说法就召不回单独编码answer用户问法和答案措辞不匹配也会失败。常见做法是把question answer拼接后编码模型学到的句子表征同时包含问题语义与回答内容。长文档场景则直接编码段落文本但段落前后各加一个特殊标记有助于模型理解边界。以下用sentence-transformers演示离线建库与在线查询流程注意要在有8GB以上内存的机器上预计算并存入npy文件避免每次启动重复编码python from sentence_transformers import SentenceTransformermodel SentenceTransformer(paraphrase-multilingual-MiniLM-L12-v2)def encode_all(records, batch_size32): texts [f{r[question]}。{r[answer]} if question in r else r[text] for r in records] vectors model.encode( texts, batch_sizebatch_size, show_progress_barTrue, normalize_embeddingsTrue # 输出单位向量余弦相似度退化为内积 ) np.save(data/vectors.npy, vectors)def vector_search(query, top_k20): qv model.encode([query], normalize_embeddingsTrue)[0] mat np.load(data/vectors.npy) scores mat qv # 归一化后内积等价余弦相似度 idx np.argsort(scores)[::-1][:top_k] return [(i, float(scores[i])) for i in idx]normalize_embeddingsTrue这个参数不能省一旦开启向量点积的结果范围就是[-1,1]可以和BM25分数统一到同一量级。模型选择上不要盲目追求大模型CPU推理时MiniLM-L12的速度是large类模型的四倍以上效果差距在中文问答上远小于速度差距。若系统里没有sentence-transformers依赖也可以跳过本节只依赖3.1的稀疏检索对中小语料足够。3.3 融合排序两种召回分数量纲统一与权重调整拿到两路候选后直接相加是常见错误。BM25分数范围受文档长度影响最小为0、最大通常不超过30余弦相似度范围是[-1,1]两者相加等于让BM25主导一切。正确做法是分别做Min-Max归一化到[0,1]再按权重融合。这里给出可调的融合函数python def normalize_scores(score_dict): score_dict: {doc_id: raw_score} if not score_dict: return {} min_s min(score_dict.values()) max_s max(score_dict.values()) if max_s min_s: return {k: 1.0 for k in score_dict} return {k: (v - min_s) / (max_s - min_s) for k, v in score_dict.items()}def fuse_results(bm25_scores, vec_scores, alpha0.6, top_k10): bm25_norm normalize_scores(bm25_scores) vec_norm normalize_scores(vec_scores) all_ids set(bm25_norm) | set(vec_norm) final {} for doc_id in all_ids: b bm25_norm.get(doc_id, 0.0) v vec_norm.get(doc_id, 0.0) final[doc_id] alpha * b (1 - alpha) * v return sorted(final.items(), keylambda x: x[1], reverseTrue)[:top_k]融合场景BM25权重α向量权重1-α适用语料FAQ短文本0.30.7问题言简意赅问法多变长文档检索0.70.3答案散布在段落中字面匹配更可靠中英混合语料0.50.5无明显偏向时可从对称权重起步调参顺序建议先固定α为0.5用小批量query看召回命中分布如果排名靠前的几乎全是字面重合而语义相关样本排后面则增大向量权重反之减小。提示语料本身只有一两千条的情况下向量权重建议直接调到0.8以上因为稀疏检索在小语料上的区分度很差几乎所有样本都会命中。4. 答案抽取模块从候选段落里定位精确答案4.1 基于规则先兜底正则与依存句法定位候选段落不是最终答案自然语言问答系统与搜索引擎的区别就在于它能抽出精确片段。从工程角度说永远先写一层规则抽取规则覆盖不了再上模型。规则抽取的模板来自业务常见问法问时间就找段落中的日期实体问数量就找数字及其量词问原因就定位“因为”“由于”后的子句。这个思路用正则实现成本低、可解释若系统跑在纯CPU环境它可以兜住一半以上问题python import rePATTERNS { time: r\d{4}年\d{1,2}月\d{1,2}日|\d{1,2}月\d{1,2}日, number: r[0-9](?:.\d)?[%]|[-], reason: r因为(.{2,30}?)|由于(.{2,30}?)|原因是(.{2,30}?)。, }def rule_extract(text, q_type): if q_type not in PATTERNS: return None match re.search(PATTERNS[q_type], text) if match: # reason 类型有多个捕获组取第一个非空值 groups [g for g in match.groups() if g] return groups[0] if groups else match.group(0) return None这里q_type是从提问中识别出的意图类别需要在前面用关键词映射例如“什么时候”映射到time“多少人/多少比例”映射到number。规则层输出的答案必须附上原文出处doc_id和回答段落否则下游无法展示引用这也是自然语言问答系统在可信度上优于纯生成式方案的地方。4.2 基于BERT的抽取SQuAD式起止位置预测当规则层未命中或问题类型不在模板覆盖内就需要抽取式阅读理解模型。它的任务是把候选段落tokenize后输出两个概率分布分别代表答案起始与结束位置。transformers库中的AutoModelForQuestionAnswering可以直接加载中文预训练权重。模型选择上没有GPU时不要碰roberta-large一个输入序列就要数秒用bert-base-chinese或macbert的CPU推理可以接受。部署要点是限制输入长度候选段落只取前384个token过长部分会拖慢推理且答案大概率在头尾。参考实现python from transformers import AutoTokenizer, AutoModelForQuestionAnswering import torchmodel_name hfl/rbt3 # 轻量中文模型约110MCPU可跑 tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForQuestionAnswering.from_pretrained(model_name)def extract_answer(question, context, max_len384): inputs tokenizer( question, context, max_lengthmax_len, truncationonly_second, # 只截断正文保留完整问题 return_tensorspt ) with torch.no_grad(): outputs model(**inputs) start_logits outputs.start_logits end_logits outputs.end_logits start_idx start_logits.argmax(dim-1).item() end_idx end_logits.argmax(dim-1).item() if start_idx end_idx: return None answer tokenizer.convert_tokens_to_string( tokenizer.convert_ids_to_tokens( inputs[input_ids][0][start_idx:end_idx 1] ) ) return answer参数说明truncationonly_second是重中之重如果不指定策略transformers默认可能截断question部分导致问题和正文错位start_idx end_idx时说明模型认为段落中没有答案这时宁可不返回也不要硬拼接出乱码。模型推理前应统一model.eval()并包在torch.no_grad()里避免梯度图占用内存导致长连接查询下内存上涨。4.3 置信度阈值与“无答案”判定抽取式模型的置信度不能只看start_prob × end_prob的乘积因为长答案天然有更低的乘积概率。业界普遍用“答案片段概率 − 无答案概率”作为修正信号。实现上可以在start_logits和end_logits基础上计算python import torch.nn.functional as Fdef confidence_score(start_logits, end_logits): probs_start F.softmax(start_logits, dim-1) probs_end F.softmax(end_logits, dim-1) best_start probs_start.max(dim-1).values best_end probs_end.max(dim-1).values # 无答案位置的 cls token 概率 cls_start probs_start[:, 0] cls_end probs_end[:, 0] score (best_start * best_end - cls_start * cls_end).item() return score该置信度低于-0.5时系统应直接回答“知识库中未找到相关内容”而不是强行输出。阈值需要结合60到100条人工标注评估集来定统计不同阈值下的精确率与召回率取F1最高点。注意很多自然语言问答系统项目在置信度这个位置偷懒直接输出概率最高的token串这是答案质量差的头号原因。5. 接口与落地包成Web服务、设好参数并盯住耗时5.1 用FastAPI暴露统一查询入口把预处理、检索、抽取、置信度判断串成一条管道后对外接口设计直接影响可用性。本人通常用FastAPI包一层/api/ask接收query字符串返回answer、confidence、source_docs三个字段。路由代码要短业务逻辑拆到retriever.py和extractor.py这样后续调参不用改接口层。参考最小实现python from fastapi import FastAPI from pydantic import BaseModelapp FastAPI()class AskRequest(BaseModel): query: str top_k: int 5class SourceDoc(BaseModel): doc_id: int text: str score: floatclass AskResponse(BaseModel): query: str answer: str confidence: float sources: list[SourceDoc]app.post(/api/ask, response_modelAskResponse) def ask(req: AskRequest): candidates hybrid_retrieve(req.query, top_kreq.top_k) best_answer, conf, source extract_best(candidates, req.query) return AskResponse( queryreq.query, answerbest_answer, confidenceconf, sources[SourceDoc(doc_idi, textt, scores) for i, t, s in source] )启动命令为uvicorn main:app --host 0.0.0.0 --port 8000生产环境再加一层gunicorn多worker。top_k不要开放给用户任意调大候选多的后果不是更准而是BERT抽取延迟线性上涨一般限制在5到10之间。若系统要支持高并发必须给检索与抽取加缓存相同或高度相似的问题直接返回历史结果相似度阈值设为0.92。5.2 三个最容易拖垮线上性能的瓶颈参数当选型固定后性能优化看三个参数。第一个是语料切片的max_len从500降到300BM25索引体积和BERT输入长度同步下降耗时减少近半代价是召回片段可能缺上下文。第二个是向量检索的top_k从50减到20重排阶段耗时下降前提是融合排序中两路候选重叠率高。第三个是SentenceTransformer的batch_size离线建库时设成64和设成8时间差可达三倍以上但显存占用也线性增高。列出参考范围供直接套用参数建议范围对性能影响对效果影响max_len300~500越长越慢影响BERT推理太短答案被切碎BM25 top_k20~50几乎不影响太小导致抽取候选不足向量 top_k20~50影响排序耗时同左置信度阈值-0.5~0无控制无答案输出率缓存相似度阈值0.88~0.95显著降低重复请求耗时阈值太高缓存命中率低5.3 压测与排错先看分段耗时再动参数上线前跑一轮最朴素的压测脚本逐阶段打印耗时。但不要依赖print打点用time.perf_counter()记录检索和抽取的耗时分布抽样打印P50与P95。如果检索阶段P95超300毫秒优先检查是否每次请求都重新加载了向量文件应当把向量矩阵常驻内存如果抽取阶段P95超过1秒且CPU没有跑满怀疑是top_k太大导致BERT依次推理多次果断降到3到5。还有一个隐蔽坑jieba.load_userdict如果在函数内部重复调用分词字典会累积重复词条导致内存膨胀必须放到模块导入处只执行一次。定位问题时把日志级别设为INFO输出每个候选的doc_id和分数能快速看出是召回环节漏了还是排序环节把正确答案压到后面。本文还有配套的精品资源点击获取
返回列表