1. 检索主链路到底在搭什么:先把“第一次问答闭环”这件事说透
很多人做RAG项目,卡住的地方从来不是“模型不会回答”,而是“链路根本没跑通”。尤其是从零到一搭企业级智能问答系统,到了检索主链路这一章,意味着你已经把文档入库、向量化、索引构建这些前置工作做完了,现在要面对的是最核心的一件事:用户问一句话,系统怎么把这句话变成一次完整的、可观测的、可复现的问答闭环。
我先把这件事的定义说清楚。所谓“第一次问答闭环”,指的是从用户输入问题开始,经过查询理解、向量检索、上下文组装、大模型生成、流式输出,直到前端完整收到答案并正确渲染的整条链路。这条链路里任何一个环节断了,用户看到的就是转圈、超时、空回答或者半截话。热搜词里出现的“stream disconnected before completion: idle timeout waiting for sse”就是典型的链路断点问题,后面我会专门拆。
为什么这一章特别关键?因为在此之前,你做的都是离线批处理,数据进得去就行。但从这一刻开始,系统要面对真实用户的实时请求,延迟、并发、超时、流式中断、检索召回质量,所有问题会同时爆发。我见过太多项目,索引建得漂漂亮亮,一到问答环节就原形毕露。所以这一章的目标不是“能回答”,而是“稳定地、可观测地、可调试地回答”。
技术选型上,这套链路我采用的是LangGraph 编排 + Milvus 向量检索 + Ollama 本地推理 + SSE 流式输出的组合。LangGraph 负责把检索、生成、工具调用这些节点串成有状态图,Milvus 负责向量召回,Ollama 提供本地大模型推理能力,SSE 负责把生成结果实时推给前端。这个组合的好处是每一层都可替换、可观测、可单独调试,不会出现“黑盒一锅端”的情况。
适合谁来参考?如果你已经跑通了文档切分和向量入库,正准备把问答链路接起来,这篇内容就是给你写的。如果你还在纠结 Milvus 装 standalone 还是集群、Ollama 模型放哪个盘,也能在这里找到答案。下面我按实际搭建顺序,把每个环节的决策逻辑、参数计算、踩坑经验全部摊开讲。
2. 链路整体设计与选型逻辑:为什么是 LangGraph 而不是一条直线
2.1 从“线性链”到“状态图”的思维转变
最早我做 RAG 用的是最朴素的线性链:问题进来,直接拿去检索,检索结果拼进 prompt,丢给模型生成。这条链在 demo 阶段没问题,但一上企业场景就崩。原因很简单,真实问题不是每一句都需要检索。用户问“你好”“谢谢”“你叫什么”,你还要去 Milvus 里捞一遍向量,纯属浪费算力还拖慢响应。
LangGraph 的价值就在这里。它把问答过程建模成一张有状态图,每个节点是一个处理步骤,边决定下一步走向。你可以根据查询意图做条件路由:闲聊类问题直接走生成节点,知识类问题先走检索节点。这个“条件边”的能力,是线性链给不了的。
我实际用的图结构大致是这样:入口节点做查询预处理,然后一个路由节点判断是否需要检索。需要检索的走“向量检索 → 上下文组装 → 生成”,不需要的直接“生成”。生成节点内部再挂工具调用能力,为后续扩展留口子。这个结构的好处是,你后面加“多轮改写”“重排序”“答案校验”这些节点时,不用推翻重来,往图里插节点就行。
提示:LangGraph 的节点函数尽量保持“纯函数”风格,输入状态、输出状态增量,不要在节点里做全局副作用操作,否则调试时会非常痛苦。
2.2 Milvus 选 standalone 还是集群:先看数据量再决定
热搜里“milvus standalone模式”和“在 mac 上使用 docker 安装 milvus”出现频率很高,说明很多人卡在部署这一步。我的建议很直接:企业级项目在验证阶段一律先用 standalone。原因有三点。
第一,standalone 模式把 etcd、minio、milvus 三个组件打包在一个进程里,部署成本极低,一条 docker compose 就能起来。第二,standalone 在千万级向量以内性能完全够用,绝大多数企业知识库根本到不了这个量级。第三,等你真的需要集群时,代码层的 Milvus 客户端调用方式几乎不用改,只是连接地址从本地变成集群地址。
这里有个细节要注意。热搜里提到“服务器linux上使用 milvus_uri: str = './data/milvus.db' 本地加载 milvus”,这是 Milvus Lite 的用法,适合本地快速验证,但它和 standalone 是两套东西。Milvus Lite 把数据存成单个本地文件,不支持并发写入,也不支持完整的索引类型。如果你只是想在 mac 上跑通链路,Lite 够用;但只要涉及多人访问或者数据量上去,必须切到 standalone。
| 部署方式 | 适用场景 | 数据上限 | 并发能力 | 迁移成本 |
|---|---|---|---|---|
| Milvus Lite | 本地验证、单机 demo | 百万级 | 单写入 | 低 |
| Standalone | 企业验证、中小生产 | 千万级 | 中等并发 | 低 |
| Cluster | 大规模生产 | 亿级以上 | 高并发 | 中 |
2.3 Ollama 的角色定位:本地推理的性价比之选
选 Ollama 做推理层,核心考量是数据不出内网和成本可控。企业知识库往往涉及内部文档,走外部 API 有合规风险,Ollama 本地部署能规避这个问题。热搜里“ollama离线安装包”“ollama下载慢”“ollama安装到其他盘”这些词,说明大家最关心的就是安装和模型存储。
我的经验是,Ollama 的模型默认存在用户目录下,动辄几十 GB,系统盘很容易爆。安装前先把模型存储路径改到大容量数据盘,通过环境变量OLLAMA_MODELS指定。离线安装的话,提前在有网机器上把模型 pull 下来,整个models目录打包拷过去即可,比在线拉取稳得多。
至于模型选择,问答场景我一般用 7B 到 14B 参数区间的指令微调模型。太小了回答质量差,太大了推理慢且显存吃紧。具体选哪个,要结合你的硬件和延迟要求实测,没有标准答案。
3. 核心细节拆解:检索、组装、生成三段各自的坑
3.1 查询向量化:别小看这一步的一致性
检索链路的第一环是把用户问题转成向量。这里最容易犯的错误是入库和查询用了不同的 embedding 模型或不同的归一化方式。我踩过这个坑:入库时用的是某个模型的默认输出,查询时手贱加了归一化,结果余弦相似度全乱,召回的全是无关内容。
Milvus 里做余弦相似度检索,索引类型和度量方式要匹配。用IP(内积)度量时,向量必须归一化;用COSINE度量时,Milvus 内部会处理。热搜里“milvus余弦值”这个词说明有人已经在关注这个点。我的建议是统一用COSINE,省心,不容易出错。
# 查询向量化示例,注意与入库保持完全一致 from ollama import Client client = Client(host="http://localhost:11434") def embed_query(text: str) -> list[float]: resp = client.embeddings(model="nomic-embed-text", prompt=text) return resp["embedding"]注意:embedding 模型一旦确定,入库和查询必须锁死同一个版本。换模型等于重建整个索引,没有捷径。
3.2 上下文组装:token 预算怎么算
检索回来一堆文档片段,不能全塞进 prompt。大模型有上下文窗口限制,塞太多不仅慢,还会稀释关键信息。我一般按这个公式估算预算:
可用上下文 token = 模型窗口 - 系统提示 token - 历史对话 token - 输出预留 token
假设模型窗口 8192,系统提示占 300,历史对话占 1000,输出预留 1500,那留给检索上下文的就只有约 5400 token。按每段 300 token 算,最多塞 18 段。实际我会控制在 10 段以内,给重排序和格式留余量。
组装时我习惯给每段加上来源标记,比如[文档1],这样生成时模型能引用来源,前端也能做溯源展示。这个细节在企业场景很重要,用户需要知道答案是从哪份文档来的。
3.3 生成节点:流式输出与工具调用的衔接
生成节点是整条链路最复杂的地方,因为它同时要处理流式输出和工具调用。LangGraph 里工具调用是通过条件边实现的:模型输出里如果包含工具调用请求,就路由到工具节点执行,执行完再回到生成节点。
这里有个坑,流式输出和工具调用天然冲突。流式是一边生成一边推,工具调用需要等模型完整输出才能解析。我的处理方式是:首轮生成不开流式,先判断是否需要工具调用;确认是纯文本回答后,再走流式生成。这样虽然多一次模型调用,但逻辑清晰,不会出现半截工具调用 JSON 推给前端的尴尬。
热搜里“langgraph 工具调用”和“封装sse 流式接口调用逻辑”这两个词,正好对应这个环节。工具调用的封装要独立成模块,SSE 的封装也要独立,两者通过生成节点的状态传递衔接,不要耦合在一起。
4. 实操过程:从 Milvus 启动到前端收到第一个字
4.1 Milvus standalone 启动与集合创建
先起 Milvus。用 docker compose 是最省事的,官方仓库里有现成的 standalone 配置。启动后默认端口 19530 是 gRPC,9091 是健康检查。
# 下载官方 compose 文件后启动 docker compose -f docker-compose-standalone.yml up -d # 验证是否起来 curl http://localhost:9091/healthz集合创建时,字段设计要提前想好。我一般包含这几个字段:主键 id、向量字段 embedding、原文 chunk_text、来源 source、以及可选的元数据字段。索引类型用HNSW,度量方式COSINE,这两个参数在千万级以内表现都很稳。
from pymilvus import CollectionSchema, FieldSchema, DataType, Collection fields = [ FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True), FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768), FieldSchema(name="chunk_text", dtype=DataType.VARCHAR, max_length=4096), FieldSchema(name="source", dtype=DataType.VARCHAR, max_length=512), ] schema = CollectionSchema(fields=fields) collection = Collection(name="kb_chunks", schema=schema) index_params = { "index_type": "HNSW", "metric_type": "COSINE", "params": {"M": 16, "efConstruction": 200}, } collection.create_index(field_name="embedding", index_params=index_params) collection.load()M和efConstruction这两个参数控制索引质量和构建速度。M 越大召回越好但内存占用越高,16 是常用平衡点。efConstruction 影响构建精度,200 是稳妥值。
4.2 LangGraph 图构建:节点与条件边
图构建的核心是把每个处理步骤写成节点函数,然后用边连起来。我先把状态结构定义清楚,状态里包含问题、检索结果、上下文、答案、是否需要检索等字段。
from typing import TypedDict, List class QAState(TypedDict): question: str need_retrieval: bool retrieved_docs: List[dict] context: str answer: str路由节点判断是否需要检索,我用的规则是关键词加轻量分类。简单说,问题里包含“是什么”“怎么做”“为什么”这类词,或者长度超过一定阈值,就走检索。这个规则不完美,但胜在快且可解释,后续可以换成小模型分类。
条件边根据need_retrieval决定走向,这是 LangGraph 最直观的能力。整个图编译后就是一个可调用的对象,输入初始状态,输出最终状态。
4.3 SSE 流式接口封装:让前端逐字收到答案
SSE 这块是热搜重灾区,“stream disconnected before completion: idle timeout waiting for sse”这个报错我太熟了。根因通常是服务端在生成间隙没有发送任何数据,连接被中间层判定为空闲超时。
解决办法有两个。一是设置合理的超时时间,Nginx 里把proxy_read_timeout调大,比如 300 秒。二是服务端在生成间隙发送心跳注释行,SSE 协议里以冒号开头的行会被客户端忽略,但能保活连接。
# FastAPI 里封装 SSE 生成器 from fastapi.responses import StreamingResponse async def sse_generator(question: str): yield ": heartbeat\n\n" # 立即发一个心跳,避免首字节超时 async for token in stream_answer(question): yield f"data: {token}\n\n" yield "data: [DONE]\n\n" @app.get("/qa/stream") async def qa_stream(q: str): return StreamingResponse( sse_generator(q), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, )X-Accel-Buffering: no这个头很关键,它告诉 Nginx 不要缓冲响应,否则前端会一次性收到全部内容,流式就失去意义了。热搜里“nginx 代理 ollama 设置”也涉及类似问题,代理层不关缓冲,流式体验直接废掉。
4.4 端到端联调:第一次完整问答
联调时我建议按这个顺序验证:先单独测 Milvus 检索,确认能召回相关片段;再单独测 Ollama 生成,确认模型能正常输出;最后把两者接进 LangGraph,跑完整链路。这样出问题时能快速定位是哪一层。
第一次跑通时,我习惯在关键节点打日志,记录检索耗时、生成首 token 耗时、总耗时。这三个指标是后续优化的基准。首 token 耗时尤其重要,它直接决定用户感知的响应速度。
5. 常见问题与排查技巧实录
5.1 检索召回不准:先查向量一致性再查索引
召回不准是最常见的问题。排查顺序我固定为三步。第一步,确认入库和查询用的是同一个 embedding 模型和同一套预处理逻辑。第二步,手动拿一个已知答案的问题去检索,看返回的 top 结果里有没有正确片段。第三步,如果前两步都正常但召回还是差,考虑加重排序环节,用交叉编码器对召回结果重新打分。
热搜里“rag瓶颈”和“rag检索增强”说的就是这类问题。检索质量是 RAG 的天花板,生成模型再好也救不回错误的召回。我的经验是,与其花时间调生成 prompt,不如先把检索召回率提上去。
5.2 流式中断:超时、缓冲、心跳三件套
流式中断的排查我整理成一张表,按现象对原因。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 首字节迟迟不来 | 生成节点阻塞 | 检查检索和模型调用耗时 |
| 中途断开 | 代理层空闲超时 | 调大 proxy_read_timeout |
| 一次性收到全部 | 代理层缓冲 | 关闭 buffering |
| 偶发断开 | 无心跳保活 | 生成间隙发心跳注释 |
提示:SSE 连接断开后前端要能自动重连,重连时带上最后收到的事件 ID,服务端从断点继续。这个机制在企业场景是刚需,用户不会容忍答案看一半没了。
5.3 Ollama 推理慢:模型大小与硬件匹配
Ollama 推理慢通常不是软件问题,是模型和硬件不匹配。7B 模型在纯 CPU 上跑,每秒可能只有几个 token,体验很差。有 GPU 的话优先用 GPU,显存不够就用量化版本。热搜里“ollama部署私有大模型”和“ollama离线安装包”说明很多人在这块折腾,我的建议是先确认硬件,再选模型,别反过来。
另外,Ollama 默认会加载模型到显存并常驻,多个模型切换时会反复加载卸载,很慢。生产环境建议固定一个模型,或者用keep_alive参数控制驻留时间。
5.4 工具调用不触发:检查模型能力和 prompt
LangGraph 工具调用不触发,八成是模型本身不支持 function calling,或者 prompt 里没把工具描述清楚。不是所有 Ollama 模型都支持工具调用,选模型时要确认这一点。prompt 里工具的名称、参数、用途要写得明确,模型才知道什么时候该调。
我一般会先用一个明确的测试问题验证工具调用链路,比如“帮我查一下今天的天气”,确认能触发后再接真实业务工具。这样能把模型能力和业务逻辑分开调试。
6. 链路可观测性与后续扩展方向
6.1 埋点:让每一次问答都可追溯
企业级系统和 demo 最大的区别就是可观测性。我在链路的每个节点都埋了耗时和状态埋点,记录问题、检索到的文档 ID、生成的答案、各阶段耗时。这些数据存下来,既能做问题排查,也能做效果分析。
具体做法是在 LangGraph 的状态里加一个trace字段,每个节点往里追加自己的执行记录。链路结束后把 trace 落库。这样任何一个线上问题,都能还原出当时的完整执行路径。
6.2 扩展:多轮对话与知识库更新
第一次闭环跑通后,扩展方向主要有两个。一是多轮对话,把历史对话纳入状态,让模型能理解指代和上下文。二是知识库增量更新,新文档进来后增量入库,不用重建整个索引。
多轮对话的难点是历史对话的 token 管理,不能无限累积。我的做法是保留最近若干轮,更早的做摘要压缩。知识库更新则要注意 Milvus 的删除和插入操作,删除是标记删除,需要定期 compact 回收空间。
6.3 性能优化:缓存与并发
性能优化上,查询向量化结果可以缓存,相同问题不用重复算。检索结果也可以做短时缓存,热点问题直接命中。并发方面,Milvus 和 Ollama 都支持一定程度的并发,但要注意资源竞争,尤其是 GPU 显存。
我实测下来,单机 standalone 加一个 7B 模型,支撑几十路并发问题不大。再往上就要考虑模型服务拆分和 Milvus 集群了。这个量级判断要基于实际压测,别拍脑袋。
这套链路我从零搭过好几遍,每次都会在细节上踩新坑。第一次闭环跑通的那一刻,看着前端一个字一个字蹦出答案,那种感觉确实不一样。后面要做的就是把这条链路打磨稳,让它经得起真实用户的折腾。