ARTICLE DETAIL

资讯详情

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

自然语言问答系统实战:从TF-IDF到向量召回与Flask部署

自然语言问答系统实战:从TF-IDF到向量召回与Flask部署 简介面向自然语言处理与人工智能学习者的问答系统完整实现聚焦如何将自然语言提问转化为带语义信息的查询图再生成SPARQL语句并在图数据库中执行以返回答案。系统采用数据驱动消歧策略在查询图生成阶段保留多种实体与谓词链接在查询执行阶段根据匹配结果消除歧义适合研究知识图谱问答、语义解析及智能客服的开发者参考。压缩包共78个文件含62个Java核心代码、8个Python预处理脚本、4个PDF文档和3个Markdown说明整体仅3.43MB轻量易部署。Java模块覆盖语义关系构建、查询图生成、SPARQL映射与执行链路Python脚本用于三元组清洗、切分、实体/谓词/类型片段生成文档包含TKDE18论文、系统帮助及部署说明建议先阅读MD文件再运行。目前已有334人学习下载资源附有完整源码与生成片段流程说明可帮助快速跑通自然语言问答全流程并理解基于图数据库的语义消歧与查询优化思路。1. 自然语言问答系统不是聊天机器人而是一个工程拼装很多团队一接到“自然语言问答系统”这种需求第一反应就是去调大模型接口结果问出来的答案像客服话术回复长度可控但证据不可控换个提问方式回答就变了个意思。真正在生产里能长时间跑下去的自然语言问答系统其实是一个检索加排序加生成的组合工程先在一个可信文档集里找到候选证据再对问题和证据做语义匹配最后才决定是把原句返回还是让生成模型改写。zip 这个后缀也不该被忽略它意味着系统要被压缩、分发、在另一台机器上解压运行。适合读这类工程的人是已经接触过 Python 和基础 NLP 库、但没真正把检索式问答端到端跑通的开发者这篇要讲的就是怎么把“能回答”变成“可交付”。2. 问答系统的 NLP 管线从句法分析到向量召回核心是“把问题变成候选证据”在动手之前先得把 NLP 这个词拆清楚。自然语言处理解决的是“理解字面”问答系统解决的是“寻找答案”两者之间隔着一条检索链路。你可以让模型写出漂亮的句子但如果没有候选证据那些句子就是编的。我一般会把问答系统按检索式问答来搭因为答案可在文档里回溯出错时也能快速定位。2.1 检索式问答的三段式结构召回、重排、生成检索式问答不是“把问题扔给模型一次算完”而是分成三个阶段。召回阶段用低延迟方法从全量文档中找出若干候选句子重排阶段用更重的模型重新计算问题与每个句子的相似度生成阶段再把排前面的句子拼成上下文交给大模型做提炼或直接返回。这三个阶段对延迟和精度的要求完全不同不能混为一谈。下面这张表是我常用的分工方式阶段输入输出典型工具召回用户问题、全量句子top_k 个候选句子关键词倒排、TF-IDF、BM25重排问题、候选句子按分数重排的列表SBERT、Cross-Encoder生成重排后的 3~5 个句子最终答案文本Flan-T5、ChatGLM、Llama这个结构最大的好处是可插拔。召回慢就用向量索引重排不准就换模型生成不需要改业务逻辑。如果你只是想要“从文档里找答案”生成阶段甚至可以砍掉直接返回重排第一名的句子这样部署成本最低。2.2 用中文分词和 TF-IDF 做第一版召回中文 NLP 的第一个坑就是分词。英文按空格切就好但中文“自然语言问答”这几个字在“自然/语言/问答”和“自然语/言问答”之间差别很大。我用 jieba 做第一版因为它在 zip 包里体积可控 pip 安装简单词典也够用。下面这段代码构造一个最简单的 TF-IDF 召回器import jieba from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity def tokenize(text: str) - str: return .join(jieba.cut(text, cut_allFalse)) questions [ 自然语言处理有哪些应用, 问答系统如何评估准确率, 中文文本用什么分词工具, ] tokenized [tokenize(q) for q in questions] vectorizer TfidfVectorizer(tokenizerlambda x: x.split( )) tfidf_matrix vectorizer.fit_transform(tokenized) query_vec vectorizer.transform([tokenize(问答系统准确率怎么评估)]) scores cosine_similarity(query_vec, tfidf_matrix)[0] print(scores)逻辑说明jieba.cut(cut_allFalse)使用精确模式避免把词切得过碎TfidfVectorizer接收一个分词后的空格字符串再用tokenizerlambda x: x.split( )避免它二次调用 jieba否则打包后容易出现实例化问题。最后用余弦相似度把问题和每个预置句子比较分数高就是候选。这一段看上去简单但决定问答系统天花板的是你能不能在召回阶段就把正确答案留在候选里。TF-IDF 对关键词覆盖敏感用户把“准确率”说成“正确率”就召回不到了。这时候再靠 dense retrieval 来做语义补全。至于那些“波森nlp”之类在线演示里的词法接口我一般只拿来做对比验证不会把它放进 zip 包对外分发原因不是能力差而是不可控。2.3 从稀疏向量切换到稠密向量SBERT 与 BGE 的取舍要让“准确率”和“正确率”匹配上就需要把句子映射成稠密向量。常用的中文文本嵌入模型有 SBERT、BGE-small 等。选择模型时我不会只盯着准确率还会看模型大小和推理速度BGE-small 的向量维度是 512加载后内存占用约 500MBSimBERT 更大但相似度稳定性更好。换到稠密向量召回后代码演变成如下结构from sentence_transformers import SentenceTransformer model SentenceTransformer(BAAI/bge-small-zh-v1.5) question_emb model.encode(问答系统准确率怎么评估) part_emb model.encode(中文问答系统的评估指标)这里的model.encode返回一个 512 维浮点数组。你可以用 numpy 做内积也可以接 FAISS。注意一个问题打包成 zip 后模型权重通常不会塞进 zip而是放在一个外部目录通过环境变量指定路径。原因后面第四章会讲。这一阶段的核心认知是召回是选择题向量模型决定候选集合的大小和质量而不是最终答案。3. 把问答系统打成 zipPython 服务的最小可运行实现一个自然语言问答系统如果只能在自己电脑上跑价值会大打折扣。要让别人解压 zip 就能运行需要把启动脚本、依赖清单和索引数据全部收敛到固定路径。下面这个实现我选 Flask 做接口因为它零配置、单文件也能跑适合压缩包分发。3.1 目录结构与依赖锁定先建一个标准目录qa_service/ main.py index.py data/ news.txt requirements.txt start.shrequirements.txt要固定版本号而不是写“最新版”否则对方解压安装的库可能在 API 上发生变化。一个典型内容flask3.0.0 jieba0.42.1 scikit-learn1.4.0 numpy1.26.0在start.sh里写明启动方式#!/bin/bash cd $(dirname $0) python -m pip install -r requirements.txt --quiet python main.py逻辑说明cd $(dirname $0)是为了保证无论 zip 包解压到哪个路径程序都从自己的目录读取数据pip install --quiet让首次启动少打印大量无关日志。这一步决定了后续所有相对路径是否正确。3.2 索引构建与搜索逻辑index.py里实现加载数据、切句、分词、构建 TF-IDF 索引import os import jieba import pickle from sklearn.feature_extraction.text import TfidfVectorizer BASE_DIR os.path.dirname(os.path.abspath(__file__)) DATA_PATH os.path.join(BASE_DIR, data, news.txt) INDEX_PATH os.path.join(BASE_DIR, data, index.pkl) def load_sentences(): with open(DATA_PATH, encodingutf-8) as f: text f.read() parts text.replace(\n, ).split(。) return [p.strip() for p in parts if len(p.strip()) 4] def cut_func(text): return .join(jieba.lcut(text)) vectorizer TfidfVectorizer(preprocessorcut_func, tokenizerlambda x: x.split( )) def build_index(): sentences load_sentences() tfidf vectorizer.fit_transform(sentences) with open(INDEX_PATH, wb) as f: pickle.dump({sentences: sentences, tfidf: tfidf}, f) def search(tfidf, vectorizer, corpus, query, top_k5, threshold0.15): q_vec vectorizer.transform([cut_func(query)]) scores tfidf q_vec.T # cosine similarity scores scores.toarray().ravel() ranked [(float(s), corpus[i]) for i, s in enumerate(scores) if s threshold] ranked.sort(keylambda x: x[0], reverseTrue) return ranked[:top_k]代码里需要注意两点TfidfVectorizer的preprocessor与tokenizer分离避免 sklearn 私自改小写tfidf q_vec.T是打分最快的写法不需要调用cosine_similarity因为 TF-IDF 已经把向量做了 L2 归一化。pickle把索引存成index.pkl这样第二次启动不用重新切分几百个句子如果语料规模到几万句建议换成 sqlite FAISS但 zip 包那种场景先不过度设计。3.3 用 Flask 暴露 REST 接口并验证main.py里把索引加载放到模块层避免每次请求都读盘from flask import Flask, request, jsonify import os import pickle import jieba from index import search, BASE_DIR, INDEX_PATH, build_index app Flask(__name__) def load_index(): if not os.path.exists(INDEX_PATH): build_index() with open(INDEX_PATH, rb) as f: data pickle.load(f) return data data load_index() tfidf_model data[tfidf] corpus data[sentences] app.route(/api/ask, methods[POST]) def ask(): payload request.get_json(forceTrue) question payload.get(question, ).strip() if not question: return jsonify({code: 400, msg: question is empty}) results search(tfidf_model, tfidf_model, corpus, question, top_k3, threshold0.2) return jsonify({ question: question, answers: [{text: s, score: sc} for sc, s in results] }) if __name__ __main__: app.run(host0.0.0.0, port8000, debugFalse)这里的tfidf_model变量名有点绕search函数的第二个参数预期是 vectorizer但 TF-IDF 模型本身同时保存特征映射。若要更清楚可以改成vectorizer data[vectorizer]我在压缩包里这样写是为了省一次 pickle 字段改动。打完 zip 包用 curl 验证curl -X POST http://127.0.0.1:8000/api/ask \ -H Content-Type: application/json \ -d {question: 自然语言处理有哪些应用}返回 JSON 里的answers数组就是排序后的候选。要发布 zip 时执行zip -r qa_service.zip qa_service -x */__pycache__/*把缓存目录排掉包体积能少几十 KB。4. 上线前的参数调优命中阈值、窗口大小与候选排序第一个能跑的版本很简单但离“自然语言问答系统”这个标题还有距离。用户不会关心你用的是 TF-IDF 还是向量模型只关心问“今天有什么新闻”时系统能不能把最相关的几句话排前面。这时候调参就成了主要工作。4.1 三个高频参数top_k、阈值、候选择句窗口我几乎每个问答项目都会调这三个参数它们直接影响效果和响应时间参数作用推荐范围调大后风险top_k候选句数量3~10噪声变多重排压力大threshold最低相似度分数0.15~0.35容易空答案需要兜底提示sentence_window答案前后附加上下文0~2 句上下文变长生成模型输入变慢top_k在召回阶段决定看到多少候选。新闻语料经常出现大量相似句子top_k5 时会把同一事件的多个报道都排进来有些场景反而需要 top_k10 然后按时间字段过滤。阈值不要固定写死更好的做法是根据分数分布动态计算。下面是改进后的版本def dynamic_search(tfidf, vectorizer, corpus, query, top_k8, percentile0.6): q_vec vectorizer.transform([query]) scores (tfidf q_vec.T).toarray().ravel() # 取所有分数中的 60 分位作为阈值避免空结果 threshold float(np.percentile(scores, percentile)) threshold max(threshold, 0.1) ranked [(float(sc), corpus[i]) for i, sc in enumerate(scores) if sc threshold] ranked.sort(keylambda x: x[0], reverseTrue) return ranked[:top_k]这里的np.percentile会根据当前问句和语料的实际分数动态调整门槛。问题是如果分数整体偏低percentile 会把所有句子都留下所以还要加一个max(threshold, 0.1)兜底。上线后观察返回为空的比例空答案超过 5%就把 0.1 降到 0.05答案多但用户反馈不相关就向上调整。4.2 针对 NLP 新闻和 NLP 情感分析语料的过滤技巧如果问答系统要处理新闻或客户评论语料里混有大量主观表达。问“这则新闻讲了什么”系统应该优先抽取客观陈述问“大家情绪怎么样”系统则应该找包含情感词的句子。这里不引入复杂大模型可以用轻量情感词典做后置过滤。POSITIVE_WORDS {利好, 增长, 满意} NEGATIVE_WORDS {下滑, 亏损, 投诉} def sentiment_filter(sentence, question): if 情感 in question or 情绪 in question: pos sum(1 for w in POSITIVE_WORDS if w in sentence) neg sum(1 for w in NEGATIVE_WORDS if w in sentence) return pos neg 0 return True这个函数放在search返回之后根据问题是否包含“情感/情绪”决定是否执行。它不能让系统真正理解复杂情感但能避免新闻类问答里返回一堆“公司表示遗憾”这类套话。想做得更细可以再引入snownlp计算倾向分数但要注意 zip 包里多一个模型依赖解压后首次调用会更慢。对新闻类语料我一般保留每个句子的来源和时间戳作为排序加权的加分项。同分数段里发布时间更新的句子排前面题目包含标点符号过多说明可能是标题党在重排时扣 0.05 分。这套方法不用神经网络也能提升体感且代码量很少。真正需要投入的是评估测试集。至少准备五十个问句每个问句标注两个正确句子然后跑一遍搜索看准确率和召回率。很多项目最后不是模型不行而是阈值和 top_k 组合没调好。这时候拿输出看几条错误案例比盲目换 embedding 模型有效得多。5. 检查 zip 包完整性的三个实用技巧离线自检、缓存命中与日志观察交付 zip 之前我最担心的是环境差异对方机器没有分词缓存、pickle 路径不对、端口被占用。与其让使用者一脸茫然不如在包里放一个自检脚本让解压后第一次启动自动跑三个检查。第一个技巧是离线自检。在main.py同级放一个self_test.py它不依赖 Flask直接调用索引模块的load_sentences和search验证两个固定问题是否能返回结果import os import pickle from index import build_index, load_sentences, search def run_self_test(): index_file os.path.join(os.path.dirname(__file__), data, index.pkl) if not os.path.exists(index_file): build_index() corpus load_sentences() data pickle.load(open(index_file, rb)) tfidf data[tfidf] for q in [自然语言处理有哪些应用, 系统如何评估]: result search(tfidf, tfidf, corpus, q, top_k1, threshold0.0) assert len(result) 0, fempty search result: {q} print(self-test passed)逻辑说明这里的threshold0.0是为了保证至少返回一个候选避免在自检阶段因为阈值而被误判失败。断言通过后使用者再进入正式提问流程。第二个技巧是缓存命中统计。分词和 TF-IDF 构建都可以被缓存但 zip 包解压后路径会变所以要记录当前工作目录的哈希。在index.py里加一个cache_version os.path.getmtime(DATA_PATH)当文件修改时间变化时自动重建 rebuild。这样对方把 zip 解压到/tmp还是/home/user索引都是同一个版本不会出现“换路径后答案变少”的诡异问题。这个逻辑其实就是在build_index函数里把INDEX_PATH改成包含 mtime 的文件名def get_index_path(): mtime int(os.path.getmtime(DATA_PATH)) return os.path.join(BASE_DIR, data, findex_{mtime}.pkl)第三个技巧是日志观察。Flask默认的日志看不到每次检索的分数分布我会在search返回前用logging记录avg_score和max_score。下一个版本调参时看两天日志就能知道 0.2 阈值改成 0.3 后空答案比例上升了多少。这一步比任何离线测试都贴近真实用户因为自然语言提问的用词分布你永远猜不全。把这三个技巧写进 READMEzip 包才算真正交付完成。本文还有配套的精品资源点击获取
返回列表