引言
在信息爆炸的时代,高效准确地检索所需知识变得尤为重要。FastGPT作为一款先进的知识检索工具,通过其独特的三种检索模式------语义检索、全文检索和混合检索,为用户提供了全面而精准的搜索体验。本文将深入剖析FastGPT的内部机制,包括其使用的向量模型技术、业务流程设计以及源码实现细节,旨在为开发者和用户提供一个清晰的知识检索过程全貌,以及如何通过技术手段优化检索效率和结果的相关见解
知识库检索模式
FastGPT 提供了三种检索模式,覆盖了 RAG 中的主流实现。
语义检索
语义检索模式通过先进的向量模型技术,将知识库中的数据集转换成高维向量空间中的点。在这个空间中,每个文档或数据项都被表示为一个向量,这些向量能够捕捉到数据的语义信息。当用户提出查询时,系统同样将问题转化为向量,并在向量空间中与知识库中的向量进行相似度计算,以找到最相关的结果。
- 优势:能够理解并捕捉查询的深层含义,提供更加精准的搜索结果。
- 应用场景:适用于需要深度语义理解和复杂查询处理的情况,如学术研究、技术问题解答等。
- 技术实现 :利用如
text-embedding-ada-002
等模型,对文本数据进行embedding,实现高效的语义匹配。
全文检索
全文检索模式侧重于对文档的全文内容进行索引,允许用户通过输入关键词来检索文档。这种模式通过分析文档中的每个词项,并建立一个包含所有文档的索引数据库,使用户可以通过任何一个词或短语快速找到相关的文档。
- 优势:检索速度快,能够对大量文档进行广泛的搜索,方便用户快速定位到包含特定词汇的文档。
- 应用场景:适用于需要对文档库进行全面搜索的场景,如新闻报道、在线图书馆等。
- 技术实现:采用倒排索引技术,通过关键词快速定位到文档,同时结合诸如TF-IDF等算法优化搜索结果的相关性。
混合检索
混合检索模式结合了语义检索的深度理解和全文检索的快速响应,旨在提供既精准又全面的搜索体验。在这种模式下,系统不仅会进行关键词匹配,还会结合语义相似度计算,以确保搜索结果的相关性和准确性。
- 优势:兼顾了全文检索的速度和语义检索的深度,提供了一个平衡的搜索解决方案,提高了用户满意度。
- 应用场景:适合于需要综合考虑检索速度和结果质量的场景,如在线客服、内容推荐系统等。
- 技术实现:通过结合倒排索引和向量空间模型,实现对用户查询的全面理解和快速响应。例如,可以先通过全文检索快速筛选出候选集,再通过语义检索从候选集中找出最相关的结果。
向量模型
我是在内网搭建的平台使用的是BGE-M3
作为向量模型,它是智源开源的模型,具体介绍可以查看 FlagEmbedding 仓库的介绍。简单来说这个模型具有以下特点:
- 多功能:可以同时执行三种检索功能:单向量检索、多向量检索和稀疏检索。
- 多语言:支持100多种工作语言。
- 多粒度:它能够处理不同粒度的输入,从短句子到长达8192个词汇的长文档。
整体来说中文的使用效果还是挺不错的,以下是我的使用截图。
业务流程及源码分析
以下将以知识库的搜索测试功能为入口,分析FastGPT的知识检索流程。
填写测试文本,选择检索模式
检索时会调用接口api/core/dataset/searchTest
,相关业务逻辑在对应文件里。源码如下:
js
export default withNextCors(async function handler(req: NextApiRequest, res: NextApiResponse<any>) {
try {
await connectToDatabase();
const {
datasetId,
text,
limit = 1500,
similarity,
searchMode,
usingReRank,
datasetSearchUsingExtensionQuery = false,
datasetSearchExtensionModel,
datasetSearchExtensionBg = ''
} = req.body as SearchTestProps;
if (!datasetId || !text) {
throw new Error('缺少参数');
}
const start = Date.now();
// auth dataset role
const { dataset, teamId, tmbId, apikey } = await authDataset({req, authToken: true, authApiKey: true, datasetId, per: 'r' });
// auth balance
await authTeamBalance(teamId);
// query extension
const extensionModel =
datasetSearchUsingExtensionQuery && datasetSearchExtensionModel
? getLLMModel(datasetSearchExtensionModel)
: undefined;
const { concatQueries, rewriteQuery, aiExtensionResult } = await datasetSearchQueryExtension({
query: text,
extensionModel,
extensionBg: datasetSearchExtensionBg
});
const { searchRes, charsLength, ...result } = await searchDatasetData({
teamId,
reRankQuery: rewriteQuery,
queries: concatQueries,
model: dataset.vectorModel,
limit: Math.min(limit, 20000),
similarity,
datasetIds: [datasetId],
searchMode,
usingReRank
});
// push bill
const { total } = pushGenerateVectorBill({
teamId,
tmbId,
charsLength,
model: dataset.vectorModel,
source: apikey ? BillSourceEnum.api : BillSourceEnum.fastgpt,
// ... 省略部分
});
if (apikey) {
updateApiKeyUsage({
apikey,
usage: total
});
}
jsonRes<SearchTestResponse>(res, {
data: {
list: searchRes,
duration: `${((Date.now() - start) / 1000).toFixed(3)}s`,
usingQueryExtension: !!aiExtensionResult,
...result
}
});
} catch (err) {
// ...
}
});
分析源码知道触发检索之后会做以下几件事:
- 检测用户是否有知识库的"读"权限;
- 检测团队的账户余额;
- 检测是否开启问题补全配置,如开启则将对应的搜索文本、对话记录传给AI模型,重新生成检索文本;
- 调用
searchDatasetData
去检索相关数据; - 更新团队的账单、apikey的使用记录;
- 检索用时记录,返回检索结果;
基本上都是一些业务逻辑的处理,检索数据的最核心逻辑在searchDatasetData
内部。为更好理解代码,以下对一些变量、参数进行说明:
text
: 用户输入的检索文本;searchMode
:检索模式;limit
: 引用的 token上限;similarity
: 最低相关度;datasetSearchUsingExtensionQuery
: 是否开启问题补全;datasetSearchExtensionModel
: 问题补全所用的模型;datasetSearchExtensionBg
: 问题补全的对话背景描述;datasetId
: 知识库 id;usingReRank
: 是否对召回文本进行相关性重排,需要结合rerank
模型;rewriteQuery
: 开启问题补全则为大模型重新的问题,未开启则为text
原文;
searchDatasetData
对应文件路径为projects/app/src/service/core/dataset/data/controller.ts
。这里面代码比较长,主要逻辑为:
- 根据检索模式设置向量检索和文本检索的
chunk
数量限制,例如:语义检索模式下embeddingLimit=100
,fullTextLimit=0
; - 根据
embeddingLimit
,fullTextLimit
数量限制分别通过向量检索、文本检索召回数据,并采用RFF
算法排序; - 如果开启
rerank
则调用rerank model
进行重新排序; - 对相同数据结果进行去重,并根据用户设置的
similarity
过滤相关度较低的数据; - 返回最终结果;
下面对主要逻辑及相关代码进行解析。
入口参数解析
主要解析参数,确定检索模式和是否使用rerank
重排;
💀 此处还有一个写死的逻辑,
limit
如果设置token数小于50实际是不起效的。
js
let {
teamId,
reRankQuery,
queries,
model,
similarity = 0,
limit: maxTokens,
searchMode = DatasetSearchModeEnum.embedding,
usingReRank = false,
datasetIds = []
} = props;
/* init params */
searchMode = DatasetSearchModeMap[searchMode] ? searchMode : DatasetSearchModeEnum.embedding;
usingReRank = usingReRank && global.reRankModels.length > 0;
// Compatible with topk limit
if (maxTokens < 50) {
maxTokens = 1500;
}
根据检索模式设置向量检索和文本检索的限制
js
//函数定义
const countRecallLimit = () => {
if (searchMode === DatasetSearchModeEnum.embedding) {
return {
embeddingLimit: 100,
fullTextLimit: 0
};
}
if (searchMode === DatasetSearchModeEnum.fullTextRecall) {
return {
embeddingLimit: 0,
fullTextLimit: 100
};
}
return { //混合模式语料组成
embeddingLimit: 60,
fullTextLimit: 40
};
};
// 调用
const { embeddingLimit, fullTextLimit } = countRecallLimit();
multiQueryRecall recall:首先分别获取 embedding、fulltext 的召回语料;
js
const multiQueryRecall = async ({
embeddingLimit,
fullTextLimit
}: {
embeddingLimit: number;
fullTextLimit: number;
}) => {
// multi query recall
const embeddingRecallResList: SearchDataResponseItemType[][] = [];
const fullTextRecallResList: SearchDataResponseItemType[][] = [];
let totalCharsLength = 0;
await Promise.all(
// queries ["数组形式的检索内容"] ,why inverse 2 arr?
queries.map(async (query) => {
const [{ charsLength, embeddingRecallResults }, { fullTextRecallResults }] =
await Promise.all([
embeddingRecall({
query,
limit: embeddingLimit
}),
fullTextRecall({
query,
limit: fullTextLimit
})
]);
totalCharsLength += charsLength;
embeddingRecallResList.push(embeddingRecallResults);
fullTextRecallResList.push(fullTextRecallResults);
})
);
// rrf concat
const rrfEmbRecall = datasetSearchResultConcat(
embeddingRecallResList.map((list) => ({ k: 60, list }))
).slice(0, embeddingLimit);
const rrfFTRecall = datasetSearchResultConcat(
fullTextRecallResList.map((list) => ({ k: 60, list }))
).slice(0, fullTextLimit);
return {
charsLength: totalCharsLength,
embeddingRecallResults: rrfEmbRecall,
fullTextRecallResults: rrfFTRecall
};
};
embeddingRecall
逻辑:
- 调用 getVectorsByText 获取查询语句的向量;
- 使用
recallFromVectorStore
函数,根据得到的向量和限制参数limit
,从向量存储中检索最相似的数据点。datasetIds
参数用于限制搜索的数据集。 - 使用
MongoDatasetData
模型查询MongoDB数据库,根据teamId
、datasetId
和results
中的id
来检索相关的数据记录。查询结果被populate
方法进一步丰富,以包含关联的集合信息。 - 评分和排序:将检索到的数据记录按照得分进行排序,并将得分添加到每个数据记录中。
- 格式化结果:将排序后的数据记录转换成统一的
SearchDataResponseItemType
格式,并返回包含这些格式化结果和charsLength
(字符长度)的对象。
rerank 中的去重逻辑
- 合并 embedding 和 fulltext 的结果,并根据 id 去重;
- 对qa字符串拼接,并删除空格、标点符号,对字符串进行hash编码并去重;
- 如果配置了 rerank 模型,那调用模型进行重排序,并在 score 中新增 rerank 的得分;没有则不会增加 rerank的得分;
合并三种检索的结果:对重复的数据去重并使用最高得分;计算 rrfScore 并以其为依据排序;
php
// embedding recall and fullText recall rrf concat
const rrfConcatResults = datasetSearchResultConcat([
{ k: 60, list: embeddingRecallResults },
{ k: 64, list: fullTextRecallResults },
{ k: 60, list: reRankResults }
]);
结果去重
typescript
// remove same q and a data
set = new Set<string>();
const filterSameDataResults = rrfConcatResults.filter((item) => {
// 删除所有的标点符号与空格等,只对文本进行比较
const str = hashStr(`${item.q}${item.a}`.replace(/[^\p{L}\p{N}]/gu, ''));
if (set.has(str)) return false;
set.add(str);
return true;
});
根据用户设置的最小分数过滤数据
js
// score filter
const scoreFilter = (() => {
if (usingReRank) {
usingSimilarityFilter = true;
return filterSameDataResults.filter((item) => {
const reRankScore = item.score.find((item) => item.type === SearchScoreTypeEnum.reRank);
if (reRankScore && reRankScore.value < similarity) return false;
return true;
});
}
if (searchMode === DatasetSearchModeEnum.embedding) {
usingSimilarityFilter = true;
return filterSameDataResults.filter((item) => {
const embeddingScore = item.score.find(
(item) => item.type === SearchScoreTypeEnum.embedding
);
if (embeddingScore && embeddingScore.value < similarity) return false;
return true;
});
}
return filterSameDataResults;
})();