首页 / 资讯中心 / 文章详情

智能体项目复盘 · 掌柜智库篇:设备手册 RAG 的完整工程——三段式切分、双向量混合检索、RRF + 断崖精排

智能体项目复盘 · 掌柜智库篇:设备手册 RAG 的完整工程——三段式切分、双向量混合检索、RRF + 断崖精排 ★ FEATURED ARTICLE
智能体项目复盘 · 掌柜智库篇设备手册 RAG 的完整工程——三段式切分、双向量混合检索、RRF 断崖精排这一篇对应课程的05_项目_掌柜智库。它是整个课程系列里第一个有甲方的项目不是聊天机器人 Demo而是要把 H3C 路由器手册、华为 MateBook 说明书、HAK180 烫金机安全手册、苏伯尓 5000W 电磁炉说明书这些东西变成一个能回答这个万用表怎么换电池“烫金机怎么只转印顶部 50-170mm 区域”面板按键在哪的问答系统而且答案里要带图。它也是我见过的中文教学项目里RAG 参数给得最全的一个chunk 上限 2000 字符、短块合并阈值 500、稠密/稀疏权重 0.8/0.2、RRF 常数 k60、主体确认双阈值 0.85/0.60、精排断崖 0.5 绝对差 25% 相对差、上下文预算 12000 字符。每一个数字都能在代码里找到出处。但更有意思的是它的不完美教案宣称 overlap 10~20%代码里写的是chunk_overlap0注释说稠密稀疏各占 50%代码传的是(0.8, 0.2)Neo4j 图谱召回只有一堆空桩两份 MongoDB 工具类并存其中改进版没接线。这份代码是一个真实演进中的项目被截了个快照我按快照写。1. 一句话架构两条 LangGraph 状态机 四类存储先建立全局认知。掌柜智库自述代码总行数 5507骨架其实是两条完全独立的图。【导入链路】PDF/Word -- node_entry(按扩展名分发) -- node_pdf_to_md(MinerU 云端解析) -- node_md_img(VLM 图片理解 MinIO 上传 正则替换图片链接) -- node_document_split(三段式切分) -- node_item_name_recognition(文档主体识别) -- node_bge_embedding(BGE-M3 双向量) -- node_import_milvus 【查询链路】node_item_name_confirm(双阈值确认主体) --条件边并行扇出-- node_search_embedding / node_search_embedding_hyde / node_web_search_mcp -- node_rrf(带权重倒数排名融合) -- node_rerank(动态 TopK 断崖截断) -- node_answer_output(流式生成 回捞图片) -- END教案把准确率工程归纳成六层闭环这个总结我认可数据结构化 -- 语义切片 -- 多路召回 -- 混合检索 -- 假设增强(HyDE) -- 精排裁决四类存储各自的角色分工很清楚别混存储存什么为什么是它Milvuschunk 的稠密 稀疏向量 元数据一个 collection 同时装FLOAT_VECTOR和SPARSE_FLOAT_VECTOR原生支持混合检索MongoDB只有chat_message会话历史半结构化、按 session 读写不需要事务MinIO手册里抽出来的图片图片要变成可外链 URL 塞进 MarkdownNeo4j——只有脚手架见第 7 节2. 技术栈与版本都来自pyproject.toml可核对层选型版本 / 关键值WebFastAPI uvicorn不是 Flask导入服务:8000查询服务:8001两个独立进程编排LangGraph 1.1.2 LangChain 1.2.12StateGraph/TypedDict状态 /add_conditional_edges向量库Milvuspymilvus[model]2.6.10MilvusClientAnnSearchRequestWeightedRanker文档库MongoDBpymongo4.16对象存储MinIOfput_objectLLMChatOpenAI走兼容接口默认qwen3-32benable_thinking: False通过extra_body透传关掉千问思考链VLMVL_MODEL测试用例见qwen-vl-plus只用于图片描述EmbeddingBAAI/bge-m31024 维稠密 稀疏normalize_embeddingsTrueRerankbge-reranker-largeFlagEmbedding.FlagReranker文档解析MinerU 在线 APImineru.netmodel_versionvlm本地装了magic-pdf 1.3.12但流水线没走本地联网搜索MCPopenai-agents0.4.2Streamable HTTP调百炼bailian_web_search环境uvuv.toml清华镜像Python 3.11.env.env.exampleload_dotenv(overrideTrue)日志loguru统一封装含穿透调用栈修正name/function/line可视化grandalf给 LangGraph 的_ascii用架构上限先说项目里没有 Celery / Redis 队列 / Kafka。导入任务状态是task_utils.py里 4 个进程内 dict_tasks_running_list/_done_list/_status/_result。这意味着——单进程内存态重启即丢不可水平扩展。做 Demo 完全够当生产架构就是天花板。3. 导入链路三段式切分是这半边的核心这一节有全项目最值得抄的一段代码。3.1 PDF → MarkdownMinerU 三步走取上传签名 URL -- PUT 上传文件 -- 轮询 batch 结果间隔 3s5xx 重试超时按 30/60/120s 分档-- 下载 zip -- 解压取 md3.2 图片处理把看图变成可检索文本手册类文档的图片承载了关键信息面板图、接线图丢了图片等于丢了答案。做法是node_md_img扫描 MD 里的图片引用 → 取图片上下各 100 字符作为语境 → 喂给 VLM 出中文摘要 → 上传 MinIO → 正则把![原图名](本地路径)替换成![VL描述](MinIO URL)。提示词长这样image_summary.prompt逐字这是{root_folder}文件中的一张图片图片上文部分为{image_content[0]} 下文部分为{image_content[1]}请用中文简要总结这张图片的内容 用于 Markdown 图片标题控制在50字以内。三个设计点值得注意显式喂上文/下文窗口做视觉-文本对齐不给语境 VLM 会瞎说、点明用途“用于 Markdown 图片标题”、50 字上限防止摘要污染 chunk 预算。3.3 三段式切分标题优先 长切短合不是定长切是三段① 标题树初切 正则 ^\s*#{1,6}\s.但必须用 in_code_block 标志跳过 和 ~~~ 里的伪标题 无标题则整篇降级为单节 无标题 ② 超长章节二切 DEFAULT_MAX_CONTENT_LENGTH 2000 RecursiveCharacterTextSplitter( chunk_size 2000 - len(f{title}\n\n), # 给标题留位 chunk_overlap 0, separators [\n\n,\n,。,,,,.,!,?,;, ]) 每块回贴标题前缀命名 f{title}-{idx}带 parent_title / part ③ 短块合并 MIN_CONTENT_LENGTH 500只合并同 parent_title 且当前块 500的相邻块 合并时剥掉下一块开头重复的父标题这里有个教案与代码的直接冲突必须先点出来教案文档里写chunk 之间保留10%~20% overlap但代码里chunk_overlap0。真实的语义重叠是靠**“每个子块回贴父标题前缀 同父短块合并”**实现的不是滑动窗口重叠。我认为这个做法比 overlap 更聪明重叠的目的本来就是让每块自带上下文贴标题比贴上一段的尾巴信息密度高得多而且不产生重复 token。如果你照抄10~20% overlap去理解这个项目方向就错了。其他兜底细节标题本身超预算available_len 0直接返回原章节\r\n归一为\nWindows 语料不归一会让标题正则和段落分隔双双失效part缺省补 0Milvus 必填结果落local_dir/chunks.json备份。3.4 主体识别给每个 chunk 贴这是哪个商品的取前 5 个切片DEFAULT_ITEM_NAME_CHUNK_K5| 单片截断 800 字符 | 总上下文 2500 字符 -- product_recognition_system(system) item_name_recognition(human) -- item_name -- 空返回 / 异常一律回退 file_titleitem_name有两个用途贴到该文档所有 chunk 上当元数据同时单独向量化写进item_name_collection文档级索引。后者是查询链路能做先确认主体再检索的前提。3.5 入库 schema 与索引dense_vector FLOAT_VECTOR(dim1024)# 索引HNSW COSINE, M16, efConstruction200sparse_vector SPARSE_FLOAT_VECTOR# 索引SPARSE_INVERTED_INDEX IP,# DAAT_MAXSCORE, normalizeTruecontent/title/parent_title/file_title/item_name VARCHAR(65535)part INT8,chunk_id INT64 PK auto_id,enable_dynamic_fieldsTrue待嵌入文本 item_namecontent拼接文本增强。这一步很关键手册里第 3 步长按电源键 5 秒这种句子脱离商品名之后在向量空间里和几百个商品的同类句子挤在一起。插入前按item_name删旧数据实现重跑幂等。4. 查询链路三路并行召回 → RRF → 断崖精排这一节是整个项目的技术核心五步。4.1 第一步查询重写 指代消解一次 LLM 调用干两件事多轮对话里用户会说那它支持 5G 吗必须先重写成独立问题并抽出主体。返回 JSON{item_names:[商品A,商品B],rewritten_query:关于商品A和商品B...}4.2 第二步主体确认双阈值分流0.85 / 0.60用item_names去item_name_collection做混合检索然后score 0.85 -- 确认集直接作为检索过滤条件 0.60 score 0.85 -- 可选集可能相关但不确定 确认集为空 -- 提前写入 answer 反问用户走条件边直达 node_answer_output这一步的价值是把检索不到变成问清楚再检索。而 LangGraph 的意图路由就在这里靠的是条件边返回元组defroute_after_node_item_name_confirm(state):ifstate.get(answer):returnnode_answer_output# 主体不明 → 提前退出反问return(node_search_embedding,node_search_embedding_hyde,node_web_search_mcp)# 返回元组 并行扇出注意本项目完全没有 text-to-SQL / text-to-Mongo / NL2Query。MongoDB 的查询全是手写 PyMongo自然语言理解发生在提取item_names→ 用item_name in [...]做 Milvus 标量过滤这条路上。如果你想在简历里把这个项目写成支持自然语言查询数据库那是写错了。4.3 第三步三路并行召回① 向量混合检索 hybrid_search(ranker_weights(0.8, 0.2), norm_scoreTrue, limit5) 稠密 COSINE 稀疏 IPAnnSearchRequest 各取 limit10 ② HyDE 让 LLM 先写一篇 ≤300 字回答范文再把 query hyde_doc 拼一起向量化 走同样的混合检索 ③ 联网搜索 MCPMCPServerStreamableHttp 调百炼 bailian_web_searchcount5 max_retry_attempts3HyDE 解决的是一对具体矛盾用户 query 短“怎么换电池”手册文档长一整节两者不在同一个语义粒度上向量天然不对齐。让模型先写一段假装知道答案的回答这段假答案和真手册的向量距离就近得多。hyde_prompt.prompt里有一条约束特别好要求 3. 不要使用假设、可能等不确定的词汇 5. 使用中文回答且不超过300字禁不确定词是因为模糊语气会让假设文档的向量偏离真实手册文档的语义空间——手册是陈述句写的。300 字是防止范文过长反而稀释原 query。4.4 第四步RRF 融合k60自实现的带权重倒数排名融合没有用 Milvus 内置 ranker因为要多路 联网混合score(d) Σ_i weight_i × 1 / (k rank_i(d)) k 60, 两路权重各 1.0, max_results 10工程细节比公式重要_as_entity_list()要把 pymilvus 的Hit/ 嵌套 dict / 扁平 dict三种返回形态统一成 entity dict主键取chunk_id or id。为什么要or idMilvus 在 API 层把主键统一叫id而 schema 里定义的是chunk_id。不处理这一条RRF 去重会静默失效。4.5 第五步精排 动态 TopK 断崖截断FlagReranker.compute_score([[question, text], ...])。本地切片rrf_chunks和联网结果web_search_docs先合并成统一的{text, source, chunk_id, url, title}结构再一起打分。断崖算法是这个项目我最想抄走的一段RERANK_MAX_TOPK 10, RERANK_MIN_TOPK 1 GAP_ABS 0.5 相邻两条分差的绝对值阈值 GAP_RATIO 0.25 相邻两条分差的相对值阈值 从第 2 条起逐条比较gap 0.5 or gap / (|s1| 1e-6) 0.25 -- 立即截断为什么这比固定 Top-K5好得多不同问题的证据密度不一样。这个型号支持几个 G可能只有 1 篇文档讲整机不工作怎么排查可能散在 8 篇里。固定 K 要么带进噪声要么截掉证据断崖截断让相关性分布自己决定取几条。配套的另一条保命逻辑reranker 模型异常时降级返回原序、score0.0绝不中断链路。精排是增益环节不是必要环节不能让它变成单点故障。4.6 生成上下文预算与图片回捞MAX_CONTEXT_CHARS 12000字符数不是 token中文要自己折算 每篇资料渲染成[i] [local|web] [chunk_id...] [url...] [score0.xxxx] [title...] 正文图片 URL 不采信模型输出而是_extract_images_from_docs(reranked_docs)从精排文档里回捞。原因写在answer_out.prompt里2 ...图片只能来自于本地切片文本中的图片请在答案最后追加一个独立的图片区块格式严格如下 【图片】 图片URL1 每行一个URL如果没有合适图片则不要输出【图片】区块【图片】是一个可解析的分隔标记代码用_IMAGE_BLOCK_MARKER把它拆出来交给前端渲染最后用FINAL事件把answer image_urls一起推下去。这套模型只负责文字图片由代码回捞的分工是所有多模态 RAG 都该采用的模式——让模型生成 URL 必然会产生幻觉链接。5. 六个 .prompt 文件把提示词当接口定义写课程目录里有 24 个.prompt但 md5 比对后只有 6 个唯一文件——它们在day01/代码/prompts、day02/代码/prompts、掌柜智库项目/代码/PythonProject16/prompts、掌柜智库项目/资料/prompts各存了一份副本每天代码是全量快照。结构约定UTF-8 纯文本无 frontmatter、无元数据文件名即用途由load_prompt(name, **kwargs)读入后str.format填坑。内容一律是四段式任务说明 编号约束清单 输出格式契约 无法确定时的兜底角色边界单拆一个*_system.prompt。product_recognition_system.prompt全文只有一句你是商品识别专家只输出识别的字符串即可**“把角色和任务拆成 system / human 两条消息”**这个做法我单独提因为它比写在同一段里省 token且模型对 system 的遵循度更高。最有代表性的是主体识别的item_name_recognition.prompt文件名{file_title} 正文切片用于辅助识别{context} 1. 返回内容为字符串形式最好是带品牌、型号和名称的完整商品名称。比如苏伯尓5000W大功率电磁炉 3. 如果无法识别商品名称,请返回空字符串。它给的是一个具体命名范式的 few-shot品牌 功率 品类而不是抽象描述要完整。而返回空字符串这个契约对应代码里空则回退file_title——prompt 的兜底路径和代码的兜底路径必须一一对应否则模型返回空你只能崩。一个必踩的格式坑因为用str.formatprompt 里所有 JSON 示例的花括号必须双写成{{ }}否则load_prompt直接KeyError。这是用文本模板做结构化输出最常见的隐性成本。6. 服务端与工程细节SSE 事件协议day06 用sse_step_1/2.py|html递进教学day07 定型ready -- progress* -- delta* -- final -- (__close__ | error)SSEEvent定义这 6 类事件_session_stream: Dict[str, queue.Queue]按session_id路由线程池里的图节点put协程生成器yield。这是跨线程做流式推送的标准解法——queue.Queue线程安全queue.SimpleQueue或asyncio.Queue在这里都不对。查询服务接口接口作用GET /chat.html对话页POST /query提交问题BackgroundTasks后台跑query_appGET /stream/{session_id}StreamingResponseSSE 通道GET /history/{session_id}拉会话历史DELETE /history/{session_id}清空GET /health健康检查MongoDB 会话历史create_index([(session_id,ASCENDING),(ts,DESCENDING)])# 复合索引find({session_id:sid}).sort(ts,ASCENDING).limit(10)# 索引降序读取升序索引建降序、读用升序——因为查询要最近 10 条靠索引高效定位但喂给 LLM 时要按对话时间正序模型对时序敏感。这个细节值得记。写入用save_chat_message有message_id走update_one$set无则insert_one回填用update_message_item_names批量补主体名。两份 Mongo 工具类并存mongo_history_utils.py含image_urls参数$in无条件覆盖与mongo_history_utils_new.py去掉image_urls但更新加了$or: [{item_names:{$exists:false}}, {item_names:[]}, {item_names:null}]守卫只回填尚未设置主体名的历史消息。线上跑的是旧版改进版没接线。典型的教学演进型代码库遗迹。一个真实可改进点day07 把时间戳拼进指代消解的 prompt 时塞的是原始 Unix 浮点数聊天角色{role}回答内容{text}重写问题{rewritten_query}关联主体{...}时间1774520425.627模型读不出这个数是什么意思。这行要么格式化成2026-09-29 03:14要么直接删掉。7. 踩坑清单20 条全部可溯源到代码或教案#坑说明1注释与代码不符node_search_embedding传ranker_weights(0.8, 0.2)紧跟的注释写稠/稀疏权重各占 50%。照注释理解会误判调参方向2教案说 overlap 10~20%代码是 0真实重叠靠回贴标题 同父合并3str.format与 JSON 花括号冲突prompt 里必须{{ }}4模型结构化输出要四层防御day08 注释原话“1 设置 json 格式化 2 提示词中明确 3 一定要给模型参考示例 4 做好返回格式的校验”。对应json_modeTrue→ 剥 json 围栏 →if item_names not in dict→rewritten_query缺省回落原问题5numpy 类型不可序列化 / 不可哈希稀疏向量必须.tolist()np.int64→int做 dict key、np.float32→float做 JSON/Milvus 值否则TypeError。稠密同理6Milvus 主键在 API 层叫idschema 里叫chunk_idRRF 去重要item.get(chunk_id) or item.get(id)缺 ID 的打 warning 跳过7Milvus filter 表达式注入商品名含引号/换行会炸掉item_name in [...]。必须走escape_milvus_string\→\\、→\、\r\n\t→空格8WeightedRanker不开norm_scoreTrue权重会失效稠密 COSINE 与稀疏 IP 量纲不同不归一化时稀疏分数会劫持结果9FlagReranker.compute_score的(query, passage)顺序不可颠倒代码注释用整段强调“颠倒会导致打分结果失真”10并行节点只能返回局部 deltanode_web_search_mcp注释明写并行的不要直接返回 stateday07 的node_item_name_confirm却return state正是 day08 要修的地方11模块加载期异常不能抛Mongo 预连接若raise会导致整个包 import 失败、进程起不来 →try/except只warning 懒加载兜底。create_index天然幂等不用先判断存在12正则替换要用 lambdapattern.sub(f![{summary}]({url}), ...)里 summary/URL 含\$会被当反向引用。教案说想写防御性代码请坚持使用 Lambda 写法——但它自己的示例代码最终仍用了 f-string 版13上下文预算是共享的used在资料和历史对话之间累加资料过长会静默把历史截光14图片 URL 无法从 state 回读教案注释“没办法直接从 result state 里拿到 image_urls因为它是作为 SSE 推送出去的或者存库了” → 每轮重新从reranked_docs抽15VLM 调用要限速dequeapply_api_rate_limit(max_requests, window_seconds60)阻塞式time.sleep做滑动窗口会拖慢整条导入流水线16MinIO 对象名里的反斜杠被编码成%5C访问 URL 要手工按protocol://endpoint/bucket/object拼重跑前必须clean_minio_directory清旧前缀否则同名图片内容混淆17代码块内的#被误判为标题切分器必须维护in_code_block状态和~~~ 都要处理18\r\n不归一标题正则与段落分隔双双失效19enable_thinking要显式关千问思考链走extra_body: {enable_thinking: False}。不关的话延迟和 token 都翻倍而这个任务不需要思考链20教学代码遗留脏 importday07node_item_name_confirm.py里有from mpmath import limit、未使用的os。别当范式照搬QNeo4j 图谱召回到底做了没有没有。需要诚实标注clients/neo4j_utils.py -- 存在脚手架 .env 里 NEO4J_* 已配好 node_query_kg.py -- time.sleep(1) 空桩 node_import_kg -- 只存在于中文节点名字典里无实现文件 node_multi_search / node_join -- 预留未实现写博客或简历时把它说成已实现图谱召回是不成立的。Qday 编号到几目录名会骗你。这个目录实际是day01–day10不是到 day07。而且 day08item 确认打分 向量查询、day09RRF / MCP / Rerank 真实现、day10答案输出 总结 面试题才是查询模块的核心——只看目录名到day07_查询模块_web和时间以及mongodb就收工会得到一个只有空桩的版本。同理day07 的node_web_search_mcp只是time.sleep(1)返回{web_search_docs: []}真正的百炼 MCP 在 day09 才补上。另外那 345 个.pdf绝大多数是语料不是课件H3C 路由器/无线网关、华为 MateBook、Aolynk Cable 网桥、CS2610DNW、HAK180 烫金机、万用表安全手册另有一个 317MB 的《设备手册汇总.zip》重复出现 3 次。教案.zip也无需解压——它的 22 个.md已原样存在于day10_.../教案/教案/zip 里其余是约 100 张配图。最后总结这个项目值得学的不是用了 RAG是它把 RAG 拆成了六层可独立调参的环节结构化切片标题树 2000/500→ 主体标签item_name双向量→ 多路召回向量 HyDE 联网→ 混合检索0.8/0.2 归一化→ 融合RRF k60→ 裁决bge-reranker 断崖 TopK。每一层都能单独评测、单独换。两个最值得直接抄走的机制①断崖式动态 TopK绝对差 0.5 / 相对差 25% 立即截断让相关性分布自己决定取几条②图片由代码回捞、不由模型生成把多模态 RAG 最容易出幻觉的一环封死。三个低成本高收益的技巧把item_name拼进待嵌文本解决手册短指令句的向量歧义、chunk 回贴父标题代替 overlap重叠区信息密度更高、HyDE 提示词禁假设/可能保持与真文档同一语义空间。双阈值0.85/0.60确认主体是把检索不到变成反问用户的关键一步。绝大多数 RAG 系统体验差不是因为检索不准是因为在主体都不确定的时候硬答。架构上的天花板要心里有数进程内 dict 存任务状态重启即丢、不能横向扩展、上下文预算按字符不按 token、VLM 限流是阻塞 sleep。当 Demo 满分当生产要重做这三处。读教学项目代码的正确姿势以最后一天的快照为最终态把中间日期当过程遗迹注释、教案、代码三者互相核对本篇 20 个坑里有 5 个就是文档与实现不一致。笔者后端 架构的判断如果只能从这个项目里学一件事我学**“把提示词当接口定义写”**——输入变量、输出契约、兜底路径、格式校验四层防御一一对应。这套做法我后来在 deepagents 和智能客服两个项目里都见到了而且都是同一批人在强调它。下一篇讲智能客服系统当 RAG 遇上多轮流程型对话架构要再加一层对话栈。参考资料 致谢[1] Milvus - GitHub[2] BGE-M3 - Hugging Face[3] bge-reranker-large - Hugging Face[4] FlagEmbedding - GitHub[5] MinerU文档解析- GitHub[6] LangGraph 官方文档[7] Reciprocal Rank Fusion 原始论文 - arXiv[8] Precise Zero-Shot Dense Retrieval without Relevance Labels (HyDE) - arXiv[9] Model Context Protocol - 官网[10] 阿里云百炼 Web Search MCP - 官方文档[11] FastAPI - 官方文档[12] [尚硅谷大模型智能体线上速成班 V2.0 · Day05《项目掌柜智库》课件、源码与教案]注项目.env中出现的是 38 个变量名OPENAI_API_KEY、MILVUS_URL、MINERU_API_TOKEN、NEO4J_*等本文未引用任何密钥值。文中的准确率、阈值、参数均为课程快照2026 年 2 月配置实际调优请以自己的评测集为准。
阅读完成 · 觉得有帮助?
咨询建站