QA G-Cite Dify 平台功能设计
文档版本:v2.3
更新日期:2026-06-16
文档定位:G-Cite 引用归因的 Dify 平台侧功能设计(Chatflow 编排 / 合并节点 / LLM 配置 / SSE 协议)
所属体系:QA_Chat Agent 的知识库引用渲染方案,属于 QA_闲聊对话_Agent设计.md 的补充实现文档。
配套文档:
- 移动端功能设计(SSE 流式接收 / idMap 重映射 / 流式渲染 / 半屏) → QA_G-Cite_移动端功能设计.md
- 交互原型设计(ASCII Wireframes 3 个场景) → 微信小程序_QA_G-Cite_引用归因_交互原型设计.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)并行检索,统一
UnifiedChunkschema,下游 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_chunks | Array[RagChunk] | Dify 原生格式,含 metadata.doc_metadata 多层嵌套 |
| 检索分支 B(Web) | Web Search → web_chunks | Array[WebChunk] | 扁平格式,字段随 API 不同而变化 |
| 合并节点 | 两源归一化 → 全局编号 | formatted_chunks + chunk_metadata | 抹平格式差异,分发给两条下游链路 |
| LLM 链路 | 合并 → LLM → Answer | SSE 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_finished | data.outputs.chunk_metadata | 合并节点的完整 Chunk 元数据数组(含 document_title 和 source_file_url) | 一次(Code 节点执行完毕后立即发送) |
text_chunk | data.text | LLM 流式文本(含 [C:N] 裸标记) | 每个 token 一次 |
message_end | metadata | 对话结束元数据(含 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_chunk | text_chunk + node_finished(UnifiedChunk,含 source_type) |
| Chunk schema | Dify 原始 | UnifiedChunk:RAG + Web 兼容 |
| 引用格式 | [来源: 文档名称](文档级) | [C:N](段落级,跨源统一编号) |
| 引用内容 | 检索到的全部 Chunk | LLM 只标注实际使用的内容(可来自任意源) |
| 原文跳转 | 无 | 摘要栏 / 半屏 → 跳转(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)
不合并会带来三个问题:
- LLM 无法统一理解 — 两份上下文格式不同,引用标注容易冲突
- 编号冲突 — 两个源各自的 chunk 都从各自索引开始,放入同一段 Prompt 可能混淆
- 移动端拿不到结构化元数据 —
document_title、source_type、source_file_url等字段散落在不同位置,无法直接渲染引用摘要栏
合并节点的使命:将两个源的数据归一化为统一的 UnifiedChunk 结构,全局重新编号,计算好展示用的副标题,然后分发给两条下游链路。
3.2 输入:两个数据源
3.2.1 rag_chunks(来自知识检索节点)
Dify 知识检索 节点的 result 输出,经元数据过滤(§2.1)后传入。
| 关键字段路径 | 说明 |
|---|---|
item.content | Chunk 正文 |
item.metadata.score | 向量检索相似度 |
item.metadata.dataset_name | 所属知识库名称(如"测试知识库20260610") |
item.metadata.doc_metadata.document_name | Dify 处理后的 .md 文件名(如 "云巡-巡检端说明书.md") |
item.metadata.doc_metadata.document_title | 人类可读的文档标题(来自元数据标注) |
item.metadata.doc_metadata.source_file_url | 原始文件 OSS 链接 |
item.metadata.segment_position | Chunk 在原文档中的段落位置 |
3.2.2 web_chunks(来自 Web Search 节点)
Dify Web Search 节点的 result 输出。本期用 Mock 数据验证架构,未来接入真实搜索 API(Bocha / Tavily / 百度)。
| 关键字段 | 说明 |
|---|---|
item.title | 搜索结果标题 |
item.url | 结果页面 URL |
item.snippet 或 item.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_title、source_type、display_subtitle、source_file_url 等移动端渲染引用摘要栏所需的全部字段。通过 Dify 原生的 node_finished SSE 事件,在 LLM 开始输出之前就发给移动端缓存。
3.4 统一 Chunk Schema(UnifiedChunk)
UnifiedChunk 是合并节点输出的统一 chunk 结构,兼容 RAG 和 Web 两种源。
核心字段(移动端展示直接使用):
| 字段 | 类型 | RAG 来源 | Web 来源 |
|---|---|---|---|
id | str | 合并后全局编号(1, 2, 3...) | 同左 |
content | str | item.content | item.snippet 或 item.content |
source_type | str | "rag" | "web" ← 移动端按此字段做差异化渲染 |
document_title | str | doc_metadata.document_title(回退 document_name) | item.title |
url | str | doc_metadata.source_file_url | item.url(Web 必有) |
score | float | 向量检索相似度 | 搜索相关性分 |
display_subtitle | str | dataset_name | 降级链计算结果(见 §3.6) |
扩展字段(调试/溯源用,不作为展示主字段):
| 字段 | 说明 |
|---|---|
document_name | 原始文件名(RAG)或 URL(Web) |
dataset_name | 知识库名称(仅 RAG 有值) |
site_name | Web 站点展示名(如"知乎") |
site_host | 从 URL 提取的 host(如 zhihu.com),降级兜底 |
source_file_url | 原始文件链接 |
segment_position | Chunk 在原文档中的段落位置(仅 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_name | published_date | host | display_subtitle |
|---|---|---|---|---|
| Bocha AI 完整返回 | "知乎" | "2025-08-12" | zhihu.com | 知乎 · 2025-08-12 |
| Tavily(无 site_name) | "" | "2025-08-12" | zhihu.com | zhihu.com · 2025-08-12 |
| 百度普通版(无日期) | "知乎" | "" | zhihu.com | 知乎 |
| 完全裸 URL | "" | "" | example.com | example.com |
| URL 都为空 | "" | "" | "" | ""(空串,移动端不渲染) |
为什么放在合并节点而不是移动端:
- 降级链是业务规则 — site_name 缺失时降级到 host,这是数据归一化逻辑
- Python 易测试 — 单测覆盖 4 级降级,移动端无需重复实现
- 避免重复实现 — 所有端共用同一份
display_subtitle,未来扩展新端零成本接入
移动端使用(一行代码,零加工):
subEl.textContent = chunk.display_subtitle || '';3.7 输出示例
{
"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 引用编号重映射 → 子文档
包含: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 }> } | siteName → site_name、datePublished → published_date |
| Tavily | { results: Array<{ title, url, content, score, published_date }> } | 字段直接对应,content 代替 snippet |
| 百度搜索 API | 需先用 web_search 工具节点爬取网页 | 同 Bocha 映射规则 |
本期 Web Search 节点用 hardcoded
web_chunksmock 验证架构,真实工具接入留待未来。
5. LLM 节点配置
5.1 节点设置
| 配置项 | 值 |
|---|---|
| 上下文(Context) | 清空(不再直接使用知识检索的 #context#) |
| 系统提示词 | 见 §5.2 |
| 用户提示词 | {{#sys.query#}} |
| 输出模式 | 流式 |
5.2 系统提示词
# 角色设定
你是欧爪宝,一栋智能写字楼的 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
检索片段:
{{#context#}}注意:Dify 中
{{#context#}}变量需选择合并节点的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 相关文档:
- QA_G-Cite_Dify平台功能设计.md — 本文档(Dify 平台侧设计)
- QA_G-Cite_移动端功能设计.md — 移动端引用渲染 PRD
- 微信小程序_QA_G-Cite_引用归因_交互原型设计.md — 交互原型设计稿
上游文档:
- QA_闲聊对话_Agent设计.md — QA_Chat Agent 整体设计
- 知识库元数据标签体系设计.md —
document_title/display_subtitle字段定义 - RAG引用归因_G-Cite方案调研.md — G-Cite vs P-Cite 行业调研
- 2026_AI空间智能体_Master_Agent_设计.md — Master Agent 路由与 QA 分支定义
附录 A. Chunk编号 代码节点源码 (Python)
对应 §3 合并代码节点设计,在 Dify Chatflow 中创建"代码节点",语言选 Python,粘贴以下代码。
已知限制:Dify 百度搜索工具的 Top-K 参数存在 Bug,设置后不生效,始终返回 10 条结果。临时通过下方
MAX_WEB_RESULTS = 2在代码节点层截断,待 Dify 修复后可移除该变量直接透传。
"""
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 *作者:强哥 *
