自然语言问答系统实战:从语料切分到检索抽取部署全解
2026/9/13 13:56:18 网站建设 项目流程

简介:这是一套面向自然语言处理与知识图谱问答研究者的自然语言问答系统实现,聚焦将自然语言问题解析为语义查询图并转换为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,基础检索用sklearnTfidfVectorizerCountVectorizer,如果包里有faisshnswlib,说明作者在向量检索上下了功夫。从依赖清单能反向推断系统瓶颈在哪:只有sklearn没有faiss,说明语料规模预期在十万条以内,检索方式是暴力余弦相似度;出现transformers则代表答案抽取阶段接了BERT类模型,这个目录下通常还会有models/bert_weights/。有一个常见误区是直接忽略版本号,真实踩坑案例里sklearnscikit-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缺失,绝大多数此类自然语言问答系统只需jiebasklearnflaskpandas四件套即可启动。

2.2 语料目录的三种常见组织方式

打开数据目录,先看文件后缀和存放层级。本人经手过的自然语言问答系统语料,无非三种组织方式。第一种是各一个txt文件,一行一条样本,适合FAQ型问答;第二种是csvjson,字段通常包含questionanswercategory,最易处理;第三种是长文档按章节切分,每个章节存成一个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", encoding="utf-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 unicodedata

def 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 text

def 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”会被切成BERT,“Transformer”被切成Transformer。自定义词典格式为“词语 词频 词性”,逐行写入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,你面对的就是纯稀疏方案;若有faisssentence-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 np

def build_index(chunks): vectorizer = TfidfVectorizer( tokenizer=jieba.lcut, ngram_range=(1, 2), # 加入二元词组,缓解同义改写 min_df=1, max_df=0.9, # 过滤几乎出现在所有文本中的词 sublinear_tf=True # 对长文档做 tf 压缩 ) matrix = vectorizer.fit_transform(chunks) return vectorizer, matrix

def retrieve(query, top_k=20): 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_tf=True将词频取对数,防止长文档因词频高而占便宜;相似度低于0.1的候选直接丢弃,这个阈值要按语料调试,FAQ型语料可以提到0.25,长文档型语料则降到0.05。提示:如果系统已有whooshelasticsearch,应优先用原生BM25实现而不是TF-IDF近似,两者在停用词处理和词频归一化上都有细节差异。

3.2 向量召回的关键:把拼接字段和段落编号一起编码

向量召回部分,核心参数不在模型而在文本组织方式。FAQ场景中如果单独编码question,用户换个说法就召不回;单独编码answer,用户问法和答案措辞不匹配也会失败。常见做法是把question + answer拼接后编码,模型学到的句子表征同时包含问题语义与回答内容。长文档场景则直接编码段落文本,但段落前后各加一个特殊标记有助于模型理解边界。

以下用sentence-transformers演示离线建库与在线查询流程,注意要在有8GB以上内存的机器上预计算并存入npy文件,避免每次启动重复编码:

python from sentence_transformers import SentenceTransformer

model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")

def encode_all(records, batch_size=32): texts = [f"{r['question']}。{r['answer']}" if "question" in r else r["text"] for r in records] vectors = model.encode( texts, batch_size=batch_size, show_progress_bar=True, normalize_embeddings=True # 输出单位向量,余弦相似度退化为内积 ) np.save("data/vectors.npy", vectors)

def vector_search(query, top_k=20): qv = model.encode([query], normalize_embeddings=True)[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_embeddings=True这个参数不能省,一旦开启,向量点积的结果范围就是[-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, alpha=0.6, top_k=10): 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(), key=lambda x: x[1], reverse=True)[: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 re

PATTERNS = { "time": r"\d{4}年\d{1,2}月\d{1,2}日|\d{1,2}月\d{1,2}日", "number": r"[0-9]+(?:.\d+)?[%%]|[0-9]+", "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-chinesemacbert的CPU推理可以接受。部署要点是限制输入长度,候选段落只取前384个token,过长部分会拖慢推理且答案大概率在头尾。参考实现:

python from transformers import AutoTokenizer, AutoModelForQuestionAnswering import torch

model_name = "hfl/rbt3" # 轻量中文模型,约110M,CPU可跑 tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForQuestionAnswering.from_pretrained(model_name)

def extract_answer(question, context, max_len=384): inputs = tokenizer( question, context, max_length=max_len, truncation="only_second", # 只截断正文,保留完整问题 return_tensors="pt" ) 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

参数说明:truncation="only_second"是重中之重,如果不指定策略,transformers默认可能截断question部分,导致问题和正文错位;start_idx > end_idx时说明模型认为段落中没有答案,这时宁可不返回也不要硬拼接出乱码。模型推理前应统一model.eval()并包在torch.no_grad()里,避免梯度图占用内存导致长连接查询下内存上涨。

4.3 置信度阈值与“无答案”判定

抽取式模型的置信度不能只看start_prob × end_prob的乘积,因为长答案天然有更低的乘积概率。业界普遍用“答案片段概率 − 无答案概率”作为修正信号。实现上可以在start_logitsend_logits基础上计算:

python import torch.nn.functional as F

def 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字符串,返回answerconfidencesource_docs三个字段。路由代码要短,业务逻辑拆到retriever.pyextractor.py,这样后续调参不用改接口层。参考最小实现:

python from fastapi import FastAPI from pydantic import BaseModel

app = FastAPI()

class AskRequest(BaseModel): query: str top_k: int = 5

class SourceDoc(BaseModel): doc_id: int text: str score: float

class AskResponse(BaseModel): query: str answer: str confidence: float sources: list[SourceDoc]

@app.post("/api/ask", response_model=AskResponse) def ask(req: AskRequest): candidates = hybrid_retrieve(req.query, top_k=req.top_k) best_answer, conf, source = extract_best(candidates, req.query) return AskResponse( query=req.query, answer=best_answer, confidence=conf, sources=[SourceDoc(doc_id=i, text=t, score=s) 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降到300,BM25索引体积和BERT输入长度同步下降,耗时减少近半,代价是召回片段可能缺上下文。第二个是向量检索的top_k,从50减到20,重排阶段耗时下降,前提是融合排序中两路候选重叠率高。第三个是SentenceTransformerbatch_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和分数,能快速看出是召回环节漏了还是排序环节把正确答案压到后面。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询