Skip to content

QA G-Cite Dify 平台功能设计

文档版本:v2.3

更新日期:2026-06-16

文档定位:G-Cite 引用归因的 Dify 平台侧功能设计(Chatflow 编排 / 合并节点 / LLM 配置 / SSE 协议)

所属体系:QA_Chat Agent 的知识库引用渲染方案,属于 QA_闲聊对话_Agent设计.md 的补充实现文档。

配套文档


1. 背景与目标

1.1 问题定义

Dify 内置的"引用和归属"功能存在结构性缺陷:它展示的是检索召回了什么,而不是 LLM 实际用了什么。

[知识库检索]                          [LLM 生成]
召回 5 个 Chunk                       实际只用 2 个
    │                                     │
    └──→ "引用和归属"直接列出全部 5 个 ──→ 用户看到 3 个无关引用

典型场景:用户问"今天天气怎么样",LLM 回复"我无法提供天气信息",但 Dify 仍然把知识库里 5 个不相关 Chunk 列为引用来源,造成误导。

1.2 方案选择:G-Cite(Generation-Time Citation)

流派做法流式兼容精度成本
G-Cite(生成时标注)LLM 生成时内联标注 [C:N]✅ 不破坏 TTFT低(不额外调用 LLM)
P-Cite(事后标注)先生成文本再打引用标签❌ 需完整输出高(额外 LLM 调用)

选择 G-Cite:与流式输出兼容,不破坏 TTFT 体验,不额外消耗 LLM 调用次数。

1.3 目标

  • 将引用格式从文档级([来源: 文档名称])升级为段落级([C:N]
  • LLM 只标注实际使用的 Chunk,无关 Chunk 不出现在引用列表中
  • 移动端通过流式缓冲器实时渲染为上标 <sup>,用户看不到裸标记
  • 支持多信息源(RAG 知识库 + Web Search)并行检索,统一 UnifiedChunk schema,下游 LLM / SSE / 前端对源类型无感知差异
  • 根据 source_type 在前端做差异化展示(图例颜色、副标题内容、完整原文按钮默认状态)
  • 前端引用编号按 LLM 出现顺序重映射为连续 1, 2, 3...(同 chunk 复用同一序号),摘要栏去重展示,详见 移动端功能设计 §4

2. Chatflow 编排设计

2.1 元数据过滤前置

知识检索 节点支持元数据过滤(Metadata Filter):在 Chatflow 编排中,可先用一个变量获取节点读取当前用户角色(如 user_role),再将角色值以变量形式传入 知识检索 节点的"元数据过滤"配置项,实现有筛选的知识库检索(例如:仅对当前用户可见的制度文档、未脱敏的设备参数等)。

作用:在 Chunk 进入合并节点之前就完成权限 / 范围控制,确保返回的 chunk 全部对当前用户可见且相关,避免后续在 LLM 层做权限判断。

本节范围:仅做架构级说明,提示 Chatflow 编排中应配置"变量获取 + 元数据过滤"前置环节;具体变量命名、过滤规则由业务方按租户 / 角色策略配置,本文不展开。

2.1.1 Dify 元数据过滤能力边界

经调研,知识检索节点的元数据过滤存在以下已知限制

局限说明影响
仅支持单层 AND/OR所有过滤条件平级,不支持嵌套如 (A AND B) OR (C AND D)复杂多条件组合无法在 Dify 节点内实现
JSON string 数组无法做元素匹配access_roles 存为 JSON string ["运维工程师","物业经理"],Dify 视为原子字符串in / contains 均针对整个字符串操作,无法做"文档角色数组 ∩ 用户角色数组 ≠ ∅"多角色文档的权限过滤不精确;用户是多角色时无法做交集判断
null 字段无法表达"无限制"若文档 access_roles 为空(不设限,对所有人可见),加了过滤条件就永远召不回;不加过滤条件又无法控制权限无法同时满足"有角色的按角色过滤、没角色的全可见"这一业务规则

⚠️ 当前风险access_roles(多角色数组字段)的权限过滤无法在 Dify 知识检索节点内直接实现。需要业务侧评估替代方案(如物理库隔离、外部统一检索服务等),下期迭代优先解决此问题。

2.2 Chatflow 编排(双源并行)

链路设计说明

链路路径输出说明
检索分支 A(RAG)知识检索 → rag_chunksArray[RagChunk]Dify 原生格式,含 metadata.doc_metadata 多层嵌套
检索分支 B(Web)Web Search → web_chunksArray[WebChunk]扁平格式,字段随 API 不同而变化
合并节点两源归一化 → 全局编号formatted_chunks + chunk_metadata抹平格式差异,分发给两条下游链路
LLM 链路合并 → LLM → AnswerSSE text_chunk含 [C:N] 流式文本
元数据链路合并 → node_finished 事件data.outputs.chunk_metadata移动端缓存,用于渲染摘要栏 + 半屏

💡 为什么并行 + 单一 LLM:并行检索简单可预测,避免意图路由的复杂度;单一 LLM 调用能统一处理所有源 chunk,避免引用编号冲突、调度复杂;LLM 一次性看到所有源做选择更连贯。

💡 元数据传递机制:Dify 原生支持 workflow 级 SSE 事件(node_started / node_finished)。Code 节点("Chunk编号")执行完毕后,Dify 通过 node_finished 事件将 data.outputs.chunk_metadata 传递给前端。移动端监听此事件即可提前获取 chunk 元数据(早于 LLM 文本流),无需额外透传节点或独立 API 调用。

2.3 SSE 协议定义

Dify 通过 SSE 向移动端发送以下事件

事件类型字段内容频率
node_finisheddata.outputs.chunk_metadata合并节点的完整 Chunk 元数据数组(含 document_titlesource_file_url一次(Code 节点执行完毕后立即发送)
text_chunkdata.textLLM 流式文本(含 [C:N] 裸标记)每个 token 一次
message_endmetadata对话结束元数据(含 usage、retriever_resources)一次(流结束)

SSE 事件时序

时间轴    事件                          移动端处理
─────────────────────────────────────────────────────
t0       node_finished (Code节点)      缓存 chunk_metadata
t1       text_chunk: "巡更人员..."      流式缓冲器解析 [C:N]
t2       text_chunk: "需要先[C:1]。"    转换为 <sup>1</sup>
...      ...                           ...
tn       message_end                   渲染摘要栏 + 结束

备选方案(Plan B):如 node_finished 事件解析困难,可在 Code 节点后增加一个 HTTP 请求节点,将 chunk_metadata 写入后端 Redis/API,移动端通过 conversation_id 查询。node_finished 为首选方案(无额外网络开销、时序天然正确)。

2.4 与原有流程的差异

改动点Dify 原生当前方案(多源 RAG + Web)
检索源数量1(RAG)2(RAG 并行 Web)
检索 → LLM 中间节点统一合并节点(RAG + Web → UnifiedChunk)
LLM Context知识检索的 #context#清空,改用合并节点的 formatted_chunks(含两源)
元数据传递合并节点 → Dify node_finished 事件(含 source_type
SSE 事件只有 text_chunktext_chunk + node_finished(UnifiedChunk,含 source_type)
Chunk schemaDify 原始UnifiedChunk:RAG + Web 兼容
引用格式[来源: 文档名称](文档级)[C:N](段落级,跨源统一编号)
引用内容检索到的全部 ChunkLLM 只标注实际使用的内容(可来自任意源)
原文跳转摘要栏 / 半屏 → 跳转(Web URL 必填,RAG 依 source_file_url
前端差异化RAG 📄 蓝 / Web 🌐 绿;副标题差异;按钮默认状态差异

3. 合并代码节点设计

3.1 为什么要合并

Chatflow 中有两个独立的检索分支——RAG(知识检索)和 Web(Web Search),各自返回不同格式的数据:

  • RAG 返回 Dify 原生格式:{ content, metadata: { doc_metadata: {...}, dataset_name, score, segment_position } },字段深嵌套在 metadata.doc_metadata
  • Web 返回扁平格式:{ title, url, snippet, site_name, published_date, score },字段名还随 API 不同而变化(Bocha 用 siteName,Tavily 用 content 不是 snippet

不合并会带来三个问题:

  1. LLM 无法统一理解 — 两份上下文格式不同,引用标注容易冲突
  2. 编号冲突 — 两个源各自的 chunk 都从各自索引开始,放入同一段 Prompt 可能混淆
  3. 移动端拿不到结构化元数据document_titlesource_typesource_file_url 等字段散落在不同位置,无法直接渲染引用摘要栏

合并节点的使命:将两个源的数据归一化为统一的 UnifiedChunk 结构,全局重新编号,计算好展示用的副标题,然后分发给两条下游链路。

3.2 输入:两个数据源

3.2.1 rag_chunks(来自知识检索节点)

Dify 知识检索 节点的 result 输出,经元数据过滤(§2.1)后传入。

关键字段路径说明
item.contentChunk 正文
item.metadata.score向量检索相似度
item.metadata.dataset_name所属知识库名称(如"测试知识库20260610")
item.metadata.doc_metadata.document_nameDify 处理后的 .md 文件名(如 "云巡-巡检端说明书.md")
item.metadata.doc_metadata.document_title人类可读的文档标题(来自元数据标注)
item.metadata.doc_metadata.source_file_url原始文件 OSS 链接
item.metadata.segment_positionChunk 在原文档中的段落位置

3.2.2 web_chunks(来自 Web Search 节点)

Dify Web Search 节点的 result 输出。本期用 Mock 数据验证架构,未来接入真实搜索 API(Bocha / Tavily / 百度)。

关键字段说明
item.title搜索结果标题
item.url结果页面 URL
item.snippetitem.content搜索结果摘要(字段名随 API 不同)
item.site_name站点展示名(如"知乎"、"百度百科")— 不一定返回
item.published_date发布日期 — 不一定返回
item.score搜索相关性分

不同 API 的字段差异:Bocha 用 siteName + datePublished,Tavily 用 content 而非 snippet。合并节点上游需要额外代码做字段映射(统一为 web_chunks 标准格式),映射规则见 §4 Web 工具对接映射

3.3 输出:两条下游链路

合并节点产出两份数据,分两条路径分发:

链路一:formatted_chunks → LLM 节点 Context

一条拼接好的文本字符串,每段格式为 [C:N]\n内容\n\n。LLM 通过 {#context#} 变量读取,生成回答时用 [C:N] 标注引用。

[C:1]
巡更人员执行巡检计划时,需先进入"巡更"功能项...

[C:2]
巡检过程中如发现异常,可在终端直接上报...

链路二:chunk_metadata → 移动端(SSE node_finished 事件)

完整的 UnifiedChunk 数组(JSON),包含 document_titlesource_typedisplay_subtitlesource_file_url 等移动端渲染引用摘要栏所需的全部字段。通过 Dify 原生的 node_finished SSE 事件,在 LLM 开始输出之前就发给移动端缓存。

3.4 统一 Chunk Schema(UnifiedChunk)

UnifiedChunk 是合并节点输出的统一 chunk 结构,兼容 RAG 和 Web 两种源。

核心字段(移动端展示直接使用):

字段类型RAG 来源Web 来源
idstr合并后全局编号(1, 2, 3...)同左
contentstritem.contentitem.snippetitem.content
source_typestr"rag""web" ← 移动端按此字段做差异化渲染
document_titlestrdoc_metadata.document_title(回退 document_nameitem.title
urlstrdoc_metadata.source_file_urlitem.url(Web 必有)
scorefloat向量检索相似度搜索相关性分
display_subtitlestrdataset_name降级链计算结果(见 §3.6)

扩展字段(调试/溯源用,不作为展示主字段):

字段说明
document_name原始文件名(RAG)或 URL(Web)
dataset_name知识库名称(仅 RAG 有值)
site_nameWeb 站点展示名(如"知乎")
site_host从 URL 提取的 host(如 zhihu.com),降级兜底
source_file_url原始文件链接
segment_positionChunk 在原文档中的段落位置(仅 RAG)
published_date发布日期(仅 Web)

核心设计原则display_subtitle 是移动端唯一需要展示的副标题字段。所有降级逻辑、host 提取、空值兜底都在合并节点里完成,移动端只做 textContent = chunk.display_subtitle零加工

3.5 处理逻辑(5 步)

输入: rag_chunks (Dify原生格式) + web_chunks (扁平格式)


步骤1: 归一化 RAG chunks
       ├── 从 item.content 取正文
       ├── 从 item.metadata.doc_metadata 取 document_title / document_name / source_file_url
       ├── source_type 标记为 "rag"
       └── display_subtitle = dataset_name(即知识库名称)

步骤2: 归一化 Web chunks
       ├── 从 item.title / item.url / item.snippet 取对应字段
       ├── source_type 标记为 "web"
       └── display_subtitle = 4 级降级链结果(见 §3.6)

步骤3: 排序
       ├── RAG 优先(source_type == "rag" 排在前面)
       ├── 同源内按 score 降序
       └── 目的:LLM 看到的最前面是最高质量的知识库内容

步骤4: 全局编号 + 构造输出
       ├── 从 1 开始给所有 chunk 重新编号
       ├── 生成 formatted_chunks:"[C:1]\n内容1\n\n[C:2]\n内容2\n\n..."
       └── 同时给 chunk_metadata 每一项写入 id 字段

步骤5: 分发
       ├── formatted_chunks → 注入 LLM 节点 Context(`#context#` 变量)
       └── chunk_metadata → Dify node_finished 事件 → 移动端缓存

3.6 副标题降级链

Web 来源的副标题数据不完整是普遍现象(不是 bug):

  • Bocha AI 等高质量 API 同时返回 site_name + published_date
  • Tavily / 百度普通版 / SerpAPI 等经常缺 site_name
  • 部分网页本身没有发布日期元数据

为保证任何 API 接入都不会出现空副标题,合并节点实现 4 级降级链:

Level 1: site_name + published_date → "知乎 · 2025-08-12"
      ↓ site_name 为空
Level 2: host + published_date → "zhihu.com · 2025-08-12"
      ↓ host 也为空
Level 3: 只剩 published_date → "2025-08-12"
      ↓ date 也为空
Level 4: 返回空串 → 移动端不渲染副标题行

降级链演示(同一 API 不同返回完整度):

API / 场景site_namepublished_datehostdisplay_subtitle
Bocha AI 完整返回"知乎""2025-08-12"zhihu.com知乎 · 2025-08-12
Tavily(无 site_name)"""2025-08-12"zhihu.comzhihu.com · 2025-08-12
百度普通版(无日期)"知乎"""zhihu.com知乎
完全裸 URL""""example.comexample.com
URL 都为空""""""""(空串,移动端不渲染)

为什么放在合并节点而不是移动端

  1. 降级链是业务规则 — site_name 缺失时降级到 host,这是数据归一化逻辑
  2. Python 易测试 — 单测覆盖 4 级降级,移动端无需重复实现
  3. 避免重复实现 — 所有端共用同一份 display_subtitle,未来扩展新端零成本接入

移动端使用(一行代码,零加工):

javascript
subEl.textContent = chunk.display_subtitle || '';

3.7 输出示例

json
{
  "formatted_chunks": "[C:1]\n巡更人员执行巡检计划时...\n\n[C:2]\n云巡 9 巡检端说明书\n\n[C:3]\n物业巡检一般分为日常巡检、专项巡检、应急巡检三类...",
  "chunk_count": 3,
  "chunk_metadata": [
    {
      "id": "1",
      "source_type": "rag",
      "content": "巡更人员执行巡检计划时...",
      "document_title": "巡更操作指南 v2.1",
      "dataset_name": "测试知识库20260610",
      "score": 0.73,
      "source_file_url": "oss://kb-raw/2026/06/巡更.pdf",
      "display_subtitle": "测试知识库20260610"
    },
    {
      "id": "2",
      "source_type": "rag",
      "content": "云巡 9 巡检端说明书",
      "document_title": "云巡9巡检端说明书",
      "score": 0.65,
      "display_subtitle": "测试知识库20260610"
    },
    {
      "id": "3",
      "source_type": "web",
      "content": "物业巡检一般分为日常巡检、专项巡检、应急巡检三类...",
      "document_title": "物业巡检流程标准 - 知乎",
      "site_name": "知乎",
      "url": "https://www.zhihu.com/question/123/answer/456",
      "score": 0.68,
      "published_date": "2025-08-12",
      "display_subtitle": "知乎 · 2025-08-12"
    }
  ]
}

💡 典型场景:合并节点典型输出是 5 RAG + 5 Web(即 Top-K=5 × 2 源)。LLM 拿到后按需挑选(不按顺序、可能重复)。10 个 chunk 的完整分布表 + 重映射示例,详见 移动端功能设计 §2

3.8 引用编号重映射 → 子文档

📦 详见 QA_G-Cite_移动端功能设计.md §4

包含:idMap 触发场景 / 设计决策 / idMap 增量建立示例 / 文字流 + 摘要栏 DOM 结构 / 跳转逻辑(data-raw-id vs data-idx)


4. Web 工具对接映射(未来接入)

不同 Web Search API 的返回字段不完全一致,合并节点上游需要额外代码做字段映射,将各 API 的返回统一为 web_chunks 标准格式后再交给合并节点。

工具数据形态需映射字段
Bocha AI{ value: Array<{ title, url, snippet, siteName, datePublished }> }siteNamesite_namedatePublishedpublished_date
Tavily{ results: Array<{ title, url, content, score, published_date }> }字段直接对应,content 代替 snippet
百度搜索 API需先用 web_search 工具节点爬取网页同 Bocha 映射规则

本期 Web Search 节点用 hardcoded web_chunks mock 验证架构,真实工具接入留待未来。


5. LLM 节点配置

5.1 节点设置

配置项
上下文(Context)清空(不再直接使用知识检索的 #context#
系统提示词见 §5.2
用户提示词&#123;&#123;#sys.query#&#125;&#125;
输出模式流式

5.2 系统提示词

text
# 角色设定
你是欧爪宝,一栋智能写字楼的 AI 助理,为楼内员工和访客提供帮助。

# 个性特征
1. 回答简洁精准,不啰嗦,不绕弯子。
2. 友好亲切,但保持职业感,不过度热情。
3. 知道就是知道,不知道就老实说不知道,绝不编造。
4. 当用户有多个问题时,逐一回答,条理清晰。

# 能力边界
1. ✅ 你可以回答关于这栋楼的问题:会议室、设备用途、服务流程、规章制度、运营指南等。
2. ✅ 你可以聊一些通用话题:技术原理、行业知识、日常常识。
3. ❌ 你不能操控任何设备(如开关灯、调空调)。
4. ❌ 你不能预定会议室、不能查询能耗等业务数据。
5. ❌ 如果用户要求以上操作,礼貌告知请他们通过业务指令来完成。

# 检索片段来源说明(多源时启用)
下方检索片段可能来自两种信息源:
1. **知识库(rag)**:本楼宇内部知识库,包含规章制度、巡检流程、设备说明等
2. **公网(web)**:来自第三方网站(知乎、CSDN、官方文档站等)的公开信息
两种来源同等重要,根据用户问题选择最合适的片段回答。

# 回答规则(重要)
1. 回答时严格基于下方编号的检索片段。
2. 当你引用某个片段的**具体内容**来回答时,在句末标注 [C:N](例如 [C:1])。
3. 如果某个片段与用户问题无关,忽略它,不要用它,也不要标注它。
4. 涉及楼宇流程、规章制度、设备参数、操作步骤时,**优先**标注知识库来源。
5. 涉及通用技术、行业知识、跨楼宇参考时,**优先**标注公网来源。
6. 如果所有片段都无法回答问题,直接说"根据现有资料无法确定",
   不要编造,此时不需要任何 [C:N] 标注。

---

```text
检索片段:
&#123;&#123;#context#&#125;&#125;

注意:Dify 中 &#123;&#123;#context#&#125;&#125; 变量需选择合并节点formatted_chunks 输出(含 RAG + Web 两源片段)。

设计取舍:LLM System Prompt 不告诉 LLM 源类型(不输出 source_type / site_name 等元信息),仅依赖 LLM 根据 Prompt 中"优先标注"规则自主选择。这样 Prompt 更简洁,LLM 不被元信息干扰。源类型差异化由前端半屏 / 摘要栏体现。


6. 局限与边界

局限说明缓解措施
LLM 自标注不是 100% 准确LLM 有时标注"可能支持"的源而非"实际依赖"的源对关键场景可额外加 NLI 验证层
多轮对话引用追溯后续轮次引用可能依赖前文已引入的上下文Prompt 中保留已引用 [C:N] 列表
段落级而非句子级G-Cite 标注到 Chunk 级别,非句子级底部摘要栏展开后可查看文档标题,用户自行核对
[C:N] 格式约定前端流式缓冲器依赖 [C:N] 格式识别三端共享同一格式约定,Prompt 中明确语法
Chunk 内容换行丢失formatted_chunks\n 拼接,可能丢失原始排版可优化代码节点保留更多格式信息
Web 数据质量参差公网检索结果可能包含不准确、过期、营销内容Prompt 要求 LLM 优先用知识库回答;Web 来源在摘要栏明确标注"公网"
Web 实时性搜索引擎数据存在快照延迟,可能引用已下架/已变更内容Web chunk 中保留 published_date 给用户判断时效
跨源冲突同一事实在 RAG 和 Web 中同时出现,LLM 可能选错源当前 RAG 优先(排序时 RAG 在前);未来可加跨源 rerank
引用编号稳定性合并节点每次全局重新编号,同一 query 多轮重问时 [C:N] 可能不同多轮场景下 Prompt 中保留已用 [C:N] 列表,避免重映射

注:移动端特有局限(idMap 建立时机、跨轮次编号持久性等)见 移动端功能设计 §7


7. 参考资料

G-Cite 相关文档

上游文档


附录 A. Chunk编号 代码节点源码 (Python)

对应 §3 合并代码节点设计,在 Dify Chatflow 中创建"代码节点",语言选 Python,粘贴以下代码。

已知限制:Dify 百度搜索工具的 Top-K 参数存在 Bug,设置后不生效,始终返回 10 条结果。临时通过下方 MAX_WEB_RESULTS = 2 在代码节点层截断,待 Dify 修复后可移除该变量直接透传。

python
"""
Dify Chatflow Chunk编号 代码节点 (v2.2)
来源:QA_G-Cite_Dify平台功能设计.md §3

输入:
  - rag_chunks: 知识检索节点 result(Dify 原生格式)
  - web_chunks: 百度搜索节点 result

输出:
  - formatted_chunks: 注入 LLM Context 的格式化文本
  - chunk_count: 总 chunk 数
  - chunk_metadata: 完整 UnifiedChunk 数组(→ SSE node_finished 事件)

v2.2 变更:
  - 修复 web_chunks 实际结构解析([{references: [...], request_id}])
"""


# Web 结果最多保留条数(取排序后的前 N 条)
MAX_WEB_RESULTS = 2


def _extract_web_items(raw) -> list[dict]:
    """从百度搜索节点输出提取 references 列表。

    百度搜索节点输出格式(试验证):
        web_chunks = [ { "references": [ ... ], "request_id": "..." } ]

    兼容 mock 阶段直接传 list 或 dict 信封的情况。
    """
    if not raw:
        return []

    # 情况 1:实际格式 —— [{references: [...], request_id}]
    if isinstance(raw, list) and len(raw) > 0:
        first = raw[0]
        if isinstance(first, dict) and "references" in first:
            refs = first.get("references")
            if isinstance(refs, list):
                return refs

    # 情况 2:兜底 —— 工具信封格式(老旧 mock)
    if isinstance(raw, dict):
        json_arr = raw.get("json")
        if isinstance(json_arr, list) and len(json_arr) > 0:
            refs = json_arr[0].get("references")
            if isinstance(refs, list):
                return refs

    # 情况 3:兜底 —— 直接传了 list
    if isinstance(raw, list):
        return raw

    return []


def _extract_host(url: str) -> str:
    """从 URL 提取 host,自动去掉 www. 前缀。"""
    if not url:
        return ""
    try:
        from urllib.parse import urlparse
        host = urlparse(url).netloc
        if host.startswith("www."):
            host = host[4:]
        return host
    except Exception:
        return ""


def _normalize_date(raw: str) -> str:
    """统一日期格式,只保留 YYYY-MM-DD。

    百度搜索 date 示例:"2026-05-27 12:35:55"
    """
    if not raw:
        return ""
    return raw.strip()[:10]


def _compute_web_subtitle(item: dict) -> str:
    """Web 副标题 4 级降级链。详见 §3.7。"""
    site = (item.get("site_name") or "").strip() or _extract_host(item.get("url", ""))
    date = (item.get("published_date") or "").strip()
    if site and date:
        return f"{site} · {date}"
    if site:
        return site
    if date:
        return date
    return ""


def main(rag_chunks: list[dict], web_chunks: list[dict]) -> dict:
    """多源合并:RAG + 百度搜索 → UnifiedChunk → 编号 → 下发。"""

    unified = []

    # ── 1. RAG ──
    for item in (rag_chunks or []):
        content = (item.get("content") or "").strip()
        if not content:
            continue
        metadata = item.get("metadata") or {}
        doc_meta = metadata.get("doc_metadata") or {}
        document_name = doc_meta.get("document_name") or metadata.get("document_name", "")
        document_title = (doc_meta.get("document_title") or "").strip() or document_name
        dataset_name = metadata.get("dataset_name", "")
        chunk = {
            "content": content,
            "source_type": "rag",
            "document_title": document_title,
            "document_name": document_name,
            "dataset_name": dataset_name,
            "site_name": "",
            "site_host": "",
            "source_file_url": doc_meta.get("source_file_url", ""),
            "url": doc_meta.get("source_file_url", ""),
            "score": float(metadata.get("score") or 0),
            "segment_position": int(metadata.get("segment_position") or 0),
            "published_date": "",
            "display_subtitle": dataset_name,
        }
        unified.append(chunk)

    # ── 2. 百度搜索 ──
    for item in _extract_web_items(web_chunks)[:MAX_WEB_RESULTS]:
        content = (item.get("content") or item.get("snippet") or "").strip()
        if not content:
            continue
        url = item.get("url", "")
        chunk = {
            "content": content,
            "source_type": "web",
            "document_title": item.get("title", ""),
            "document_name": url,
            "dataset_name": "",
            "site_name": item.get("website") or item.get("site_name", ""),
            "site_host": _extract_host(url),
            "source_file_url": url,
            "url": url,
            "score": float(item.get("authority_score") or item.get("score") or 0),
            "segment_position": 0,
            "published_date": _normalize_date(item.get("date") or item.get("published_date")),
            "display_subtitle": "",
        }
        chunk["display_subtitle"] = _compute_web_subtitle(chunk)
        unified.append(chunk)

    # ── 3. 排序 ──
    unified.sort(key=lambda x: (0 if x["source_type"] == "rag" else 1, -x["score"]))

    # ── 4. 编号 + 输出 ──
    formatted = ""
    chunk_metadata = []
    for i, item in enumerate(unified, start=1):
        chunk_id = str(i)
        item["id"] = chunk_id
        formatted += f"[C:{chunk_id}]\n{item['content']}\n\n"
        chunk_metadata.append(item)

    return {
        "formatted_chunks": formatted.strip(),
        "chunk_count": len(unified),
        "chunk_metadata": chunk_metadata,
    }

文档版本:v2.4更新日期:2026-06-17 *作者:强哥 *

Released under the Private License.