RAG项目案例--02在线检索&过滤流水线

「开发流程」

为了确保整体流程设计的科学性与执行连贯性,采用 **"Top-Down"(自顶向下)**的开发模式,以 "总指挥部" 的全局视角统筹推进,具体实施步骤如下:

  1. 搭建节点骨架(Stubs) :优先定义全流程所需的所有功能节点,仅保留核心日志打印能力(如节点进入 / 退出日志),暂不实现内部复杂业务逻辑,快速搭建起流程的 "骨架结构";

  2. 串联主图(Graph) :基于预设的业务流转规则,编写主图逻辑将所有节点骨架按序串联,明确节点间的输入输出关系、分支判断条件(如文件格式分流逻辑),形成完整的流程链路;

  3. 验证流程通畅性 :启动端到端测试,验证节点间的调用链路是否通顺、数据流转是否符合预期、分支跳转是否准确,确保流程无阻塞、无逻辑漏洞;

  4. 填充节点核心逻辑:在流程链路验证通过后,再逐一聚焦每个节点的内部实现,完成复杂业务逻辑的开发(如 查询重写、定向查询、结果重排处理等),实现 "骨架" 到 "完整系统" 的落地。

该模式的核心优势在于:先保障 "流程走得通",再聚焦 "功能做得好",避免因局部逻辑复杂导致整体流程设计偏差,大幅提升开发效率与流程稳定性。

【 阶段二:在线检索与过滤流水线 】

技术栈: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_chunkshyde_embedding_chunksweb_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 代词指代不明、漏召回的"总闸口":

  1. 基础环境收拢与会话历史拉取(Step 1 & Step 2)
  • 动作 :接收当前的 session_id 和用户的原始提问 original_query

  • 业务逻辑 :从 MongoDB 历史库中拉取近期几轮的上下文会话记录(history),为接下来的语义理解提供背景画布。

  1. 大模型意图提取与问题改写(Step 3)
  • 动作 :将 original_queryhistory 喂给 LLM。

  • 业务逻辑:利用大模型做两件事:

    1. 提取实体 :分析用户究竟在问哪一个或哪几个商品,输出一个标准 JSON 列表 item_names

    2. 消除指代(反向指代消解) :将口语化的、依赖上下文的问题改写为一个独立、完整、不带指代词 的提问 rewritten_query(例如将"它的操作步骤"改写为"HAK 180 烫金机的具体操作步骤是什么")。

  1. 标量商品库的物理对齐与召回(Step 4 & Step 5)
  • 动作 :将 LLM 提取出的粗糙商品名进行向量化,然后去 Milvus 的标准化商品名称集合 (如 ITEM_NAME_COLLECTION)中执行混合检索。

  • 业务逻辑 :LLM 提取的名字可能是"HAK180烫金机"(缺空格),而官方标准名称是"HAK 180 烫金机"。这一步通过向量库的语义匹配,计算置信度(Score),把用户口中的代号与企业知识库里的绝对主键/唯一标识进行强行强对齐。

  1. 核心防御机制与条件熔断决策(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)

负责从本地私有知识库中精准捞取最相关的硬核技术切片。

  • 工作原理

    1. 接收上游节点的 rewritten_query,调用 BGE-M3 模型生成稠密向量。

    2. 构造一个带有商品名标量强过滤filter=f'item_name == "{item_name}"')的 Milvus 检索请求。

    3. 在 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)在数学几何空间中信息量不对等"导致的漏召回问题。

  • 工作原理

    1. Step 1 (LLM 联想) :将 rewritten_query 喂给 LLM,加载专门的 HyDE 模板(hyde_doc_generation),命令大模型"凭空编造"一段几十到几百字的、语气和技术术语高度符合原厂说明书习惯的"假设性假答案" hyde_doc

    2. 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"))

用于弥补向量检索在面对"强关联实体推理""显式多跳查询"时的硬伤。

  • 工作原理

    1. 解析查询中的实体,并借助统一的系统凭证 session_id 追踪任务进度。

    2. 异步连接到 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}")

负责打破企业私有知识库的"信息闭合圈",为系统注入全网时效性资讯和动态外援

  • 工作原理

    1. 采用行业前沿的 MCP (Model Context Protocol,模型上下文协议) 标准规范,异步初始化一个标准的 SSE 客户端(MCPServerSse)。

    2. 建立与阿里云百炼远程 MCP 搜索网关的物理长连接,像调用本地函数一样调用远程的 web_search 工具。

    3. rewritten_query 送入互联网引擎捞取全网最新的网页快照(Snippets),并将其规整为包含 titleurlsnippet 的标准化文档列表。

  • 工程亮点 :完美补充了离线知识库的短板。如果用户提问涉及最新的官方通知、电商价格变动、或者是离线手册里由于印刷错误未录入的冷门偏门行业八卦,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.pynode_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 图片语法: ![alt text](image_url)。
    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: "参考图片 ![面板图](http://img.com/1.jpg) 如下"
            # 返回 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℃ 左右。
            具体的操作面板布局请参考下图:
            ![操作面板布局图](http://local-server/images/panel_view.jpg)
            
            如果是进行局部烫金,请调节侧面的旋钮。
            ![侧面旋钮细节](http://local-server/images/knob_detail.png)
            """
        },
        {
            "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 导致显存溢出或触发模型上下文截断。

🌟 这套线上检索端架构的工业级智慧

  1. 金融级的健壮性与防重复反问 :在 RAG 中,最怕用户乱问(如"今天天气如何"),本系统在 node_item_name_confirm 阶段就会把这类不包含企业主商品的提问直接拦截并生成"拒绝回答",根本不会浪费后续的四路检索算力和大模型 Token 成本。

  2. 多源多维度的混合召回:字面精准匹配(Sparse) + 语义泛化(Dense) + 虚构联想(HyDE) + 外部动态信息(MCP),几乎穷尽了当前 RAG 领域最先进的所有召回策略,保障了知识库的无死角覆盖。

  3. 闭环的用户会话持久化 :系统在最后一个节点不仅执行了 SSE 的流式实时推送(Delta),还在收尾时调用 save_chat_message 将改写后的完整问答对(包含标准商品标签)沉淀回 MongoDB。这为后期的离线系统自动化迭代、用户意图挖掘和强化学习(RLHF)提供了最源始、最高纯度的数据资产。

相关推荐
动能小子ohhh1 小时前
DocForge平台的设计与开发--文件上传接口的实现
开发语言·人工智能·python·langchain·ocr·fastapi
朴马丁1 小时前
预制菜的“数字厨房”:PLM如何支撑菜品标准化与供应链高效协同?
大数据·人工智能·食品行业·流程行业plm
ab_dg_dp1 小时前
Android 17+ 提取 AIDL 生成 Java 文件的实用脚本
android·java·python
小沈同学呀1 小时前
SpringAI+MCPServer实战-StreamableHTTP协议打造企业级AI工具服务
人工智能·微服务架构·springai·mcpserver·javaai·streamablehttp
net3m332 小时前
一阶软件低通滤波器算法
人工智能·算法
武汉唯众智创2 小时前
边缘端部署 AI 心理分析:自研边缘主机跑通人脸 + 语音双模态推理,不用云端算力详解
人工智能·ai心理健康·校园心理健康·多模态推理·人脸情绪识别·语音情感分析·心理健康信息化平台
IT_陈寒2 小时前
Python的线程池把我坑惨了,原来异步不是万能的
前端·人工智能·后端
夏语灬2 小时前
cryptography:Python 密码学标准库的终极选择
开发语言·python·密码学
水木流年追梦2 小时前
大模型入门-大模型优化方法12-YaRN 长文本外推技术
人工智能·分布式·算法·正则表达式·prompt