RAGFlow 知识库检索流程

一、输入预处理阶段:标准化清洗,剔除检索噪声

核心目标:将用户非标准化输入转换为统一、干净的检索文本,为后续分词和权重计算奠定基础

  1. 问题接收:获取用户原始查询问题
  2. 中英文分隔:调用add_space_between_eng_zh方法,在中英文词汇间自动添加空格,避免连写误判
  3. 特殊字符清理:通过正则re.sub(r"[ :|\r\n\t,,。??/!!&^%%(){}<>]+"," ", ...)`,将所有标点、空白符、特殊符号统一替换为单个空格
  4. 文本归一化:执行rag_tokenizer.tradi2simp(rag_tokenizer.strQ2B(txt.lower())),完成小写转换 + 全角转半角 + 繁简转换
  5. 分词处理:调用rag_tokenizer.tokenize(question).split(),将清洗后的文本拆解为独立词汇列表tks

二、权重计算阶段:量化词汇重要性,聚焦核心检索词

核心目标:为分词结果分配权重,区分核心词汇与辅助词汇,让检索系统优先聚焦关键信息

2.1 初始权重计算

通过self.tw.weights(tks, preprocess=False)计算每个分词的重要性权重,输出[(token, weight)]格式的权重列表 tks_w

2.2 权重优化与噪声过滤

对初始权重列表进行多轮清洗,剔除无效词汇、清理残留字符,核心代码如下:

python

运行

python 复制代码
# 清理词汇内引号、空格等特殊字符
tks_w = [(re.sub(r"[ \\\"'^]", "", tk), w) for tk, w in tks_w]
# 过滤单个字母/数字(无实际检索价值)
tks_w = [(re.sub(r"^[a-z0-9]$", "", tk), w) for tk, w in tks_w if tk]
# 清理词汇前导加减号
tks_w = [(re.sub(r"^[\+-]", "", tk), w) for tk, w in tks_w if tk]
# 首尾去空格并过滤空值
tks_w = [(tk.strip(), w) for tk, w in tks_w if tk.strip()]
python 复制代码
import logging  # 导入日志模块,用于记录程序运行信息
import math     # 导入数学模块,提供数学运算功能
import json     # 导入JSON模块,用于处理JSON格式数据
import re       # 导入正则表达式模块,用于字符串匹配和替换
import os       # 导入操作系统接口模块,用于文件路径操作
import numpy as np  # 导入numpy库并重命名为np,用于数值计算
from rag.nlp import rag_tokenizer  # 从rag.nlp模块导入rag_tokenizer分词器
from common.file_utils import get_project_base_directory  # 从common.file_utils导入项目根目录获取函数


class Dealer:  # 定义词项权重计算器类
    def __init__(self):  # 初始化方法
        # 定义停用词集合,包含常见的无意义词语
        self.stop_words = set(["请问",
                               "您",
                               "你",
                               "我",
                               "他",
                               "是",
                               "的",
                               "就",
                               "有",
                               "于",
                               "及",
                               "即",
                               "在",
                               "为",
                               "最",
                               "有",
                               "从",
                               "以",
                               "了",
                               "将",
                               "与",
                               "吗",
                               "吧",
                               "中",
                               "#",
                               "什么",
                               "怎么",
                               "哪个",
                               "哪些",
                               "啥",
                               "相关"])

        def load_dict(fnm):  # 内部函数:加载字典文件
            res = {}  # 创建空字典用于存储结果
            f = open(fnm, "r")  # 打开指定文件
            while True:  # 无限循环读取文件
                line = f.readline()  # 读取一行
                if not line:  # 如果没有更多行
                    break  # 跳出循环
                arr = line.replace("\n", "").split("\t")  # 移除换行符并按制表符分割
                if len(arr) < 2:  # 如果分割后少于2个元素
                    res[arr[0]] = 0  # 设置第一个元素的值为0
                else:
                    res[arr[0]] = int(arr[1])  # 设置第一个元素对应第二个元素的整数值

            c = 0  # 初始化计数器
            for _, v in res.items():  # 遍历字典的所有值
                c += v  # 累加所有值
            if c == 0:  # 如果总和为0
                return set(res.keys())  # 返回键的集合
            return res  # 返回原始字典

        fnm = os.path.join(get_project_base_directory(), "rag/res")  # 构建资源文件夹路径
        self.ne, self.df = {}, {}  # 初始化命名实体和文档频率字典
        try:  # 尝试加载NER词典
            self.ne = json.load(open(os.path.join(fnm, "ner.json"), "r"))  # 加载NER JSON文件
        except Exception:  # 捕获异常
            logging.warning("Load ner.json FAIL!")  # 记录NER文件加载失败警告
        try:  # 尝试加载术语频率文件
            self.df = load_dict(os.path.join(fnm, "term.freq"))  # 加载术语频率文件
        except Exception:  # 捕获异常
            logging.warning("Load term.freq FAIL!")  # 记录术语频率文件加载失败警告

    def pretoken(self, txt, num=False, stpwd=True):  # 预分词函数,处理文本去除标点和停用词
        # 定义标点符号模式列表,用于识别需要过滤的字符
        patt = [
            r"[~---\t @#%!<>,\.\?\":;'\{\}\[\]_=\(\)\|,。?》•●○↓《;'':""【¥ 】...¥!、·()×`&\\/「」\\]"
        ]
        # 定义正则表达式替换规则列表(当前为空)
        rewt = [
        ]
        for p, r in rewt:  # 遍历替换规则
            txt = re.sub(p, r, txt)  # 执行正则替换

        res = []  # 初始化结果列表
        # 对输入文本进行分词并遍历每个词元
        for t in rag_tokenizer.tokenize(txt).split():
            tk = t  # 当前词元赋值给tk
            # 如果启用停用词过滤且词元在停用词表中,或者词元是数字且不保留数字
            if (stpwd and tk in self.stop_words) or (
                    re.match(r"[0-9]$", tk) and not num):
                continue  # 跳过此词元
            for p in patt:  # 遍历标点模式
                if re.match(p, t):  # 如果词元匹配标点模式
                    tk = "#"  # 替换为占位符
                    break  # 跳出循环
            # tk = re.sub(r"([\+\\-])", r"\\\1", tk)  # 注释掉的转义特殊字符代码
            if tk != "#" and tk:  # 如果不是占位符且非空
                res.append(tk)  # 添加到结果列表
        return res  # 返回预处理后的词元列表

    def token_merge(self, tks):  # 词元合并函数,将单字符词元合并成多字符词
        def one_term(t):  # 内部函数:判断是否为单字符词
            return len(t) == 1 or re.match(r"[0-9a-z]{1,2}$", t)  # 单字符或1-2位数字字母

        res, i = [], 0  # 初始化结果列表和索引
        while i < len(tks):  # 遍历词元列表
            j = i  # 设置结束索引
            # 如果首词是单字符且下一个词非单字符,则合并前两个词
            if i == 0 and one_term(tks[i]) and len(
                    tks) > 1 and (len(tks[i + 1]) > 1 and not re.match(r"[0-9a-zA-Z]", tks[i + 1])):
                res.append(" ".join(tks[0:2]))  # 合并前两个词
                i = 2  # 更新索引
                continue  # 继续循环

            # 查找连续的单字符词元
            while j < len(
                    tks) and tks[j] and tks[j] not in self.stop_words and one_term(tks[j]):
                j += 1
            if j - i > 1:  # 如果找到多个连续单字符词
                if j - i < 5:  # 如果数量小于5
                    res.append(" ".join(tks[i:j]))  # 合并所有词元
                    i = j  # 更新索引
                else:  # 如果数量大于等于5
                    res.append(" ".join(tks[i:i + 2]))  # 只合并前两个
                    i = i + 2  # 更新索引
            else:  # 如果没有连续单字符词
                if len(tks[i]) > 0:  # 如果当前词非空
                    res.append(tks[i])  # 添加当前词
                i += 1  # 索引递增
        return [t for t in res if t]  # 返回非空词元列表

    def ner(self, t):  # 命名实体识别函数
        if not self.ne:  # 如果NER字典为空
            return ""  # 返回空字符串
        res = self.ne.get(t, "")  # 获取词元的NER标签
        if res:  # 如果存在标签
            return res  # 返回标签

    def split(self, txt):  # 文本分割函数,处理相邻的英文字词合并
        tks = []  # 初始化词元列表
        for t in re.sub(r"[ \t]+", " ", txt).split():  # 规范化空白字符并分割
            # 如果上一个词和当前词都是英文字母,且都不是函数类型
            if tks and re.match(r".*[a-zA-Z]$", tks[-1]) and \
                    re.match(r".*[a-zA-Z]$", t) and tks and \
                    self.ne.get(t, "") != "func" and self.ne.get(tks[-1], "") != "func":
                tks[-1] = tks[-1] + " " + t  # 合并两个词
            else:
                tks.append(t)  # 添加新词
        return tks  # 返回处理后的词元列表

    def weights(self, tks, preprocess=True):  # 计算词元权重函数
        # 数字模式:匹配2位及以上数字、逗号、点号
        num_pattern = re.compile(r"[0-9,.]{2,}$")
        # 小写字母模式:匹配1-2位小写字母
        short_letter_pattern = re.compile(r"[a-z]{1,2}$")
        # 数字空格模式:匹配2位及以上数字、点号、空格、横线
        num_space_pattern = re.compile(r"[0-9. -]{2,}$")
        # 字母模式:匹配小写字母、点号、空格、横线
        letter_pattern = re.compile(r"[a-z. -]+$")

        def ner(t):  # 内部函数:命名实体权重计算
            if num_pattern.match(t):  # 如果是数字
                return 2  # 权重为2
            if short_letter_pattern.match(t):  # 如果是短字母串
                return 0.01  # 权重为0.01
            if not self.ne or t not in self.ne:  # 如果不在NER字典中
                return 1  # 权重为1
            # 定义NER类型权重映射
            m = {"toxic": 2, "func": 1, "corp": 3, "loca": 3, "sch": 3, "stock": 3,
                 "firstnm": 1}
            return m[self.ne[t]]  # 返回对应NER类型的权重

        def postag(t):  # 内部函数:词性标注权重计算
            t = rag_tokenizer.tag(t)  # 获取词性标签
            if t in set(["r", "c", "d"]):  # 如果是代词、连词、副词
                return 0.3  # 权重较低
            if t in set(["ns", "nt"]):  # 如果是地名、机构名
                return 3  # 权重较高
            if t in set(["n"]):  # 如果是名词
                return 2  # 权重中等
            if re.match(r"[0-9-]+", t):  # 如果是数字序列
                return 2  # 权重中等
            return 1  # 默认权重

        def freq(t):  # 内部函数:词频权重计算
            if num_space_pattern.match(t):  # 如果是数字空格模式
                return 3  # 返回固定权重
            s = rag_tokenizer.freq(t)  # 获取词频
            if not s and letter_pattern.match(t):  # 如果词频为0且符合字母模式
                return 300  # 返回高权重
            if not s:  # 如果词频为0
                s = 0  # 设置为0

            if not s and len(t) >= 4:  # 如果词频仍为0且长度>=4
                # 获取细粒度分词结果
                s = [tt for tt in rag_tokenizer.fine_grained_tokenize(t).split() if len(tt) > 1]
                if len(s) > 1:  # 如果有多个子词
                    s = np.min([freq(tt) for tt in s]) / 6.  # 计算最小词频除以6
                else:
                    s = 0  # 否则设为0

            return max(s, 10)  # 返回最大值,最小为10

        def df(t):  # 内部函数:文档频率权重计算
            if num_space_pattern.match(t):  # 如果是数字空格模式
                return 5  # 返回固定权重
            if t in self.df:  # 如果在文档频率字典中
                return self.df[t] + 3  # 返回频率加3
            elif letter_pattern.match(t):  # 如果符合字母模式
                return 300  # 返回高权重
            elif len(t) >= 4:  # 如果长度>=4
                # 获取细粒度分词结果
                s = [tt for tt in rag_tokenizer.fine_grained_tokenize(t).split() if len(tt) > 1]
                if len(s) > 1:  # 如果有多个子词
                    return max(3, np.min([df(tt) for tt in s]) / 6.)  # 返回最小DF除以6的最大值

            return 3  # 默认返回3

        def idf(s, N):  # 内部函数:逆文档频率计算
            return math.log10(10 + ((N - s + 0.5) / (s + 0.5)))  # IDFs平滑计算公式

        tw = []  # 初始化词权重列表
        if not preprocess:  # 如果不进行预处理
            # 计算基于词频的IDF值数组
            idf1 = np.array([idf(freq(t), 10000000) for t in tks])
            # 计算基于文档频率的IDF值数组
            idf2 = np.array([idf(df(t), 1000000000) for t in tks])
            # 计算综合权重:0.3*词频IDF + 0.7*文档频率IDF,乘以NER权重和词性权重
            wts = (0.3 * idf1 + 0.7 * idf2) * \
                  np.array([ner(t) * postag(t) for t in tks])
            wts = [s for s in wts]  # 转换为列表
            tw = list(zip(tks, wts))  # 将词元和权重配对
        else:  # 如果进行预处理
            for tk in tks:  # 遍历每个词元
                # 预处理词元并合并
                tt = self.token_merge(self.pretoken(tk, True))
                # 计算词频IDF
                idf1 = np.array([idf(freq(t), 10000000) for t in tt])
                # 计算文档频率IDF
                idf2 = np.array([idf(df(t), 1000000000) for t in tt])
                # 计算综合权重
                wts = (0.3 * idf1 + 0.7 * idf2) * \
                      np.array([ner(t) * postag(t) for t in tt])
                wts = [s for s in wts]  # 转换为列表
                tw.extend(zip(tt, wts))  # 扩展词权重列表

        S = np.sum([s for _, s in tw])  # 计算所有权重的总和
        return [(t, s / S) for t, s in tw]  # 返回归一化的词元权重对

三、同义词扩展阶段:打破表述限制,提升检索鲁棒性

核心目标:解决用户提问表述多样性问题,通过同义词扩展扩大召回范围,同时保证检索精准度

  1. 同义词查找:遍历权重列表前 256 个词汇(兼顾效率与效果),调用self.syn.lookup(tk)获取每个词汇的同义词

  2. 同义词分词:对同义词列表执行rag_tokenizer.tokenize(" ".join(syn)).split(),保持与原分词格式统一

  3. 同义词权重分配:将同义词权重设定为原词权重的 1/4 ,避免稀释核心词汇重要性,核心代码:

    python

    运行

    复制代码
    syn = ["\"{}\"^{:.4f}".format(s, w / 4.) for s in syn if s.strip()]

四、查询构建阶段:构建 ES 可执行查询,组合单词 + 词组检索

核心目标:将权重词汇与同义词转换为 Elasticsearch(ES)可识别的检索语句,兼顾词汇级和短语级匹配

4.1 单词查询构建

结合原词权重与同义词,构建格式为(token^weight syn1^0.25 syn2^0.25)的单词检索语句:

python

运行

复制代码
q = ["({}^{:.4f}".format(tk, w) + " {})".format(syn) for (tk, w), syn in zip(tks_w, syns) if tk and not re.match(r"[.^+\(\)-]", tk)]

4.2 词组查询构建

构建二元连续词组,提升短语匹配精准度,词组权重为两词最大权重的 2 倍(强化核心短语优先级):

python

运行

复制代码
for i in range(1, len(tks_w)):
    q.append('"%s %s"^%.4f' % (tks_w[i - 1][0], tks_w[i][0], max(tks_w[i - 1][1], tks_w[i][1]) * 2,))

五、ES 多字段检索配置:差异化字段权重,聚焦高价值信息

核心目标:针对文档不同字段的信息价值,分配差异化检索权重,让 ES 优先匹配核心字段

5.1 核心字段权重配置(权重越高,检索优先级越高)

python

运行

复制代码
self.query_fields = [
    "title_tks^10",        # 标题词汇
    "title_sm_tks^5",      # 标题细粒度词汇
    "important_kwd^30",    # 重要关键词(最高优先级)
    "important_tks^20",    # 重要词汇
    "question_tks^20",     # 问题词汇
    "content_ltks^2",      # 内容词汇
    "content_sm_ltks",     # 内容细粒度词汇(默认权重1,最低)
]

5.2 多字段查询构建

通过MatchTextExpr将构建好的检索语句与字段权重结合,生成 ES 多字段匹配查询,ES 将按字段权重 + 词汇权重进行综合评分。

python 复制代码
GET /ragflow_b9ab746cfcc511f0beaf1ae1f88af9af/_search
{
  "query": {
    "bool": {
      "must": [
        // 用 query_string 替换原 multi_match,实现词级加权+短语加权
        {
          "query_string": {
            "query": "(背景)^0.5094773480212931 (项目)^0.49052265197870687 (\"项目 背景\"~2)^1.5",
            "fields": [
              "title_tks^10",
              "title_sm_tks^5",
              "important_kwd^30",
              "important_tks^20",
              "question_tks^20",
              "content_ltks^2",
              "content_sm_ltks"
            ],
            "minimum_should_match": "30%"  // 对应 extra_options 里的 0.3
          }
        }
      ],
      "filter": [
        // 保留你原有的 kb_id 过滤
        {
          "term": {
            "kb_id": "187a8b30000011f19ace1ae1f88af9af"
          }
        }
      ]
    }
  },
  "size": 10  // 对应 topn = 100
}

注意:这是只是初步检索阶段计算 ,其实本质上面是没有用这个分分数, 是基于下面的排序,自定义的分数计算规则

六、向量检索构建:捕捉语义相关性,弥补关键词检索短板

核心目标:将用户问题转换为语义向量,通过向量相似度匹配,解决关键词检索 "语义脱节" 问题

6.1 问题向量编码

通过嵌入模型将文本转换为低维稠密向量,为向量检索提供基础:

python

运行

复制代码
qv, _ = emb_mdl.encode_queries(txt)
embedding_data = [get_float(v) for v in qv]
vector_column_name = f"q_{len(embedding_data)}_vec"

6.2 文本 + 向量融合查询

采用加权融合方式组合文本检索与向量检索,突出语义相关性的核心地位,核心配置:

python

运行

复制代码
# 构建向量检索表达式(余弦相似度匹配)
matchDense = MatchDenseExpr(vector_column_name, embedding_data, 'float', 'cosine', topk, {"similarity": similarity})
# 融合配置:文本检索0.05,向量检索0.95
fusionExpr = FusionExpr("weighted_sum", topk, {"weights": "0.05,0.95"})
matchExprs = [matchText, matchDense, fusionExpr]

检索流程:

1、ES执行多字段文本检索

2、向量数据库执行语义检索

3、融合算法合并两种结果

4、重排序生成最终结果

七、ES 检索执行:执行融合查询,获取初始检索结果

核心目标:调用 ES 检索接口,执行文本 + 向量融合查询,返回原始匹配结果

  1. 执行检索:调用self.dataStore.search()方法,传入文本匹配、向量匹配及融合表达式
  2. 结果提取:从 ES 返回结果中提取三大核心信息:
    • ids:匹配文档 / 片段的唯一 ID 列表
    • field:匹配内容的各类字段数据(标题、正文、关键词等)
    • highlight:检索结果的高亮匹配标注,定位核心匹配位置

八、重排序阶段:二次优化排序,提升结果贴合度

核心目标:对 ES 初始结果进行二次重排序,结合词项相似度与向量相似度,让结果更贴合用户需求

8.1 文本词汇权重二次调整

强化核心字段词汇的重要性,重新分配词汇权重(按字段价值加权):

python

运行

python 复制代码
   for i in sres.ids:
            # 获取内容词汇,使用OrderedDict去重但保持顺序
            content_ltks = list(OrderedDict.fromkeys(sres.field[i][cfield].split()))
            # 获取标题词汇,过滤掉空字符串
            title_tks = [t for t in sres.field[i].get("title_tks", "").split() if t]
            # 获取问题词汇,过滤掉空字符串
            question_tks = [t for t in sres.field[i].get("question_tks", "").split() if t]
            # 获取重要关键词
            important_kwd = sres.field[i].get("important_kwd", [])
            # 构建词汇列表,给不同类型的词汇分配不同权重:内容词汇*1, 标题词汇*2, 重要关键词*5, 问题词汇*6
            tks = content_ltks + title_tks * 2 + important_kwd * 5 + question_tks * 6
            # 将词汇列表添加到文本权重列表
            ins_tw.append(tks)

8.2 混合相似度计算(核心重排序逻辑)

融合向量相似度 (语义)与词项相似度 (关键词),计算公式:混合相似度 = 向量相似度 ×0.7 + 词项相似度 ×0.3,核心代码:

python

运行

python 复制代码
    def hybrid_similarity(self, avec, bvecs, atks, btkss, tkweight=0.3, vtweight=0.7):
        # 导入余弦相似度计算函数,用于计算向量之间的相似度
        from sklearn.metrics.pairwise import cosine_similarity
        # 导入numpy库,用于数值计算
        import numpy as np

        # 计算查询向量(avec)与多个目标向量(bvecs)之间的余弦相似度
        sims = cosine_similarity([avec], bvecs)
        # 计算基于关键词的文本相似度,atks是查询的关键词列表,btkss是目标文本的关键词列表
        tksim = self.token_similarity(atks, btkss)
        # 检查向量相似度是否全为0(即没有有效的向量相似度计算结果)
        if np.sum(sims[0]) == 0:
            # 如果向量相似度全为0,则只返回基于关键词的相似度结果
            return np.array(tksim), tksim, sims[0]
        # 将向量相似度和关键词相似度按权重进行加权求和,返回综合相似度得分、关键词相似度和向量相似度
        return np.array(sims[0]) * vtweight + np.array(tksim) * tkweight, tksim, sims[0]

8.3 词项相似度计算

以 "查询词汇匹配权重和 / 查询词汇总权重" 为核心,计算关键词匹配度(添加 1e-9 避免除零):

python

运行

复制代码
def similarity(self, qtwt, dtwt):
    s = 1e-9
    for k, v in qtwt.items():
        if k in dtwt:
            s += v  # 累加匹配权重
    q = 1e-9
    for k, v in qtwt.items():
        q += v  # 累加查询总权重
    return s / q  # 词项相似度值

九、最终结果处理:过滤 + 分页 + 标准化,输出可用结果

核心目标:将重排序后的结果进行最终处理,确保输出结果的有效性、规范性和可用性

  1. 相似度阈值过滤:剔除低于阈值的低相关结果,valid_idx = [int(i) for i in sorted_idx if sim_np[i] >= similarity_threshold]
  2. 结果排序:按混合相似度降序排列过滤后的结果
  3. 分页处理:根据用户需求计算索引范围,返回指定页面结果
  4. 标准化输出:每个结果包含统一结构,便于后续调用:
    • 基础标识:chunk_id(块 ID)
    • 相似度指标:similarity(综合)、vector_similarity(向量)、term_similarity(词项)
    • 内容字段:标题、正文、关键词等原始字段数据

十、特殊处理机制:应对边缘场景,提升系统鲁棒性

核心目标:解决检索过程中的边缘问题,避免无结果、结果偏差等情况,提升系统稳定性

10.1 空结果重试机制

当初次检索无结果时,自动降低检索阈值重新执行,尽可能召回相关内容:

  • 降低min_match(最小匹配数)阈值至 0.1
  • 调整similarity(相似度)阈值至 0.17
  • 重新执行完整检索流程

10.2 排名特征补充计算

在混合相似度基础上,补充额外特征得分,优化最终排序:

  • PageRank 得分:评估文档的权威度和关联性
  • 标签特征得分:结合文档标签与用户问题的匹配度
  • 将特征得分融入最终相似度计算,让权威、贴合的结果排名更靠前