前沿重器
栏目主要给大家分享各种大厂、顶会的论文和分享,从中抽取关键精华的部分和大家分享,和大家一起把握前沿技术。具体介绍:仓颉专项:飞机大炮我都会,利器心法我还有。(算起来,专项启动已经是20年的事了!)
2023年文章合集发布了!在这里:又添十万字-CS的陋室2023年文章合集来袭
往期回顾
前沿重器[47] | RAG开源项目Qanything源码阅读3-在线推理
前沿重器[48] | 聊聊搜索系统1:开篇语
前沿重器[49] | 聊聊搜索系统2:常见架构
前沿重器[50] | 聊聊搜索系统3:文档内容处理
前沿重器[51] | 聊聊搜索系统4:query理解
RAG在整个大模型技术栈里的重要性毋庸置疑,而在RAG中,除了大模型之外,另一个不可或缺的部分,就是搜索系统,大模型的正确、稳定、可控生成,离不开精准可靠的搜索系统,大量的实验中都有发现,在搜索系统足够准确的前提下,大模型的犯错情况会骤然下降,因此,更全面、系统地了解搜索系统将很重要。
听读者建议,像之前的对话系统一样(前沿重器[21-25] | 合集:两万字聊对话系统),我也会拆开揉碎地给大家讲解搜索系统目前业界比较常用的架构、技术方案,目前的计划是分为这几个模块讲解:
开篇语(前沿重器[48] | 聊聊搜索系统1:开篇语):给大家简单介绍一下搜索系统的概况,以及现在大家比较关注在大模型领域的发展情况。
搜索系统的常见架构(前沿重器[49] | 聊聊搜索系统2:常见架构):经过多代人的探索,目前探索出相对成熟可靠,适配多个场景、人力、生产迭代等因素的综合性方案。
文档内容处理(前沿重器[50] | 聊聊搜索系统3:文档内容处理):对原始文档内容、知识的多种处理方案。
Query理解(前沿重器[51] | 聊聊搜索系统4:query理解):对query内容进行解析,方便后续检索使用。
召回:检索、粗排、多路召回(本期):使用query理解的结果,从海量数据中找到所需的信息。
精排:对检索的内容进行进一步的精筛,提升返回的准确性。
其他搜索的附加模块:补充说明一些和搜索有关的模块。
本期的内容是检索和多路召回。这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
比较推荐大家看我在XXX中提到的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-用户点击的方式,结合内容来源、内容质量等特征,构造对比学习来学习向量的方式,因此大家可以考虑把思路打开,语义向量检索只是向量的一部分。
第二,是有关向量的特征,除了文本可以转化,还有其他特征,这点可以从大家比较熟知的推荐系统中借鉴,如果存在个性化信息,用户的行为、偏好、年龄、地点之类的是可以作为表征的输入的,另外物料侧,除了考虑多种类型的文本,如问题、回答、标题的常规已有特征外,还有内容质量、内容用户画像、话题标签等特征,内容质量可以是用户平均停留时间、点击率之类的,内容用户画像则是对表达喜欢的用户的特征进行表征,另外话题是可以通过内容理解来抽取,这些特征都非常有利于进行召回。
多路召回
多路召回是搜索里面很常见的操作。因为用户提问的复杂性、内容的多样性等原因,我们往往不会一路把所有内容都召回回来,如何分路成为一个值得探讨的问题,下面我会分几个维度来讲多路召回可能的操作,并会提及具体的使用场景,供大家参考使用。
意图/路由划分下的多路召回。针对不同的需求,可能需要不同的操作来满足,例如要查音乐和查天气,后续的操作会不同,搜音乐以来音乐库,查天气则是查询天气接口,此时显然就是要走不同的链路来进行内容召回,再者同样是音乐类,用户可能是按歌手、歌名、流派等因素查,复杂以后很可能需要多路召回来实现,另外也需要配合上游的意图识别、实体抽取等因素,来切分链路,然后根据链路来进行召回。
不同内容结构下的多路召回。这个比较简单,举例,音乐和购物,背后的数据结构是不同的,但用户的说法可能是模糊的,例如说一个专辑名,可能是要买专辑,也可能是想听音乐,此时多路召回是一个不错的选择,不着急直接看哪个优先级高,这事可以放精排层来做。
不同检索方式下的多路召回。这个也比较简单,对不同的检索方案,不好放一起的,分两路是不错的选择(当然也有不分的方法),例如向量召回和字面召回两路。
不同表征方式的多路召回。上面有提到向量召回的多样性,基于表征目标和表征特征是可以有多种向量表征方式的,基于不同的表征特征,就可以有多种不一样的召回方式。
提醒,多种召回方式,在一般情况下都是并发进行的,毕竟他们运行需要一定的时间,而且一般互不影响,一般就是服务化后用多进程请求的方式来进行。
小结
本文讨论了搜索系统中召回层的操作,重点聚焦在索引、粗排、向量召回、多路召回等工作中,供大家更深入全面理解搜索系统的召回部分。另外,仍旧建议大家多去翻翻《信息检索导论》,虽然现在视角里面的内容算是旧的,但时至今日仍有大量知识会用到,大家可以系统学习。