掌柜智库项目 Day 07|导入接口实现与查询图结构定义
记录导入模块三个接口(html 返回、文件上传、状态查询)的实现,梳理查询模块 8 个节点的图结构与并发 state 报错,并整理 SSE 基本用法及 task_util 与 sse_util 的关系。
本篇要点
- 导入模块三个接口:html 页面返回、文件上传、状态查询
- 查询模块图结构定义:8 个节点的读 / 写字段规划
- 并发节点
state增量更新的报错原因与规避方式 - SSE 技术的基本使用与前端三种交互方案
内容回顾
1、上一天主题(Day06)
node_bge_embedding:item_name + content 拼接成向量,批量处理,8192 token上限、6 个一批node_import_milvus:创建 schema 与 index,先删后插写入 Milvus- FastAPI 使用回顾:路径、参数(路径 / 查询 / json / 文件)、响应(json / 文件 / 流式)、异步
BackgroundTasks
今天内容
1、导入模块的三个接口实现
1.1 三个接口的约定
导入模块一共提供三个对外的 HTTP 接口,接口定义如下。
接口 1:返回 html 页面
1 | url: /import/html |
接口 2:文件上传
1 | url: /upload |
接口 3:状态查询
1 | url: /status/{task_id} |
1.2 准备工作:定义两个响应 Schema
1、定义上传文件响应的 JSON 数据格式
1 | class UploadResponseSchema(BaseModel): |
2、定义一个响应状态 JSON 数据格式
1 | class StatusResponseSchema(BaseModel): |
1.3 实现接口
第一步:创建导入接口文件
1 | /http/import_server.py |
第二步:粘贴一些导入文件。
第三步:html 页面返回接口。
第四步:文件上传接口。
先定义 api 接口函数:
1 |
|
函数内部按四个步骤实现:
- 准备三个参数
1 | task_id = str(uuid.uuid4()) |
- 将文件存储到本地磁盘
1 | with local_file_path.open("wb") as out_file: |
- 异步调用执行函数
1 | backgroundtask.add_task(函数名, task_id, local_file_path, local_dir) |
- 返回结果
1 | return xxResponse(code=..., message=..., task_ids=[...]) |
再定义真正执行图的函数:
1 | def xx(task_id, local_file_path, local_dir): |
第五步:状态查询接口。
根据 task_utils 获取任务状态、正在进行的以及已经完成的任务列表,装到响应数据格式里即可。
注意:
- 要修改异步图执行!添加
update_task_status更新状态。- 文件上传也算一个节点:存储之前
add_running,存储完成add_done,节点名固定,与task_utils的 map key 对比。
2、查询模块图结构定义
查询模块的图一共规划了 8 个节点,先回顾每个节点的作用、读取与写入的 state 字段。
2.1 用户提问
用户提问是整个流程的起点。
2.2 意图识别和问题重写节点
- 意图识别:由「问题 + 历史聊天记录」确定
item_name,结果分三种:- 明确的
item_name(向量数据库搜索相似度分高于0.75) - 不确定的
item_name(相似度分没有达到0.75,落在0.75 - 0.6之间) - 完全不确定
item_name(没有高于0.6分)
- 明确的
- 问题重写:把「他好用么?」改成「烫金机好用么?」——口语化、没有主语的问题要改成标准的问题,利用模型 + 历史聊天记录完成。
该节点读写字段:
- 读
original_query:用户原始问题,例如「他好吃么??」 - 读
history:历史对话记录,用于上下文理解(history 历史记录来自数据库) - 读
session_id:会话追踪。一个客户端一个 session,每次问题不并发! - 写
item_names:识别出的商品名称列表(确认的实体名称) - 写
rewritten_query:改写后的精准问题 - 写
answer:如果无法确认或需要反问,直接写入答案并提前结束流程
1 | answer 示例: |
2.3 问题向量搜索
重写后的问题 -> 向量数据库。
- 读
rewritten_query:改写后的问题进行向量检索 - 读
item_names:限定检索范围(只在这些商品内搜索) - 读
session_id:任务追踪 - 写
embedding_chunks:普通向量检索回来的 Top-K 切片
2.4 假设性问题向量搜索
重写后的问题 -> LLM -> 润色出一个初版本的答案,再用「问题 + 答案」去向量数据库搜索。
- 读
rewritten_query:生成假设性答案的原材料 - 读
item_names:限定检索范围 - 读
session_id:任务追踪 - 写
hyde_embedding_chunks:HyDE 策略检索回来的切片
2.5 mcp 网络信息搜索
问题 -> mcp[模型上下文协议] -> bailian 提供网络搜索工具 -> 网络数据搜索。
- 读
rewritten_query:网络搜索的查询词 - 读
session_id:任务追踪 - 写
web_search_docs:联网搜索返回的文档列表
2.6 同源数据融合
问题搜索和假设性问题搜索各 10 条 -> 向量数据库 10 条 -> 用 rrf 算法融合成 10 条。
- 读
embedding_chunks:向量检索结果 - 读
hyde_embedding_chunks:HyDE 检索结果 - 读
session_id:任务追踪 - 写
rrf_chunks:RRF 融合排序后的统一列表
2.7 非同源进行模型打分 + 动态 topk 算法
rrf 10 条 + mcp 10 条 -> reranker 打分 20 条 -> 动态 topk => topk 数据。
- 读
rrf_chunks:待重排的文档列表 - 读
web_search_docs:网络搜索结果 - 读
rewritten_query:用于计算相关性得分 - 读
session_id:任务追踪 - 写
reranked_docs:重排序后的最终 Top-K 文档
2.8 进行数据的格式化处理
模型进行润色 + 图片的处理 + 前端的响应。
- 读
reranked_docs:作为 LLM 回答的参考依据 - 读
rewritten_query:用户问题 - 读
history:历史对话 - 读
session_id / is_stream:控制输出方式 - 写
answer:最终生成的答案 - 写
image_urls:从引用文档中提取的图片链接
2.9 并发节点的 state 增量更新问题
在图节点测试时出现如下报错:
1 | langgraph.errors.InvalidUpdateError: At key 'session_id': |
报错原因:图节点对 state 进行增量更新时出现冲突。
注意:并发节点,每个节点只能返回自己节点修改的 key!并发节点不能返回相同的 key!!
举例说明并发场景:
1 | 问题搜索 -> session_id |
三个并发节点都返回了 session_id 这个相同的 key,就会触发上面的 InvalidUpdateError。
3、SSE 技术基本使用
3.1 SSE 的作用
SSE 是一种服务端单向向客户端(前端)推送数据的技术,分两部分:
- 第一部分:后端 -> 推数据,
StreamingResponse - 第二部分:前端 -> 接数据,
EventSource(js)
3.2 SSE 的数据格式
1 | event:[空格]事件类型(自定义)\n |
后端写法:
1 | StreamingResponse(生成器函数(), ...) |
3.3 前后端交互的三种方案
方案 1:基础交互款(没有事件,只有数据,就一个接口)
1 | # 后端 |
1 | // 前端 |
方案 2:进阶交互款(有事件)
1 | # 后端 |
1 | // 前端 |
方案 3:模拟项目返回结构
1 | 后端:StreamingResponse(生成器函数(), ...) -> 循环 + yield -> 取值 -> 队列 |
3.4 项目提问和推送数据的交互流程
接口 1:提问接口
1 | 前端 => session_id / query => 问题接口: |
接口 2:流式响应接口
1 | 前端 => /stream/{session_id} => 流式接口: |
推送是怎么做的?摒弃了生成器中「循环 + 定时休眠」的方式,改成生成器中「循环 + 阻塞获取队列」的函数。
1 | 2. 调用异步函数执行查询图对象 -> node node node |
3.5 理解 task_util 与 sse_util 的作用和关联
task_util:
1 | task_util -> add_running_task / add_done_task / update_task_status(session_id, "节点名/任务状态", is_stream=False) |
可以用一个比喻来理解:task_util 类似「外卖员将外卖放在货架」——负责把数据搬到货架旁边。
1 | ============= 以上就是外卖员的工作 ===================== |
sse_util:
- 把
task_push_queue(session_id)(来自 task_util)真正地放在货架上,queue == push_to_session - 属性:
_session_stream: Dict[str, queue.Queue] = {session_id, 队列} - 方法:
get_sse_queue(session_id: str):根据 session_id 获取队列(不需要我们调用)push_to_session(session_id, event, data):stream_queue.put({"event": event, "data": data})remove_sse_queue(session_id: str):清空队列!每次提问执行之前先清空!create_sse_queue(session_id: str):创建队列,每次提问都需要创建一次队列![每次调用]
- 封装了生成器方法:
1 | sse_generator(session_id) -> 1. session_id 获取队列 2. while True 3. get 4. _sse_pack |
今日小结:
- 导入模块的三个接口
- 查询 state / 节点 / 图结构 / 测试 -> 并发节点 -> state 报错 -> 增量
- sse 的使用:1. 基本使用 2. 带有事件 3. 项目结构
- 理解 task_util / sse_util 的关系和作用
