「开发流程」
为了确保整体流程设计的科学性与执行连贯性,采用 **"Top-Down"(自顶向下)**的开发模式,以 "总指挥部" 的全局视角统筹推进,具体实施步骤如下:
-
搭建节点骨架(Stubs) :优先定义全流程所需的所有功能节点,仅保留核心日志打印能力(如节点进入 / 退出日志),暂不实现内部复杂业务逻辑,快速搭建起流程的 "骨架结构";
-
串联主图(Graph) :基于预设的业务流转规则,编写主图逻辑将所有节点骨架按序串联,明确节点间的输入输出关系、分支判断条件(如文件格式分流逻辑),形成完整的流程链路;
-
验证流程通畅性 :启动端到端测试,验证节点间的调用链路是否通顺、数据流转是否符合预期、分支跳转是否准确,确保流程无阻塞、无逻辑漏洞;
-
填充节点核心逻辑:在流程链路验证通过后,再逐一聚焦每个节点的内部实现,完成复杂业务逻辑的开发(如 查询重写、定向查询、结果重排处理等),实现 "骨架" 到 "完整系统" 的落地。
该模式的核心优势在于:先保障 "流程走得通",再聚焦 "功能做得好",避免因局部逻辑复杂导致整体流程设计偏差,大幅提升开发效率与流程稳定性。
【 阶段二:在线检索与过滤流水线 】
技术栈:LangGraph + Milvus (Dense/Sparse Hybrid) + BGE-M3 + 阿里云百炼 MCP + Reranker + Neo4j + SSE (Server-Sent Events)
这套系统的核心亮点在于其"四路并发检索、两阶段精细重排、条件式意图收敛与多模态数据闭环"的工业级设计。
🗺️ 检索端全局架构拓扑图
在进入代码细节前,先通过全局拓扑图看一下数据和控制流是如何在各个节点(Nodes)和边(Edges)之间流转的
==================================================================================================
【输入层】 [ 用户输入提问 (original_query) ]
│
▼
==================================================================================================
【意图收敛层】 【 节点:node_item_name_confirm 】
(LLM 历史语义重写 + Milvus 实体库标量对齐)
│
┌─────────────────┴─────────────────┐
(若商品不明确:反问/查无此人:拒绝) (若成功锁定标准化商品)
▼ ▼
【 熔断/快速反问分支 】 【 虚拟分叉点:node_multi_search 】
│ │
│ ┌───────────┼───────────┬───────────┐
│ ▼ ▼ ▼ ▼
│ 【路A:向量】 【路B:HyDE】【路C:图谱】【路D:MCP】
│ node_search node_hyde node_kg node_mcp
│ │ │ │ │
│ └───────────┼───────────┴───────────┘
│ │ (并发执行、异步聚合)
│ ▼
==================================================================================================
【混合排序层】 │ 【 节点:node_rrf 】
│ (向量与 HyDE 两路倒数排名融合)
│ │
│ ▼
│ 【 节点:node_rerank 】
│ (本地 RRF 成果 + 联网 MCP 深度重排)
│ │
└─────────────────┬─────────────────┘
│ (合并控制流)
▼
==================================================================================================
【生成与响应层】 【 节点:node_answer_output 】
(动态上下文窗口控制 + 视觉图表摘要反查 + SSE流式推送)
│
【最终输出】 [ 终端输出 (流式 Delta / 最终 Answer) + Mongo 历史归档 ]
==================================================================================================
🧱 核心代码逐节点硬核解构:
1. 状态大脑 ── state.py (数据载体)
python
from typing_extensions import TypedDict
from typing import List
class QueryGraphState(TypedDict):
"""
QueryGraphState 定义了整个查询流程中流转的数据结构。
"""
session_id: str # 会话唯一标识
original_query: str # 用户原始问题
# 检索过程中的中间数据
embedding_chunks: list # 普通向量检索回来的切片
hyde_embedding_chunks: list # HyDE 检索回来的切片
kg_chunks: list # 图谱检索回来的切片
web_search_docs: list # 网络搜索回来的文档
# 排序过程中的数据
rrf_chunks: list # RRF 融合排序后的切片
reranked_docs: list # 重排序后的最终 Top-K 文档
# 生成过程中的数据
prompt: str # 组装好的 Prompt
answer: str # 最终生成的答案
# 辅助信息
item_names: List[str] # 提取出的商品名称
rewritten_query: str # 改写后的问题
history: list # 历史对话记录
is_stream: bool # 是否流式输出标记
整个图(Graph)是一个纯粹的状态机,所有节点不通过局部变量传参,而是共同读写同一个 QueryGraphState 字典:
-
解耦优势 :通过定义
embedding_chunks、hyde_embedding_chunks、web_search_docs等多路独立容器,使得上游并发节点可以同时向同一个 state 写入数据而不会发生脏数据覆盖。 -
业务标识 :
item_names(锁定的标准化商品列表)和rewritten_query(改写后的独立提问)是整个检索流的"核心通行证"。
2. 编排中枢 ── main_graph.py (条件分支控制)
python
from langgraph.graph import StateGraph, END
from app.query_process.agent.state import QueryGraphState
# 导入所有节点函数
from app.query_process.agent.nodes.node_item_name_confirm import node_item_name_confirm
from app.query_process.agent.nodes.node_query_kg import node_query_kg
from app.query_process.agent.nodes.node_answer_output import node_answer_output
from app.query_process.agent.nodes.node_rerank import node_rerank
from app.query_process.agent.nodes.node_rrf import node_rrf
from app.query_process.agent.nodes.node_search_embedding import node_search_embedding
from app.query_process.agent.nodes.node_search_embedding_hyde import node_search_embedding_hyde
from app.query_process.agent.nodes.node_web_search_mcp import node_web_search_mcp
# 初始化状态图
builder = StateGraph(QueryGraphState)
# 注册所有节点
builder.add_node("node_item_name_confirm", node_item_name_confirm) # 确认商品
builder.add_node("node_multi_search", lambda x: x) # 虚拟节点:多路搜索分叉点
builder.add_node("node_search_embedding", node_search_embedding) # 向量搜索
builder.add_node("node_search_embedding_hyde", node_search_embedding_hyde)
builder.add_node("node_query_kg", node_query_kg)
builder.add_node("node_web_search_mcp", node_web_search_mcp)
builder.add_node("node_join", lambda x: {}) # 虚拟节点:多路搜索合并点
builder.add_node("node_rrf", node_rrf) # 排序
builder.add_node("node_rerank", node_rerank) # 重排
builder.add_node("node_answer_output", node_answer_output) # 生成
# 虚拟节点的作用:作为流程的「分叉 / 合并中转站」,解决多分支流程的组织问题,本身无业务逻辑;
# lambda x:x 含义:接收 state 并原样返回,是最轻便的 "无逻辑传递" 方式;
# 普通函数替换:定义 def 函数名(state): return state 即可完全等价,优势是易扩展、易调试;
# 设置起点
builder.set_entry_point("node_item_name_confirm")
def route_after_item_confirm(state: QueryGraphState):
# 如果已有答案(Branch B/C),直接跳到输出
if state.get("answer"):
"""
这主要发生在 node_item_name_confirm 节点无法直接确定唯一的商品型号,从而需要"反问用户"或"拒绝回答"的场景。
具体来说,有以下两种情况会导致 state 中直接出现 answer ,从而跳过后续的检索流程,直接输出:
1. 多选一(反问用户) :
- 场景 :用户问得太模糊(比如"华为P60"),系统发现数据库里有"华为P60 128G"和"华为P60 Art"两个型号,且置信度都不足以直接确认。
- 处理 :节点会生成一条反问句作为 answer ,例如:"您是想问以下哪个产品:华为P60 128G、华为P60 Art?请明确一下型号。"
- 结果 :此时不需要再去检索文档了,直接把这句话发给用户让他选。
2. 查无此人(拒绝回答) :
- 场景 :用户问了一个系统里压根没有的商品(比如"小米15",但库里只有华为的数据),或者评分过低(<0.6)。
- 处理 :节点会生成一条拒绝句作为 answer ,例如:"抱歉,未找到相关产品,请提供准确型号以便我为您查询。"
- 结果 :同样不需要后续检索,直接结束流程。
"""
return "node_answer_output"
# 否则继续搜索流程
return "node_multi_search"
# 1. 意图确认 -> (条件分叉) -> 多路搜索 / 答案输出
builder.add_conditional_edges(
"node_item_name_confirm",
route_after_item_confirm
)
# 2. 并发执行四路搜索
builder.add_edge("node_multi_search", "node_search_embedding")
builder.add_edge("node_multi_search", "node_search_embedding_hyde")
builder.add_edge("node_multi_search", "node_web_search_mcp")
builder.add_edge("node_multi_search", "node_query_kg")
# 3. 四路搜索 -> 结果合并
builder.add_edge("node_search_embedding", "node_join")
builder.add_edge("node_search_embedding_hyde", "node_join")
builder.add_edge("node_web_search_mcp", "node_join")
builder.add_edge("node_query_kg", "node_join")
# 4. 合并 -> 排序 -> 重排 -> 生成 -> 结束
builder.add_edge("node_join", "node_rrf")
builder.add_edge("node_rrf", "node_rerank")
builder.add_edge("node_rerank", "node_answer_output")
builder.add_edge("node_answer_output", END)
# 编译生成可执行的 Runnable 应用
query_app = builder.compile()
展示了工程编排中非常高级的"熔断与快速反问机制":
-
极其优雅的容错与熔断机制:条件路由的设计避免了系统"一条道走到黑"的通病。在商用环境中,可以随时扩展这个决策函数(例如加入敏感词拦截过滤、黑名单拦截),具备极高的商业扩展性。
if state.get("answer"): # 意图确认节点直接吐出了反问句或拒绝句 return "node_answer_output" # 核心熔断:跳过后续所有多路检索,直达输出层 return "node_multi_search" # 否则平滑进入四路并发检索 -
并发分支分叉(
add_edge) :通过建立一个虚拟空节点node_multi_search,同时拉出四条静态边连向向量、HyDE、图谱和联网 MCP 节点。在 LangGraph 底层,这会触发 Asyncio 并发事件循环 ,让四路检索在多个线程/协程中同时向各自的服务器发起请求,大幅压低整体长尾延迟(Latency)。 -
完美平衡了检索深度与系统耗时:通过将本地检索(向量/HyDE)进行一阶段 RRF 融合,再在二阶段让 Reranker 模型对本地与网络数据联合进行"滑动断崖精排",配合 LangGraph 强大的并发底层,做到了"既要捞得全,又要排得准,响应还要快"的极致生产体验。
3. 意图确认与商品对齐 ── node_item_name_confirm.py
python
import sys
import os
import json
import logging
from typing import List, Dict, Any, Optional
from langchain_core.messages import SystemMessage, HumanMessage
from app.core.load_prompt import load_prompt
from app.query_process.agent.state import QueryGraphState
from app.utils.task_utils import add_running_task, add_done_task
from app.clients.mongo_history_utils import get_recent_messages, save_chat_message, update_message_item_names
from app.lm.lm_utils import get_llm_client
from app.lm.embedding_utils import generate_embeddings
from app.clients.milvus_utils import get_milvus_client, create_hybrid_search_requests, hybrid_search
from dotenv import load_dotenv, find_dotenv
from app.core.logger import logger
load_dotenv(find_dotenv())
def step_3_extract_info(query: str, history: List[Dict]) -> Dict:
"""
利用LLM从当前问题以及历史会话中提取出主要询问的商品名称item_names(可多个,JSON列表形式)
若商品名不够明确则返回空列表,同时根据上下文重新改写问题,保证问题独立完整
:param query: 字符串 - 用户当前原始查询问题(如:"这个多少钱?")
:param history: 列表[字典] - 近期会话历史
:return: 字典 - 提取结果,格式:{"item_names": [], "rewritten_query": ""}
"""
logger.info("Step 3: 开始提取信息 (LLM)")
# 1. 初始化准备
client = get_llm_client(json_mode=True)
# 构造历史对话文本
history_text = ""
for msg in history:
history_text += f"{msg.get('role', 'unknown')}: {msg.get('text', '')}\n"
logger.info(f"Step 3: 历史上下文构建完成,长度: {len(history_text)} 字符")
# 2. 加载提示词
try:
# 使用关键字参数传递,避免参数位置错误
prompt = load_prompt("rewritten_query_and_itemnames", history_text=history_text, query=query)
logger.debug(f"Step 3: 提示词加载成功,Prompt长度: {len(prompt)}")
except Exception as e:
logger.error(f"Step 3: 加载提示词失败: {e}")
return {"item_names": [], "rewritten_query": query}
messages = [
SystemMessage(content="你是一个专业的客服助手,擅长理解用户意图和提取关键信息。"),
HumanMessage(content=prompt)
]
try:
logger.info("Step 3: 正在调用 LLM 进行提取...")
response = client.invoke(messages)
content = response.content
logger.debug(f"Step 3: LLM 原始响应: {content}")
# 清理 Markdown 代码块
if content.startswith("```json"):
content = content.replace("```json", "").replace("```", "")
result = json.loads(content)
# 健壮性检查
if "item_names" not in result:
result["item_names"] = []
if "rewritten_query" not in result:
result["rewritten_query"] = query
logger.info(f"Step 3: 提取结果解析成功 - 商品名: {result['item_names']}, 重写问题: {result['rewritten_query']}")
return result
except Exception as e:
logger.error(f"Step 3: LLM 提取或解析失败: {e}")
return {"item_names": [], "rewritten_query": query}
def step_4_vectorize_and_query(item_names: List[str]) -> List[Dict]:
"""
对提取的 item_names 进行向量化并在 Milvus 中进行混合搜索
"""
logger.info(f"Step 4: 开始向量化检索,目标商品: {item_names}")
results = []
client = get_milvus_client()
if not client:
logger.error("Step 4: 无法连接到 Milvus")
return results
collection_name = os.environ.get("ITEM_NAME_COLLECTION")
if not collection_name:
logger.error("Step 4: 环境变量中未找到 ITEM_NAME_COLLECTION")
return results
try:
logger.info("Step 4: 正在生成 Embedding (Dense + Sparse)...")
embeddings = generate_embeddings(item_names)
logger.info(f"Step 4: 向量生成完成,开始 Milvus 搜索 (Collection: {collection_name})")
for i, name in enumerate(item_names):
try:
dense_vector = embeddings.get("dense")[i]
sparse_vector = embeddings.get("sparse")[i]
# 构造混合搜索请求
reqs = create_hybrid_search_requests(
dense_vector=dense_vector,
sparse_vector=sparse_vector,
limit=5
)
# 执行混合搜索
# 权重调整为 0.8 (Dense) / 0.2 (Sparse) 以优化评分
search_res = hybrid_search(
client=client,
collection_name=collection_name,
reqs=reqs,
ranker_weights=(0.8, 0.2),
limit=5,
norm_score=True,
output_fields=["item_name"]
)
matches = []
if search_res and len(search_res) > 0:
for hit in search_res[0]:
entity = hit.get("entity") or {}
item_name = entity.get("item_name")
score = hit.get("distance")
if item_name:
matches.append({
"item_name": item_name,
"score": score
})
logger.debug(f"Step 4: '{name}' 匹配项: {item_name} (Score: {score:.4f})")
results.append({
"extracted_name": name,
"matches": matches
})
logger.info(f"Step 4: 商品 '{name}' 检索完成,找到 {len(matches)} 个匹配项")
except Exception as inner_e:
logger.error(f"Step 4: 处理商品 '{name}' 时出错: {inner_e}")
results.append({"extracted_name": name, "matches": []})
except Exception as e:
logger.error(f"Step 4: 向量化或搜索过程发生全局错误: {e}")
return results
def step_5_align_item_names(query_results: List[Dict]) -> Dict:
"""
根据 Milvus 搜索评分,对齐商品名,生成「确认商品名」和「候选商品名」
"""
logger.info("Step 5: 开始对齐商品名 (Score Analysis)")
confirmed_item_names = []
options = []
for res in query_results:
extracted_name = res.get("extracted_name", "").strip()
matches = res.get("matches", []) or []
if not matches:
logger.info(f"Step 5: '{extracted_name}' 无匹配结果")
continue
# 按分数降序
matches.sort(key=lambda x: x.get("score", 0), reverse=True)
# 打印详细评分日志辅助调试
top_matches_log = ", ".join([f"{m['item_name']}({m['score']:.3f})" for m in matches[:3]])
logger.info(f"Step 5: '{extracted_name}' Top匹配: {top_matches_log}")
# 筛选
high = [m for m in matches if m.get("score", 0) > 0.85]
mid = [m for m in matches if m.get("score", 0) >= 0.6]
# 规则 A: 单个高置信度
if len(high) == 1:
confirmed_name = high[0].get("item_name")
confirmed_item_names.append(confirmed_name)
logger.info(f"Step 5: 规则A命中 (Single High) -> 确认: {confirmed_name}")
continue
# 规则 B: 多个高置信度
if len(high) > 1:
picked = None
# 优先匹配同名
if extracted_name:
for m in high:
if m.get("item_name") == extracted_name:
picked = m
logger.info(f"Step 5: 规则B命中 (Exact Match in High) -> 确认: {picked.get('item_name')}")
break
# 否则取最高分
if not picked:
picked = high[0]
logger.info(f"Step 5: 规则B命中 (Highest Score) -> 确认: {picked.get('item_name')}")
confirmed_item_names.append(picked.get("item_name"))
continue
# 规则 C: 无高置信度,取中置信度候选
if len(mid) > 0:
current_options = [m.get("item_name") for m in mid[:5]]
options.extend(current_options)
logger.info(f"Step 5: 规则C命中 (Mid Confidence) -> 添加候选: {current_options}")
continue
logger.info(f"Step 5: 规则D命中 (Low Confidence) -> 无匹配")
result = {
"confirmed_item_names": list(set(confirmed_item_names)),
"options": list(set(options))
}
logger.info(f"Step 5: 对齐结果: {result}")
return result
def step_6_check_confirmation(state: Dict, align_result: Dict, session_id: str, history: List[Dict], rewritten_query: str) -> Dict:
"""
检查对齐结果,更新 State
"""
logger.info("Step 6: 检查确认状态并更新 State")
# 健壮性处理
if align_result is None:
align_result = {}
confirmed = align_result.get("confirmed_item_names", [])
options = align_result.get("options", [])
# 分支 A: 有确认商品名
if confirmed:
logger.info(f"Step 6: [分支A] 存在确认商品名: {confirmed}")
# 更新历史消息中的 item_names
ids_to_update = []
for msg in history:
if not msg.get("item_names"):
mid = msg.get("_id")
if mid:
ids_to_update.append(str(mid))
if ids_to_update:
logger.info(f"Step 6: 更新 {len(ids_to_update)} 条历史消息的关联商品名")
update_message_item_names(ids_to_update, confirmed)
state["item_names"] = confirmed
state["rewritten_query"] = rewritten_query
if "answer" in state:
del state["answer"]
return state
# 分支 B: 有候选商品名
if options:
logger.info(f"Step 6: [分支B] 存在候选商品名: {options}")
options_str = "、".join(options[:3])
answer = f"您是想问以下哪个产品:{options_str}?请明确一下型号。"
state["answer"] = answer
state["item_names"] = []
return state
# 分支 C: 无结果
logger.info("Step 6: [分支C] 无确认也无候选")
state["answer"] = "抱歉,未找到相关产品,请提供准确型号以便我为您查询。"
state["item_names"] = []
return state
def step_7_write_history(state: Dict, session_id: str, history: List[Dict], rewritten_query: str, message_id: str) -> Dict:
"""
写入最终历史记录
"""
logger.info("Step 7: 写入会话历史")
# 如果有助手回答(分支 B/C),写入助手消息
if state.get("answer"):
logger.info("Step 7: 保存助手回答")
save_chat_message(
session_id=session_id,
role="assistant",
text=state["answer"],
rewritten_query="",
item_names=[]
)
# 更新用户消息(关联 rewrite_query 和 item_names)
logger.info(f"Step 7: 更新用户消息 (ID: {message_id})")
save_chat_message(
session_id=session_id,
role="user",
text=state["original_query"],
rewritten_query=rewritten_query,
item_names=state.get("item_names", []),
message_id=message_id
)
return state
def node_item_name_confirm(state: QueryGraphState) -> QueryGraphState:
"""
主节点函数:商品名称确认流程
"""
logger.info(">>> node_item_name_confirm: 开始处理")
session_id = state["session_id"]
original_query = state.get("original_query", "")
is_stream = state.get("is_stream", False)
# 标记任务开始
add_running_task(session_id, "node_item_name_confirm", is_stream)
# 1. 获取历史记录
history = get_recent_messages(session_id, limit=10)
logger.info(f"Node: 获取到 {len(history)} 条历史消息")
# 2. 保存用户当前消息 (初始保存,后续 step 7 会更新)
message_id = save_chat_message(session_id, "user", original_query, "", state.get("item_names", []))
logger.debug(f"Node: 用户消息已初始保存, ID: {message_id}")
# 3. 提取信息
extract_res = step_3_extract_info(original_query, history)
item_names = extract_res.get("item_names", [])
rewritten_query = extract_res.get("rewritten_query", original_query)
# 更新 State 中的 rewrite_query
state["rewritten_query"] = rewritten_query
align_result = {}
# 4. & 5. 如果有提取到商品名,进行搜索和对齐
if len(item_names) > 0:
query_results = step_4_vectorize_and_query(item_names)
align_result = step_5_align_item_names(query_results)
else:
logger.info("Node: 未提取到商品名,跳过向量检索")
# 6. 检查确认状态
state = step_6_check_confirmation(state, align_result, session_id, history, rewritten_query)
# 7. 写入最终历史
final_state = step_7_write_history(state, session_id, history, rewritten_query, message_id)
# 将 history 存入 state,供后续节点(如 node_answer_output)使用
final_state["history"] = history
# 标记任务完成
add_done_task(session_id, "node_item_name_confirm", is_stream)
logger.info(f"Node: 处理结束, Final State Item Names: {final_state.get('item_names')}")
return final_state
if __name__ == "__main__":
# 测试代码块
print("\n" + "="*50)
print(">>> 启动 node_item_name_confirm 本地测试")
print("="*50)
# 模拟输入状态
mock_state = {
"session_id": "test_debug_session_001",
"original_query": "HAK 180 烫金机多少钱?", # 针对用户提到的具体 case
"is_stream": False,
"item_names": []
}
try:
# 运行节点
result = node_item_name_confirm(mock_state)
print("\n" + "="*50)
print(">>> 测试结果摘要:")
print(f"Rewritten Query: {result.get('rewritten_query')}")
print(f"Item Names: {result.get('item_names')}")
print(f"Answer: {result.get('answer')}")
print("="*50)
except Exception as e:
logger.exception(f"测试运行期间发生未捕获异常: {e}")
这是整个系统解决 RAG 代词指代不明、漏召回的"总闸口":
- 基础环境收拢与会话历史拉取(Step 1 & Step 2)
-
动作 :接收当前的
session_id和用户的原始提问original_query。 -
业务逻辑 :从 MongoDB 历史库中拉取近期几轮的上下文会话记录(
history),为接下来的语义理解提供背景画布。
- 大模型意图提取与问题改写(Step 3)
-
动作 :将
original_query和history喂给 LLM。 -
业务逻辑:利用大模型做两件事:
-
提取实体 :分析用户究竟在问哪一个或哪几个商品,输出一个标准 JSON 列表
item_names。 -
消除指代(反向指代消解) :将口语化的、依赖上下文的问题改写为一个独立、完整、不带指代词 的提问
rewritten_query(例如将"它的操作步骤"改写为"HAK 180 烫金机的具体操作步骤是什么")。
-
- 标量商品库的物理对齐与召回(Step 4 & Step 5)
-
动作 :将 LLM 提取出的粗糙商品名进行向量化,然后去 Milvus 的标准化商品名称集合 (如
ITEM_NAME_COLLECTION)中执行混合检索。 -
业务逻辑 :LLM 提取的名字可能是"HAK180烫金机"(缺空格),而官方标准名称是"HAK 180 烫金机"。这一步通过向量库的语义匹配,计算置信度(Score),把用户口中的代号与企业知识库里的绝对主键/唯一标识进行强行强对齐。
- 核心防御机制与条件熔断决策(Step 6 & Step 7)
-
动作:检查对齐结果,决定是否放行。
-
三大线上生产路径决策:
-
路径 A(完美放行) :置信度极高且锁定了唯一商品。将标准化名称写入
state["item_names"],将改写后的完整问题写入state["rewritten_query"]。下游的向量检索节点(如node_search_embedding)可以直接用这个商品名作为filter表达式去 Milvus 里实施物理隔离检索,确保绝不串台。 -
路径 B(多选一反问) :用户输入模糊(如"华为主机"),向量库匹配出库里有"华为服务器 A型"和"华为服务器 B型",置信度均等。此时节点在内部直接组装反问句("请问您是指以下哪款设备:..."),并原地写入
state["answer"]。 -
路径 C(查无此人拒绝) :匹配得分极低(如
< 0.6),说明用户问的内容超出了私有知识库的服务边界(如拿着小米的问题来问华为的客服库)。节点直接组装拒绝句("抱歉,未找到相关产品... "),并原地写入state["answer"]。
-
优势:
-
绝对的算力安全保护(Security & Cost Control) : 在企业线上环境中,恶意的长尾请求或无关提问(如"能给我讲个笑话吗")会轻易通过传统 RAG 系统。由于没有前置实体强对齐,系统会盲目进行 4 路并发检索、图谱查询以及大模型回答,产生巨大的 Token 费用和时延。该节点在 Step 6 的熔断设计 ,让系统在 50ms 内就能识别并直接拦截这些"脱靶"请求,直接在网关层完成拦截,极大地保护了企业内部资源的安全性。
-
从源头消灭大模型"张冠李戴"的幻想(Hallucination Defense) : 如果用户购买了"HAK 180 烫金机",却提问"怎么调温?",若不做商品名锁定,传统的向量检索可能会把"HAK 200 烫金机"或"HAK 100 印刷机"的调温手册切片混杂着检索出来。大模型在两份不同机器的说明书中极易发生信息交叉污染,给出错误的错误指导。该节点确认并输出了官方标准实体
item_names,使得下游node_search_embedding可以直接对 Milvus 施加硬编码过滤:filter='item_name == "HAK 180 烫金机"'从数学物理层面卡死了检索空间,使得捞出来的每一条切片绝对纯净,彻底消灭了型号混淆导致的幻觉。 -
大幅提升一阶段检索的语义召回率(Recall) : 通过消除了"它"、"那个"等口语化代词,将问题富化重写为包含完整实体主谓宾的
rewritten_query。这使得无论是一阶段的向量模型(BGE-M3),还是 HyDE 节点的大模型假设生成,都能在最佳的语义坐标系中进行高密度的相似度匹配,召回率相较于口语化提问直接大幅提升。
4. 多路检索层 ── 语义、泛化、图谱与联网
四路检索分别代表了不同的语义表征维度、泛化能力、实体关联与实时外援,旨在构建一张毫无死角的知识召回网。
bash
【 虚拟分叉点:node_multi_search 】
│
┌───────────────────────┼───────────────────────┬───────────────────────┐
▼ ▼ ▼ ▼
【第 1 路:精准向量流】 【第 2 路:泛化联想流】 【第 3 路:知识图谱流】 【第 4 路:网络扩展流】
node_search_embedding node_search_embedding node_query_kg node_web_search_mcp
_hyde
│ │ │ │
─── 密集+稀疏双通道 ─── ── LLM虚构长文本对齐 ── ── 实体级多跳挖掘 ─── ── 百炼MCP连接外网 ──
│ │ │ │
└───────────────────────┼───────────────────────┘ │
▼ ▼
【 一阶段汇聚 】 【 跨界会师 】
节点:node_rrf 节点:node_rerank
四路检索节点的深度拆解
第 1 路:精准向量检索流 (node_search_embedding.py)
python
import sys
import os
from app.utils.task_utils import add_running_task,add_done_task
from app.lm.embedding_utils import generate_embeddings
from app.clients.milvus_utils import create_hybrid_search_requests,hybrid_search,get_milvus_client
from app.core.logger import logger
from dotenv import load_dotenv,find_dotenv
load_dotenv(find_dotenv())
def node_search_embedding(state):
"""
核心节点函数:基于已确认商品名+改写后的用户问题,执行Milvus向量数据库混合检索
流程:用户问题向量化 → 构造带商品名过滤的混合搜索请求 → 执行稠密+稀疏混合检索 → 返回检索结果
:param state: Dict - 会话状态字典,包含上游传递的核心信息,关键字段:
{
"session_id": str, # 会话唯一标识
"rewritten_query": str, # step3改写后的完整用户问题(含商品名)
"item_names": list[str], # step6已确认的标准化商品名列表
"is_stream": bool/None # 是否为流式响应,可选
}
:return: Dict - 检索结果字典,仅包含embedding_chunks字段,供下游节点使用:
{
"embedding_chunks": List[Dict] # Milvus检索结果列表,无结果则为空列表
# 每个元素为一条匹配的向量数据,含业务字段
}
"""
logger.info("---search_milvus 开始处理---")
add_running_task(state["session_id"],sys._getframe().f_code.co_name,state["is_stream"])
# 1. 从会话状态中提取核心入参,为后续检索做准备
query = state.get("rewritten_query") # 提取改写后的用户问题(含商品名,独立完整)
item_names = state.get("item_names") # 提取已确认的标准化商品名列表(精准过滤用)
logger.info(f"核心入参提取: query='{query}', item_names={item_names}")
# 2. 对改写后的用户问题执行向量化,生成BGEM3稠密+稀疏向量
logger.info(f"开始为文本获取嵌入值: {query[:50]}..." if len(query) > 50 else f"开始为"{query}"文本获取嵌入值...")
# 调用向量化函数,入参为列表(支持批量,此处仅单条查询)
# 生成与商品名匹配的语义向量,用于后续相似性检索
embeddings = generate_embeddings([query])
dense_vec = embeddings.get("dense")[0]
sparse_vec = embeddings.get("sparse")[0]
# 打印稠密/稀疏向量日志,便于调试向量生成结果
logger.debug(f"向量生成成功: dense_dim={len(dense_vec)}, sparse_len={len(sparse_vec)}")
# 3. 准备Milvus向量数据库连接相关配置,指定检索的集合
# 从环境变量中获取Milvus中存储「文本片段向量」的集合名(表名),避免硬编码
collection_name = os.environ.get("CHUNKS_COLLECTION")
logger.info(f"正在连接到 Milvus 并准备集合 '{collection_name}'...")
# 4. 构造Milvus混合搜索请求对象(核心步骤)
# 先通过辅助函数生成商品名过滤表达式,精准过滤检索范围
# 'item_name in ["苹果15", "华为P60"]'
# 若无商品名,直接返回None(不做过滤)
if not item_names:
logger.warning("item_names 为空,跳过检索,返回空结果")
return {"embedding_chunks": []}
# 对每个商品名添加双引号,拼接为Milvus支持的in语法格式
quoted = ", ".join(f'"{v}"' for v in item_names)
# 构造最终过滤表达式
expr = f"item_name in [{quoted}]"
logger.info(f"创建搜索请求过滤表达式: {expr}")
# 构造稠密+稀疏混合搜索请求,整合向量、过滤条件、搜索参数
reqs = create_hybrid_search_requests(
dense_vector=dense_vec, # 取用户问题的稠密向量(单条,故取索引0)
sparse_vector=sparse_vec, # 取用户问题的稀疏向量(单条,故取索引0)
expr=expr, # 商品名过滤表达式,缩小检索范围(仅检索指定商品名的向量)
limit=10 # 底层检索返回数量(后续会再过滤为5,预留更多结果做重排序)
)
# 5. 执行Milvus稠密+稀疏混合向量检索(核心调用)
logger.info("开始执行 Milvus 混合检索...")
client = get_milvus_client()
res = hybrid_search(
client=client,
collection_name=collection_name, # 检索的目标集合名(文本片段向量集合)
reqs=reqs, # 构造好的混合搜索请求对象(稠密+稀疏)
ranker_weights=(0.8, 0.2), # 稠/稀疏向量评分权重配比,各占50%(提升关键词精确匹配)
norm_score=True, # 开启评分归一化,将距离值转为0-1区间的相似度评分
limit=5, # 最终返回的TOP5相似度最高结果
output_fields=["chunk_id", "content", "item_name"] # 指定返回的业务字段
)
# 打印节点处理成功日志,输出原始检索结果,便于调试
hit_count = len(res[0]) if res and len(res) > 0 else 0
logger.info(f"节点 search_embedding 处理成功,检索到 {hit_count} 条相关片段")
if hit_count > 0:
logger.debug(f"Top1 检索结果示例: {res[0][0]}")
# 标记当前任务完成,更新任务状态
add_done_task(state["session_id"], sys._getframe().f_code.co_name, state.get("is_stream"))
# 6. 构造并返回结果:若检索结果非空,取res[0](适配Milvus批量搜索格式),否则返回空列表
# res[0]为当前单条查询的检索结果,包含TOP5匹配的向量数据及业务字段
return {"embedding_chunks": res[0] if res else []}
if __name__ == "__main__":
# 模拟测试数据
test_state = {
"session_id": "test_search_embedding_001",
"rewritten_query": "HAK 180 烫金机使用说明", # 模拟改写后的查询
"item_names": ["HAK 180 烫金机"], # 模拟已确认的商品名
"is_stream": False
}
print("\n>>> 开始测试 node_search_embedding 节点...")
try:
# 执行节点函数
result = node_search_embedding(test_state)
logger.info(f"检索结果汇总:{result}")
# 验证结果
chunks = result.get("embedding_chunks", [])
print(f"\n>>> 测试完成!检索到 {len(chunks)} 条结果")
if chunks:
print("\n>>> Top 1 结果详情:")
top1 = chunks[0]
# 打印关键字段(注意:entity字段可能包含具体业务数据)
print(f"ID: {top1.get('id')}")
print(f"Distance: {top1.get('distance')}")
entity = top1.get('entity', {})
print(f"Item Name: {entity.get('item_name')}")
print(f"Content Preview: {entity.get('content', '')[:100]}...")
else:
print("\n>>> 警告:未检索到任何结果,请检查 Milvus 数据或 item_names 是否匹配")
except Exception as e:
logger.error(f"测试运行失败: {e}", exc_info=True)
负责从本地私有知识库中精准捞取最相关的硬核技术切片。
-
工作原理:
-
接收上游节点的
rewritten_query,调用 BGE-M3 模型生成稠密向量。 -
构造一个带有商品名标量强过滤 (
filter=f'item_name == "{item_name}"')的 Milvus 检索请求。 -
在 Milvus 内部执行 Dense(密集向量,抓泛化语义) + Sparse(稀疏向量/BM25,抓特定型号、数字等硬关键词) 的混合搜索(Hybrid Search)。
-
-
工程亮点 :由于加装了
item_name的物理隔离锁,无论用户问得多么通用(例如"怎么清零?"),它被限制在当前选定商品的库内,绝对不会召回其他不相干商品的切片,从物理层斩断了 RAG 跨型号幻觉的可能。 -
返回值归宿 :召回的 Top-5 原始碎片数组,写入
state["embedding_chunks"]。
第 2 路:假设性文档检索流 (node_search_embedding_hyde.py)
python
# HyDE节点
import sys
from app.utils.task_utils import add_running_task, add_done_task
from app.lm.lm_utils import *
from app.lm.embedding_utils import *
from app.clients.milvus_utils import *
from app.core.logger import logger
from app.core.load_prompt import load_prompt
from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv())
def step_1_create_hyde_doc(rewritten_query: str) -> str:
"""
阶段1:利用大模型根据用户查询生成假设性文档(Hypothetical Document)。
HyDE的核心在于:利用LLM生成一个"虚构但相关"的文档,用该文档的向量去检索真实的文档,
从而缓解短查询(Query)与长文档(Document)在语义空间不匹配的问题。
:param rewritten_query: 用户改写后的查询语句
:return: LLM生成的假设性文档内容
"""
if not rewritten_query:
logger.error("Step 1 Error: rewritten_query 为空")
raise ValueError("rewritten_query 不能为空")
logger.info(f"Step 1: 开始生成假设性文档 (HyDE), Query: {rewritten_query}")
try:
llm = get_llm_client()
# 加载提示词模板,生成假设文档
# 提示词通常引导LLM:"请为这个问题写一段专业的回答..."
hyde_prompt = load_prompt("hyde_prompt", rewritten_query=rewritten_query)
logger.debug(f"Step 1: Prompt加载成功, 长度: {len(hyde_prompt)}")
# 调用LLM生成
response = llm.invoke(hyde_prompt)
hyde_doc = response.content
logger.info(f"Step 1: 假设文档生成完成, 长度: {len(hyde_doc)} 字符")
logger.debug(f"Step 1: 文档预览: {hyde_doc[:50]}...")
return hyde_doc
except Exception as e:
logger.error(f"Step 1: 生成假设文档失败: {e}")
raise e
def step_2_search_embedding_hyde(
rewritten_query: str,
hyde_doc: str,
item_names=None,
req_limit: int = 10,
top_k: int = 5,
ranker_weights=(0.8, 0.2), # 调整默认权重以偏向稠密向量 (0.8, 0.2)
norm_score: bool = True, # 默认开启归一化
output_fields=["chunk_id", "content", "item_name"],
):
"""
阶段2:利用"重写问题 + 假设性文档"生成 embedding,并到向量库检索切片。
:param rewritten_query: 改写后的查询
:param hyde_doc: Step 1 生成的假设性文档
:param item_names: 商品名称列表,用于元数据过滤 (item_name in [...])
:param req_limit: Milvus 搜索时的候选召回数量
:param top_k: 最终返回的 Top K 结果数量
:param ranker_weights: 混合检索权重 (Dense, Sparse)
:param norm_score: 是否对分数进行归一化
:param output_fields: 返回结果中包含的字段
:return: 检索结果列表
"""
if not rewritten_query:
raise ValueError("rewritten_query 不能为空")
if not hyde_doc:
raise ValueError("hypothetical_doc 不能为空")
# 1. 拼接查询与假设文档,形成更丰富的语义上下文
combined_text = rewritten_query + " " + hyde_doc
logger.info(f"Step 2: 拼接 Query + HyDE Doc, 总长度: {len(combined_text)}")
# 2. 生成向量 (Dense + Sparse)
logger.info("Step 2: 正在生成混合向量 (Embedding)...")
embeddings = generate_embeddings([combined_text])
# 3. 准备 Milvus 检索
collection_name = os.environ.get("CHUNKS_COLLECTION")
if not collection_name:
logger.error("Step 2 Error: 环境变量 CHUNKS_COLLECTION 未设置")
return []
logger.info(f"Step 2: 准备在集合 '{collection_name}' 中执行混合检索")
# 构造过滤表达式 (如果有商品名限制)
expr = None
if item_names:
# 处理 item_names 中的引号,防止注入或语法错误
quoted = ", ".join(f'"{v}"' for v in item_names)
expr = f"item_name in [{quoted}]"
logger.info(f"Step 2: 应用过滤条件: {expr}")
else:
logger.info("Step 2: 未指定商品名过滤,将全库检索")
try:
# 构造搜索请求
reqs = create_hybrid_search_requests(
dense_vector=embeddings.get("dense")[0],
sparse_vector=embeddings.get("sparse")[0],
expr=expr,
limit=req_limit,
)
client = get_milvus_client()
if not client:
logger.error("Step 2 Error: 无法连接到 Milvus")
return []
# 执行混合检索
logger.info(f"Step 2: 执行 Hybrid Search, Weights={ranker_weights}, TopK={top_k}")
res = hybrid_search(
client=client,
collection_name=collection_name,
reqs=reqs,
ranker_weights=ranker_weights,
norm_score=norm_score,
limit=top_k,
output_fields=list(output_fields),
)
hit_count = len(res[0]) if res and len(res) > 0 else 0
logger.info(f"Step 2: 检索完成, 找到 {hit_count} 个匹配切片")
return res
except Exception as e:
logger.error(f"Step 2: 检索过程发生异常: {e}")
return []
def node_search_embedding_hyde(state):
"""
HyDE (Hypothetical Document Embedding) 检索节点
核心思想:通过LLM生成假设性答案(HyDE文档),将其向量化后用于检索,以解决短查询语义稀疏问题。
执行步骤:
1. 参数提取:从会话状态中获取改写后的查询(rewritten_query)和已确认的商品名(item_names)。
2. 生成假设文档 (Step 1):调用LLM,基于用户问题生成一段假设性的理想回答(即HyDE文档)。
3. 混合检索 (Step 2):
- 将"用户问题 + 假设文档"合并,生成BGE-M3稠密+稀疏向量。
- 在Milvus中执行混合检索(带商品名过滤),召回最相似的知识切片。
4. 结果封装:返回检索到的切片列表和生成的假设文档,更新会话状态。
:param state: 会话状态字典,包含 session_id, rewritten_query, item_names 等
:return: 包含 hyde_embedding_chunks (检索结果) 和 hyde_doc (假设文档) 的字典
"""
logger.info("---HyDE (假设文档检索) 节点开始处理---")
# 记录任务开始状态
add_running_task(state["session_id"], sys._getframe().f_code.co_name, state.get("is_stream"))
# 1. 参数提取与校验
# 优先使用改写后的查询,若无则降级使用原始查询
rewritten_query = state.get("rewritten_query")
if not rewritten_query:
rewritten_query = state.get("original_query")
if not rewritten_query:
logger.error("HyDE节点错误: 未找到有效的用户查询 (rewritten_query/original_query 均为空)")
return {}
item_names = state.get("item_names")
logger.info(f"HyDE检索入参: query='{rewritten_query}', item_names={item_names}")
# 阶段1:生成假设性文档
hyde_doc = ""
try:
logger.info("Step 1: 开始生成假设性文档 (HyDE Doc)...")
hyde_doc = step_1_create_hyde_doc(rewritten_query)
logger.info(f"Step 1: 假设文档生成成功 (长度: {len(hyde_doc)})")
logger.debug(f"假设文档预览: {hyde_doc[:100]}...")
except Exception as e:
logger.error(f"Step 1 (生成假设文档) 发生异常: {e}", exc_info=True)
# HyDE生成失败属于非阻断性错误,可选择直接返回空或降级处理,此处直接返回空结果
return {}
# 阶段2:用"重写问题 + 假设文档"检索切片
try:
logger.info("Step 2: 基于假设文档执行 Milvus 混合检索...")
res = step_2_search_embedding_hyde(
rewritten_query=rewritten_query,
hyde_doc=hyde_doc,
item_names=item_names,
top_k=5,
)
hit_count = len(res[0]) if res and len(res) > 0 else 0
logger.info(f"Step 2: 检索完成,召回 {hit_count} 条相关切片")
if hit_count > 0:
# 打印第一条结果用于调试
first_hit = res[0][0]
score = first_hit.get("distance")
content_preview = first_hit.get("entity", {}).get("content", "")[:30]
logger.debug(f"Top1 结果: Score={score}, Content='{content_preview}...'")
return {
"hyde_embedding_chunks": res[0] if res else [],
"hyde_doc": hyde_doc,
}
except Exception as e:
logger.error(f"Step 2 (向量生成与检索) 发生异常: {e}", exc_info=True)
return {}
finally:
# 无论成功失败,均标记任务结束
add_done_task(state["session_id"], sys._getframe().f_code.co_name, state.get("is_stream"))
logger.info("---HyDE 节点处理结束---")
if __name__ == "__main__":
# 本地测试代码
print("\n" + "="*50)
print(">>> 启动 node_search_embedding_hyde 本地测试")
print("="*50)
# 模拟输入状态
mock_state = {
"session_id": "test_hyde_session_001",
"original_query": "HAK 180 烫金机怎么操作?",
"rewritten_query": "HAK 180 烫金机的具体操作步骤是什么?",
"item_names": ["HAK 180 烫金机"],
"is_stream": False
}
try:
# 运行节点
result = node_search_embedding_hyde(mock_state)
print("\n" + "="*50)
print(">>> 测试结果摘要:")
print(f"HyDE Doc Generated: {bool(result.get('hyde_doc'))}")
if result.get("hyde_doc"):
print(f"Doc Preview: {result.get('hyde_doc')[:50]}...")
chunks = result.get("hyde_embedding_chunks", [])
print(f"Chunks Found: {len(chunks)} , chunks内容:{chunks}")
if chunks:
print(f"Top Chunk Score: {chunks[0].get('distance')}")
print("="*50)
except Exception as e:
logger.exception(f"测试运行期间发生未捕获异常: {e}")
专门用来解决"短查询(Query)与长切片(Document)在数学几何空间中信息量不对等"导致的漏召回问题。
-
工作原理:
-
Step 1 (LLM 联想) :将
rewritten_query喂给 LLM,加载专门的 HyDE 模板(hyde_doc_generation),命令大模型"凭空编造"一段几十到几百字的、语气和技术术语高度符合原厂说明书习惯的"假设性假答案"hyde_doc。 -
Step 2 (假文捞真文) :将这个长篇大论的
hyde_doc整体向量化,然后拿着这个"长文本假向量"去检索真实的 Milvus 手册库(同样带有商品标量过滤)。
-
-
工程亮点 :用户的提问只有几个字,蕴含的数学特征很微弱;而 LLM 编造的假答案里充满了"调节螺丝、顺时针旋转、压力表读数"等手册专业词汇。在几何坐标系中,长假文本特征去匹配长说明书切片,其重合度呈指数级拉近,能够捞出很多隐藏极深、但非常关键的长尾切片。
-
返回值归宿 :召回的结果数组写入
state["hyde_embedding_chunks"],虚构出的假文本写入state["hyde_doc"]备查。
第 3 路:知识图谱检索流 (node_query_kg.py)
python
import time
import sys
from app.utils.task_utils import add_running_task, add_done_task
def node_query_kg(state):
"""
节点功能:在 Neo4j 知识图谱中查询实体关系。
"""
print("=== node_query_kg 图谱查询处理 ===")
add_running_task(state["session_id"], sys._getframe().f_code.co_name, state.get("is_stream"))
time.sleep(1)
# ...
add_done_task(state['session_id'], sys._getframe().f_code.co_name, state.get("is_stream"))
用于弥补向量检索在面对"强关联实体推理"和"显式多跳查询"时的硬伤。
-
工作原理:
-
解析查询中的实体,并借助统一的系统凭证
session_id追踪任务进度。 -
异步连接到 Neo4j 图数据库 ,通过图查询语言(Cypher)去执行多跳关联查找(例如:查询 "HAK 180 烫金机 --> 拥有组件--> 压力控制阀 --> 对应的常见故障" 的逻辑链路)。
-
-
工程亮点 :向量库只能做"相似度"计算,如果用户问"原厂推荐的压力控制阀的供应商是谁?",向量检索可能会捞出一堆包含"压力阀、供应商"的说明书废话,却很难精准提取出特定的属性值。而知识图谱通过实体边联结,能够提供确定性的事实、参数属性、层级隶属关系,提供金融级的确定性知识支撑。
-
返回值归宿 :结构化的关系或实体属性文本,写入
state["kg_chunks"]。
第 4 路:外部网络扩展流 (node_web_search_mcp.py)
python
import sys
import json
import asyncio
from app.utils.task_utils import add_done_task, add_running_task
from app.conf.bailian_mcp_config import mcp_config
from agents.mcp import MCPServerSse
from app.core.logger import logger
async def mcp_call(query):
"""
异步调用百炼MCP搜索服务的核心函数。
该函数负责初始化MCP客户端,建立SSE连接,调用远程工具,并返回原始结果。
:param query: 搜索查询词(通常是经过改写后的精准Query)
:return: MCP返回的原始结果对象 (包含 content, isError 等字段)
"""
# ==================================================================================
# 初始化百炼MCP SSE客户端
# ----------------------------------------------------------------------------------
# MCPServerSse 是一个基于 SSE (Server-Sent Events) 协议的 MCP 客户端实现。
# 它的作用是连接到阿里云百炼提供的 MCP 服务端点,从而让我们可以像调用本地函数一样调用远程工具。
#
# 参数解释:
# name: 客户端名称,用于日志标识,方便调试。
# params: 连接配置字典
# - url: MCP 服务的 SSE 接口地址 (例如: .../mcps/WebSearch/sse)
# - headers: HTTP 请求头,必须包含 Authorization 字段传入 API Key 进行鉴权。
# - timeout: 连接建立和整体请求的超时时间。
# - sse_read_timeout: 读取 SSE 事件流的超时时间,防止流中断导致挂起。
# ==================================================================================
search_mcp = MCPServerSse(
name="search_mcp",
params={
"url": mcp_config.mcp_base_url,
"headers": {"Authorization": mcp_config.api_key},
"timeout": 300,
"sse_read_timeout": 300
}
)
try:
logger.info(f"[MCP] 正在连接百炼 WebSearch 服务: {mcp_config.mcp_base_url}")
# 建立与MCP服务的SSE连接(异步方法,需await)
await search_mcp.connect()
logger.info(f"[MCP] 连接成功,正在调用工具 'bailian_web_search' 查询: {query}")
# 调用百炼MCP的搜索工具(核心步骤)
# tool_name: "bailian_web_search" 是百炼官方定义的工具名称
# arguments: 工具所需的参数,这里需要 "query" (查询词) 和 "count" (返回数量)
result = await search_mcp.call_tool(
tool_name="bailian_web_search",
arguments={"query": query, "count": 5}
)
logger.info("[MCP] 工具调用完成,已获取返回结果")
return result
except Exception as e:
logger.error(f"[MCP] 调用过程中发生异常: {e}", exc_info=True)
return None
finally:
# 无论调用成功/失败,最终都关闭MCP连接(释放资源,异步方法)
await search_mcp.cleanup()
def node_web_search_mcp(state):
"""
LangGraph同步节点函数:处理MCP搜索逻辑,作为整个搜索流程的入口。
该节点会调用 mcp_call 异步函数获取搜索结果,并将其解析为结构化数据存储到 state 中。
:param state: LangGraph的全局状态对象,包含 session_id, rewritten_query 等信息
:return: 字典,包含结构化的搜索结果 web_search_docs,供后续节点使用
"""
logger.info("---node_web_search_mcp 开始处理---")
# 1. 标记任务开始
add_running_task(state["session_id"], sys._getframe().f_code.co_name, state.get("is_stream"))
# 2. 获取查询词
query = state.get("rewritten_query", "")
if not query:
# 尝试回退到原始查询
query = state.get("original_query", "")
docs = []
# 3. 执行搜索
if query:
try:
# 同步-异步桥接:通过asyncio.run()执行异步的mcp_call函数
logger.info(f"启动异步 MCP 调用,Query: {query}")
# ======================================================================
# MCP 返回结果格式解析说明
# ----------------------------------------------------------------------
# result 是一个 CallToolResult 对象 (定义在 agents.mcp.types 中)
# result.content 是一个 TextContent 对象的列表,通常只有一项
# result.content[0].text 是一个 JSON 字符串,包含实际的搜索结果
#
# 示例数据结构:
# result.content[0].text = """
# {
# "pages": [
# {
# "title": "HAK 180 烫金机使用手册",
# "url": "http://example.com/manual",
# "snippet": "在出厂默认状态下,若想设置局部转印..."
# },
# ...
# ]
# }
# """
# ======================================================================
result = asyncio.run(mcp_call(query))
# 4. 解析结果
if result and not result.isError and result.content:
# 解析MCP原始结果:提取文本内容并转为JSON对象
# result.content 通常是一个列表,第一项包含文本结果
raw_text = result.content[0].text
try:
data = json.loads(raw_text)
pages = data.get("pages") or []
logger.info(f"MCP 返回原始页面数量: {len(pages)}")
# 遍历结果,统一封装为结构化格式
for item in pages:
snippet = (item.get("snippet") or "").strip()
url = (item.get("url") or "").strip()
title = (item.get("title") or "").strip()
# 过滤无核心摘要的结果
if not snippet:
continue
docs.append({"title": title, "url": url, "snippet": snippet})
except json.JSONDecodeError:
logger.error(f"MCP 返回结果解析 JSON 失败: {raw_text[:100]}...")
else:
if result and result.isError:
logger.error(f"MCP 返回错误: {result}")
else:
logger.warning("MCP 返回结果为空或无效")
logger.info(f"结构化搜索结果数量: {len(docs)}")
except Exception as e:
logger.error(f"MCP 搜索节点执行异常: {e}", exc_info=True)
else:
logger.warning("查询词为空,跳过 MCP 搜索")
# 5. 标记任务结束
add_done_task(state["session_id"], sys._getframe().f_code.co_name, state.get("is_stream"))
logger.info("---node_web_search_mcp 处理结束---")
# 若有有效搜索结果,返回结果供后续节点使用;无则返回空字典
if docs:
return {"web_search_docs": docs}
return {}
if __name__ == '__main__':
# 测试代码:单独运行该文件时,验证MCP搜索功能是否正常
print("\n" + "="*50)
print(">>> 启动 node_web_search_mcp 本地测试")
print("="*50)
test_state = {
"session_id": "test_mcp_session",
"rewritten_query": "HAK 180 在出厂默认状态下,若想在纸张上只把烫金膜转印到顶部 50 mm--170 mm 的局部区域,应在操作面板上如何设置",
"is_stream": False
}
try:
# 调用MCP搜索节点函数,执行测试
result_state = node_web_search_mcp(test_state)
print("\n" + "="*50)
print(">>> 测试结果摘要:")
search_results = result_state.get('web_search_docs', [])
print(f"搜索结果数量: {len(search_results)}")
if search_results:
print("首条结果预览:")
print(json.dumps(search_results[0], indent=2, ensure_ascii=False))
else:
print("未获取到搜索结果")
print("="*50)
except Exception as e:
logger.exception(f"测试运行期间发生未捕获异常: {e}")
负责打破企业私有知识库的"信息闭合圈",为系统注入全网时效性资讯和动态外援。
-
工作原理:
-
采用行业前沿的 MCP (Model Context Protocol,模型上下文协议) 标准规范,异步初始化一个标准的 SSE 客户端(
MCPServerSse)。 -
建立与阿里云百炼远程 MCP 搜索网关的物理长连接,像调用本地函数一样调用远程的
web_search工具。 -
将
rewritten_query送入互联网引擎捞取全网最新的网页快照(Snippets),并将其规整为包含title、url和snippet的标准化文档列表。
-
-
工程亮点 :完美补充了离线知识库的短板。如果用户提问涉及最新的官方通知、电商价格变动、或者是离线手册里由于印刷错误未录入的冷门偏门行业八卦,MCP 联网流能跨界召回最及时的网络子弹。
-
返回值归宿 :结构化的网页快照列表,写入
state["web_search_docs"]。
一旦商品得到确认,系统将启动强大的四路组合拳:
-
精确语义流 (
node_search_embedding.py) :使用当前锁定的标准化商品名作为硬性标量过滤条件(filter=f'item_name == "{item_name}"'),在 Milvus 中执行 Dense+Sparse 混合搜索。因为有标量死锁,检索绝对不会漂移到其他不相干商品的切片上。 -
启发式泛化流 (
node_search_embedding_hyde.py):-
HyDE(假设性文档检索)精髓:用户的问题通常很短(Query),而知识库的切片很长(Document),短向量搜长向量由于信息量不对等容易漏召回。
-
该节点先让 LLM 虚构一段"专业的假设性回答"(
hyde_doc),然后再用这个长文本的向量去捞 Milvus。这极大缓解了短查询与长文档在数学几何空间中的不匹配问题。
-
-
网络扩展流 (
node_web_search_mcp.py) :通过对接阿里云百炼的 MCP (Model Context Protocol) 联网搜索服务,利用标准的 SSE 客户端(MCPServerSse)向远程 Web 发起请求,捞取最新的网络资讯,补充本地知识库可能存在的滞后性。
5. 排序融合层 ── node_rrf.py 与 node_rerank.py
node_rrf.py
python
import sys
from typing import List, Dict, Any
from app.utils.task_utils import add_running_task, add_done_task
from app.core.logger import logger
# RRF节点
def _as_entity_list(state_list) -> List[Dict[str, Any]]:
"""
将上游节点输出统一规整为 entity dict 列表。
兼容:
- dict: {"entity": {..属性名和对应的字.}, "distance": ...} 或直接就是 {...}
- pymilvus Hit: 不是 dict,但通常支持 hit.get("entity") 或 hit.entity
- 其他:当作 chunk_id
"""
out: List[Dict[str, Any]] = []
for doc in (state_list or []):
if not doc:
continue
final_ent = {}
# 情况A: doc 是 Pymilvus 的 Hit 对象 (具有 entity 属性)
# Hit 对象结构通常是: id=xxx, distance=xxx, entity={field1: val1, ...}
# 这里的 id 是 Milvus 内部的主键 ID (int64 或 str)
if hasattr(doc, "entity") and hasattr(doc, "id"):
# 1. 提取 entity 中的业务字段 (如 content, item_name, chunk_id 等)
# 注意: doc.entity 可能是一个 Entity 对象,也可能直接是 dict
entity_content = doc.entity
if hasattr(entity_content, "to_dict"):
final_ent = entity_content.to_dict()
elif isinstance(entity_content, dict):
final_ent = entity_content.copy()
else:
# 尝试直接作为 dict 访问 (某些版本 sdk)
try:
final_ent = dict(entity_content)
except:
pass
# 2. 补充最外层的 id 和 distance
# 优先保留 entity 内部已有的 chunk_id/id,如果没有,则把外层的 id 补进去
if "id" not in final_ent and "chunk_id" not in final_ent:
final_ent["id"] = doc.id
# 补充 distance (score)
if hasattr(doc, "distance"):
final_ent["score"] = doc.distance
# 情况B: doc 已经是字典 (模拟数据或已处理数据)
elif isinstance(doc, dict):
# 尝试获取 entity 字段 (嵌套结构 {"entity": {...}, "id": ...})
if "entity" in doc:
ent = doc["entity"]
if isinstance(ent, dict):
final_ent = ent.copy()
# 尝试从外层补充 id/score
if "id" in doc and "id" not in final_ent:
final_ent["id"] = doc["id"]
if "distance" in doc:
final_ent["score"] = doc["distance"]
else:
# 扁平结构,直接使用
final_ent = doc
# 情况C: 其他对象 (尝试 get 方法)
elif hasattr(doc, "get"):
ent = doc.get("entity") or doc
if isinstance(ent, dict):
final_ent = ent
# 最终校验:必须是非空字典
if final_ent and isinstance(final_ent, dict):
out.append(final_ent)
return out
def reciprocal_rank_fusion(
source_weights: list,
k: int = 60,
max_results: int = None,
) -> List[tuple]:
"""
通用带权重的RRF算法实现
:param source_weights: 列表,每个元素是(来源文档列表, 权重)的元组
例如: [([doc1, doc2], 1.0), ([doc2, doc3], 0.8)]
:param k: RRF 常数,默认 60。用于平滑排名影响,避免高排名文档占据过大优势。
:param max_results: 只返回前 N 个,None 表示全部
:return: [(元素, RRF 得分), ...] 按得分降序排列
"""
# score_map: 记录 chunk_id 到 RRF 累加得分的映射
score_map = {}
# chunk_map: 记录 chunk_id 到文档实体对象的映射,用于最终返回
chunk_map = {}
# 1. 遍历所有来源,计算每个文档的 RRF 分数
# source_weights 结构: [(doc_list, weight), ...]
for docs, weight in source_weights:
# enumerate(docs, start=1): 获取排名 (rank),从 1 开始
for rank, item in enumerate(docs, start=1):
# 获取文档唯一标识 ID
# Milvus 设计上把主键字段在 API 层面统一叫 id,不管你在 schema 里定义的字段名是 pk、id 还是其他
# 这是为了保持 API 兼容性:无论用户怎么命名主键,SDK 都用 id 来指代 "这条数据的唯一标识"
# 你在向量数据库 UI 里看到的 pk 是表结构定义名,而代码里拿到的 id 是API 返回的统一主键别名
chunk_id = item.get("chunk_id") or item.get("id")
if not chunk_id:
# 如果找不到 ID,记录警告并跳过,避免程序崩溃
logger.warning(
f"RRF Warning: item missing chunk_id/id: {list(item.keys()) if isinstance(item, dict) else item}")
continue
# RRF 核心公式: score += weight * (1 / (k + rank))
score_map[chunk_id] = score_map.get(chunk_id, 0.0) + weight * (1.0 / (k + rank))
# 只记录第一次遇到的文档实体对象
chunk_map.setdefault(chunk_id, item)
# 2. 将结果转换为列表并排序
merged = []
for chunk_id, score in score_map.items():
doc_item = chunk_map[chunk_id]
merged.append((doc_item, score))
# 按分数降序排序 (得分越高越靠前)
merged.sort(key=lambda x: x[1], reverse=True)
# 3. 截断结果
if max_results is not None:
merged = merged[:max_results]
return merged
def node_rrf(state):
"""
RRF (Reciprocal Rank Fusion) 倒数排名融合节点
功能:
将来自不同检索源(如 Embedding 检索、HyDE 检索、知识图谱检索等)的结果进行融合排序。
RRF 是一种无需训练的算法,仅根据文档在不同列表中的排名来计算最终得分。
步骤:
1. 提取各路检索结果:从 state 中获取 embedding_chunks 和 hyde_embedding_chunks。
2. 结果标准化:将不同格式的检索结果统一转换为包含 chunk_id 的实体列表。
3. 设置权重:为不同来源分配权重(当前配置:Embedding=1.0, HyDE=1.0)。
4. 执行 RRF:计算融合分数并重新排序。
5. 结果截断:保留 Top K 个结果。
6. 更新状态:将融合后的结果存入 state["rrf_chunks"]。
"""
logger.info("---RRF (倒数排名融合) 开始处理---")
add_running_task(state["session_id"], sys._getframe().f_code.co_name, state.get("is_stream"))
# 第一步:获取上游检索节点返回的文档
# 上游检索节点(Milvus hybrid_search)返回的通常是 hit 列表:
# {"entity": {...fields...}, "distance": ...}
# RRF 需要使用 chunk_id 做去重与计分,因此这里必须保留 entity(而不是仅抽取 content 字符串)。
embedding_chunks = _as_entity_list(state.get("embedding_chunks"))
hyde_embedding_chunks = _as_entity_list(state.get("hyde_embedding_chunks"))
logger.info(f"RRF 输入统计: Embedding源={len(embedding_chunks)}条, HyDE源={len(hyde_embedding_chunks)}条")
# Debug 日志:打印部分 ID 以便核对
if embedding_chunks:
logger.debug(f"Embedding源 chunk_ids (前5个): {[c.get('chunk_id') for c in embedding_chunks[:5]]}")
if hyde_embedding_chunks:
logger.debug(f"HyDE源 chunk_ids (前5个): {[c.get('chunk_id') for c in hyde_embedding_chunks[:5]]}")
# 第二步:为不同来源设置权重
# 当前策略:两路召回权重相等,均为 1.0
source_weights = [
(embedding_chunks, 1.0),
(hyde_embedding_chunks, 1.0)
]
# 第三步:应用带权重的RRF计算最终得分
# k=60 是 RRF 算法的经典常数,max_results=10 限制最终召回数量
rrf_res = reciprocal_rank_fusion(source_weights, k=60, max_results=10)
# 第四步:解包结果,提取文档和分数
rrf_chunks = [doc for doc, score in rrf_res]
# 记录任务结束
add_done_task(state['session_id'], sys._getframe().f_code.co_name, state.get("is_stream"))
return {"rrf_chunks": rrf_chunks}
if __name__ == "__main__":
print("\n" + "="*50)
print(">>> 启动 node_rrf 本地测试")
print("="*50)
# 1. 构造假数据 (模拟真实数据库字段)
# 模拟 Embedding 检索结果
mock_embedding_chunks = [
{
"id": "doc_1",
"pk": "pk_1",
"file_title": "操作手册_v1.pdf",
"item_name": "HAK 180 烫金机",
"content": "内容1:打开电源开关...",
"score": 0.9
},
{
"id": "doc_2",
"pk": "pk_2",
"file_title": "维修指南.pdf",
"item_name": "HAK 180 烫金机",
"content": "内容2:遇到故障请联系...",
"score": 0.8
},
{
"id": "doc_3",
"pk": "pk_3",
"file_title": "参数表.xlsx",
"item_name": "HAK 180 烫金机",
"content": "内容3:电压220V...",
"score": 0.7
}
]
# 模拟 HyDE 检索结果 (包含 3 个文档,顺序不同,且有新文档 doc_4)
mock_hyde_chunks = [
{
"id": "doc_3",
"pk": "pk_3",
"file_title": "参数表.xlsx",
"item_name": "HAK 180 烫金机",
"content": "内容3:电压220V...",
"score": 0.85
},
{
"id": "doc_1",
"pk": "pk_1",
"file_title": "操作手册_v1.pdf",
"item_name": "HAK 180 烫金机",
"content": "内容1:打开电源开关...",
"score": 0.82
},
{
"id": "doc_4",
"pk": "pk_4",
"file_title": "安全须知.docx",
"item_name": "HAK 180 烫金机",
"content": "内容4:操作时请佩戴手套...",
"score": 0.75
}
]
# 模拟输入状态
mock_state = {
"session_id": "test_rrf_session",
"is_stream": False,
"embedding_chunks": mock_embedding_chunks,
"hyde_embedding_chunks": mock_hyde_chunks
}
try:
# 运行节点
result = node_rrf(mock_state)
# 验证结果
rrf_chunks = result.get("rrf_chunks", [])
print("\n" + "="*50)
print(">>> 测试结果摘要:")
print(f"输入数量: Embedding={len(mock_embedding_chunks)}, HyDE={len(mock_hyde_chunks)}")
print(f"输出数量: {len(rrf_chunks)}")
print("-" * 30)
# 打印详细排名
print("最终排名:")
for i, doc in enumerate(rrf_chunks, 1):
# 注意:返回结果中可能没有 chunk_id 字段,而是 id
doc_id = doc.get('chunk_id') or doc.get('id')
print(f"Rank {i}: ID={doc_id}, Title={doc.get('file_title')}, Content={doc.get('content')[:20]}...")
# 验证预期逻辑:
ids = [d.get("id") or d.get("chunk_id") for d in rrf_chunks]
if "doc_1" in ids and "doc_3" in ids:
print("\n[PASS] 交叉文档 (doc_1, doc_3) 成功融合保留")
else:
print("\n[FAIL] 交叉文档丢失")
if len(ids) == 4:
print("[PASS] 并集数量正确 (3+3-2重叠=4)")
else:
print(f"[FAIL] 并集数量错误: 期望4, 实际{len(ids)}")
print("="*50)
except Exception as e:
logger.exception(f"测试运行期间发生未捕获异常: {e}")
node_rerank.py
python
from app.utils.task_utils import *
from app.lm.reranker_utils import get_reranker_model
from app.core.logger import logger
import sys
# -----------------------------
# Rerank / TopK 全局常量(不从 state 读取)
# -----------------------------
# 动态 TopK 硬上限:最多取前 N 条(<=10)
RERANK_MAX_TOPK: int = 10
# 最小 TopK:至少保留前 N 条(>=1,且 <= RERANK_MAX_TOPK)
RERANK_MIN_TOPK: int = 1
# 断崖阈值(相对) 分比例
RERANK_GAP_RATIO: float = 0.25
# 断崖阈值(绝对) 分值
RERANK_GAP_ABS: float = 0.5
# Rerank节点(工作流入口)
def step_1_merge_docs(state):
"""
阶段一:文档合并与标准化
目标:将多路召回(本地知识库 + 联网搜索)的异构数据,统一合并为 Reranker 模型可处理的标准格式。
输入来源:
1. rrf_chunks (List[Dict]): 本地知识库检索结果(经 RRF 融合排序)。
- 结构:包含 Milvus entity 信息的复杂字典或对象。
- 关键字段:chunk_id, content, title/item_name。
2. web_search_docs (List[Dict]): 联网搜索结果(经 MCP 搜索返回)。
- 结构:包含搜索摘要的扁平字典。
- 关键字段:snippet, title, url。
输出结果 (List[Dict]):
- 标准化文档列表,每项包含:
- text: 用于重排序的核心文本(content 或 snippet)
- title: 标题(用于增强语义或展示)
- doc_id/chunk_id: 唯一标识(本地文档有,联网文档为 None)
- url: 来源链接(本地为空,联网文档有)
- source: 来源标记 ("local" 或 "web")
"""
# 1. 提取输入源
rrf_docs = state.get("rrf_chunks") or []
web_docs = state.get("web_search_docs") or []
logger.info(f"Step 1: 开始合并文档 - 本地RRF源: {len(rrf_docs)}条, 联网Web源: {len(web_docs)}条")
doc_items = []
# ---------------------------------------------------------
# 2. 处理本地知识库文档 (rrf_chunks)
# ---------------------------------------------------------
for i, doc in enumerate(rrf_docs):
# 简化:直接使用 dict(doc) 转换,如果 doc 本身是 dict 则无损,如果是对象则尝试转换
# 由于上游 RRF 节点已经做了 _as_entity_list 处理,这里 doc 极大概率已经是纯字典
# 因此可以移除繁琐的 try-except 和 entity 嵌套判断,直接取值
# 兼容性处理:优先取 'entity' 字段(防守式编程),若无则视为 doc 本身即 entity
# 注意:这里的 doc 应当已经是字典(由上游 _as_entity_list 保证)
entity = doc.get("entity") if isinstance(doc, dict) and "entity" in doc else doc
# 提取核心文本 (content),这是重排序的依据
# 如果不是字典或无 content,则跳过
if not isinstance(entity, dict):
logger.warning(f"本地文档格式异常 (index={i}): {type(entity)}")
continue
content = entity.get("content")
if not content:
# 仅在 debug 模式记录,避免生产环境日志刷屏
logger.debug(f"跳过无内容文档 (index={i}, keys={list(entity.keys())})")
continue
# 提取元数据 (使用 .get 链式回退,简洁明了)
doc_id = entity.get("chunk_id") or entity.get("id")
title = entity.get("title") or entity.get("item_name") or ""
# 组装标准化对象
doc_items.append({
"text": content,
"doc_id": doc_id,
"chunk_id": doc_id, # 兼容旧逻辑保留字段
"title": title,
"url": "",
"source": "local",
})
# ---------------------------------------------------------
# 3. 处理联网搜索文档 (web_search_docs)
# ---------------------------------------------------------
for i, doc in enumerate(web_docs):
# 兼容不同字段名:优先取 snippet (摘要),其次 content
text = (doc.get("snippet") or doc.get("content") or "").strip()
url = (doc.get("url") or "").strip()
title = (doc.get("title") or "").strip()
if not text:
logger.debug(f"跳过无内容联网结果 (index={i})")
continue
doc_items.append({
"text": text,
"doc_id": None, # 联网结果无固定 ID
"chunk_id": None,
"title": title,
"url": url,
"source": "web",
})
logger.info(f"Step 1: 文档合并完成,共输出 {len(doc_items)} 条标准化文档")
return doc_items
def step_2_rerank_docs(state, doc_items):
"""
阶段二:对文档进行重排序
- 输入 doc_items:[{ text,doc_id}, ...](由第一阶段产出)
- 输出:在 state 中写入 reranked_docs(结构化列表)
"""
question = state.get("rewritten_query") or state.get("original_query") or ""
# 如果没有文档或问题,直接返回
if not doc_items or not question:
logger.warning("Step 2: 跳过重排序 (无文档或无问题)")
return []
logger.info(f"Step 2: 开始重排序 (Rerank), 待排序文档数: {len(doc_items)}")
# 初始化重排序模型(这里以使用 BGE 重排序模型为例)
texts = [x["text"] for x in doc_items]
try:
reranker = get_reranker_model()
# 构建查询-文档对(必须是 str)
"""
格式:列表,每个元素是二元元组 / 列表,严格遵循 (query, passage) 顺序(即你的「问题、答案」):
第 1 个元素(query):用户的问题 / 检索词(如 "什么是 RRF 算法?");
第 2 个元素(passage):候选答案 / 待匹配文档(如你之前 RRF 融合后的文档内容);
支持单组匹配和批量匹配:
# 单组匹配:1个问题+1个候选答案
sentence_pairs = [("什么是RRF算法?", "RRF是倒数排名融合算法,用于多来源排序结果融合")]
# 批量匹配:1个问题+多个候选答案(重排序核心场景,推荐)
sentence_pairs = [
("什么是RRF算法?", "RRF是倒数排名融合算法,用于多来源排序结果融合"),
("什么是RRF算法?", "FP16是半精度推理,能降低模型显存占用"),
("什么是RRF算法?", "FlagReranker是BGE重排序模型的封装类")
]
注意:顺序不可颠倒(必须是「问题在前,答案在后」),模型对输入顺序有严格要求,颠倒会导致打分结果失真。
2. 输出结果:scores 分数含义与格式
格式:列表,元素为浮点数,列表长度与 sentence_pairs 完全一致,一一对应(第 n 个分数对应第 n 个 (问题,答案) 元组的相关性);
分数含义:数值越高,代表「问题」与「答案」的语义匹配度 / 相关性越强(BGE 重排序模型的分数无固定取值范围,核心看相对大小,用于排序即可);
核心用途:将分数与候选答案绑定,按分数降序排列,即可得到「与问题最相关→最不相关」的答案排序,实现重排序。
"""
# 格式:列表,每个元素是二元元组 / 列表,严格遵循 (query, passage) 顺序
sentence_pairs = [[question, t] for t in texts]
# 计算相关性得分
logger.info("Step 2: 正在计算相关性得分...")
scores = reranker.compute_score(sentence_pairs)
# 将得分与文档配对并排序(按 score 降序)
scored_docs = []
for item, text, score in zip(doc_items, texts, scores):
# 保留两位小数便于日志查看
score_val = float(score)
scored_docs.append(
{
"text": text,
"score": score_val,
"source": item.get("source") or "",
"chunk_id": item.get("chunk_id"),
"doc_id": item.get("doc_id"),
"url": item.get("url") or "",
"title": item.get("title") or "",
}
)
# 按分数降序排序
scored_docs.sort(key=lambda x: x["score"], reverse=True)
return scored_docs
except Exception as e:
logger.error(f"Step 2: 重排序过程发生异常: {e}", exc_info=True)
# 出错时降级:返回原始文档顺序,分数置为 0 或 None
# 避免整个流程中断
fallback_docs = [
{
"text": x.get("text"),
"score": 0.0, # 降级分数
"source": x.get("source") or "",
"chunk_id": x.get("chunk_id"),
"doc_id": x.get("doc_id"),
"url": x.get("url") or "",
"title": x.get("title") or "",
}
for x in doc_items
]
# 在这里我们不直接修改 state,而是返回结果让主流程处理
# 但为了兼容原有逻辑(虽然函数签名是返回 scored_docs),我们记录异常并抛出或返回降级结果
# 这里选择返回降级结果,保证流程继续
return fallback_docs
def step_3_topk(scored_docs):
"""
阶段三:动态 TopK(最多 10)
基于 scored_docs(已按 score 降序排序)进行智能截断,
核心逻辑:结合固定上下限+断崖阈值判断,避免机械取前N条,保留语义相关的连续文档集合
:param scored_docs: 列表,元素为带score的文档字典,已按score降序排列,格式如[{"doc": 文档对象, "score": 相关性分数}, ...]
:return: 列表,动态截断后的TopK文档列表,数量≤10
"""
# 硬上限:最多取前10条,取全局常量与实际文档数的较小值(避免索引越界)
# 注:max_topk从全局常量读取,不依赖外部状态,保证逻辑一致性
max_topk = min(RERANK_MAX_TOPK, len(scored_docs))
min_topk = RERANK_MIN_TOPK # 硬下限:至少保留的文档数量(全局常量配置)
gap_ratio = RERANK_GAP_RATIO # 相对断崖阈值:分数下降的相对比例阈值(全局常量配置)
gap_abs = RERANK_GAP_ABS # 绝对断崖阈值:分数下降的绝对差值阈值(全局常量配置)
# 1) 断崖截断核心逻辑:从min_topk之后开始检测分数断崖,出现则提前截断
topk = max_topk # 默认值:无断崖时取满硬上限(最多10条)
# 仅当实际可取值超过硬下限时,才触发断崖检测(否则直接取满min_topk)
if topk > min_topk:
# 遍历范围:从min_topk-1到max_topk-2(索引从0开始),检测相邻两个文档的分数差
# 例:min_topk=3,max_topk=10 → 遍历i=2,3,4,5,6,7,8(对应第3~9条文档,检测与下一条的差距)
for i in range(min_topk - 1, max_topk - 1):
s1 = scored_docs[i].get("score") # 当前位置文档的分数
s2 = scored_docs[i + 1].get("score") # 下一个位置文档的分数
gap = s1 - s2 # 计算相邻文档的分数绝对差距(因已降序,gap≥0)
# 计算相对差距:绝对差距 / 当前文档分数(+1e-6避免除数为0/极小值,防止程序报错)
# 1e-6 是 Python 中科学计数法的写法,等价于 0.000001(10 的负 6 次方,也就是百万分之一)。
rel = gap / (abs(s1) + 1e-6)
# 触发断崖截断条件:绝对差距≥绝对阈值 OR 相对差距≥相对阈值
# 满足任一条件,说明下一条文档相关性骤降,截断在当前位置
if gap >= gap_abs or rel >= gap_ratio:
logger.info(f"Step 3: 触发断崖截断 @ index={i} (Score {s1:.4f} -> {s2:.4f}, Gap={gap:.4f})")
topk = i + 1 # 最终取前i+1条(索引转实际数量,如i=2 → 取前3条)
break # 触发截断后立即退出循环,不再检测后续位置
# 按最终计算的topk值,截取前topk条文档
topk_docs = scored_docs[:topk]
logger.info(f"Step 3: 截断完成,保留前 {len(topk_docs)} 条文档 (TopK={topk})")
if topk_docs:
preview = ", ".join([f"{d.get('chunk_id') or 'Web'}({d.get('score'):.3f})" for d in topk_docs[:3]])
logger.debug(f"Step 3: Top3 文档预览: {preview}")
# 返回动态TopK处理后的文档列表
return topk_docs
def node_rerank(state):
"""
Rerank节点
对检索到的文档进行重新排序,提高相关性
"""
logger.info("---Rerank (重排序) 节点开始处理---")
add_running_task(state["session_id"], sys._getframe().f_code.co_name, state.get("is_stream"))
# 阶段一:合并文档
doc_items = step_1_merge_docs(state)
# 阶段二:对文档进行重排序
scored_docs = step_2_rerank_docs(state, doc_items)
# 阶段三:动态 TopK
topk_docs = step_3_topk(scored_docs)
logger.info(f"Rerank 节点处理结束, 最终输出 {len(topk_docs)} 条文档")
add_done_task(state['session_id'], sys._getframe().f_code.co_name, state.get("is_stream"))
return {"reranked_docs": topk_docs}
if __name__ == "__main__":
print("\n" + "="*50)
print(">>> 启动 node_rerank 本地测试")
print("="*50)
# 1. 模拟数据
# 1.1 RRF 本地文档数据
mock_rrf_chunks = [
{"chunk_id": "local_1", "content": "RRF是一种倒数排名融合算法", "title": "算法介绍", "score": 0.9},
{"chunk_id": "local_2", "content": "BGE是一个强大的重排序模型", "title": "模型介绍", "score": 0.8},
{"chunk_id": "local_3", "content": "无关的测试文档内容", "title": "测试文档", "score": 0.1} # 预期低分
]
# 1.2 MCP 联网搜索数据
mock_web_docs = [
{"title": "Rerank技术详解", "url": "http://web.com/1", "snippet": "Rerank即重排序,常用于RAG系统的第二阶段"},
{"title": "无关网页", "url": "http://web.com/2", "snippet": "今天天气不错,适合出去游玩"} # 预期低分
]
mock_state = {
"session_id": "test_rerank_session",
"rewritten_query": "什么是RRF和Rerank?", # 查询意图:想了解这两个算法
"rrf_chunks": mock_rrf_chunks,
"web_search_docs": mock_web_docs,
"is_stream": False
}
try:
# 运行节点
result = node_rerank(mock_state)
reranked = result.get("reranked_docs", [])
print("\n" + "="*50)
print(">>> 测试结果摘要:")
print(f"输入文档总数: {len(mock_rrf_chunks) + len(mock_web_docs)}")
print(f"输出文档总数: {len(reranked)}")
print("-" * 30)
print("最终排名:")
for i, doc in enumerate(reranked, 1):
print(f"Rank {i}: Source={doc.get('source')}, Score={doc.get('score'):.4f}, Text={doc.get('text')[:20]}...")
# 验证逻辑:
# 预期 "local_1", "local_2", "Rerank技术详解" 分数较高
# 预期 "local_3", "无关网页" 分数较低,可能被截断或排在最后
top1_score = reranked[0].get("score")
if top1_score > 0:
print("\n[PASS] Rerank 打分正常")
else:
print("\n[FAIL] Rerank 打分异常 (均为0或负数)")
print("="*50)
except Exception as e:
logger.exception(f"测试运行期间发生未捕获异常: {e}")
多路召回回来的异构数据(Milvus Hit 对象、字典、网页 Snippet)无法直接比较,必须经过两阶段清洗:
-
一阶段融合:RRF (倒数排名融合算法):
-
该算法完全不看向量的绝对得分(Score),只看各个切片在
embedding流和hyde流中的相对排名(Rank)。 -
通过公式 \\text{RRF\\_Score} = \\sum_{m \\in M} \\frac{1}{k + r_m(d)}(代码中 k=60),给那些在两路检索中都靠前的切片赋予极高的加权。它天然具有无量纲、抗噪声、多路平权的模型无关优势。
-
-
二阶段精排:Rerank 深度模型 + 动态断崖控制:
-
深度重排是非常重(Heavy)的操作,代码做到了极致的精细化。
-
动态 Top-K 硬限断崖控制 :Rerank 模型会给出
0~1之间的绝对置信度。代码设置了RERANK_GAP_RATIO(相对断崖比 0.25)和RERANK_GAP_ABS(绝对断崖值 0.5)。 -
当发现第一名和第二名之间、或者相邻两条数据之间的分数出现断崖式下跌时,系统会自动在此处"切刀拦截",把后面高噪声的低分切片全部斩断。真正做到了少而精,防止大模型被无关信息误导。
-
6. 终点响应生成 ── node_answer_output.py
python
import sys
from app.utils.task_utils import add_running_task, add_done_task, set_task_result
from app.utils.sse_utils import push_to_session, SSEEvent
from app.query_process.agent.state import QueryGraphState
from app.core.logger import logger
from app.core.load_prompt import load_prompt
from app.lm.lm_utils import get_llm_client
from app.clients.mongo_history_utils import save_chat_message
import re
_IMAGE_BLOCK_MARKER = "【图片】"
MAX_CONTEXT_CHARS = 12000
def step_1_check_answer(state) -> bool:
"""
阶段一:检查 state 中是否已有 answer。
- 若已存在:按需推送流式 delta(用于 SSE),并返回 True
- 若不存在:返回 False
"""
answer = state.get("answer", None)
is_stream = state.get("is_stream" )
if answer:
if is_stream:
logger.info("---Step 1: 发现已有答案,执行流式推送---")
push_to_session(state["session_id"], SSEEvent.DELTA, {"delta": answer})
else:
set_task_result(state["session_id"], "answer", answer)
return True
else:
return False
# 目标结构
# HAK 180 烫金机的操作面板位于机器正前方。开启电源后,您需要先设置温度,默认建议设置在 110℃ 左右。
# 具体的按键位置请参考下图:
# 【图片】
# http://local-server/images/panel_view.jpg
# http://local-server/images/button_detail.jpg
def step_2_construct_prompt(state: QueryGraphState) -> str:
"""
第一阶段:构建 Prompt
根据state中的问题、重新问题、历史对话、提问商品(item_names)、 重排内容 组织prompt
"""
# 1. 获取相关信息
original_query = state.get("original_query", "")
rewritten_query = state.get("rewritten_query", "")
# 优先使用重写后的问题
question = rewritten_query if rewritten_query else original_query
history = state.get("history", [])
item_names = state.get("item_names", [])
reranked_docs = state.get("reranked_docs") or []
# 2 从重排内容中,提取为资料字符串,不可超过限额
# 优先使用结构化 reranked_docs(包含 source/chunk_id/url/score),便于约束与引用
# ---------------------------------------------------------
# 逻辑解释:
# 1. 遍历重排序后的文档列表 (reranked_docs),这些文档已经按相关性从高到低排序。
# 2. 对每个文档提取关键信息 (text, source, chunk_id, url, title, score)。
# 3. 构造 "元数据头 + 正文" 格式的字符串,例如:
# "[1] [local] [chunk_id=123] [score=0.95] [title=操作手册]
# 这里是文档的正文内容..."
# 4. 累加字符长度,如果超过 MAX_CONTEXT_CHARS (如 12000 字符),则停止添加,
# 确保 Prompt 长度在 LLM 的处理范围内,避免 Token 溢出。
# ---------------------------------------------------------
docs = []
used = 0
for i, doc in enumerate(reranked_docs, start=1):
text = (doc.get("text") or "").strip()
if not text:
continue
source = doc.get("source") or ""
chunk_id = doc.get("chunk_id")
url = (doc.get("url") or "").strip()
title = (doc.get("title") or "").strip()
score = doc.get("score")
meta_parts = [f"[{i}]"]
if source:
meta_parts.append(f"[{source}]")
if chunk_id:
meta_parts.append(f"[chunk_id={chunk_id}]")
if url:
meta_parts.append(f"[url={url}]")
if score is not None:
# 保留四位小数
meta_parts.append(f"[score={float(score):.4f}]")
if title:
meta_parts.append(f"[title={title}]")
doc = " ".join(meta_parts) + "\n" + text
if used + len(doc) > MAX_CONTEXT_CHARS:
break
docs.append(doc)
# 计算使用长度! + 2 两个\n\n
used += len(doc) + 2
context_str = "\n\n".join(docs) if docs else "无参考内容"
# 3. 格式化 History (历史对话)
# ---------------------------------------------------------
# 逻辑解释:
# 1. 遍历历史对话记录 (history)。
# 2. 将每轮对话格式化为 "用户: ... \n 助手: ..." 的文本块。
# 3. 同样进行长度累加判断 (used),确保历史记录+参考文档的总长度不超过 MAX_CONTEXT_CHARS。
# 注意:这里的 used 变量是接着上面处理文档后的长度继续累加的,
# 意味着如果文档占用了太多 Token,历史记录可能会被截断或完全丢弃。
# ---------------------------------------------------------
history_str = ""
if history:
for msg in history:
# 修正:MongoDB存储格式为 {"role": "user"/"assistant", "text": "..."}
role = msg.get("role")
text = msg.get("text")
if role == "user" and text:
history_str += f"用户: {text}\n"
elif role == "assistant" and text:
history_str += f"助手: {text}\n"
used += len(history_str) + 2
if used > MAX_CONTEXT_CHARS:
break
else:
history_str = "无历史对话"
# 4. 格式化 Item Names (提问商品)
item_names_str = ", ".join(item_names) if item_names else "无指定商品"
# 5. 组装 Prompt
prompt = load_prompt("answer_out",
context=context_str,
history=history_str,
item_names=item_names_str,
question=question
)
logger.info(f"组装后的提示词为:{prompt}")
return prompt
def step_3_generate_response(state: QueryGraphState, prompt: str) -> QueryGraphState:
"""
第二阶段:生成回答
调用llm生成答案,支持流式输出
"""
logger.info("---Step 3: 开始生成回答 (LLM Generation)---")
logger.debug(f"最终Prompt内容: {prompt}")
# 获取 LLM 客户端
# 注意:这里我们使用统一的 get_llm_client 获取实例
llm = get_llm_client()
# 判断是否需要流式输出
# 通常 state 中会注入 stream_queue 用于 SSE 推送
session_id = state.get("session_id")
is_stream = state.get("is_stream")
if is_stream:
logger.info(f"模式: 流式输出 (Streaming), Session: {session_id}")
final_text = ""
try:
# 使用 stream 方法进行流式生成
for chunk in llm.stream(prompt):
delta = getattr(chunk, "content", "") or ""
if delta:
final_text += delta
# 将增量内容放入队列
push_to_session(session_id, SSEEvent.DELTA, {"delta": delta})
logger.info(f"流式输出完成,总长度: {len(final_text)}")
except Exception as e:
logger.error(f"流式生成出错: {e}", exc_info=True)
# 发生错误时,尝试推送到前端
push_to_session(session_id, SSEEvent.ERROR, {"error": str(e)})
state["answer"] = final_text
else:
# 非流式直接调用
logger.info(f"模式: 非流式输出 (Blocking), Session: {session_id}")
try:
response = llm.invoke(prompt)
content = response.content
state["answer"] = content
set_task_result(session_id, "answer", content)
logger.info(f"生成回答完成,长度: {len(content)}")
except Exception as e:
logger.error(f"生成回答出错: {e}", exc_info=True)
state["answer"] = "抱歉,生成回答时出现错误。"
return state
def _extract_images_from_docs(docs):
"""
辅助方法:从文档列表中提取图片URL
核心逻辑:
1. 遍历所有相关文档(包括本地知识库切片和联网搜索结果)。
2. 策略一:直接检查文档的 'url' 字段(常见于联网搜索结果)。
- 验证后缀名是否为图片格式 (.jpg, .png 等)。
3. 策略二:使用正则表达式扫描文档 'text' 正文内容(常见于本地 Markdown 文档)。
- 匹配 Markdown 图片语法: 。
4. 对提取到的 URL 进行去重处理,返回唯一图片列表。
:param docs: 文档列表,每个文档为字典格式
:return: 图片 URL 字符串列表
"""
images = []
seen = set() # 用于去重,避免同一张图片重复出现
if not docs:
return []
# ---------------------------------------------------------
# 正则表达式解释:r'!\[.*?\]\((.*?)\)'
# 1. !\[ -> 匹配 Markdown 图片语法的开头 "![" (注意 [ 需要转义)
# 2. .*? -> 非贪婪匹配图片描述文本 (Alt Text),即 [] 中间的内容
# 3. \] -> 匹配描述文本的结束符 "]"
# 4. \( -> 匹配 URL 部分的开始符 "("
# 5. (.*?) -> 捕获组 (Group 1):非贪婪匹配括号内的实际 URL 内容
# 6. \) -> 匹配 URL 部分的结束符 ")"
# ( ... ) (不带反斜杠):这就是 捕获组 。
# 它的作用是告诉程序:"虽然我匹配了整个  结构,但我 只要 这括号里的内容"。
# ---------------------------------------------------------
md_img_pattern = re.compile(r'!\[.*?\]\((.*?)\)')
logger.info(f"开始提取图片,待处理文档数: {len(docs)}")
for i, doc in enumerate(docs):
# 1. 优先检查 url 字段 (主要针对 Web Search 结果)
url = (doc.get("url") or "").strip()
if url:
# 简单后缀判断:确保是静态图片资源
if url.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg')):
if url not in seen:
logger.debug(f"文档[{i}] 发现图片 URL (字段): {url}")
seen.add(url)
images.append(url)
# 2. 检查 text 字段中的 Markdown 图片 (主要针对 Local Chunk)
text = (doc.get("text") or "").strip()
if text:
# findall 机制解释:
# 正则表达式 r'!\[.*?\]\((.*?)\)' 中包含一个捕获组 (.*?)
# 当存在捕获组时,findall 只返回括号内匹配到的内容(即 URL),而不是整个  字符串
# 示例:
# 输入 text: "参考图片  如下"
# 返回 matches: ['http://img.com/1.jpg']
matches = md_img_pattern.findall(text)
for img_url in matches:
img_url = img_url.strip()
if img_url and img_url not in seen:
logger.debug(f"文档[{i}] 正文发现 Markdown 图片: {img_url}")
seen.add(img_url)
images.append(img_url)
logger.info(f"图片提取完成,共找到 {len(images)} 张唯一图片: {images}")
return images
def step_4_write_history(state: QueryGraphState, image_urls = None) -> QueryGraphState:
"""
阶段四:把本轮答案写入 MongoDB history。
利用 utils/mongo_history_utils.py 中的 save_chat_messages 方法。
"""
session_id = state.get("session_id", "default")
answer = (state.get("answer") or "").strip()
item_names = state.get("item_names") or []
try:
if answer:
save_chat_message(
session_id=session_id,
role="assistant",
text=answer,
rewritten_query="",
item_names=item_names,
image_urls=image_urls,
message_id=None
)
except Exception as e:
# 写历史失败不应影响主链路
logger.error(f"写入Mongo历史记录失败: {e}")
return state
def node_answer_output(state: QueryGraphState) -> QueryGraphState:
"""
1 判断state 中的answer是否已经存在,如果存在直接输出answer中的答案,注意判断是否需要流式输出需要则流式输出
2 根据state中的问题、重新问题、历史对话、提问商品(item_names)、 重排内容 组织prompt 并调用llm 生成答案
3 阶段三:调用大模型输出答案 注意判断是否需要流式输出需要则流式输出
4 把答案写入到mongodb的history中 利用utils/mongo_history_utils.py中的save_chat_message方法
5 做最后一次push操作(主要是为了触发前端图片渲染)
{
"answer": "HAK 180 烫金机的操作面板位于...(大模型生成的纯文本)...",
"status": "completed",
"image_urls": [
"http://local-server/images/panel_view.jpg",
"http://local-server/images/button_detail.jpg"
]
}
"""
logger.info("---node_answer_output (答案生成) 节点开始处理---")
add_running_task(state['session_id'], sys._getframe().f_code.co_name, state.get("is_stream"))
# 阶段一:检查answer是否存在,如果存在直接输出answer中的答案
answer_exists = step_1_check_answer(state)
# 阶段二 如果没有answer则 构建 Prompt
if not answer_exists:
prompt = step_2_construct_prompt(state)
state["prompt"] = prompt
# 阶段三: 如果没有answer则 调用大模型输出答案
step_3_generate_response(state, prompt)
# 提取图片URL(用于历史记录和前端展示)
image_urls = _extract_images_from_docs(state.get("reranked_docs") or [])
# 阶段四:把答案写入到mongodb的history中
if state.get("answer"):
logger.info("---写入MongoDB历史记录---")
step_4_write_history(state, image_urls=image_urls)
add_done_task(state['session_id'], sys._getframe().f_code.co_name, state.get("is_stream"))
# 阶段五: 流式输出结束,发送 final 事件 [最后兜底,确保图片都能争取渲染和结束]
logger.info(f"---发送 final 事件---图片为:{image_urls}")
if state.get("is_stream"):
push_to_session(
state['session_id'],
SSEEvent.FINAL,
{
"answer": state["answer"],
"status": "completed",
"image_urls": image_urls # 发送图片URL给前端
}
)
logger.info("---node_answer_output 节点处理结束---")
return state
if __name__ == "__main__":
print("\n" + "="*50)
print(">>> 启动 node_answer_output 本地测试")
print("="*50)
# 1. 构造模拟数据
# 模拟重排序后的文档列表 (reranked_docs)
# 包含:本地文档(带Markdown图片)、联网结果(带URL字段)、纯文本文档
mock_reranked_docs = [
{
"chunk_id": "local_101",
"source": "local",
"title": "HAK 180 烫金机操作手册_v2.pdf",
"score": 0.95,
"text": """
HAK 180 烫金机的操作面板位于机器正前方。
开启电源后,您需要先设置温度,默认建议设置在 110℃ 左右。
具体的操作面板布局请参考下图:

如果是进行局部烫金,请调节侧面的旋钮。

"""
},
{
"chunk_id": None,
"source": "web",
"title": "HAK 180 常见故障排除 - 官网",
"score": 0.88,
"url": "http://example.com/hak180_troubleshooting.jpeg", # 这是一个直接指向图片的URL(虽然少见,但用于测试提取)
"text": "如果机器无法加热,请检查保险丝是否熔断..."
},
{
"chunk_id": "local_102",
"source": "local",
"title": "安全注意事项",
"score": 0.82,
"text": "操作时请务必佩戴隔热手套,避免高温烫伤。"
}
]
# 模拟历史记录
mock_history = [
{"role": "user", "text": "你好,这款机器怎么用?"},
{"role": "assistant", "text": "您好!请问您具体指的是哪一款机器?"},
{"role": "user", "text": "HAK 180 烫金机"}
]
# 模拟输入状态
mock_state = {
"session_id": "test_answer_session_001",
"original_query": "HAK 180 烫金机怎么操作?",
"rewritten_query": "HAK 180 烫金机的具体操作步骤和面板设置方法",
"item_names": ["HAK 180 烫金机"],
"history": mock_history,
"reranked_docs": mock_reranked_docs,
"is_stream": False, # 测试非流式
# "is_stream": True, # 若要测试流式,需确保 SSE 环境或 mock 相关函数
"answer": None # 初始无答案
}
try:
# 运行节点
result = node_answer_output(mock_state)
print("\n" + "="*50)
print(">>> 测试结果摘要:")
# 1. 验证 Prompt 构建
if "prompt" in result:
print(f"[PASS] Prompt 构建成功 (长度: {len(result['prompt'])})")
# print(f"Prompt 预览:\n{result['prompt'][:200]}...")
else:
print("[FAIL] Prompt 未构建")
# 2. 验证答案生成
answer = result.get("answer")
if answer and len(answer) > 10:
print(f"[PASS] 答案生成成功 (长度: {len(answer)})")
print(f"答案预览: {answer[:50]}...")
else:
print(f"[WARN] 答案生成可能异常 (Content: {answer})")
# 3. 验证图片提取
# 我们期望提取到 3 张图片:
# 1. http://local-server/images/panel_view.jpg (来自 local_101)
# 2. http://local-server/images/knob_detail.png (来自 local_101)
# 3. http://example.com/hak180_troubleshooting.jpeg (来自 web 结果的 url 字段)
# 注意:这里我们没办法直接从 result state 里拿到 image_urls,因为它是作为 SSE 推送出去的,或者存库了
# 但我们可以通过日志观察 _extract_images_from_docs 的输出
# 如果需要验证,可以临时修改 node_answer_output 返回 image_urls
print("\n[INFO] 请检查上方日志中是否包含 '图片提取完成' 及以下 URL:")
print(" - http://local-server/images/panel_view.jpg")
print(" - http://local-server/images/knob_detail.png")
print(" - http://example.com/hak180_troubleshooting.jpeg")
print("="*50)
except Exception as e:
logger.exception(f"测试运行期间发生未捕获异常: {e}")
这是把高价值知识资产转化为用户最终可读体验的出口:
-
多模态图表反查(视觉对齐):
-
这是最惊艳的一个细节设计。在离线导入时,图片块被转为了包含 HTML 注释的 `` 并带有 MinIO URL。
-
该节点在组装最终 Prompt 前,会利用正则表达式
re.findall(r'src="(.*?)"', ...)疯狂抽取 Rerank 胜出切片中的所有物理图片地址。 -
在向用户输出文本答案的同时,利用 SSE(服务器发送事件,
push_to_session) 通道,将这些图片作为独立的多模态卡片率先或同步推给前端展示,完美解决了大模型无法稳定吐出原版配图的行业痛点。
-
-
严格的窗口防御(
MAX_CONTEXT_CHARS = 12000):在循环拼接切片时,一旦字符数逼近 12000 字上限,立刻启动熔断,防止将多余的内容喂给 LLM 导致显存溢出或触发模型上下文截断。
🌟 这套线上检索端架构的工业级智慧
-
金融级的健壮性与防重复反问 :在 RAG 中,最怕用户乱问(如"今天天气如何"),本系统在
node_item_name_confirm阶段就会把这类不包含企业主商品的提问直接拦截并生成"拒绝回答",根本不会浪费后续的四路检索算力和大模型 Token 成本。 -
多源多维度的混合召回:字面精准匹配(Sparse) + 语义泛化(Dense) + 虚构联想(HyDE) + 外部动态信息(MCP),几乎穷尽了当前 RAG 领域最先进的所有召回策略,保障了知识库的无死角覆盖。
-
闭环的用户会话持久化 :系统在最后一个节点不仅执行了 SSE 的流式实时推送(Delta),还在收尾时调用
save_chat_message将改写后的完整问答对(包含标准商品标签)沉淀回 MongoDB。这为后期的离线系统自动化迭代、用户意图挖掘和强化学习(RLHF)提供了最源始、最高纯度的数据资产。