前沿重器[48-54] 合集:四万字聊搜索系统

前沿重器

栏目主要给大家分享各种大厂、顶会的论文和分享,从中抽取关键精华的部分和大家分享,和大家一起把握前沿技术。具体介绍:仓颉专项:飞机大炮我都会,利器心法我还有。(算起来,专项启动已经是20年的事了!)

2023年文章合集发布了!在这里:又添十万字-CS的陋室2023年文章合集来袭

经过自己连续两个月的努力,终于把搜索系统的完整讲解给写完了。下面是文章的合集内容,系统讲解了搜索系统中各个部分的工作以及设计思路,我希望能通过这一系列文章,让大家最整个搜索系统有更完整的了解。写完之后发现,自己相比之前写对话系统的时候要吐的内容更多更丰富了,这次,四万字!


RAG在整个大模型技术栈里的重要性毋庸置疑,而在RAG中,除了大模型之外,另一个不可或缺的部分,就是搜索系统,大模型的正确、稳定、可控生成,离不开精准可靠的搜索系统,大量的实验中都有发现,在搜索系统足够准确的前提下,大模型的犯错情况会骤然下降,因此,更全面、系统地了解搜索系统将很重要。

听读者建议,像之前的对话系统一样(前沿重器[21-25] | 合集:两万字聊对话系统),我也会拆开揉碎地给大家讲解搜索系统目前业界比较常用的架构、技术方案,目前的计划是分为这几个模块讲解:

前沿重器[48] | 聊聊搜索系统1:开篇语

开篇语主要是和大家介绍一下搜索系统的概况,包括他的具体场景,以及在目前大模型环境下的地位以及使用场景区别。

目录:

  • 搜索系统概念的理解。

  • 搜索系统的场景。

  • 大模型场景下搜索系统的理解。

搜索系统概念的理解

所谓搜索系统,大家比较普遍认可的概念是,能从海量的信息中找到所需要的内容的方案,都可以被称为搜索,除了大家所熟知的百度搜索外,在图书馆里找一本书,在音乐软件里找一首音乐,在美团上找一家外卖,这些背后的事,都可以被称为搜索。有几个角度希望和大家展开说一下。

首先是,搜索并不是一个很新的概念,而是一个发展比较久的学问了。北大有图书馆学情报学系,清华大学也有“信息管理与信息系统专业”,虽说所属方向略有差别,但其内部有一个关键的目标就是对信息的优化管理以及使用,其本质和搜索系统非常匹配,这么看搜索的研究可以说是历史悠久了。

第二,搜索的内容并非局限,而且各自的技术也非常丰富。搜索不能局限在百度这样的大搜上(一般而言大搜是指开放域,比较广泛的搜索),刚才提了几个例子,搜图书、搜音乐、搜外卖、搜商品,这些都算是搜索,而且实际上也已经渗透到大家生活的方方面面了,往高端的说法,可以说是“多模态”,当然可能也不局限在多模态,类似商品、外卖这种异构信息的搜索,就已经超出了常规的文本、图片、声音、视频的简单模态范畴了,尽管多模态和异构的研究看似有些陌生或者不成熟,但也已经在自己的领域非常满足用户的需求,因此,在对问题进行研究、分析的时候不能把搜索问题局限在文本之类的简单信息上。

第四,必要强调搜索和推荐的核心区别。在SIGIR相关的顶会,有说过搜索和推荐在匹配上的类似点,搜索是文档和query的相似,推荐是物料和用户的相似,但实际从产品层面,用户对匹配的要求是不一样的,在大部分情况下,用户对搜索系统的要求更高更严格,其背后的根本原因是用户在使用搜索系统的时候是有明确目标的,相关都不见的足够,毕竟类似复述query的答案、高频文档不见得有意义,要求要所答即所问,在张俊林比较出名的搜索上书籍《这就是所搜索引擎》里同样也强调了“准”的重要性。

搜素系统的场景

为了拓宽大家对搜索系统的理解,这里举几个比较典型的搜索系统场景,同时也和大家说一下这几个搜索场景内部具体的业务特性以及应对方法。

泛搜/大搜

比较常用的通用性搜索,常见的如百度、谷歌等,当然现在很多所谓的AI搜索,大都属于这个范畴里。对于早期版本,比较简单的方式就是不做很特别的处理,依赖通用baseline方案直接搜,直接用字面、向量等,当然大模型也是一定程度可依赖的,但实际上,也可能会有很多特别地问题,此时就要分意图、分领域针对性的处理,这个在之前的说法叫做意图识别,现在就是路由(route)或者是更潮的方案就是agent。

当然,不同的系统里面可能会有不同的结果,具体好不好,就见仁见智了。例如同样是搜索“六级词汇”,百度和号称AI搜索的天工AI的结果分别是这样的:

9c9505f018582e7e00233a4b70b712b9.png

百度搜索结果
4416e2f055c1f68e050fd96e87e937d6.png
天工AI搜索结果

两者对比下来,可能百度的结果在多样性和有效性上会更高一些,天工的这个结果应该是经过了模型生成处理,内容上没什么毛病,不过基本上没有解决实际的问题,只是把内容拓展解释了一下,没有给出六级词汇表之类的。

音乐/视频搜索

音乐/视频搜索,这种搜索对物料的性质有很高的要求,而且用户在现实的使用上,更多倾向于标签或者某些关键字段的检索,同样举例子吧。

比较常见的,根据歌手返回音乐,下面是网易云音乐的结果:

c6003d99127db6fdf80870a08507ec1f.png

这个没有想象中那么简单,对于用户的输入,我们需要首先识别到他是一个“歌手”,把他从歌名或者歌词中解区分出来,这里就需要大量的实体抽取的技术来实现,显然这不是直接字面检索或者向量检索就能准确稳定的实现的。

当然,音乐还有一种非常特别的技术——听歌识曲,这里的用户输入就很明显和其他的不同,这里输入的是一段音频了,如何把给定的片段表征好然后再曲库里检索,也同样非常有技术含量,这里推荐看这些文章,我自己并不擅长于此,就不班门弄斧啦。

  • 腾讯音乐的新一代歌曲识别技术:https://zhuanlan.zhihu.com/p/541450154

  • 这个系列的文章也值得推荐:https://zhuanlan.zhihu.com/p/75360272

商品搜索

商品搜索可以说是最接近推荐系统的一种搜索了。我们在淘宝、京东搜索的时候,往往都只会给很简单的一个实体名词,例如“电脑”,顶多会加一些补充说明的关键词,例如“4060”,或者是“i7”,但实际情况下,这些搜索词下都会有大量的商品,典型的还有就是服装类的,如“长裤”、“裙子”等,下面有数不尽符合这个query的产品,此时的排序显然就不能随机了,可以考虑依靠热度、用户偏好、最近活动、新品等信息来进行综合的评判,在这个情况下,就和推荐系统极为相似了。

在我很早的一篇淘宝的embedding的文章(前沿重器[18] | KDD21-淘宝向量检索)来看,这个性质就非常明显,在模型里,要兼顾query信息、短长期行为、产品信息等,综合起来完成表征,这里因地制宜的思想体现的很明确,背后也体现了作者对业务问题的理解深度,简单的生搬硬套向量匹配显然并不能解决这些问题。

在论文内,作者还简单介绍了淘宝搜索的技术架构,技术架构的事先不展开说,下一篇我会专门讲,大家可以提前感受一下:

4a86a8a7b146e977a5dc143e6e1695a7.jpeg

大模型场景下搜索系统的理解

NLP最常见的两个场景,就是搜索和对话,在大模型的新时代,也是如此,此时,搜索成为一个重要的NLP载体,让大模型上了船,而另一方面,大模型也让搜索系统更新换代,带来很多新的发展。这里,我来说说我的几个理解吧。

首先,要认清这两个名词的性质和所面临的问题。大模型的本质还是一项技术,或者说是一个工具,它能被用在很多场景,但既然是工具,就有被替代、不被选择的可能,直接说“大模型替代了XXX”,显然是鲁莽的,相反,在面对实际问题的时候,我们应该把他和原有的方案放在一起,进行严格的对比分析再来进行选择,如果不好分辨,则可以进行相应的实验;而搜索系统,这个和推荐系统类似,都是一个系统,也可以说是一个产品,既然是系统或者产品,内部就是多个技术合力完成的,这里可以放大模型,也可以是别的模型,甚至精排至今还在高频使用估计已经被称为原始时代的机器学习模型,而另一方面类似推荐系统也仍旧有NLP的一席之地——内容理解。而为了成就一个优秀的产品,我们对技术的看法就不能简单的新不新、low不low,而还要考虑具体的算法效果,达成效果所需要的成本,以及用户应用时的性能体验。

其次,RAG是大模型的一个很好的应用,这里的R,可以把他拓展成一个五脏俱全的搜索系统,而在优化他的过程中,一个比较稳的方案,就是直接去参考类似的搜索系统常用方案,这里的架构也可以充分参考来构建,之所以说是稳的,是因为这都是前人已经踩平的路了,其他的可能多少也有尝试。当然,如果要创新,肯定还是要多想多尝试,沿着旧路肯定最多就只能第二了。

第三,在我的视角看,一些所谓的AI搜索,然后把这些东西和之前就有或者有了很久的搜索给割席,在技术视角看就像是一个运营话术,为了破圈让用户尝试而已。从我看的资料来看,不外乎就是用了大模型生成,或者就是用了所谓的自然语言处理技术而已,但这些技术老牌大厂百度、谷歌、必应之类的,就不能用吗,显然不是,而且真不是没有用到,下图就是百度的结果,那这个算不算AI搜索呢?至于自然语言处理技术,就更离谱了,类似的技术早就在搜索圈里广泛使用了,所以做技术的还是尽量不要被这种说法给带偏了,以为只有用啥才是AI,这样技术选型就被无关的内容限制了。

86dba67b3204162aa69b521b8cdd8a4d.png

最后,搜索领域发展至今来看,一直是稳定的代表。从搜索的发展历史来看,搜索的顶峰似乎都不是很高,但是每次打的迭代都能让搜索喝到汤,从早年NLP起步,到百度等的搜索系统崛起,再到后续BERT、大模型等,虽并不耀眼,但一直在发光,而且人类对知识检索的需求一直都存在,因此搜索场景可能是一个相对稳定的领域方向吧,至少我自己是这个看法,且我的选择也在践行我的看法。

小结

开篇语没想到能写这么长,可能是自己还是有很强的归属感和亲切感吧,前面有几周鸽了,跟这小系列的规划有些关系,写这个我还是比较有热情的。这个的内容主要还是想和大家分享一下搜索系统世界的多样性和广阔性,搜索不止有百度谷歌,还有很多细小特别的场景,尽管我们没感知但是已经经常在用了,这里都有大量前辈们的智慧,也有大量的未知等待大家挖掘。

后面的文章,请大家继续期待,下面就真的开始讲技术了。

前沿重器[49] | 聊聊搜索系统2:常见架构

本期给大家介绍一下目前常见的搜索系统架构,这些架构多半是大厂经过实验探索得到的比较通用的方案,时至今日仍在被广泛使用,而且这些结构很大程度上恒本RAG的应用所借鉴。

本篇的结构和内容,会和之前写的对话系统类似而且有所借鉴(前沿重器[22] | 聊聊对话系统:技术架构),思路上是参考案例然后是进行拓展和解释。

目录:

  • 先来几个案例

  • 搜索的架构

  • 架构背后的考量

  • 架构和项目阶段的关系

先来几个案例

腾讯搜索架构

首先放出来的是我在很早之前写的一篇有关腾讯搜索的架构文章(前沿重器[4] | 腾讯搜索的Quer理解如何直击心灵),在这篇文章中,腾讯搜索给出的架构是这样的。

a8365aa5855395e0be7126073bc1e271.png

这里把整个搜索引擎划分为7个模块,分别是客户端、AS、BS、Query理解、召回组件(搜索引擎)、排序模型和离线,各有各负责的部分。

  • 客户端是向用户直接展示搜索功能的窗口,下属结果页、直达、联想词、相关搜索这几个部分,这也是搜索除了检索内容外的生态下其他内容。

  • AS是高级检索,Advanced Seach,对外提供搜索引擎能力,在别的一些地方也有成为Center之类的,在我的视角下一般类似BAT之类的厂商或者有关的人员,都会有这个AS的说法,不过其他厂商好像会比较少。

  • Query理解,是对用户输入的Query进行完整完善的解析,我们熟知的预处理、意图啥的就都放在这个部分,旨在抽取有利于后续召回、排序等步骤的信息。

  • BS是基础检索,即Basic Search,完成基本的检索能力,文本(字面)召回、Tag召回、语义(向量)召回等都是在这个部分,直接面对的就是下面的搜索引擎了,架构上作者把排序,即精排和粗排也放在这里。

  • 召回组件,这里我倾向于叫做搜索引擎,主要是因为这个就是工程层面用于加速召回的各种检索的组件了,毕竟对于海量的知识,要快速查询出来,这个并不是一件容易的事。此处提到的是ES,即ElasticSearch,可以支持多种索引,至今仍然是很常用的搜索工具。

  • 排序,这里强调的是在线的LTR(learning to rank),结合更多其他信息来进行综合排序,注意此处不止有语义本身,还有一些额外的信息,例如文档的质量(如点击量)、用户偏好、时间热点等信息,这些信息很多都来源于离线的挖掘(利息按模块负责),此时简单的语义模型肯定办不到的,这里常用的模型和推荐系统很类似,例如LR、XGBoost、Wide&Deep等,旨在融合多个来源的特征。

  • 离线模块主要是对各种离线数据进行处理,包括搜索文档、推搜日志的处理,这块有大量的工作,单独建立一个模块处理是非常有必要的。

电商搜索

商品搜索给出两个例子,一个是我之前写过的淘宝搜索,另一篇是闲鱼搜索。

先讲淘宝搜索里面的例子,在论文"Embedding-based Product Retrieval in Taobao Search"中,作者给出了淘宝内部检索引擎中的一个初步的架构,这个同样非常有代表性。

e226901f7ecb732da39277c7a7a8071a.jpeg

商品搜索相比通用搜索带来的特殊性,这个我在上一期也有讲过(前沿重器[48] | 聊聊搜索系统1:开篇语),商品搜索的用户在习惯上大都是输入简单的品牌、商品名词,这种简单输入下符合检索词的物料就会非常多,为了进行进一步的精筛,就依赖其他信息,例如用户偏好,到了这一步,与其说是搜索,不如说是推荐了,协同过滤之类的招数就直接用上了。

首先对query,作者进行了3个维度的处理:倒排字面检索、item协同过滤、向量表征检索(论文的重点),而后经历合并去重后,就开始进入排序了。排序内有相似度、重排,还有考虑内容视频、广告的合并等。

另外这篇论文我也写过解读(前沿重器[18] | KDD21-淘宝向量检索),商品搜索是通用搜索下非常特别的一个品类,仔细拼读有利于对这个方向的理解。

然后是闲鱼搜索,这篇文章“电商搜索里都有啥?详解闲鱼搜索系统(长文)”,原文在:https://zhuanlan.zhihu.com/p/568564519(后面有空我应该也会专门写文章聊),这里直接给出他提供的技术架构。

ca99b79177ca9bc155458d698bdedc2f.jpeg

这个结构和前面的腾讯的结构非常接近,基本的query理解、召回等都有,只是划分会有些不一样,与之不同的是搜索相关的组件变得更加丰富。首先,大家应该比较关心的应该是推理的关键流程:

  • 请求接入,进入应用层。应用层实质是内部服务对外的接口层,如H5、APP端的接入。

  • 应用层请求排序接入层,这里的排序接入层是指到达核心搜索引擎内部。

  • 意图预测。此处会进行各种query理解的工作。

  • 搜索引擎。向底层数据库发起请求,进行内容的检索,重点是海选、粗排、精排3个阶段。

  • 精排模型打分。对内容进行最终的筛选。

  • 结果返回。重新返回到排序接入层、应用层、接入层,完成渲染,把结果有序返回给用户。

而其中的不同点,我也整理了出来。

  • 容灾,主要针对服务,虽然没有展开说,不过提出来还是点出了安全的重要性。

  • 运营投放平台,一般是给运营使用的综合性平台,包括安全合规、广告平台等。这个的背后需要和算法等模块通力合作,才能让整个搜索系统更为可控稳定。

  • 中控台。中控此处的概念会有些不同,内容好像比较杂,舆情、debug、评测、请求回放的功能都会放在这,实质看起来像是一个供给开发等职能的工作者进行分析和研究的工具。

  • 离线,即图中的紫色部分,也给出了离线需要做的工作,包括离线的各种特征的处理、物料等内容的理解和入库、模型训练。

RAG架构

特别地,这里我把RAG的常见架构也写在文章里,这里我也根据之前我看文章的经验,把有关结构这块的讨论给出来。

首先是,在科研界看来,RAG整体模块的划分,目前还在一个研究试验的阶段,无论是从RAG的综述来看(前沿重器[41] | 综述-面向大模型的检索增强生成(RAG)),还是在论文的讨论上看(CRAG:前沿重器[43] | 谷歌中科院新文:CRAG-可矫正的检索增强生成、self-RAG:前沿重器[42] | self-RAG-大模型决策的典型案例探究),都在非常大胆的实验中,其核心原因是,需要探索Retrieval和大模型如何更好地结合,希望以更低的消耗成本达到强化效果的效果,在这个原因下,研究的中心不会局限在检索本身,但重点还是离不开优秀的检索模块,在综述来看,RAG在结构上的研究,可以简单地用这个图表示。

cd95bb314ac127df12de23ea2a9e8ede.png

显然,模块化应该是高级RAG后续发展的共识,但具体如何划分模块,划分逻辑该是什么样的,有待进一步的实验验证,但归根结底还是离不开“Modular”这个单词。

有意思的是,如何模块化这点,在被深入研究,里面划分的模块在现在比较前沿的搜索系统中还是会出现的,如Rewrite对应的是query的改写,decide、rank、rerank的设计思路和检索模块的粗排、精排、重排的概念也非常相似,大有向现行搜索系统的架构靠近的倾向。

然后让我们把目光聚焦在开源项目上,之前一连3篇给大家介绍了国产RAG开源项目QAnything的源码:

其主要架构如图所示:

2ede9c8c166df76a9cff5351b26db29a.png

里面的架构是比较接近综述内所提及的Advanced RAG的概念,主要就是分了3块:

  • offline:离线的文档切分梳理模块,该内容最终会入库。

  • embedding&search:向量模型和检索服务,内部提到了milvus向量检索服务和经典的Elasticsearch。

  • LLM服务。

这里的检索,使用的两阶段排序,这个也和现在比较完整的搜索系统架构也非常接近,有关这点,作者在README内有专门进行解释。

知识库数据量大的场景下两阶段优势非常明显,如果只用一阶段embedding检索,随着数据量增大会出现检索退化的问题,如下图中绿线所示,二阶段rerank重排后能实现准确率稳定增长,即数据越多,效果越好。

a62b6878b3ce138a39c30b5c4df6daee.jpeg

第三想和大家分享的是我对RAG系统的理解,我自己是有对落地架构进行过讨论,(心法利器[113] | RAG结构思考:搜索系统范式和大模型作用压缩),里面详细讨论了我对RAG架构的设计思路,并给出比较严谨的迭代思路,可以看到,在Retrieval部分,我的思路同样是比较接近现在的搜索系统的,因为从搜索系统角度来看,RAG是对搜索系统的又一次新的应用,且RAG在搜索层面,大部分情况下和传统搜索的目标是一致的——检索精准,既然如此,原本搜索系统所总结的经验,在现在RAG的应用中,想必也很有参考价值。

搜索架构

纵观上述多个不同场景搜索系统的案例,我们可以看到,比较统一的划分方式,通常有如下部分:

  • 离线部分:主要是内容理解工作,另外还有用户画像等工作,按需增加。

  • Query理解:负责对用户query进行处理,并提取下游检索、排序所需要的信息。

  • 检索召回:从数据库中快速找出合适的内容,并进行阶段。

  • 排序:对内容进行精筛,找到最优的TOPN内容。

总结下来,这4个应该是成熟的搜索系统比较关键的4个模块,里面的功能模块组可能并不一致,但是基本都会按照这个结构来划分工作,内部的功能也相对独立。

搜索不可或缺的一部分就是物料,即被人检索出来的材料,这个内容无论是为了展示还是方便检索,都需要离线进行各种处理,例如清洗、合规性检测、标签实体抽取、embedding等,另外还有一些类似用户画像、模型训练的操作也是放在这里,这些都是不可避免、且不方便放在在线做的工作,这些流程不需要或者不方便跟随整个用户query请求流程做,此时就要离线或定时或实时地处理。

Query理解是逐步从检索流程拆解出来的模块,要对领域划分分别处理需要意图识别,要抽取实体进行精准匹配需要实体抽取,要进行向量召回要向量化,这些本质上都是为了下游检索、精排而做的准备,同时又是针对输入query进行的针对性处理,Query理解就由此诞生。这个可以说是传统搜索在字面检索时代的产物,时至今日向量化已经蓬勃发展,但因为向量化仍无法一统江湖,所以完整的Query理解甚至是内部的意图、实体抽取工作,仍有作用,而且还是不可或缺的作用,甚至到后续的Agent崛起,例如“路由”模块的作用,仍和Query理解里的意图识别很接近。

检索召回,指从海量的数据库里找到符合要求的内容,这里关键依赖的是搜索引擎,内部的通过多种数据结构的方式,把检索的时间复杂度降到最低,尽可能要求检索速度和库内数据量的关系降低,从而达到快速查询的效果,一般比较牛的检索引擎能让速度降低到几ms的级别,大大提升效率,把时间留给其他部分,如精排、生成等。

排序模块的核心目标,是把检索召回的内容进行精筛,筛选出最符合用户要求的内容推送给用户,尽管不同的系统排序的逻辑不同,甚至还有多次排序,但排序模块作为确保搜索精准的最后一道防线,在架构设计上一直屹立不倒。

搜索架构设计逻辑

回归搜索的核心目标,给出符合用户query表达需求最佳的结果,要分裂出两个方向:

  • 内容,即物料,要足量,要处理好。

  • 充分理解用户需求,即对应用户query,要照着用户query提供的信息查询内容。

对前者,要求内容的完整性、真实性,且为了更方便检索,需要构造索引,就跟图书馆要有索书号一样,而且为了多个角度都充分找到,需要对内容进行尽可能多角度的表征,就这点要求而言,一般的向量,尤其是一个向量,很难完成这事,传统的结构化方案反而更容易、且可解释地找到,例如作者、年份、类目、标签、书名,都能提取,则对于用户而言,这几个方向都能轻松定位到具体内容,因此,内容理解成为非常必要的部分。

后者,这里首要的一个内容就是query的理解,而理解query是为了后面的查询用的,同样以图书检索为例,用户可能搜书名、作者、年份、标签,我们需要充分提取这些信息,以便更快从对应字段中搜出来,这就是query理解。

在理解以后,则需要根据query理解检索内容进行查询了,对早期项目,查询出来根据初步的相似度排序截断即可,而为了更加精准,可能会有更严格的二次排序,亦或者根据用户偏好进行更有针对性的筛选,例如同样是“统计”,可能是数学类、金融类的统计,可以根据用户的研究背景来精筛,此时就有从检索中分裂出了召回和精排两个工作。

有关召回和排序的拆分,在几年前我也对这个现象进行了分析和解释(心法利器[15] | 准招分治效果调优方案),提出过一个概念——准招分治,即对准确率和召回率都比较高的时候,可以通过拆分这两者到两个模块中来进行的方式来分别调优,召回阶段负责从多角度召回大量的结果,这里也维护了搜索系统的多样性,很好地避免了召回单一内容的尴尬,提升召回率的同时,一定程度也控制了准确率不至于太低(这里指不相关的肯定不在这一轮漏斗里),而准确率的提升可以来后面的排序,借助更多维度的信息来分析那几个更适合排到前面,当然拿了,可以通过打散等方式来增加多样性,于是起到了提升准确率而控制召回率的问题,控制召回率过低的情况。

另外有关多阶段排序的进一步解释,参考前面QAnything的讨论,从模型层面也可以初见端倪,一般的两阶段排序下的模型负责的任务并不相同,第一阶段的排序要负责区分“相关”和“不相关”,这本质是个二分类的问题,用简单的0-1样本基本都能做得很好,但是大量的实验表明,对于前排的几个,区分度其实并不会很高,因为相似的物料相似度基本都集中在0.9+,甚至0.95+,这块的排序效果并不是很好,此时如果再有一个模型可以优中选优,则会更加理想,这一步本质是个“排序”问题,区分的是“谁更好”,此时LTR,即learning to rank会更优秀,如果需要样本,在线的点击或者人工标注的排序样本会更加合适,虽然难度更高但是效果会很明显。

架构和项目阶段的关系

在之前聊对话系统架构的时候,有专门聊这个(前沿重器[22] | 聊聊对话系统:技术架构),此处再结合搜索系统重新聊。

抛开需求谈架构绝对是不合适的。

放在搜索系统里亦然,如果是项目初步建立的阶段,搜索系统并不需要上面的所有内容,只需要满足一个baseline即可。以之前我写的basic_rag项目为例(心法利器[105]  基础RAG-大模型和中控模块代码(含代码)),这里就没有意图识别之类的模块,检索部分非常简单,就是一个向量推理+FAISS检索就完事了,对于搜索系统就是差不多这样子。

下面结合项目发展阶段讲讲各个阶段的迭代建议。

0-1版本

早期的搜索系统,可以直接把query扔进检索引擎进行检索就可以,这里一般是两条路,是目前很常用的做法:

  • ElasticSearch之类的系统搭建起检索模块,query可以直接进行字面检索,ES自带BM25排序后直接截断即可。

  • 用通用的向量召回模型对物料和query都做向量化,然后走类似Milvus、FAISS的方式向量召回。

个人建议优先考虑前者,前者虽然泛化能力不行,但是准确率是比较可控的,甚至可以配合cqr+ctr(心法利器[18] | cqr&ctr:文本匹配的破城长矛心法利器[99] | 无监督字面相似度cqr/ctr源码)的方式进行精排再把控一层,至少能出的内容还是相对可靠的,这符合搜索中的关键要素——准确。

后者虽然泛化能力强,但是后者对不对称内容的能力并不突出(即类似QA匹配的相似度),而且即使内容对称,细节内容的不稳定性也比较难把控,这个相比泛化能力而言还是比较致命的,可以考虑会上,而并不要着急第一个版本上,有可能为第一版上线带来风险。

注意,第一版往往需要把大量时间花在基础结构、基础代码、中间件的建立上,且发版存在工程和算法上的风险很大,因此在算法层面,需要用更稳定、可预见、可控的方案,尽可能降低这方面的风险。

中期

等到baseline完成后,就已经到中期了,中期可以开始结合用户反馈、第一版遗留问题开始,以最终架构为蓝图逐步搭建完整的搜索体系。

query理解模块可以逐步建立起来,有目的性地、分门别类地处理问题,能显著地在关键问题上得到提升(时刻注意bad case分析,具体可参考bad case系列:心法利器[40] | bad case治疗术:解决篇)。

多路召回,配合现有的召回缺失问题(一般不会那么快暴露),开始增加召回链路,并以多路召回融合为契机,开始规划精排,从早期的规则精排开始,逐步过渡到模型精排,记得保留一定的可控可配置能力。

后期

后期的整体架构逐步完整,场景特异化问题开始逐步突出,即出现自己场景所特有的一些问题,这时候就会转为研究型或者探索型的任务了,例如前文有提到的淘宝搜索,对用户画像等行为的表征和处理,另一方面,可以开始平台化或者通用化的沉淀,形成通用的NLP、运营平台,降低业务拓展成本,再者,也可以考虑做类似query推荐、相关搜索、多轮搜索等内容。

小结

本文通过多个成熟的案例,分析了搜索系统常见的架构,提炼出搜索系统目前比较常见的拆分模式,并在最后给了迭代思路,本文有6000多字,写得好刺激,希望大家能喜欢~

前沿重器[50] | 聊聊搜索系统3:文档内容处理

本期介绍的是文档内容处理,这块主要聚焦在离线非推理阶段,俗话说,巧妇难为无米之炊,文档就是搜索系统的米,没有文档资源,搜索无从谈起,然而,面对各式各样的文档,我们往往需要进行各种处理,使之变成方便检索、方便展示的形态,才能为我们所用,本文主要就讲解,文档内容处理的核心工作以及常用的方法。

叠个甲,因为数据的繁杂和复杂性,具体用什么怎么做需要结合实际数据情况来选型,即使给出了通用方案也无法保证所有问题药到病除,所以,文章中更多是说思路和思想,顶多用例子来辅助,请不要用类似“XXX数据就处理不了”、“这不够AI“之类的话术来喷里面的一些方法。

目录:

  • 前言

  • 文档内容处理的流程

  • 内容解析

  • 内容理解

  • 内容入库/索引

  • 小结

前言

加一段前言,是因为我在翻最近的文章发现,最近我已经在文件处理上写过两篇文章了,一篇是从现在RAG角度讲可能遇到的文件的处理方法(心法利器[110] | 知识文档处理和使用流程),另一篇则是从开源项目QAnything的角度讲解文档的具体工作(前沿重器[46] RAG开源项目Qanything源码阅读2-离线文件处理),但回到搜索场景重新看会发现,文档处理的外延还需要更宽,而且因为搜索面向的文档在数据量上会更复杂,相比早期的RAG内容就更丰富了,这次,我会结合之前的一些内容更完整地讲一下搜索的文档处理有哪些工作。

文档内容处理的流程

和原来讲RAG文档处理文章里写的相同(心法利器[110] | 知识文档处理和使用流程),知识从原始材料到最终使用,主要会经历离线和在线两个阶段。离线阶段是指,是把原始材料存入数据库的流程,这个阶段是离线处理的,所以被称为离线阶段;在现阶段是指通过query被检索出来并且使用的,这就是在线阶段。

离线阶段会经历这几个过程:

  • 基础文档解析——将各种形式的文档,转化可以容易处理模式,常见的是转为文本。

  • 内容理解——从文档中提取各种重要信息,可以是关键词、实体、标签、摘要、切片等显式信息,也可以是embedding这种隐式信息。

  • 入库——存到数据库中,做成方便检索的索引,供在线的搜索使用。

在线阶段会经历这几个过程:

  • 检索出库——被搜索出来,从数据库中被取出。

  • 排序、判别、过滤——对检索出来的知识进行进一步的筛选,选出最合适的备选知识。

  • 内容使用——对检索出来的结果进行利用。

可以看到,整个流程和RAG相比只有最后一步略有不同,其他的情况都非常类似。

在线阶段本期先不讲,后面的章节会逐步展开,本文主要讲离线阶段的工作。

内容解析

离线阶段的工作就是把原始材料接入内部搜索引擎,这里的关键难题在于原始材料的类型是多种多样的,一般通用RAG的原始材料的来源是用户自主上传的文档,所以很多开源项目的工作重心都放在各种类型文档的解析(前沿重器[46] RAG开源项目Qanything源码阅读2-离线文件处理),例如QAnything内就有大量针对pdf、doc、png等格式的处理,然而从搜索的角度看,输入的原始材料并不局限于此。

有关各种用户直接上传的文件的处理,大家可以直接在这两个链接里看:

现在要说的,是另一种格式,那就是接口模式和HTML内容的接入。

接口模式大都来源于企业或者大团队之间的合作,在进行合作后,会有一些文档数据,会通过接口的方式接通,常见的就是通过http链接,通过批量的方式进行同步,举个例子:

import json,requests

def run_client_info(url, query):
    response = requests.post("{}/info".format(url), json={"query": query})
    return json.loads(response.text)

此处是通过http请求一个接口,并将返回的内容转化为json,在python里就是dict类型,内部的数据就已经完成了读取。

另一种就是HTML内容接入,这种一般是通过网络爬虫的方式接入的,时至今日有些搜索引擎的爬虫(前排提醒,爬虫需要注意版权等法律风险),这里的内容很大概率就是html的格式,例如这个是百度热搜下一个条目的内容:

<div class="toplist1-tr_1MWDu"><div class="toplist1-td_3E2-U opr-toplist1-link_2Ag1v"><span class="c-index-single toplist1-hot_1xI8N  c-color-red toplist1-hot-normal_3wyjO" style="opacity:1;"><i class="c-icon icon-top_1leHV"></i><!--13--></span><a target="_blank" title="照见传承的两棵榕树" href="/s?wd=%E7%85%A7%E8%A7%81%E4%BC%A0%E6%89%BF%E7%9A%84%E4%B8%A4%E6%A3%B5%E6%A6%95%E6%A0%91&amp;usm=4&amp;ie=utf-8&amp;rsv_pq=9fcc13a6003bab94&amp;oq=%E7%BD%91%E7%BB%9C%E7%88%AC%E8%99%AB%20python&amp;rsv_t=bc08apZFnVPcuNWJDfalpiE6%2FR438LchYynixw4xpr8INGuCoZ3DQztg%2Bxo&amp;rqid=9fcc13a6003bab94&amp;rsf=a7c75473cc57caa39528e7bf5ff29a3f_1_15_1&amp;rsv_dl=0_right_fyb_pchot_20811&amp;sa=0_right_fyb_pchot_20811" class="c-font-medium c-color-t opr-toplist1-subtitle_1uZgw" data-click="{&quot;clk_info&quot;:&quot;{\&quot;index\&quot;:1,\&quot;page\&quot;:1,\&quot;city\&quot;:\&quot;\&quot;,\&quot;type\&quot;:\&quot;hot_fyb\&quot;}&quot;}">照见传承的两棵榕树<!--14--></a><!--15--></div></div>

一般我们关注的是两个内容,一个是href下的链接方便进一步爬取,另一个是具体的文字内容,即“照见传承的两棵榕树”这部分,常见的html解析方案,大家可以参考beautifulsouplxml

内容理解

内容理解,这次我换个说法,内容理解的核心目的是为了提升在线搜索的匹配效果。我们原始获取、解析好的内容一般是混杂的,不规则的,而且很大概率和用户的搜索习惯并不匹配,直接使用很可能会搜不到,而如果我们对这些内容进行合适的处理,例如抽取一些关键词、关键标签,或者进行合理的切片、向量化,就能很大程度降低在线检索的压力,提升搜索的准确率和召回率,需要强调的是,在线搜索的准召,并不只在于在线流程里的query理解、召回和排序,还需要关注离线的内容理解,内容理解如果有足够的优化,能降低在线准召很大的压力。

这里讲两种最常见的内容理解手段,标签抽取和向量化,并对这俩内部的含义进行详细的扩展,让大家更能理解这两个方法在实际应用中的地位,以及具体实操需要关注的细节。

标签抽取

标签抽取式内容理解中很常见的方案,为了更好地进行在线匹配,离线提前做好标签抽取,标签抽取可以配合query理解,让搜索快速达到高准确的效果。

举个例子,对于音乐场景的搜索,用户的搜索习惯一般是输入歌手、歌名、专辑、风格、作曲人、作词人等,还有一些是歌词,这些内容多半有一个特点——标签化,用户输入“周杰伦”,那直接查“singer=‘周杰伦’”即可,而这里的前提就是,知识库内需要有这个字段,即结构化的信息,而有这个的前提是,需要从原有的内容里抽取。至于抽取的方法,在NLP领域这方面已经非常成熟,这就是一个NER(命名实体识别)或者是序列标注任务,当然如果有合适的词典,直接用词典匹配也可以轻松解决(心法利器[41] | 我常说的词典匹配到底怎么做)。

这种针对性更强的思路相比于大家很常见的向量召回方案,有如下几个优点:

  • 准确率高。

  • 可解释性更强,可控性也比较高。

  • 因为直面用户搜索习惯,所以大盘效果和用户体验提升非常明显。

缺点也比较明显:

  • 离线工作增加,而且离线内容理解存在效果问题。

  • 定制化明显不通用。(但其实比较成熟的系统,有自己的NLP通用能力,可以把这种类似标签提取的任务通用化,也是可以的)

另外需要注意的是,标签抽取强依赖对用户习惯的洞察,我们要有目的地提取有效的标签,如果命中率低了,这点的意义肯定不会很大。

向量化

向量化应该是现在大家最容易想到(只能想到)的内容理解方法,这确实是一个比较通用的方案了,但实际上这里会有很多需要考量的因素,我列举一些:

  • 向量化的依据是什么,内容文本语义、用户行为、领域方向、划分粒度等。不同的向量化依据会衍生出不同的向量和匹配模式。

  • 目前的内容是否适合向量,例如数字、地理位置这种,向量可能不是那么好的选择。

  • 即使适合,还有很多因素会影响向量的质量,例如长文本会稀释句子里的关键信息,从而无法被搜到或者总是被搜到。

可见,即使是向量化,背后的要考虑的内容还是会比较多的,现在仔细展开来讲。

向量化依据,即,要把一个文档或者某个内容做向量化,是需要有参考依据的,否则和拿随机数做向量并无区别。来展开说下向量化可能的依据以及内部要考虑的细节:

  • 语义的向量大家会比较熟悉,甚至现在已经有开源的模型可以直接使用。但相似的概念在具体业务视角可能并不相同,需要结合实际业务定义,可能要考虑做进一步的模型微调,当然包括QA匹配和QQ匹配等句子对是否对称的问题,需要根据这个合理选择或者拆解任务模型。

  • 用户行为模的模型。类似推荐系统里,有些用户行为或者高频的query,可以根据用户的点击行为,找到更喜欢被点击的内容,这个思路和协同过滤是类似的。

  • 对于语义空间差距比较大的多个领域,模型统一处理的效果可能并不是很好,此时拆开是一个不错的选择,各自模型管各自的领域,对综合效果的提升也有收益。

第二个问题则是向量模型的缺陷,向量模型是有自己的优势和劣势的,并非所有方案都适合用向量,或者说向量模型并非万能,在使用过程中要警惕向量模型是否踩到了某些业务的痛点从而导致不可用。

  • 数字等比较特殊的数据类型,现在的模型普遍对数字不够敏感,数字效果较差,如果是对数字比较重视的业务场景,向量模型可能并不那么适合。

  • 相似实体较多的场景,像工业场景下的型号,差一个字可能差距很远的,化学、医学领域像氯化钠、氯化钾啥的,对向量模型而言会比较困难,相比之下不如考虑用标签提取的思路,可能会更加适合。

第三点则是经验之谈,有关向量的调优和问题的定位问题,例如长文本表征总会因为内容过多而被稀释或者忽略,此时相似度做起来就没那么容易,这个需要和切片之类的操作配合,实现更好的表征和检索。

最后强调一下,向量不仅为了通用,为了突破效果上限,真的很有必要精雕,通过任务拆细、微调的方式,对最终的召回质量提升会很明显,在系统搭建的后期会是重要的提升点。

其他操作

这里为了完整性,多提一些操作,为了方便检索提升召回率啥的,是可以进一步多去做一些挖掘工作的,这些活可能看起来比较脏,但做起来是真的有些收益,包括但不限于这些:

  • 切片尽量把内容缩短,或者直接对长句做个摘要转化(用大模型)再来入库。

  • 多挖掘一些同义词、同义说法,放一起检索有利于提升召回率。

  • 能结构化尽量结构化,包括各种库表数据,不要着急合并成纯文本来做。

内容入库/索引

入库本质是个偏工程的活,但在实际应用过程,算法会高频修改这个,例如新增挖掘的字段,更新向量检索方案之类的,所以我个人是非常推荐大家尽可能去了解,甚至把这活一定程度把握到自己的手里,会更方便自己把事推进下去。

先提一嘴,在心法利器[110] | 知识文档处理和使用流程一文我有给出向量召回入库的其中一种方案,还有代码。大家可以做例子。

然后,因为搜索引擎目前发展的已经比较完整完善,所以此处更多是给大家推荐多种技术方案,方便大家进行选择,同时也很建议大家能系统学习。

因为入库是个数据工程,不同的数据量应该采取不同的方案,此处按照3个等级来给大家介绍。

  • 如果是千条以下数据,其实并没有什么必要上特别的数据库和数据结构,只要模型不大,逐个匹配的性能已经可以足够支撑,除非目前已经成为了明显短板,否则不用升级,常规的排序模型面对的差不多也就是这个数据量。

  • 如果是几十万以内的数据,目前机器的内存仍然吃得下,那单机方案也是可以支持的,简单的可以自己写一个倒排索引,类似向量召回则可以考虑FAISS、ngt、hnswlib等工具直接随着服务来弄即可。

  • 如果数据比较多,或者机器内存压力比较大,则要考虑分布式的中间件来做了,比较常用的例如ElasticSearch、Milvus等,都可以支撑检索,当然选型的时候要注意看各种数据库支持的索引,是否支持自己预期的那种,别到时候用一半发现自己需要的功能中间件不支持会很尴尬。

在此,给大家推荐几个比较合适的工具,建议大家系统学学:

  • 单机的向量检索库,非常火的FAISS,然后hnswlib、annoy、ngt都非常推荐大家了解下。

  • python的字面索引生态好像不是很好,我只知道woosh,如果大家还知道别的欢迎评论区推荐。

  • 分布式的中间件工具,ElasticSearch、Milvus比较常见,postgre、redis、mysql等工具有空也建议大家学习下。

  • 互联网场景,数据比较多的,大数据生态也要了解,关键词有hadoop、hdfs、hive、spark(python版有pyspark)。

小结

本文主要讲搜索场景下对原始文档内容、知识的多种处理方案,在讲解方案的同时也给出了可能面对的问题以及方案思考的注意点,希望对大家有所帮助。

另外再次补充,之前写过两篇非常相似的文章,有些内容并未在本文出现,例如某些实操的细节,RAG场景的特定操作等,有兴趣可以继续阅读。

前沿重器[51] | 聊聊搜索系统4:query理解

query理解在我之前的文章里可以说写的非常多了,但是随着技术发展,这个模块无论是所需要做的事,还是完成任务的方法,都已经发生了很大的变化,本期我会在原有技术体系的同时,也放入一些比较新的思路和技术方案。往期提及的可能有这些:

另外,一些之前写过的内容,本文就不再展开了,我会在对应位置放上我之前写过文章的链接,大家有兴趣可以跳转链接学习。

目录:

  • 主要目标

  • 主要工作概述

  • 意图识别

  • Term分析

query理解目标

query理解是对query内容的处理并进行信息抽取,query是用户最直接表达的需求,毋庸置疑,尤其是搜索场景,在用户已经明确提出需求的情况下,只有充分理解用户的需求,才能得到正确的结构。

显然,query理解,处理对象就是query,而具体需要做什么,是要根据下游需求来定的,例如如果需要特定的实体,则query理解要抽实体,如果下游要做向量召回,则query理解需要做向量化。

拍脑袋想或者是扩大需求范围、难度都是无意义的。有些时候我们考虑的比较多也比较深入,例如多意图、不连续实体、指代之类的问题,在特定项目或者特定阶段下并非高频、重点问题时,可以暂时跳过,优先处理高频、重点问题,等后续类似这些问题逐步明显再来考虑,这个错误很容易在做query理解时犯,所以特别提醒一下。常见的问题一般是实体说法覆盖度、分类边界、长句短句等的问题会比较多。

写的过程中回忆起,在大模型出来不久后我就对query理解的地位和意义做了一些分析(心法利器[82] | chatgpt下query理解是否还有意义),现在回头看一些技术判断还是正确的,随着大模型系统的逐渐成熟,某些关键部位还是得以保留,大模型逐渐成为更底层基础的能力,但是架构设计上还是相对稳定的。

主要工作概述

主要是新技术逐步引入和迭代,此处分为基础工作和拓展工作,基础工作主要聚焦传统的信息抽取,如意图、实体之类的基础工作,而拓展工作则更多是向量化、改写(特指类似大模型拓展之类的工作)等比较新的工作。

基础工作

query理解是需要对query内容进行处理并进行信息抽取。我们来从下面一张腾讯搜索给出的图,粗略地看query理解需要经历哪些流程(这张图来自https://zhuanlan.zhihu.com/p/112719984,同时我也对该文章进行过解读前沿重器[4] | 腾讯搜索的Query理解如何直击心灵)。

921a9ab5b94ac05c5b726880c1197271.png
query理解过程

从图中可以看到,主要有下面几个工作(之前专门写过的,我会附上我之前写过的文章):

从这个流程来看,大到意图识别、改写、term分析这些常见且精力花费比较多的模块,小到大小写、新词发现之类的小操作,都被放在了query理解里面,这些工作尽管历史已经非常悠久(其实也就十来年吧),但目前,无论是历史同样悠久的成熟搜索系统,还是新建的搜索系统(甚至包括multiagent),都仍有他们的影子,还是非常重要。

拓展工作

拓展工作因为还处于探索阶段,所以并没有体系化,从我现在的视角看,有两个非常重要的工作要提及。

第一是向量化。向量化是随着向量表征模型以及向量索引的发展而逐步成为了一个基本操作,对现在的很多系统而言,向量检索都是大家很容易想到的方法,而他的便捷性也让他最近的选型优先级超过字面检索成为了重要的baseline。

第二是改写。与其说是新工作,不如说是原有改写工作的升级。大模型的出现让句子改写这一任务的门槛降低,且用户对性能的容忍度提高。改写能让下游检索和相似度计算更加简单,且prompt工程能让bad case修复变得更加简单,所以也成为query理解中的宠儿。

下面我就展开讲几个比较重要。通用的query理解模块。

意图识别

意图识别应该是我的文章里聊的最多的东西了,因为无论是在搜索还是在对话,都有很重要的作用,甚至可以说,贯穿整个系统的生命周期,一直需要花费很多精力。

先说什么是意图识别。从用户需求出发,意图识别是为了识别用户具体要什么,以便系统给出适当的回应;而对于系统而言,他是拆分后续处理模式的一个分流器,举个例子,用户是想查天气,系统是通过query来理解到这层信息,让下游去执行查天气这个动作,这个分流动作在现实应用并不罕见,语音助手里可以叫技能识别,多轮下要做对话决策,大模型里的function call需要决策用哪个function,Agent领域里有路由,本质上都是类似的工作,细品这里面的关系,可以发现领域不同叫法不同罢了。

有关意图识别的技术方案,目前已经形成比较稳定的几个技术流派:

我在意图识别做的时间比较久了,斗胆在这里提几个可能比较经常遇到的难题以及解决方案吧。

  • 高频类目体系变化的问题。对于一个还在发展建设的搜索提供,意图增加是很正常,如果用一个分类模型来做所有的意图识别(多分类),越到后面越会觉得力不从心,此时可以考虑多个小的二分类模型或者长尾的意图,用以搜代分来处理,然后定期对稳定且比较大的类目用多分类模型管理起来,这套方案会更合适。

  • 超多分类。在电商搜索、成熟开放域推荐系统、复杂对话系统等常见可能会遇到,成百上千的分类,而且类目样本数据很不均衡,对于高频的类目,可以考虑用多分类模型(预留other类),低频类目在这个多分类模型下会被分到other类,再用以搜代分处理,毕竟样本很少分类模型也不好学。

  • 兜底问题。无意义、超纲query出现的概率很低,在各个分类模型下,需要预留other类来作为“垃圾桶”放这些东西,否则模型会自己选一个已有的类作为这个“垃圾桶”,从而出现无法观测的bad case。

  • 短句问题。类似手机场景,用户对长句输入一般是反感的,所以短句非常常见,但短句是模型容易失效的部分,没有上下文信息,无法推测,而词典规则则很好处理,如果大部分样本都是3-5字以内的短句,推荐大家优先考虑词典之类的方式来做。

  • 长句问题。长句也是模型容易出问题的部分,一方面在训练集里容易比较低频,另一方面长句信息多容易造成干扰、丢失和稀释。针对这个问题,一般可以升级(使用更大size的)模型、增加长句样本、句子截断等方式来做。(之前我有专门聊过长短句的问题:心法利器[51] | 长短句语义相似问题探索

Term分析

如果说意图识别是对整个query进行分析,那Term分析就是对query内部的内容进行分析了,常规的工作有这些:

这里重点讲一下实体抽取,有两个需要强调的内容。

  • 实体抽取的重要性。向量召回不太好解决的常见,往往用实体抽取配合字面检索有很好的效果,例如数字相关的召回(1962年出生的人)、严格名词相关的召回(搜“周杰”容易误召“周杰伦”)

  • 大模型对实体抽取的提升是很大的。早年用实体抽取模型,尽管纸面指标比较好,但是仍然会出现归一化、不连续实体之类的问题,需要配合分类模型或者魔改模型来做,但是大模型这种生成式的方案,能很大程度解决上述的问题。

改写

改写也是搜索中常见的手段,主要目标是通过改写,改成适合下游进行检索、相似度计算的说法,例如KFC改成肯德基,能很大程度方便直接字面检索,向量检索上也有很大收益(毕竟向量模型也会很大程度参考字面的相似度)。有些时候,也会把纠错放在这个模块里(NLP.TM[37] | 深入讨论纠错系统),因为两者性质还是接近的。

常见的改写手段,就是挖掘同义词然后同义词替换,然后就是纠错了,当然有些场景会训练一些seq2seq的方式来做,之前我也分享过知乎的改写工作(前沿重器[13] | 知乎query改写思路启示),但在实践上这么操作还是比较少,主要是因为改写一般业务定制的要求会比较高,他的输出对下游计算的影响很大,但可控性又比较尴尬,因此也会相对谨慎。

然而大模型的出现,生成任务的启动成本降低,类似换个说法、风格迁移之类的问题能很快解决,因此改写能做的事产生了一定的变化,有些时候甚至可以帮忙做拓展了,而不只是改写那么简单。例如我之前分享过的query2doc(前沿重器[38] | 微软新文query2doc:用大模型做query检索拓展)以及在此之前的HyDE论文都有提到,可以通过让大模型提前回复用户的问题,用大模型的答案来做检索提升召回率的方案,这个在我自己的场景下也有重大收益,非常推荐大家尝试,尤其是早期各种数据都不太够的情况下,同时大家也能继续发挥想象,看看这个领域还有什么可以尝试的创新点。

向量化

向量化这事并不是最近一两年就有,而是很久之前就已经有了,而且也被广泛应用在多个领域。

搜索的主要向量化目标,都是基于语义相似来做的,早年的孪生网络到后续sentence-bert,然后是simcse,现在流行的BGE、M3E、BCE,都非常优秀。

之前的研究都聚焦在对称相似度上,即query和doc都在同一个语义空间,两个句子在语义上是相似或者相同的,因为这种方式的样本比较好构造和标注,这类型的问题基本在三四年前就已经研究的比较成熟,随着对比学习的发展,simcse基本成为那时候的版本答案,但在现实应用中我们很难抽取出很适合的样本用于在线检索,我们更多只能拿到问答对,甚至只有文档,连问都没有,此时对称相似度就很难用上,只能考虑不对称相似度了。

对于不对称相似度,即query和doc不在一个语义空间,如问答对等,早年都需要依赖样本来进行训练,在后续的发展中,类似BGE等,有了prompt的加持,还有类似RetroMAE的模型架构更新,不对称相似度计算的通用能力得到大幅加强,不依赖相似度样本进行微调也可达到较好的效果,后续大家也可以持续使用。

前沿重器[52] | 聊聊搜索系统5:召回:检索、粗排、多路召回

本期的内容是检索和多路召回。这3个内容都是围绕着搜索引擎的工作,所以把他们放在一起来说,我就分章节来详细描述吧。

目录:

  • 索引和检索

    • 倒排索引的相关概念

    • 常见索引简介

    • 搜索数据库支持

    • 向量检索DEMO

    • 检索

  • 粗排

  • 展开聊一下向量召回

  • 多路召回

索引

搜索的核心工作是从海量的内容中找到最合适、相关的内容,即有一个“大海捞针”的工作,在“大海”的情况下,逐个匹配的地毯式搜索方案显然并不是,而索引的核心价值,就是加速这个搜索,用尽可能低的复杂度,尤其是时间复杂度,来完成这项工作,甚至可以一定程度牺牲空间复杂度,提到复杂度,后续大家也可以看到有提到大量数据结构方面的知识,要理解这块需要很结实的数据结构基础。

所谓的索引,是指一种存储结构,该存储结构能让检索变得更加快速方便。最早的搜索一般就要从“倒排索引”开始讲起,这是搜索最最基本的技术。此处我也从倒排开始说起。

倒排索引的相关概念

这块内容在(心法利器[96] | 写了个向量检索的baseline)有详细解释,但是因为比较靠后,这里我搬过来。

首先先给大家解释倒排,抛开向量检索,先说字面检索,首先了解为什么我们搜“倒排”,能够出很多有关倒排索引的文章,是因为底层有一套kv结构,和这个就叫做倒排,key是切好的词汇,value是包含这个词汇的所有文档的title,即:

{
 "倒排":["搜索引擎概述之倒排索引 - 知乎","倒排索引简介","什么是倒排","倒排索引 | Elasticsearch: 权威指南 | Elastic", ...],
 "搜索":["搜狗搜索","搜索(汉语词语) - 百度百科", ....],
 "索引":["搜索引擎概述之倒排索引 - 知乎","倒排索引简介","倒排索引 | Elasticsearch: 权威指南 | Elastic", "索引 - 百度百科"...]
 ...
}

我们只需要找到你的检索词,把所有value都给你弄出来,这就叫做查询到了,然而随着库的变大,我们肯定不能把输入的每个字和库里面的做逐一匹配:

query = "倒排"
result = []
for index_key in database:
    if index_key == query:
        result.extend(database[index_key])

时间复杂度肯定就有问题(O(n)),不要小看这个线性复杂度,当库里面有千万甚至更多的内容时,线性复杂度也远远不够,我们就要用特定的数据结构来降低检索的时间复杂度,甚至不惜牺牲空间复杂度,对字面的,会考虑trie树等结构,可以把对数据条目数的复杂度降低到常数级,这些结构,我把他叫做索引

至于正排,则是存的对应内容的详情的,例如这个:

[{
    "title":"搜索引擎概述之倒排索引 - 知乎",
    "docs":"xxxxxxxxxx",
    "insert_time":"2023081315550000"
},{
    "title":"倒排索引 - 百度文库",
    "docs":"xxxxxxxxxx",
    "insert_time":"2023081316550000"
}]

我们搜的时候,可能是针对title搜的,然而,我们没必要也不可以把别的和查询无关的信息也存到索引中,因此,我们构造了一个额外的数据结构,这样:

{"id1":{
    "title":"搜索引擎概述之倒排索引 - 知乎",
    "docs":"xxxxxxxxxx",
    "insert_time":"2023081315550000"
},"id2":{
    "title":"倒排索引 - 百度文库",
    "docs":"xxxxxxxxxx",
    "insert_time":"2023081316550000"
}}

当我们通过倒排查到了id1后,来这个新的数据结构里面,通过id1这个钥匙就能找到这个文档的详情,并且可以展示给用户了,这个结构,就是正排。

好了,这块的科普点到为止,更多有兴趣的内容,可以看《信息检索导论》以及《这就是搜索引擎》这两本书,非常推荐大家看看的。

常见索引简介

上面提到的就是我常说的字面索引,即应对的是字面检索的情况,就是根据用户query内的词汇来进行检索完成,显然字面检索并不能完成我们日常所需,还需要大量的其他索引来支持,这里我举几个其他例子来让大家进一步理解索引多样性的必要性。

大家熟知的向量索引。(心法利器[16] | 向量表征和向量召回)。向量检索应该是大家比较熟悉的方案了,它具有非常强大的语义泛化能力,能让意思比较接近的句子都能被尽快搜索到,这项技术能大大降低我们配置同义词、配置说法的压力,底层常见的方案是hnsw(说起来原理还挺有技术含量的),另外经典的,在《统计学习方法》里,讲KNN的那章,有提到kdtree,当然也有集成的比较好的FAISS方案,有兴趣可以自己了解一下,另外我也是有文章专门给过完整的代码demo的,linux版本推荐(心法利器[96] | 写了个向量检索的baseline向量表征baseline)ngtpy,以及通用的faiss也可以用(心法利器[104] | 基础RAG-向量检索模块(含代码))。

数字索引。细想这么一个query怎么查询——“语文考90分左右的同学”,逐个匹配肯定是很方便的,对海量的数据肯定不合适,向量索引很可能召回的是60分、99分之类可能字面有些接近但是数字范围不太接近的结果,于是便需要数字索引,越接近90分相似度要越高的那种,比较常见的就是BTree系列,这在很多常见的搜索工具中肯定是有集成的。

地理哈希索引。细想这么一个query怎么查询——“故宫附近的美食”,这里依靠的就是地理位置了,常见的方案是GeoHash,通过经纬度可以把位置哈希化,哈希的是根据地理的矩形空间划分的,每一位字符表示的就是特定矩形大小下所属的矩形,因此哈希的字符串越长,代表矩形越小,即表示距离附近。

从这里可以看到,面对不同的检索问题,是需要不同的索引方案的,列举这些是希望大家能打开思路,根据合适的情况进行选择。

搜索数据库支持

尽管索引类型众多,但我们并不需要为此造轮子,目前我还是比较推荐ElasticSearch这个中间件,它具备非常完整的功能,上述提到的索引基本都支持,不支持的也可以通过安装插件的方式来解决。

当然,ElasticSearch比较重,对于数据量较少的,或者功能不需要这么多的,例如只要向量召回,那也没必要用它,Faiss是一个很不错的方案,这个就大家因地制宜吧。

向量检索DEMO

比较推荐大家看我在中心法利器[105]  基础RAG-大模型和中控模块代码(含代码)提到的Faiss方案,我在这里再展开讲一下吧。项目地址:https://github.com/ZBayes/basic_rag。

项目里和向量检索有关的模块的文件是这些:

`-- src
    |-- models
    |   |-- simcse_model.py
    |   `-- vec_model.py
    |-- searcher
    |   |-- searcher.py
    |   `-- vec_searcher
    |       |-- vec_index.py
    |       `-- vec_searcher.py

models里面是向量召回模型,searcher是检索有关的内容。

模型

首先是simcse_model.py,引用我带了链接,用的是一位大佬的模型,方便进行向量化。

import torch
import torch.nn as nn
from loguru import logger
from tqdm import tqdm
from transformers import BertConfig, BertModel, BertTokenizer

class SimcseModel(nn.Module):
    # https://blog.csdn.net/qq_44193969/article/details/126981581
    def __init__(self, pretrained_bert_path, pooling="cls") -> None:
        super(SimcseModel, self).__init__()

        self.pretrained_bert_path = pretrained_bert_path
        self.config = BertConfig.from_pretrained(self.pretrained_bert_path)
        
        self.model = BertModel.from_pretrained(self.pretrained_bert_path, config=self.config)
        self.model.eval()
        
        # self.model = None
        self.pooling = pooling
    
    def forward(self, input_ids, attention_mask, token_type_ids):
        out = self.model(input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids)

        if self.pooling == "cls":
            return out.last_hidden_state[:, 0]
        if self.pooling == "pooler":
            return out.pooler_output
        if self.pooling == 'last-avg':
            last = out.last_hidden_state.transpose(1, 2)
            return torch.avg_pool1d(last, kernel_size=last.shape[-1]).squeeze(-1)
        if self.pooling == 'first-last-avg':
            first = out.hidden_states[1].transpose(1, 2)
            last = out.hidden_states[-1].transpose(1, 2)
            first_avg = torch.avg_pool1d(first, kernel_size=last.shape[-1]).squeeze(-1)
            last_avg = torch.avg_pool1d(last, kernel_size=last.shape[-1]).squeeze(-1)
            avg = torch.cat((first_avg.unsqueeze(1), last_avg.unsqueeze(1)), dim=1)
            return torch.avg_pool1d(avg.transpose(1, 2), kernel_size=2).squeeze(-1)

然后是model.py,这个旨在包裹模型,并且给出模型预测的一些特定功能,例如推理向量,服务化转化,计算相似度等。

import torch
import torch.nn as nn
import torch.nn.functional as F
from loguru import logger

from transformers import BertTokenizer

from src.models.simcse_model import SimcseModel

class VectorizeModel:
    def __init__(self, ptm_model_path, device = "cpu") -> None:
        self.tokenizer = BertTokenizer.from_pretrained(ptm_model_path)
        self.model = SimcseModel(pretrained_bert_path=ptm_model_path, pooling="cls")
        self.model.eval()
        
        # self.DEVICE = torch.device('cuda' if torch.cuda.is_available() else "cpu")
        self.DEVICE = device
        self.model.to(self.DEVICE)
        
        self.pdist = nn.PairwiseDistance(2)
    
    def predict_vec(self,query):
        q_id = self.tokenizer(query, max_length = 200, truncation=True, padding="max_length", return_tensors='pt')
        with torch.no_grad():
            q_id_input_ids = q_id["input_ids"].squeeze(1).to(self.DEVICE)
            q_id_attention_mask = q_id["attention_mask"].squeeze(1).to(self.DEVICE)
            q_id_token_type_ids = q_id["token_type_ids"].squeeze(1).to(self.DEVICE)
            q_id_pred = self.model(q_id_input_ids, q_id_attention_mask, q_id_token_type_ids)

        return q_id_pred

    def predict_vec_request(self, query):
        q_id_pred = self.predict_vec(query)
        return q_id_pred.cpu().numpy().tolist()
    
    def predict_sim(self, q1, q2):
        q1_v = self.predict_vec(q1)
        q2_v = self.predict_vec(q2)
        sim = F.cosine_similarity(q1_v[0], q2_v[0], dim=-1)
        return sim.numpy().tolist()

if __name__ == "__main__":
    import time,random
    from tqdm import tqdm
    vec_model = VectorizeModel('C:/work/tool/huggingface/models/simcse-chinese-roberta-wwm-ext')
    print(vec_model.predict_vec("什么人不能吃花生"))
检索模块

最内部的是索引,索引外事检索器,首先是索引,这里我把他组件化了。内部支持构建、数据加入、加载、保存、检索等。

import faiss
from loguru import logger
from src.models.vec_model import VectorizeModel

class VecIndex:
    def __init__(self) -> None:
        self.index = ""
    
    def build(self, index_dim):
        description = "HNSW64"
        measure = faiss.METRIC_L2
        self.index = faiss.index_factory(index_dim, description, measure)
    
    def insert(self, vec):
        self.index.add(vec)
    
    def batch_insert(self, vecs):
        self.index.add(vecs)
    
    def load(self, read_path):
        # read_path: XXX.index
        self.index = faiss.read_index(read_path)

    def save(self, save_path):
        # save_path: XXX.index
        faiss.write_index(self.index, save_path)
    
    def search(self, vec, num):
        # id, distance
        return self.index.search(vec, num)

外部包一层搜索器,内部可以构造多种索引,根据自己需要调用即可,因为目前只有一个索引,所以从调用函数来看基本是又包了VecIndex一层。

import os, json
from loguru import logger
from src.searcher.vec_searcher.vec_index import VecIndex

class VecSearcher:
    def __init__(self):
        self.invert_index = VecIndex() # 检索倒排,使用的是索引是VecIndex
        self.forward_index = [] # 检索正排,实质上只是个list,通过ID获取对应的内容
        self.INDEX_FOLDER_PATH_TEMPLATE = "data/index/{}"

    def build(self, index_dim, index_name):
        self.index_name = index_name
        self.index_folder_path = self.INDEX_FOLDER_PATH_TEMPLATE.format(index_name)
        if not os.path.exists(self.index_folder_path) or not os.path.isdir(self.index_folder_path):
            os.mkdir(self.index_folder_path)

        self.invert_index = VecIndex()
        self.invert_index.build(index_dim)

        self.forward_index = []
    
    def insert(self, vec, doc):
        self.invert_index.insert(vec)
        # self.invert_index.batch_insert(vecs)

        self.forward_index.append(doc)
    
    def save(self):
        with open(self.index_folder_path + "/forward_index.txt", "w", encoding="utf8") as f:
            for data in self.forward_index:
                f.write("{}\n".format(json.dumps(data, ensure_ascii=False)))

        self.invert_index.save(self.index_folder_path + "/invert_index.faiss")
    
    def load(self, index_name):
        self.index_name = index_name
        self.index_folder_path = self.INDEX_FOLDER_PATH_TEMPLATE.format(index_name)

        self.invert_index = VecIndex()
        self.invert_index.load(self.index_folder_path + "/invert_index.faiss")

        self.forward_index = []
        with open(self.index_folder_path + "/forward_index.txt", encoding="utf8") as f:
            for line in f:
                self.forward_index.append(json.loads(line.strip()))
    
    def search(self, vecs, nums = 5):
        search_res = self.invert_index.search(vecs, nums)
        recall_list = []
        for idx in range(nums):
            # recall_list_idx, recall_list_detail, distance
            recall_list.append([search_res[1][0][idx], self.forward_index[search_res[1][0][idx]], search_res[0][0][idx]])
        # recall_list = list(filter(lambda x: x[2] < 100, result))

        return recall_list

VecSearcher外,还可以有多个检索器,综合起来形成一个简易的搜索工具Searcher

import json,requests,copy
import numpy as np
from loguru import logger
from src.searcher.vec_searcher.vec_searcher import VecSearcher
from src.models.vec_model import VectorizeModel

class Searcher:
    def __init__(self, model_path, vec_search_path):
        self.vec_model = VectorizeModel(model_path)
        logger.info("load vec_model done")

        self.vec_searcher = VecSearcher()
        self.vec_searcher.load(vec_search_path)
        logger.info("load vec_searcher done")

    def rank(self, query, recall_result):
        rank_result = []
        for idx in range(len(recall_result)):
            new_sim = self.vec_model.predict_sim(query, recall_result[idx][1][0])
            rank_item = copy.deepcopy(recall_result[idx])
            rank_item.append(new_sim)
            rank_result.append(copy.deepcopy(rank_item))
        rank_result.sort(key=lambda x: x[3], reverse=True)
        return rank_result
    
    def search(self, query, nums=3):
        logger.info("request: {}".format(query))

        q_vec = self.vec_model.predict_vec(query).cpu().numpy()

        recall_result = self.vec_searcher.search(q_vec, nums)

        rank_result = self.rank(query, recall_result)
        # rank_result = list(filter(lambda x:x[4] > 0.8, rank_result))

        logger.info("response: {}".format(rank_result))
        return rank_result

if __name__ == "__main__":
    VEC_MODEL_PATH = "C:/work/tool/huggingface/models/simcse-chinese-roberta-wwm-ext"
    VEC_INDEX_DATA = "vec_index_test2023121201"
    searcher = Searcher(VEC_MODEL_PATH, VEC_INDEX_DATA)
    q = "什么人不能吃花生"
    print(searcher.search(q))
灌数据

离线,在进行文档处理后(参考前几周写的这篇文章:前沿重器[50] | 聊聊搜索系统3:文档内容处理),需要把处理好的数据灌入Searcher中,参考这个脚本:

import json,torch,copy
from tqdm import tqdm
from loguru import logger
from multiprocessing import Process,Queue
from multiprocessing import set_start_method
from src.models.vec_model import VectorizeModel
from src.searcher.vec_searcher.vec_searcher import VecSearcher 


if __name__ == "__main__":
    # 0. 必要配置
    VEC_MODEL_PATH = "C:/work/tool/huggingface/models/simcse-chinese-roberta-wwm-ext"
    SOURCE_INDEX_DATA_PATH = "./data/baike_qa_train.json"
    VEC_INDEX_DATA = "vec_index_test2023121301_20w"
    DEVICE = torch.device('cuda' if torch.cuda.is_available() else "cpu")
    PROCESS_NUM = 2
    # logger.info("load model done")

    # 1. 加载数据、模型
    vec_model = VectorizeModel(VEC_MODEL_PATH, DEVICE)
    index_dim = len(VectorizeModel(VEC_MODEL_PATH, DEVICE).predict_vec("你好啊")[0])
    source_index_data = []
    with open(SOURCE_INDEX_DATA_PATH, encoding="utf8") as f:
        for line in f:
            ll = json.loads(line.strip())
            if len(ll["title"]) >= 2:
                source_index_data.append([ll["title"], ll])
            if len(ll["desc"]) >= 2:
                source_index_data.append([ll["desc"], ll])
            # if len(source_index_data) > 2000:
            #     break
    logger.info("load data done: {}".format(len(source_index_data)))

    # 节省空间,只取前N条
    source_index_data = source_index_data[:200000]

    # 2. 创建索引并灌入数据
    # 2.1 构造索引
    vec_searcher = VecSearcher()
    vec_searcher.build(index_dim, VEC_INDEX_DATA)

    # 2.2 推理向量
    vectorize_result = []
    for q in tqdm(source_index_data):
        vec = vec_model.predict_vec(q[0]).cpu().numpy()
        tmp_result = copy.deepcopy(q)
        tmp_result.append(vec)
        vectorize_result.append(copy.deepcopy(tmp_result))

    # 2.3 开始存入
    for idx in tqdm(range(len(vectorize_result))):
        vec_searcher.insert(vectorize_result[idx][2], vectorize_result[idx][:2])

    # 3. 保存
    vec_searcher.save()
检索

所谓的检索,就是把内容从数据库里搜出来,这里介绍两个吧,一个是从elasticsearch(后称为ES)中把数据搜索出来,另一个是从上面我写的组件里搜出来。

ES在python有专门的客户端,配合客户端和专用的检索语句DSL,具体的逻辑参考:https://blog.csdn.net/CSDN_of_ding/article/details/131761666,这个和mysql的链接使用是类似的,难度不是很高。难度主要在检索语法的设计,因为ES主要是字面的检索,可能会有一些复杂的逻辑,与、或、非还有一些打分逻辑啥的,这块功能做得很灵活,具体的可以参考权威指南:https://www.elastic.co/guide/cn/elasticsearch/guide/current/getting-started.html。

至于上述写的组件,则比较简单,就是直接一个语句就好了。Search里面内置了对应的模型,内部已经把向量化和搜索都已经完成了。

if __name__ == "__main__":
    VEC_MODEL_PATH = "C:/work/tool/huggingface/models/simcse-chinese-roberta-wwm-ext"
    VEC_INDEX_DATA = "vec_index_test2023121201"
    searcher = Searcher(VEC_MODEL_PATH, VEC_INDEX_DATA)
    q = "什么人不能吃花生"
    print(searcher.search(q))

内部的逻辑可以重看一下这个函数,内部的逻辑基本就是3个过程——向量化、检索、排序。

def search(self, query, nums=3):
    logger.info("request: {}".format(query))

    q_vec = self.vec_model.predict_vec(query).cpu().numpy()

    recall_result = self.vec_searcher.search(q_vec, nums)

    rank_result = self.rank(query, recall_result)
    # rank_result = list(filter(lambda x:x[4] > 0.8, rank_result))

    logger.info("response: {}".format(rank_result))
    return rank_result

粗排

前沿重器[49] | 聊聊搜索系统2:常见架构一文中,我有对粗排、精排这些排序的模块,进行过详细的分析和解释,让大家理解,为什么要划分精排和粗排,甚至是多阶段的划分。此处,我单独把粗排拎了出来,它的核心工作是对本路内容进行一个粗略的相似度排序,毕竟检索的目的是找到最接近的TOPN,这里不可绕开的要衡量“最接近“。

粗排一定程度和检索逻辑绑定,其本质任务就是计算query和doc对应检索字段之间的相似度,利用相似度的数值,可以进行排序筛选和过滤。这里有亮点需要强调:

  • 粗排的核心目标是干掉“肯定不合适”的结果,所以常常要考虑的是“相似”or“不相似”的问题,作为对比,精排由于进入精排层的物料多半是和原query比较接近,此时的粗排的分数一般都会比较接近,此时精排任务已经变成对比哪个物料“更相似”,要求一个更能拉开物料之间分数差异的算法。

下面举几个用于粗排的相似度计算方法。

  • 如果是字面召回,我们重点关注的是字面的相似度,常见的是BM25,目前已经是非常普及的方案了,当然还有我之前有提到的cqr/ctr(心法利器[18] | cqr&ctr:文本匹配的破城长矛心法利器[99] | 无监督字面相似度cqr/ctr源码)因为BM25的数值受到句子长度影响很明显,所以并不容易卡阈值,后者cqr/ctr方案则可以很好地处理这个一点,后者一般可以作为配合前者的存在。

  • 一般的向量召回则更多考虑cos、L2之类常用的距离。

  • 如果是数字等方面的召回,直接算误差即可。

  • 如果是地理位置,可以通过经纬度很容易计算到直线距离,有地图功能时甚至可以算出导航距离。

展开聊一下向量召回

向量召回在目前之所以得到流行,除了目前已经流传已久的泛化性的原因,还有一个是灵活性。只需要根据一个目标,把内容转化为向量,即可用于进行向量召回,不需要考虑各种索引的处理,从前文大家也知道不同索引要处理的事还挺麻烦的。

首先,需要强调的第一个问题,从表征目标出发,就是向量不止有语义向量,向量表征对标的内容可以是非常丰富的,在推荐系统中,类似协同过滤的设计,是可以转为向量来做的,再者在搜索领域,也有像淘宝(前沿重器[18] | KDD21-淘宝向量检索)讲用户行为偏好转为向量的案例,就是常规意义的搜索,也可以通过query-用户点击的方式,结合内容来源、内容质量等特征,构造对比学习来学习向量的方式,因此大家可以考虑把思路打开,语义向量检索只是向量的一部分。

第二,是有关向量的特征,除了文本可以转化,还有其他特征,这点可以从大家比较熟知的推荐系统中借鉴,如果存在个性化信息,用户的行为、偏好、年龄、地点之类的是可以作为表征的输入的,另外物料侧,除了考虑多种类型的文本,如问题、回答、标题的常规已有特征外,还有内容质量、内容用户画像、话题标签等特征,内容质量可以是用户平均停留时间、点击率之类的,内容用户画像则是对表达喜欢的用户的特征进行表征,另外话题是可以通过内容理解来抽取,这些特征都非常有利于进行召回。

多路召回

多路召回是搜索里面很常见的操作。因为用户提问的复杂性、内容的多样性等原因,我们往往不会一路把所有内容都召回回来,如何分路成为一个值得探讨的问题,下面我会分几个维度来讲多路召回可能的操作,并会提及具体的使用场景,供大家参考使用。

  • 意图/路由划分下的多路召回。针对不同的需求,可能需要不同的操作来满足,例如要查音乐和查天气,后续的操作会不同,搜音乐以来音乐库,查天气则是查询天气接口,此时显然就是要走不同的链路来进行内容召回,再者同样是音乐类,用户可能是按歌手、歌名、流派等因素查,复杂以后很可能需要多路召回来实现,另外也需要配合上游的意图识别、实体抽取等因素,来切分链路,然后根据链路来进行召回。

  • 不同内容结构下的多路召回。这个比较简单,举例,音乐和购物,背后的数据结构是不同的,但用户的说法可能是模糊的,例如说一个专辑名,可能是要买专辑,也可能是想听音乐,此时多路召回是一个不错的选择,不着急直接看哪个优先级高,这事可以放精排层来做。

  • 不同检索方式下的多路召回。这个也比较简单,对不同的检索方案,不好放一起的,分两路是不错的选择(当然也有不分的方法),例如向量召回和字面召回两路。

  • 不同表征方式的多路召回。上面有提到向量召回的多样性,基于表征目标和表征特征是可以有多种向量表征方式的,基于不同的表征特征,就可以有多种不一样的召回方式。

提醒,多种召回方式,在一般情况下都是并发进行的,毕竟他们运行需要一定的时间,而且一般互不影响,一般就是服务化后用多进程请求的方式来进行。

小结

本文讨论了搜索系统中召回层的操作,重点聚焦在索引、粗排、向量召回、多路召回等工作中,供大家更深入全面理解搜索系统的召回部分。另外,仍旧建议大家多去翻翻《信息检索导论》,虽然现在视角里面的内容算是旧的,但时至今日仍有大量知识会用到,大家可以系统学习。

前沿重器[53] | 聊聊搜索系统6:精排

本期的内容是精排,即对召回的内容进行进一步筛选,从而得到更好的结果。

目录:

  • 精排的意义

  • 精排需要考虑的因素

  • 精排方案设计的框架

  • 案例分析

  • 补充讲重排

  • 小结

这个可以说是最百花齐放的模块了,在实践过程中,搜索发展到后期这部分的花活就会变得异常多,无论是论文还是各种分享,但我发现论文和各种分享多半是从自身面对的场景出发的,因地制宜的成分很多,因此这次我换个说法,先从思路下手,先把这部分的整体逻辑讲明白,然后再用一些案例来进行讲解。

精排的意义

首先,还是要重申一下精排的概念。所谓的精排,在整个搜索里,核心功能是在获得召回结果的基础上,整理出精准的结果。这里有一个前提,那就是在召回结果的基础上,没有召回的精排是没有意义的。

这里就要引出一个问题,就是两者会有什么区别。这个问题谈清楚了,大家就会明白精排在整个搜索过程中的作用。

最直接的,召回和精排的对象是不一样的。召回面对的是整个数据库里的所有数据,而精排面对的是召回已经找回来的少量数据,这是两者处理思路不同的核心原因。

召回是大海捞针,精排是百里挑一。召回的过程类似筛简历,用一些比较粗暴的方式快速过滤很多“一定不对的内容”,从而得到相对不那么离谱的答案。类似向量召回之类的,要通过把句子向量化才能进行快速匹配和筛选,其本质就是在空间中以query为中心画个圈,只把圈内的内容拿出来;精排则像是面试,对几个简历还算不错的人进行进一步的筛选,这一步因为是进行深入面试和理解,尽管精排的准确性高但是速度慢,所以需要召回来进行配合。

精排的性质是优中选优。继续从对象触发,精排面对的是召回已经找出的数据,这些数据从某个角度而言,已经被判为和query存在一定关系,但是随着物料的增加,“有一点关系”并不足够满足用户需求,所以要进行更精确的比对,因此此处要做的是进一步提升召回物料之间的差距。

另外,精排的综合性要求更高。召回层是可以基于很多不同的依据来分别做的,而精排则不然,需要尽可能把多个因素放一起综合地来做。

精排需要考虑的因素

前文有提到,精排的综合性要求很高,我们就需要考虑很多因素,相比召回,我们在这一步就要求考虑的很全面,现在我先把需要考虑的因素用比较笼统的方式列举出来,然后我们再逐个剖析他们如何表征以及怎么放入模型中。

  • 和query的相关性。这点毫无疑问应该排在前列,毕竟用户进行搜索的核心目标就是要解决query描述的问题,不相关的话搜索无从谈起。

  • 物料质量。在物料已经比较多的情况下,就要开始考虑把质量比较高的物料放到前面。

  • 用户偏好。在有用户信息的搜索系统下,或者因为业务需求,就需要将结合考虑到用户的偏好,比较典型的像音乐、电商等搜索场景,对个性化的要求就会比较高。

和query的相关性,主要可以从这几个角度来去看。

  • 语义相似度。这个大家应该都比较容易想到,早年比较流行的方式就是类似ESIM之类的方案来做(前沿重器[9] | ESIM:语义相似度领域小模型的尊严),效果确实比非交互的相似度效果好,后续基于bert的句子对相似度也是不错的,而且训练的模式会比较简单,直接用‘q-d-label’的模式就能训练一个还可以的结果,在后续的版本中,迭代到learning to rank方案也比较丝滑。

  • 用户行为体现的相似度。这个思路和推荐中用的点击率预估是非常相似的,用户搜Q点D,本质就是对内容一定程度的认可,甚至是更为直接的相似度信息,毕竟用户是对这个结果认可的。当然了,有些内容,即使不相关用户也可能点进去,例如某些猎奇或者不合规的内容。

至于物料的质量,这个可以从两个角度出发。

  • 物料本身的质量。如话题、文案的合规性、丰富度、新鲜度等,这个会比较简单。

  • 用户群体对物料的评价。如点击率、单位时间点击量、命中query丰富度、停留时间、点击离开率等,这些是基于用户行为对物料的评价。

最后就是用户偏好,应该也是后期需要花费时间比较多的一部分。

  • 多个维度的协同过滤的方式。例如搜索这个query的用户更倾向于点击的内容、某个画像的用户更倾向于点击的内容(群体特征)、该用户平时的点击偏好(个人直接特征)等。

尽管上述的因素在生产实践中并不是按照这个来应用的,但因为特征来源广泛,往往还充斥着很多无效的信息。我所提出的上述思路更多是为了让大家更加结构化地理解整个精排内部所需要的信息,不容易错漏,相比一个一个比对,根据特定结构化思维来整理效率会更高。

当然,这只是我们常说的考虑因素,而在实践过程,我们需要把上面考虑的因素,结合信息来源和使用方法,构造成适合应用的模式,即特征,从而应用在精排模型中,于是我们就能推导出精排方案的框架。

精排方案的框架

常规的算法建模,不外乎要考虑的就是三点:数据、特征、模型。早期数据可以通过语义相似度或者是人工标注数据快速构造baseline,后续则逐步切换为在线用户的行为数据,此处不赘述,本章重点讲解后面两种。

在前面一章节讲到了需要考虑的因素,在实际应用情况,我们会把内容根据信息来源以及后续使用方法进行有机组合构建。

一般地,从数据来源来看,会把所有特征按照4个维度来构造。

  • Query侧:通过query理解来获取,常见的意图、实体包括某些关键词等。

  • Doc侧:即文档信息,一般通过内容理解,即在文档内容处理模块处理构造(前沿重器[50] | 聊聊搜索系统3:文档内容处理),这里包括一些类似物料质量、文档摘要等的内容。

  • 用户侧:用户偏好信息,这个一般是在用户出现行为的过程就能记录,在推理过程一般只需要读取。

  • 交互特征:Query和Doc之间的匹配度特征,注意这里是一些小特征,如某些计算得到的相似度,语义相似度、字面相似度等,还有一些统计得到的特征,如如历史的曝光点击率等,再者还有一些召回层带下来的信息,多种粗排的相似度特征,召回链路及其个数等。

有了这些特征就可以开始构造模型把他们融合起来了。合并的链路我喜欢分为3个流派。

  • 规则加分模式。在前期数据不足不好训练的情况下,直接对几个特征用规则进行简单组合即可使用,别小看这个方法,这个方案在很长一段时间都可以拿来使用。

  • 机器学习组合模式。通过机器学习模型,例如比较有名的xgboost,可以快速把这些特征进行组合构造。

  • 深度学习及其变体。因为特别的特征需要用特别的结构来吸纳,所以出现了多种魔改的特征,但效果确实在对应场景有所提升。

于是,便有了我们所期待的模型。

这便是我想聊的框架,数据、特征、模型的基础框架,构成搜索精排在实践过程的重要组成部分。在这个框架下,我们可以结合搜索当前的状态以及目前可获得的资源,来灵活设计自己目前需要的方案。

  • 数据是模型学习的基础,是特征的原料。借助数据能发现可解释的规律,各种突出区分度的特征则是从数据筛选、分析而来。

  • 特征是数据依据的表达,是query和doc匹配的信息来源。只有构造合理的特征,模型才会生效,所以他是数据依据的表达,同时q和d所谓的是否匹配,

  • 模型是数据的通用表示,是特征有机组合完成推理的桥梁。

案例分析

相比列举方法,案例在这里可能会更合适,通过案例分析,尤其对他们面对的问题分析,让大家进一步理解“因地制宜”的重要性。

美团

美团的分享一直都维持的很好,在搜索方面也涌现了很多优秀的文章,具体可以在这里翻阅:https://www.zhihu.com/org/mei-tuan-dian-ping-ji-shu-tuan-dui。本次要讲的精排,并非作为一个专题来讲,而是分布在很多文章内部,通过多篇文章的阅读才梳理出内部的一些细节。涉及这些关键文章:

  • 搜索广告召回技术在美团的实践:https://zhuanlan.zhihu.com/p/707169501

  • 大众点评内容搜索算法优化的探索与实践:https://zhuanlan.zhihu.com/p/688404734

  • 多业务建模在美团搜索排序中的实践:https://zhuanlan.zhihu.com/p/388211657

  • Transformer 在美团搜索排序中的实践:https://zhuanlan.zhihu.com/p/131590390

美团搜索毫无疑问是一个非常具有特点的场景,我的理解结合众多技术文章的分析来看,美团搜索在场景上,具有如下的特点:

  • 特殊的多业务场景。美食、电影、机票、酒店等个典型场景聚合,显然这种聚合和一般地百度开放域搜索还不太一样,业务场景各异,且各自之间存在高低频的差异,再者不同业务还可能有不同的子目标。

  • 和电商类似,用户在输入习惯上,可能有大量的内容聚焦在特定的专有名词或者tag上,如“烧烤”、“麦当劳”等。

  • 检索需要很大程度以来用户画像信息。典型的例子——地点,美团是一个高度依赖地点的场景,美团的几乎所有场景在用户检索时都很大程度依赖地点信息,用户搜索的“烧烤”就需要很大程度地参考地点信息,当然还有别的类似用户个人偏好等也有结合实际场景的依赖,例如美食对口味有依赖等。

结合上面特点,美团也进行了大量的特别设计。

首先是特殊的多业务场景,构造了多业务配额模型(Multi-Business Quota Model,MQM)以确认综合搜索下各个业务的配额,有利于他们的有机组合。下面提供了MQM-V2的结构图,从图中可以看到:

  • 采用多目标的建模方式,以每一路召回是否被点击为目标进行建模,并计算他们整体的联合概率。

  • 引入了按业务拆分的召回方式,同时这些召回方式在精排内存在一定冷启动需求,所以构造了二维目标应对这种冷启动问题。

  • 此处的特征考虑了query、user、context、cross等特征。

  • 用户行为建模上,使用的是transformers结构。

23f640d2bdc20ebfbc73ff93a1286c3c.png
MQM-V2 模型结构图

另外,多业务的精排模型(Multi-Business Network,MBN)也根据这个场景问题进行了特别的优化,下面给出的是精排模型V4的结构。可以看到有如下特点:

  • 最下游子模块各自建模,应对不同的业务需求,形成快速、独立迭代能力。

  • 此处特殊的是CGC层,这是一种多任务学习下的思路,来源于腾讯提出的PLE(Progressive Layered Extraction (PLE): A Novel Multi-Task Learning (MTL) Model for Personalized Recommendations),考虑多个专家的模式应对不同场景的问题。(补充,在V3版本中有考虑过MMOE(Modeling Task Relationships in Multi-task Learning with Multi-gate Mixture-of-Experts))(再补充,注意这篇文章的时间线是21年7月,即3年前)

  • 值得注意的是,这里单独把POI特征给单独拿了出来做表征。

1929538ec88939b4df5e306705e80293.png
MBN-V4 模型结构图

至于第二点,就是用户比较喜欢输入tag、专名的问题,其实在特征工程上就能发现,在query特征之外,还考虑到了大量其他的特征,尤其是user、poi等特征,特别地针对用户兴趣建模,也是有专门的设计。这里比较有特点的是考虑了transformers结构,此处给出一个美团曾经使用过的一种结构(Transformer&Deep),这篇一定程度参考了AutoInt(Automatic Feature Interaction Learning via Self-Attentive Neural Networks)

988ca46ffbef08a28a10df9c43e20ba4.png
Transformer&Deep

至于最后一点,就是用户画像信息的使用,这点承接第二点,从上述模型来看,美团在用户信息表征上做了很多研究工作,尤其是用户的个人行为,毕竟用户行为很大程度反映了用户的偏好,这里给出第三版本的用户行为建模网络。同时友好的是,作者在文章里讲述了很多transformer应用的经验。

  • 实验表明Transformer能更好地对用户行为(item序列)进行有效建模,甚至由于相对简单的Attention-pooling。

  • 长序列下Transformer对比GRU优势会更明显,短序列尽管会缩小但仍旧优秀。

  • 位置编码对长序列有一定效果,但如果已经切分,则效果会被大幅度削弱。

  • Transformer编码层不需要太多。

  • “头”的个数影响不大。

7e8d45c83dd9a4fd4741f350cee97ad8.png
用户行为建模网络-V3
知乎

相比美团,知乎更像是传统意义的搜索,其主要的物料基本是用户UGC的问答和博客文章,知乎在搜索精排上的迭代发展,也表现出这一特点,而且这个过程会更加纯粹,对用户的参考会更少一些,通过分析可以看到比较纯粹的搜索精排优化,我们来看一起看看。https://mp.weixin.qq.com/s/DZZ_BCiNw0EZg7V0KvhXVw

我们直接从整个优化历程来逐个分析。

GBDT。GBDT具备比较基础的特征融合能力,也是比较高的baseline。这应该是很多搜索系统常见的基础工作了。

TF-Ranking。考虑特征容量和技术迭代,以及多目标排序原因,而升级DNN,同时形成了比较规范的特征输入模块、特征转化模块和主网络部分。

e0d76090e138c5a8858b99aeb8fd4926.jpeg
TF-Ranking

主网络下,开始考虑多目标排序,权衡阅读时长、点赞、收藏、分享、评论等反馈行为,简单的考虑共享参数层+任务独立参数层的结构来完成(文章中使用Hard sharing来表示)(类似前面美团提到的多业务配额模型),该方式需要通过实验的方式来权衡多目标之间的关系,后面则优化升级为MMOE(Modeling task relationships in multi-task learning with multi-gate mixture-of-experts.),即多个expert加权的方式,权重任务间共享又让任务之间存在独立性。

考虑搜索中常见的位置偏差(用户浏览行为是从上到下,因此靠前的更容易得到点击),这方面推荐中也是有的,因此优化方案上也存在一些推荐系统的影子,此处考虑两个策略:

  • 降低头部样本权重,如硬编码位置权重、自学习位置权重等。该方案收益不高。

  • 通过一个独立的 shallow tower 对 Position Bias 进行建模(Recommending what video to watch next: a multitask ranking system)。如前面的TF-Ranking的图中提到的(Card position)部分。

学习排序问题。前面一再强调,精排是一个优中选优的对比问题,而这类型的问题,在学术界也有专门的研究,即learning to rank(LTR,之前我竟然还写过点简单的入门,很早之前了R&S[19] | 学习排序入门级概述),此处考虑使用List wise loss方案(综合效果最优),在推理阶段同样要考虑到list wise,即在对单条打分的同时也要考虑其他被召回的文档,此处使用的是SE Block的结构(Squeeze-and-excitation networks.)。

文本特征也是需要考虑的。比较简单的方式就是先计算一个语义相似度然后把语义相似度当做特征放入模型,而比较详细的方案就是直接把语义模型(如bert)放入到LTR中进行训练,甚至是整体模型中进行训练。

另外还有一些诸如个性化、GBDT编码特征、生成对抗模型、在线学习等思路在文中也有简单提到,不赘述。

补充讲重排

重排是基于业务的一个特殊排序阶段,和推荐类似,很多时候精排考虑的更多的还是query+用户画像和doc之间的匹配度,但在重排阶段还需要考虑实际的业务需求,如多样性、内容连贯性等,例如常规的相似度的排序结果在搜索里面很可能会出现同质化,此时就需要通过重排来一定程度实现更符合业务目标的排序。

这里提3个比较常用的重排策略(https://www.zhihu.com/question/462539445/answer/3079023802)。

  • 全局排序——listwise策略,这个同样是LTR的内容,采用诸如序列生成的方式,输出一个用户体验最好的内容序列。

  • 流量调控——在重排层,借助流量的调控,把热门、新品等有利因素的物料往前倾斜。

  • 打散策略——在一定窗口下,通过惩罚的方式把某些过于同质的内容往后调整。

当然,这里的策略都有提到一个点,这些重排策略或多或少都有提到目标,从目标出发解决问题才更直接,不能因为的新颖性或者自己新学到而迫切考虑使用。

大模型精排

有关大模型的精排部分,我是有在阅读一些论文并进行一些常识。不过在实践上收益其实并不高,初步结论是“用处不大没必要硬用”,当然这个也有待进一步探索。目前是发现在精排层会有如下问题。

  • 由于特征的稀疏性、表征连续性、上下文信息等问题,这些特征很难通过prompt的形式有效输入,尤其是后期比较完善的系统。

  • 实验表现上,输入大模型的数据顺序对最终决策的影响还是比较大。

  • 上限很难打过现有比较完善的精排模型。

  • 老生常谈了,大模型的成本和更新敏捷度问题。

但值得注意的是,在比较早期,特征不多也不复杂的情况下,可以暂时充当精排模块,但也只是暂时,在同一时期只要有比较好的语义相似度模型(甚至粗排的语义表征相似度),也有可能超过简单基于prompt的大模型。

小结

精排是搜索后期优化的重点部分了,结合大厂走在前面的先决条件,精排方面的分享、论文都很多,所以本文聊下来挺容易刹不住车的,但我还是聚焦于精排研发的常用思路,方便大家把零散的内容给串起来,同时,精排以及重排是最接近用户的一部分了,因此和用户习惯、业务需求关系最为紧密,我们在进行分析的过程,还是需要多看case,多剖析用户习惯,从而实现更优秀的精排效果。

前沿重器[54] | 聊聊搜索系统6:补充模块

本期的内容是搜索的其他附加模块,重点讲几个和搜索有关的场景以及常见的解决方案。这篇文章比较轻松,都是一些概念和思路解释,只是补充说明一些可能会被忽略的内容。

  • 搜索引导。

  • 搜索广告。

  • 大模型。

搜索引导

搜索引导在很早之前我有讲过:前沿重器[12] | 美团搜索引导技术启示,这个内容比较完整的讲解,可以参考《美团机器学习实践》的8.3。

搜索引导是指在用户搜索过程中给用户提供引导的统称,具体可以分为3种,搜索前、搜索中、搜索后引导,3个的内部所用的技术因为信息不同所以具体的技术方案会有所不同。这个“引导”,简单的理解,就是给用户进行query推荐。

  • 搜索前,往往是根据用户的画像以及目前的新热内容进行推荐,常见的在搜索框下“猜你喜欢”、或者是预填在搜索框内的内容。

  • 搜索中,指搜索过程中,用户输入一半时,下方出现的推荐内容,主要是根据用户已经输入的内容进行预想。

  • 搜索后,指用户点击确定后,还给他推荐的一些相关的query。

c7f5137cb35fb5e9c2df56188d98f7af.png

下面展开聊一聊这3个内容的具体场景细节和常见技术,只是简单聊不会展开。

搜索前

搜索前引导是没什么先验信息的,因此更多是推荐性质的,再者搜索界面往往比较空,里面会放很多东西。主要思路如下:

  • 新、热信息,推荐常态了。

  • 根据用户画像进行推荐,就当做推荐系统来做。

  • 用户搜索历史。

搜索中

搜索过程中,用户已经输入了部分内容,此时的用户注意力基本聚焦在自己想输入的query上,所以这个时候没必要做太多复杂多样的工作,给出比较贴合目前用户输入内容的提示会比较好,主要思路如下:

  • 用前缀树等方式,基于前缀召回一些高度相关、高质量、高点击的内容。

  • 可以适当进行个性化。

  • 但是请注意,此处的时延要求很高,必须尽快出,卡顿的时间内用户可能已经输入很多内容,尤其是pc端web段。

搜索后

搜索后,又回到了类似搜索前的状态,但与之不同的是此时用户已经有用户query,所以可以结合用户query做一些额外的尝试:

  • 给出一些和用户query相似的句子,但检索出来的内容质量比较高(例如高有点率,即别的用户有点击的概率)。

  • 搜了这些query的用户还搜了XXX,用这个思路来找回,类似协同过滤了。

这个搜索引导的任务会挺适合做一种训练,就是根据问题思考问题特点并提出特定技术方案的训练,整个产品快速理解产品需求、用户习惯等,这些分析和探索的思维模式,非常值得吸收,大家也可以循着我的思路再过一遍。

搜索广告

广告是现在互联网非常常见的模式,搜索广告应该也不少见。

7c3ff2f654ba838c5e271e7b47d393ec.png

搜索广告目前需要考虑的内容:

  • 搜索广告和推荐广告,跟搜索和推荐的类似,搜索广告是需要结合用户query的,给出的内容大概率要和query比较相关。这个是核心点。

  • 相比搜索和推荐,广告本质是3方的权衡,用户、平台和广告方,而当考虑广告方收益后,用户的利益大概率要被一定程度牺牲。

  • 广告商要求精准投放,对特定用户投放才会有效,用户视角对相关的内容反感度也不会很高,因此在匹配度和精准度上,各方的目标还是相对一致的。

因此,在权衡全局利益下,搜索广告的整体思路如下:

  • 搜索广告考虑一定程度query相似度,否则投放质量和用户体验都会下降明显,所以query理解的相关工作仍旧需要做。

  • 广告物料肯定远不如用户生成或者已有文档的数量多,所以搜索广告在召回层的要求需要一定程度下降。

  • 精排和重排层,结合广告投放价值等进行综合排序。

大模型

大模型时代来临,大模型想必是大家关心的重点,所以我也单独拿出来说说自己的理解。大模型能助力搜索系统进行进一步提升,同时对于新的搜索系统,也很大程度降低早期启动时间。下面我分几个情况来讨论大模型在搜索中的应用情况。

  • 离线文档处理:大模型的核心优势是few-shot甚至zero-shot的高baseline能力,在文档处理内,需要对文档内进行特定的关键词、实体、摘要进行抽取,大模型是一个非常快速的方案。

  • query理解:用大模型来做分类、实体抽取之类的任务已经是不是一个困难的事了,至少能省去很多标注的成本,或者是通过大模型来做粗标,无论如何从这个角度来讲,算是一个启动捷径。另外,大模型来做query拓展,收益也不低(前沿重器[38] | 微软新文query2doc:用大模型做query检索拓展)。

  • 召回:召回层一般是直接做检索,在相似度上大模型的工作还需要探究,当然借助大模型做向量化的工作也不是没有,不过类似BGE-M3之类的操作,可能收益会比较高,直接用大模型的收益不如转为用大模型做拓展然后向量化。

  • 精排:大模型做精排在早期还是可以尝试的,不过到了后期,还是不如特征之类比较成熟的方式,特征的有机组合和迭代节奏上有优势,且上限也不低。大模型在多特征多信息的环境下还是有些难度,可能会吃不下或者理解不了。

总的来说,大模型在这里的应用有如下特点:

  • 有比较高的baseline,在数据匮乏,无法训练自有模型的时候,可以考虑大模型启动。

  • 但是到了后期,很多问题可以用小模型替代,效果和性能可能还能提升,成本下降。

这里再补充一个点,可能会有人问到RAG,这里可以看出,大模型和搜索系统是相辅相成互相帮助的关系了,大模型用在搜索系统里能对搜索系统产生正面作用,而反过来,搜索系统在RAG中能快速查出对大模型有用的信息。

小结

本文主要对系列内没提到但比较重要的部分进行补充说明,主要讲的是搜索引导、搜索广告、大模型相关部分。

95e42a715f72aeb584ee87fc878bec7c.png

  • 9
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值