1. 背景
最近,大模型(LLMs,Large Language Models)可谓是NLP领域,甚至整个科技领域最火热的技术了。凑巧的是,我本人恰好就是NLP算法工程师,面临着被LLMs浪潮淘汰的窘境,决定在焦虑之余,试着用新家伙,去解决解决老问题(缓解一下焦虑…)
所以是什么老问题呢?——那就是Bert解决了过、但问题依旧存在的那个——垂直领域问答
2. 原思路简介
由于目前的确已经有许多类似的、写得很优秀的大佬写的文章了,所以我本文不会过多涉及具体的代码。我更倾向于探讨一下我们怎么用这项**“新技术”**,来解决之前就有的、而且颇有价值的”老问题“。
首先,最基础的架构我们还是要说一下(免得一段时间以后回顾的时候一脸懵逼,只剩吹牛了)
我们先回顾一下,Bert时代的垂直领域问答系统是怎么构建的?
如上图所示,这就是个问答系统的大概面貌了。当然了,还有些其他辅助模块没在这里体现,这里只拿出了最关键的几个部分:
- query理解:充分理解query含义,挖掘query信号,给下一轮的doc(文档)”粉碎机“提供弹药!
- 文档召回:这里的召回方式大概可分为2大类,分别是基于query理解得到的信号召回,以及向量召回。
- 信号召回:基于ES或其他什么类ES自研框架(为什么要自研,还不是为了体现价值orz…)实现,可以简单理解为关键词召回,即从query和doc中分别找到关键词,然后进行匹配、打分;
- 向量召回:信号召回显然是基于”字面“的,而向量召回则是基于语义的。将doc提前向量化建库,然后query再向量化后进行向量检索。
- 最后,基于以上两路召回的文档得分,再结合一些其他trick(如质量打分)进行综合排序,取topK进入下一轮大逃杀!
- 答案抽取:使用预训练好的模型,分别对topK文档里的答案进行抽取。对于上一代Berter(哦不NLPer)而言,主模型都是Bert或其徒子徒孙。现在,则是最时髦的LLM
- 答案处理:后处理模块,可将得到的答案再次排序、可做答案融合、也可根据业务需要,进行合理的后处理,最后输出。
3. LLM出现以后的世界
3.1 LLM时代和Bert时代的差异
那么,在现如今ChatGPT、ChatGLM、Moss、LAMMA等大模型满天飞的世界,上述那套系统有什么变化了呢?
其实,这套系统整体流程变化并不大,只不过是将”答案抽取“模块的Bert时代模型替换成了LLMs时代的模型。而由于LLM在模型能力、允许输入长度等方面都比Bert强不少,所以一定程度上,LLMs时代这套系统对其他几个模块的要求也降低了。
举个例子,在Bert时代,query理解是个要做得很重、异常重要的模块,毕竟如果这里能力不足,那下游模型就”巧妇难为无米之炊了“,我上面那张图里query理解部分每个子模块都能撑得起一大块工作。而大家都知道,端到端的模型是大家一直追求的(是吗?毕竟这样要我们NLPer还能做啥?),而且能力足够的端到端模型可以最大程度减少信息损失,所以能力更强的LLMs直接把这个模块的工作包揽了下来,原本的query分词、分类、纠错、关键词等工作变成了制定合适的Prompt然后调试,具体的query理解,就由LLM来搞定了。
下面,我再简介一下结合LLM时,垂直领域问答的架构的样子
3.2 LLM时代垂直领域知识库问答常用架构
架构异常简单,暂时能力所限,仅为博君一笑~
如图所示,能看得出,整体架构比Bert时代的简单很多。我们对照着之前的架构来说:
-
query理解
VS提问模块
:前面举过例子,目前提问模块在当前架构下已经几乎没有什么任务了,但如果你想增加一路关键词召回,那么这里也可以借助大模型的能力来解决,用大模型(当然,也可以是传统nlp模型或规则词表)帮你完成关键词提取,然后到下一步中进行使用ES做关键词召回 -
两版
文档召回模块
:这一部分差别其实不大,但随着LLMs的模型能力提升,对于召回质量的忍耐度也在提升。由于Bert时代模型往往有512 tokens的长度限制,所以我们要更加小心地做好召回文段排序和截断、文段之间overlap等事情。但随着大模型目前普遍将文本可输入长度扩到了8K、甚至是16K/32K(当然这里指的是字符,汉字字数会少一些),这就意味着如果需要,我们可将更多的文段喂给LLM。也正因为此,我们现如今往往只用向量召回(LangChain)即可达到要求。当然了,为了进一步提升召回文章的丰富度,开发额外的ES关键词召回、甚至更复杂的召回链路也是可行的。笔者之前有幸在某大厂负责过电商搜索引擎的召回模块,各路召回之复杂实在让人欲仙欲死,然而它们也确实是有用的,有些召回路径满足的是搜索的精准,有的召回路径满足的是物料的丰富,有的召回路径从用户历史兴趣角度考量,也有的召回路径从品类、产地、功能等多角度试图猜到用户所需。不过,由于知识库问答场景往往没有这么多目标,所以我们就尽可能做的轻一些——less is more -
答案抽取模块
VS大模型问答
模块:虽然核心的模型从Bert变成了本地的微调过的ChatGLM、或ChatGPT-api,但整体思路是一致的。Bert时代首先需要将处理后的query和若干召回的doc拼接成类似[CLS]query[SEP]doc1
,[CLS]query[SEP]doc2
, …类似的样子,然后分别送给Bert进行答案抽取、输出;LLMs时代,则是将召回的doc和用户query装配成最终Prompt,类似的样子如下:"""已知信息: {context} 根据上述已知信息,简洁和专业地回答用户问题。如果无法从中得到答案,请说 “根据已知信息无法回答该问题” 或 “没有提供足够的相关信息”,不允许在答案中添加编造成分,答案请使用中文。 问题是:{question} """
以上Prompt模板参考链接。最后,LLM会将答案输出。
-
答案处理模块
:在LLM版本架构中,不再需要这一模块,因为LLM已经将输入资料整合并完成回答了。但Bert时代,由于多段资料分别和query拼接进行答案抽取,所以结果还要对多个答案进行排序。
4. LangChain+LLM实战心得体会
具体LangChain+LLM的代码,笔者参考该repo,并在上面进行了些细微的二次开发。由于该项目已经非常完备了,所以不再赘述。我在这里,主要说一下我在项目开发中遇到的问题和一点思考:
4.1 数据处理和清洗方面
-
关于目录:目录部分应提前识别并删除
这部分关键词巨多(误命中)、各种标点符号巨多(误分段)、大多不是自然语言(用来做目录索引的,没实际的文本含义),引入目录,会在
文档召回
阶段通过向量或关键词产生大量误召回,建议直接咔嚓掉 -
文档分段长度:模型允许的情况下,能长则长;可根据数据酌情向后扩展
在大模型可接受文本总字数有限的情况下,增加召回数量和增加单文档长度的权衡
-
首先,由于我所处理的数据是相对干货的政策法规和企业规章,所以文章质量较高;其次,相似的内容较少出现在多个出处、而且有些政策文本会有不少
1.xxx, 2.xxx, ..., 10.xxx
这种不适合切割的超长内容,所以对我的数据而言,与其召回更多个短文本段,还不如召回更少的长文本段,以减少低相关杂质引入,也防止有用内容被截断。 -
不过之前我也处理过各种政策解读、财经类新闻的语料,这种语料往往良莠不齐且同一件事有多个相似文章在描述,为了确保结果的全面性,则是召回更多个短文本段更好。
关于分段截断的问题
-
一般通用分段方式,是在固定max_length的基础上,对出现
。/;/?/....../\n
等等地方进行切割。 -
但这种方式显然比较武断,面对特殊情况需要进一步优化。比如前面的那个
1.xxx, 2.xxx, ..., 10.xxx
超长内容的情况,直接按这种方法切割就会导致潜在的内容遗漏。 -
对于这种候选语料”内聚性“很强的情况,容易想到,我们要么在切割语料时动手脚(不把连续数字符号所引领的多段文本切开);要么在切割时照常切割、但在召回时动手脚(若命中了带连续数字符号的这种长文本的开头,那么就一并把后面连续数字符号引领的多段文本一起召回)。笔者目前只想到了这两种方法且还没具体做实验,只是凭空想来,前者方案有较明显瑕疵,因为这样会——
- 相对于更短文段而言,长文段的语义更丰富,每个单独的语义点更容易被淹没,所以在有明确语义query的召回下这种长文段可能会吃亏;
- 长文段一旦被召回,只要不是针对整段文本的提问,那么也是引入了更多的噪声(不过鉴于LLM的能力,这可能也无伤大雅,就是费点显存or接口费用了)
但后者就显得更灵活些,不过确实也不够聪明。暂时没想到其他办法,有好想法的人可以来交流一下~
-
此外,有研究表明,长文本作为输入LLM输入时,LLM倾向于更关注长文本的开头、结尾处,然而中间部分的语义可能会被忽略。这里挖个小坑,下次再来关注一下这方面问题
-
4.2 模型方面
-
调整temperature参数规避模型”知识幻觉“
-
对于知识库问答而言,知识幻觉(一本正经的胡说八道)是最头疼的事情。对于ChatGPT(gpt-3.5-turbo)而言,笔者实验过,当temperature<=0.1,可以有效避免“知识幻觉”(即Prompt中没有问题答案,ChatGPT就会回答”根据已知信息无法回答该问题“),不过这也可能让模型过分谨慎(即使有语义类似的答案,模型也倾向于那不是提问范围内的)。建议可以再尝试一些0.1~0.5之间的temperature,在最大化模型联想能力的同时,确保回复的可靠性
-
但对于ChatGLM-6B(v1),该模型temperature取值范围在[0, 1],笔者尝试过0.1、0.01等数值,仍然会出现较为严重的模型幻觉。不知道ChatGLM2-6B效果会不会好些。不过6B的打不过号称175B的ChatGPT,也是正常
-
4.3 问题解决思路方面
这部分只是个人在问题解决过程中的一点想法,与大家分享。
-
明确多文档下的知识点问答,和单文档总结之间的区别
- 该项目LangChain+LLM的思路,是针对多文档下的知识点问答的,目标是在海量语料中精准找到几小段参考文档然后总结出答案;
- 而单文档总结,即类似ChatPDF的需求,和本项目目标不一致。ChatPDF的目标是尽可能全面地总结出整篇文档的信息。这个需求以目前ChatGPT可接受的16K上下文而言,不少文档可以直接丢进去问答,无需使用召回工具先做知识检索,不完全属于该项目解决的问题范围。
-
对于效果不佳的情况,首先应该从更高的角度思考,到底是当前技术不达标,还是使用场景不契合。用检索+问答这种范式解决其不擅长的问题、然后再此基础上搞一大批trick,是非常不可取的,应尽量避免
-
举个例子,有人问这种问答模式能否解决Excel表格问答?这当然可以试试,结果对表格进行问答效果很差——这个结果很容易预期,毕竟CoT的思想就提到过,做推理、算数学题,这种需要一步步思考的任务在加以一步步引导的情况下,LLM才能得到不错的结果。那么,我们应该在检索+LLM这个项目上做优化以解决Excel表格的问答问题吗?非也。我们应该意识到Excel、或者其他文件中的表结构本身属性是表格,表格是结构化数据、文本是非结构化的,而检索系统(文本召回系统)本身就是为非结构化数据准备的。所以要解决excel文件问答的问题,或许可以把它归类到“NL2SQL”上,用代码/SQL的方式去解决。
-
我做了简单的尝试,供大家参考,后台模型gpt-3.5-turbo-16K:
来自你的消息:
排名 球员 球队 得分 命中-出手 命中率 命中-三分 三分命中率 命中-罚球 罚球命中率 场次 上场时间 1 乔尔-恩比德 76人 33.10 11.00-20.10 54.8% 1.00-3.00 33% 10.00-11.70 85.7% 66 34.60 2 卢卡-东契奇 独行侠 32.40 10.90-22.00 49.6% 2.80-8.20 34.2% 7.80-10.50 74.2% 66 36.20 3 达米安-利拉德 开拓者 32.20 9.60-20.70 46.3% 4.20-11.30 37.1% 8.80-9.60 91.4% 58 36.30 4 谢伊-吉尔杰斯-亚历山大 雷霆 31.40 10.30-20.30 51% 0.90-2.50 34.5% 9.80-10.90 90.5% 68 35.50 5 扬尼斯-阿德托昆博 雄鹿 31.10 11.20-20.30 55.3% 0.80-2.70 27.5% 7.90-12.20 64.5% 63 32.10 6 杰森-塔特姆 凯尔特人 30.10 9.80-21.10 46.6% 3.20-9.30 35% 7.20-8.40 85.4% 74 36.90 7 多诺万-米切尔 骑士 28.30 10.00-20.60 48.4% 3.60-9.30 38.6% 4.70-5.40 86.7% 68 35.80 8 杰伦-布朗 凯尔特人 26.60 10.10-20.60 49.1% 2.40-7.30 33.5% 3.90-5.10 76.5% 67 35.90 9 贾-莫兰特 灰熊 26.20 9.30-19.90 46.6% 1.50-4.90 30.7% 6.10-8.10 74.8% 61 31.90 以上这些人谁的出战场次最多?所有人平均出战多少场次?直接给出答案
来自 ChatGPT 的消息:
以上这些人中,贾-莫兰特的出战场次最多,共计61场。
所有人的平均出战场次为63.1场。
来自你的消息:
排名 球员 球队 得分 命中-出手 命中率 命中-三分 三分命中率 命中-罚球 罚球命中率 场次 上场时间 1 乔尔-恩比德 76人 33.10 11.00-20.10 54.8% 1.00-3.00 33% 10.00-11.70 85.7% 66 34.60 2 卢卡-东契奇 独行侠 32.40 10.90-22.00 49.6% 2.80-8.20 34.2% 7.80-10.50 74.2% 66 36.20 3 达米安-利拉德 开拓者 32.20 9.60-20.70 46.3% 4.20-11.30 37.1% 8.80-9.60 91.4% 58 36.30 4 谢伊-吉尔杰斯-亚历山大 雷霆 31.40 10.30-20.30 51% 0.90-2.50 34.5% 9.80-10.90 90.5% 68 35.50 5 扬尼斯-阿德托昆博 雄鹿 31.10 11.20-20.30 55.3% 0.80-2.70 27.5% 7.90-12.20 64.5% 63 32.10 6 杰森-塔特姆 凯尔特人 30.10 9.80-21.10 46.6% 3.20-9.30 35% 7.20-8.40 85.4% 74 36.90 7 多诺万-米切尔 骑士 28.30 10.00-20.60 48.4% 3.60-9.30 38.6% 4.70-5.40 86.7% 68 35.80 8 杰伦-布朗 凯尔特人 26.60 10.10-20.60 49.1% 2.40-7.30 33.5% 3.90-5.10 76.5% 67 35.90 9 贾-莫兰特 灰熊 26.20 9.30-19.90 46.6% 1.50-4.90 30.7% 6.10-8.10 74.8% 61 31.90 以上这些人谁的出战场次最多?所有人平均出战多少场次?使用pandas计算,给出可执行代码,无需将全部数据重新列举一遍
来自 ChatGPT 的消息:
您可以使用以下代码来计算出战场次最多和平均出战场次:
import pandas as pd # 创建数据表 data = { '排名': [1, 2, 3, 4, 5, 6, 7, 8, 9], '球员': ['乔尔-恩比德', '卢卡-东契奇', '达米安-利拉德', '谢伊-吉尔杰斯-亚历山大', '扬尼斯-阿德托昆博', '杰森-塔特姆', '多诺万-米切尔', '杰伦-布朗', '贾-莫兰特'], '球队': ['76人', '独行侠', '开拓者', '雷霆', '雄鹿', '凯尔特人', '骑士', '凯尔特人', '灰熊'], '得分': [33.10, 32.40, 32.20, 31.40, 31.10, 30.10, 28.30, 26.60, 26.20], '命中-出手': ['11.00-20.10', '10.90-22.00', '9.60-20.70', '10.30-20.30', '11.20-20.30', '9.80-21.10', '10.00-20.60', '10.10-20.60', '9.30-19.90'], '命中率': ['54.8%', '49.6%', '46.3%', '51%', '55.3%', '46.6%', '48.4%', '49.1%', '46.6%'], '命中-三分': ['1.00-3.00', '2.80-8.20', '4.20-11.30', '0.90-2.50', '0.80-2.70', '3.20-9.30', '3.60-9.30', '2.40-7.30', '1.50-4.90'], '三分命中率': ['33%', '34.2%', '37.1%', '34.5%', '27.5%', '35%', '38.6%', '33.5%', '30.7%'], '命中-罚球': ['10.00-11.70', '7.80-10.50', '8.80-9.60', '9.80-10.90', '7.90-12.20', '7.20-8.40', '4.70-5.40', '3.90-5.10', '6.10-8.10'], '罚球命中率': ['85.7%', '74.2%', '91.4%', '90.5%', '64.5%', '85.4%', '86.7%', '76.5%', '74.8%'], '场次': [66, 66, 58, 68, 63, 74, 68, 67, 61], '上场时间': [34.60, 36.20, 36.30, 35.50, 32.10, 36.90, 35.80, 35.90, 31.90] } df = pd.DataFrame(data) # 找出出战场次最多的球员 max_appearances = df['场次'].max() player_with_max_appearances = df.loc[df['场次'] == max_appearances, '球员'].values[0] print("出战场次最多的球员是:", player_with_max_appearances) # 计算平均出战场次 average_appearances = df['场次'].mean() print("所有球员的平均出战场次是:", average_appearances)
输出结果为:
出战场次最多的球员是: 杰森-塔特姆 所有球员的平均出战场次是: 64.33333333333333
###来自你的消息:
排名 球员 球队 得分 命中-出手 命中率 命中-三分 三分命中率 命中-罚球 罚球命中率 场次 上场时间 1 乔尔-恩比德 76人 33.10 11.00-20.10 54.8% 1.00-3.00 33% 10.00-11.70 85.7% 66 34.60 2 卢卡-东契奇 独行侠 32.40 10.90-22.00 49.6% 2.80-8.20 34.2% 7.80-10.50 74.2% 66 36.20 3 达米安-利拉德 开拓者 32.20 9.60-20.70 46.3% 4.20-11.30 37.1% 8.80-9.60 91.4% 58 36.30 4 谢伊-吉尔杰斯-亚历山大 雷霆 31.40 10.30-20.30 51% 0.90-2.50 34.5% 9.80-10.90 90.5% 68 35.50 5 扬尼斯-阿德托昆博 雄鹿 31.10 11.20-20.30 55.3% 0.80-2.70 27.5% 7.90-12.20 64.5% 63 32.10 6 杰森-塔特姆 凯尔特人 30.10 9.80-21.10 46.6% 3.20-9.30 35% 7.20-8.40 85.4% 74 36.90 7 多诺万-米切尔 骑士 28.30 10.00-20.60 48.4% 3.60-9.30 38.6% 4.70-5.40 86.7% 68 35.80 8 杰伦-布朗 凯尔特人 26.60 10.10-20.60 49.1% 2.40-7.30 33.5% 3.90-5.10 76.5% 67 35.90 9 贾-莫兰特 灰熊 26.20 9.30-19.90 46.6% 1.50-4.90 30.7% 6.10-8.10 74.8% 61 31.90 以上这些人谁的出战场次最多?所有人平均出战多少场次?使用sql计算,无需将全部数据重新列举一遍
###来自 ChatGPT 的消息:
根据给出的数据,可以使用SQL查询来计算出战场次最多的球员和平均出战场次。
出战场次最多的球员可以通过查询最大的场次数来得到:
SELECT 球员, MAX(场次) AS 最多场次 FROM 表名;
平均出战场次可以通过查询所有球员的场次数并计算平均值来得到:
SELECT AVG(场次) AS 平均场次 FROM 表名;
请将"表名"替换为实际的表名,然后执行上述SQL查询即可得到结果。
总结一下:
- “以上这些人谁的出战场次最多?所有人平均出战多少场次?”这2个问题的答案分别是“杰森塔图姆”和65.67场
- 我分别让模型用3种方式输出结果:1-直接输出、2-给出pandas代码后输出、3-只给SQL代码。
- 三种方式里,方法1直接错误,两个问题皆错;方法2最后给出答案时,问题2“平均出战场次”算错了。但它给出了正确的pandas代码,执行后两个问题都能得到正确结果;方法3我禁止其输出答案,只给出SQL代码,最后验证sql代码准确无误;
- 此外,为了减少大量数据反复传给LLMs增加其负担,实际上可以只给它带表头的2~3行数据,让其明白数据格式即可。
所以由此可见,与其让它自己推理复杂计算,还不如让它给出代码,然后你自己去执行代码。当然,这里的问题确实是超级简单了,大家可以再试试上难度的统计问题
-
此外,据了解,OpenAI官方推出的
Code Interpreter
就是专门干这个的(你可以把它当成个Data Analyzer,感兴趣的童鞋可以试试:OpenAI-Code Interpreter)
-
5. 总结
本文从实战角度出发,对垂直领域知识库问答这个问题,在Bert时代和LLMs时代的解决方案均做了简述。此外,本文还对LLM时代的LangChain+LLM的方案在实战中出现的问题进行了探讨,最后,还简单尝试了一下ChatGPT在数据分析方面的潜力。
欢迎大家多多点赞关注,一起交流学习吧😊
PS,转载请注明出处哦!