Dify知识库检索与召回

in 默认分类 with 0 comment

2025-07-22T00:54:48.png

本文背景


最近研究Dify知识库,要求2022-2025年所发帖子、手册导入Dify知识库,根据关键词进行检索。找出相关的信息。由于年份跨度较大及内容较杂,刚开始时确实无从下手。在尝试各种模型、各种Dify模式、各种形式的文本及各种文本内容后总结出此文章。网上关于此Dify知识库的搭建文档相对较少,有些点基本没有提及,此文可能还是不完美,但此文档中的经验相信还是有一定参考意义,不枉费所花时间进行测试和总结。在开始例子之前,介绍一下Dify知识库中重要的模型及配置介绍。

Embedding和Reranker介绍


Embedding模型

功能为文本向量化和数据召回。
1、在文本上传至Dify知识库时,会将文档进行文档向量化,通用模式将文本向量化,父子模式将子块向量化,后续用户查询时将通过这些向量化过的数据进行查询;
2、将海量文档排序,从中快速找出候选集,作用于召回阶段,速度较快但不准确。

Reranker模型

Reranker是对已召回结果进行精细相关性排序,对上述候选集进行精细排序,提升Top结果质量作用于排序阶段,速度较慢但较为准确。Reranker会列出最接近你搜索的关键词top k个文档内容。

可理解粗略的认为Embedding模型先进行初次排序,然后再交给Reranker模型进行精准排序。排序的结果交给大语言模型进行归纳总结,最后输出。

Dify知识库中的通用模式和父子模式


通用模式(Standard Chunking)

1、文档被分割为固定大小的文本块(默认500~2000字符),每个文本块独立嵌入向量或建立关键词索引,直接用于检索匹配用户查询;

2、优势:
2.1)处理速度快:分块简单,索引构建效率高;
2.2)资源消耗低:适合实时性要求高的场景,如客服问答;

3、缺点:
3.1)上下文割裂:检索结果可能分散在多块中,导致LLM无法获取完整语义;
3.2)精度与上下文的矛盾:大分块易包含冗余信息,小分块则丢失上下文;

4、配置要点:
4.1)分块大小:通过处理规则调整chunk_size,技术文档建议800字符,长文本建议1500字符;
4.2)索引模式:可选高效模式:向量索引或精准模式、混合检索;

5、适用场景:简单问答、实时响应需求高的场景,如产品FAQ。

2025-08-13T01:51:37.png

父子模式(Parent-Child Chunking)

1、父子模式采用分层分块策略:
1.1)父块(Parent Chunk):包含子块的更大上下文单元(如完整段落),为LLM提供背景信息;
1.2)子块(Child Chunk):细粒度文本单元(如单句),用于精准匹配查询关键词;

将一个文档分为多个父段,父段再次分为多个子段。当用户搜索某个关键词时,会优先从子段中查找。当从子段中找到相关信息后,会将相关整个父块输出,以确保检索。只有子块被向量化存储,父块仅作为原始文本关联,子块定位坐标,父块提供地图。

2、优势:
2.1)召回率提升:测试显示召回率比通用模式高15%;
2.2)回答质量优化:LLM基于完整上下文生成更准确的回答;

3、缺点:
3.1)处理时间长:相同文档索引时间约为通用模式的4倍;
3.2)资源消耗大:需存储双层索引结构;

4、配置要点:
4.1)分块模式选择:段落模式:按自然段落分父块,适合技术文档;
4.2)全文模式:整篇文档作为父块,适合短文本如合同;
4.3)子块定制:支持自定义分隔符(如按句子拆分)和最大长度(默认200 tokens);

5、适用场景:
5.1)复杂推理场景,如医疗诊断、法律分析;
5.2)需高精度检索的长文档,如产品手册、学术论文。

2025-08-13T01:26:58.png

6、父子模式运行总流程
2025-08-12T12:23:10.png

文本设置思路


1、原文件是所有年份所有帖子在一个excel表格。由于excel表格可读性较差,可按年、月、日和发布时间并以升序的方式分为2022、2023、2024、2025 四个md文件。将此四个md文件进行格式整理和清洗放在同一知识库中;
2、手册是带目录的pdf文件,由于此类文件可读性高,则单独建一个知识库存放;
3、使用父子模式进行分段,父段使用【\n\n】进行分段,子段使用【 】(空格)进行分段,最后配合embedding、reranker、向量检索模式进行检索排序;
4、创建聊天机器人,将上述两个知识库绑定至此机器人。

具体步骤


以下为本次要求的例子,但原理基本相同。原数据如下所示:
2025-08-12T06:40:49.png

整理excel格式

将excel的数据整理为:

#{date}:YYYY-MM-DD ##{created_at}:HH:MM:SS{author}:xx{content}:xxxxxxxxxxxxxxx{source_url}:www.xxxx.com

此时可在excel中使用下面公式进行整理:

="#{date}:"&TEXT(A2,"yyyy-mm-dd")&CHAR(10)&"##{created_at}:"&TEXT(B2,"HH:mm:ss")&CHAR(10)&"{author}:"&C2&CHAR(10)&"{content}:"&D2&CHAR(10)&"{source_url}:"&E2&CHAR(10)

2025-08-12T06:41:21.png

新建文件夹和md文件

新建文件夹,将整理后的数据复制到md文件中,在Windows 11中,可新建一个txt文件,点击【文件】->【新建markdown选项卡】,将上述整理的文本复制到此md文件中,并保存在刚才新建的文件中。
2025-08-12T06:44:07.png
2025-08-12T06:47:18.png
2025-08-12T06:47:55.png

打开notepad++删除特殊空格

打开【notepad++】,点击【视图】->【显示符号】->【显示所有字符】。此步的目的是可以更直观的查看文件中的换行、空格及特殊字符等。

2025-08-12T06:49:14.png

PS:比如下列为特殊的空格,需打开显示所有字符方可看到,需将所有类似特殊空格都删除。
2025-08-12T07:33:01.png
2025-08-12T07:33:13.png
2025-08-12T07:33:28.png
2025-08-12T07:33:38.png
2025-08-12T07:33:51.png

排序并进行数据清洗

1、去除所有【"】(出现"是因为这是excel的换行,与markdown的换行不同,需全部去除)
2025-08-12T06:54:29.png
2、去除所有的【\r\n】
2025-08-12T06:55:49.png
3、在【##{created_at}:】前面加个空格
2025-08-12T07:00:15.png
4、在【#{date}:】前面添加【\r\n\r\n】,这个很关键,后续父子模式分段时需使用。
2025-08-12T07:01:57.png
5、最终整理后效果如下图所示,按照#{date}进行分段,##{created_at}前面均有一个点,这个点就是空格;
2025-08-12T07:03:26.png
6、双击脚本对此md文件进行清洗(此backup是原md文件,清洗后的文件是根目录下的【#{date}2022-03-01.md】)
2025-08-12T07:06:46.png
2025-08-12T07:06:25.png
2025-08-12T07:07:15.png
7、最终清洗后,同一天发布的将删除相同的#{date}(2022年3月1日),特殊数字符号转为数字,最终效果如下图所示:
2025-08-12T08:42:22.png

创建Dify知识库

1、知识库添加清洗的md文件
2025-08-12T07:50:44.png

2、使用\n\n来作为父段标志,空格为子段标志。意思为根据文档中的一个个段落来分父段,父段中存在空格则为子段。对应到md文件则为\r\n\r\n为父段标志,每一段中的空格为一个个子段标志;根据文本中所分的块中的最大的字符来设置父子段大小,如#{date}下有多条##{created_at}且字数较多,则可分为父段4000,子段2000,
2025-08-12T07:51:51.png

3、embedding模型选择bge-m3,reranker模型选择bge-reranker-v2-m3。在初期选择时,选择的Embedding模型为bge-large-zh-v1.5和Reranker模型bge-reranker-large。由于这两个模型体量问题,无法处理长文本数据(具体体现就是报错)。所以后面更换Embedding为bge-m3和Reranker模型bge-reranker-v2-m3。这两个模型对长文本和中文文本较为友好。其中的top k 选择10,reranker模型则会优先召回质量最高的10条信息,可大大提高召回质量,然后下一步直至知识库创建结束;
2025-08-12T07:52:16.png
2025-08-12T07:52:35.png

4、Dify知识库中显示如下图所示:
2025-08-12T08:43:37.png

5、按照同样的方式再创建一个知识库,手册可按照下面进行设置即可。
2025-08-12T08:30:49.png

绑定聊天机器人

PS:此处可以写一些提示词,根据实际情况填写即可

 提取以下信息:
   - 发布时间:{created_at}
   - 标题:{title}
   - 作者:{author}
   - 内容:{content}
   - 查看链接:{source_url}

2025-08-12T08:11:41.png

最终展示效果

2025-08-12T08:32:51.png
2025-08-12T08:33:12.png

Dify知识库需注意事项


关于Dify知识库中并不常见的坑,在此进行记录:

1、在Dify知识库中,所上传文档优先级:带目录且整理有序的PDF及word文档>md文件(markdown格式)>Unicode文本(必须使用Unicode 文本,Dify无法识别普通txt文件)>excel表格。以上文本均需使用UTF-8编码。不管什么内容,都需要文本和格式清晰;

2、必须对文档内容进行清洗。清洗内容包括:将标点符号等转换为相同规格、去除特殊字符、去除emoji、多余且重复的字段、建议将【。】和【~】也删除,经观察发现,有时出现【。】,Dify知识库会截断,将其变成新的一段,检索时将无法检索到此条;在知识库中存在【~】,知识库中的文本块或回答的内容会被划一根线,影响美观;

3、检索不到的原因有哪些:
3.1)出现文档块的截断。知识库中进行分块时,不管是通用模式还是父子模式,都不可以出现过大或过小的块,当某一块出现断层时,则会破坏完整性,检索和召回时将无法检索到,下面就是标准的断层,第二条{created_at}是第一块多出来的,此时访问第一块的数据极有可能全部作废(即无法访问);
2025-08-12T09:17:50.png
3.2)特殊字符或符号阻断。由于我们使用空格作为子段,由于Dify知识库无法识别【特殊空格】,会把【特殊空格】当成正常空格,此时文档块就会被截断,所以我们要将所有出现的特殊空格全部删除;
3.3)文档过长。由于Dify知识库中的父段最多支持4000字符,如果出现#{date}下面出现多条##{created_at}亦或者字数很多,则需要人为添加#{date},将其分成多个,如下图所示##{created_at}有11条且字数较多,则需要手动添加相同的#{date},将其分成两端,查找时会先查找第二段数据后再查找第一段;
2025-08-12T09:30:07.png

4、现阶段上传至Dify知识库的文档,文本本质是string,如日期和时间不是以时间戳的形式进行交互。所以无法做到完美的按照时间进行排序,需进行二开或使用特殊方式进行排序;

5、经本人测试,embedding和reranker模型对召回质量有很大影响。建议embedding模型选择bge-m3,reranker模型选择bge-large-v2-m3;知识库中的检索设置选择向量检索,打开reranker模型,选择bge-large-v2-m3并将Top K选择10,Score 阈值关闭。此配置下的召回质量较高;

6、在知识库中可搜索到不代表使用模型召回时可以识别到,知识库只是存放上传文档的地方,使用关键词百分百搜索出现是正常现象;而使用模型召回时,是需从知识库使用embedding和reranker模型进行排序,通过模型进行思考后输出,知识库质量、各模型权重等都会影响到最后的输出。

7、模型回答时一直循环相同的信息。有可能那一天或者附近出现大量相同的标题或信息,如多次出现#温馨提示##天气预警#等,所以要将多余的重复没有意义的信息。

8、调整父子模式的需在上面点击重新分段,最好不要在原有的子段中进行修改;
2025-08-12T12:24:36.png

9、当模型回复极慢时,可优先查看知识库是否有文件正在索引。

文本清洗脚本


清洗脚本代码双击以下py文件后即可运行,此代码的作用为:
1、将日期从#{date}2022-03-01转换为2022年3月1日、保留第一个#{date}并删除重复的#{date}(此次项目专属,如不需要可删除)
2、将特殊字符数字转换为正常格式数字;
3、将文本中的所有标点符号转换为统一格式;
4、去除绝大部分的特殊字符,如☆等;
5、去除所有的空格。包括正常格式空格及特殊字符空格;去除【。】和【~】;
6、删除大多数的emoji;
7、将一个文件夹中的txt文档及md文档全部按照上面规则进行清洗,清洗文件将出现在此文件夹根目录下并自动创建一个backup文件夹,将原文件备份至backup文件夹中。

PS:此脚本可以继续优化,继续往里面添加所需清洗的特殊字符或定义具体的文本格式等,此处不赘述。

import os
import re
import shutil
import time
import unicodedata
import tkinter as tk
from tkinter import filedialog, messagebox

def fullwidth_to_halfwidth(text):
    result = ""
    for char in text:
        code = ord(char)
        if code == 0x3000:
            continue  # 直接跳过全角空格
        elif 0xFF01 <= code <= 0xFF5E:
            code -= 0xFEE0
        result += chr(code)
    return result

def normalize_fancy_chars(text):
    return unicodedata.normalize('NFKC', text)

def replace_enclosed_alphanum(text):
    mapping = {
        # 带圈大写字母
        "🅰": "A", "🅱": "B", "🅲": "C", "🅳": "D", "🅴": "E", "🅵": "F", "🅶": "G",
        "🅷": "H", "🅸": "I", "🅹": "J", "🅺": "K", "🅻": "L", "🅼": "M", "🅽": "N",
        "🅾": "O", "🅿": "P", "🆀": "Q", "🆁": "R", "🆂": "S", "🆃": "T", "🆄": "U",
        "🆅": "V", "🆆": "W", "🆇": "X", "🆈": "Y", "🆉": "Z",
        # 带圈小写字母
        "ⓐ": "a", "ⓑ": "b", "ⓒ": "c", "ⓓ": "d", "ⓔ": "e", "ⓕ": "f", "ⓖ": "g",
        "ⓗ": "h", "ⓘ": "i", "ⓙ": "j", "ⓚ": "k", "ⓛ": "l", "ⓜ": "m", "ⓝ": "n",
        "ⓞ": "o", "ⓟ": "p", "ⓠ": "q", "ⓡ": "r", "ⓢ": "s", "ⓣ": "t", "ⓤ": "u",
        "ⓥ": "v", "ⓦ": "w", "ⓧ": "x", "ⓨ": "y", "ⓩ": "z",
        # 带框大写字母
        "🄰": "A", "🄱": "B", "🄲": "C", "🄳": "D", "🄴": "E", "🄵": "F", "🄶": "G",
        "🄷": "H", "🄸": "I", "🄹": "J", "🄺": "K", "🄻": "L", "🄼": "M", "🄽": "N",
        "🄾": "O", "🄿": "P", "🅀": "Q", "🅁": "R", "🅂": "S", "🅃": "T", "🅄": "U",
        "🅅": "V", "🅆": "W", "🅇": "X", "🅈": "Y", "🅉": "Z",
        # 带圈数字
        "①": "1", "②": "2", "③": "3", "④": "4", "⑤": "5",
        "⑥": "6", "⑦": "7", "⑧": "8", "⑨": "9", "⑩": "10",
        "❶": "1", "❷": "2", "❸": "3", "❹": "4", "❺": "5",
        "❻": "6", "❼": "7", "❽": "8", "❾": "9", "❿": "10",
        "➀": "1", "➁": "2", "➂": "3", "➃": "4", "➄": "5",
        "➅": "6", "➆": "7", "➇": "8", "➈": "9", "➉": "10",
        "⓵": "1", "⓶": "2", "⓷": "3", "⓸": "4", "⓹": "5",
        "⓺": "6", "⓻": "7", "⓼": "8", "⓽": "9", "⓾": "10",
    }
    return ''.join(mapping.get(ch, ch) for ch in text)

def keep_first_date_occurrences(text):
    seen_dates = set()
    date_tag_pattern = re.compile(r"(#\{date\}[::]\d{4}年\d{1,2}月\d{1,2}日)")
    def replacer(match):
        date_tag = match.group(1)
        if date_tag not in seen_dates:
            seen_dates.add(date_tag)
            return date_tag
        else:
            return ""
    return date_tag_pattern.sub(replacer, text)

def convert_date_format(text):
    pattern = re.compile(r"(#\{date\})\s*([::])\s*(\d{4})-(\d{1,2})-(\d{1,2})")
    def repl(m):
        return f"{m.group(1)}{m.group(2)}{int(m.group(3))}年{int(m.group(4))}月{int(m.group(5))}日"
    return pattern.sub(repl, text)

def clean_for_dify(text):
    text = normalize_fancy_chars(text)
    text = replace_enclosed_alphanum(text)
    text = fullwidth_to_halfwidth(text)

    # 删除 ~ 和 。
    text = text.replace("~", "").replace("。", "")

    # 删除半角空格
    text = text.replace(" ", "")

    # 统一引号
    quotes_map = {
        "‘": "'", "’": "'", "‚": "'", "‛": "'",
        "“": '"', "”": '"', "„": '"', "‟": '"',
        """: '"', "'": "'"
    }
    for k, v in quotes_map.items():
        text = text.replace(k, v)

    # 统一标点
    punct_map = {
        ",": ",", ";": ";", ":": ":",
        "?": "?", "!": "!", "(": "(", ")": ")",
        "【": "[", "】": "]", "《": "<", "》": ">",
        "—": "-", "–": "-", "-": "-"
    }
    for k, v in punct_map.items():
        text = text.replace(k, v)

    text = text.replace("……", "...").replace("…", "...")

    text = re.sub(r"[★•※◆☆○●◇→←↑↓■□▲△▼▽]", "", text)
    text = re.sub(
        r'\[emotion_[^\]]+\]'
        r'|\[[A-Za-z0-9+/=]{10,}\]'
        r'|\[[0-9a-fA-F]{4,}\]', '', text
    )

    emoji_pattern = re.compile(
        "["  
        "\U0001F300-\U0001F5FF"
        "\U0001F600-\U0001F64F"
        "\U0001F680-\U0001F6FF"
        "\U00002600-\U000026FF"
        "\U00002700-\U000027BF"
        "\U0001F900-\U0001F9FF"
        "\U0001FA70-\U0001FAFF"
        "]+", flags=re.UNICODE
    )
    text = emoji_pattern.sub('', text)

    enclosed_symbols_pattern = re.compile(
        "["  
        "\u2460-\u24FF"          
        "\U0001F100-\U0001F1FF"  
        "\u3200-\u32FF"          
        "]+", flags=re.UNICODE
    )
    text = enclosed_symbols_pattern.sub('', text)

    # 日期转换
    text = convert_date_format(text)

    # 去重日期标签
    text = keep_first_date_occurrences(text)

    return text

def read_file(filepath):
    for enc in ["utf-8", "utf-8-sig", "gbk", "gb2312"]:
        try:
            with open(filepath, "r", encoding=enc) as f:
                return f.read()
        except:
            continue
    raise ValueError(f"无法读取文件编码: {filepath}")

def select_folder():
    folder = filedialog.askdirectory(title="选择要清洗的知识库文件夹")
    if not folder:
        return

    backup_folder = os.path.join(folder, f"backup_{time.strftime('%Y%m%d_%H%M%S')}")
    os.makedirs(backup_folder, exist_ok=True)

    file_count = 0
    for filename in os.listdir(folder):
        if filename.lower().endswith((".txt", ".md")):
            src_path = os.path.join(folder, filename)
            backup_path = os.path.join(backup_folder, filename)

            shutil.copy2(src_path, backup_path)
            content = read_file(src_path)
            cleaned = clean_for_dify(content)

            with open(src_path, "w", encoding="utf-8") as f:
                f.write(cleaned)

            file_count += 1
            print(f"已清洗:{filename}")

    messagebox.showinfo("完成", f"共清洗 {file_count} 个文件\n备份已保存到:{backup_folder}")

def main():
    root = tk.Tk()
    root.withdraw()
    select_folder()

if __name__ == "__main__":
    main()

Responses