FastGPT源码深度剖析:混合检索及语料召回逻辑

引言

在信息爆炸的时代,高效准确地检索所需知识变得尤为重要。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数据库,根据teamIddatasetIdresults中的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;
  })();
相关推荐
Chikaoya1 小时前
项目中用户数据获取遇到bug
前端·typescript·vue·bug
我认不到你4 小时前
antd proFromSelect 懒加载+模糊查询
前端·javascript·react.js·typescript
火山引擎边缘云4 小时前
创新实践:基于边缘智能+扣子的智能轮椅 AIoT 解决方案
人工智能·llm·边缘计算
Baihai_IDP10 小时前
「混合专家模型」可视化指南:A Visual Guide to MoE
人工智能·llm·aigc
奔跑草-1 天前
【前端】深入浅出 - TypeScript 的详细讲解
前端·javascript·react.js·typescript
Just Jump1 天前
大语言模型LLM综述
llm·大语言模型
新星_1 天前
构造函数类型
typescript
数据智能老司机1 天前
LLM工程师手册——RAG 推理管道
人工智能·llm·aiops
清灵xmf1 天前
TypeScript 中的 ! 和 ? 操作符
前端·javascript·typescript·?·
葫芦鱼1 天前
怎么打造一个舒适的nodejs开发环境
前端·typescript