1. 项目全景:为什么汽修问答需要 RAG + 工单闭环
做汽修信息化也有几年了,我接触过的不少维修厂和连锁门店,都有一个很尴尬的现状:老师傅的经验全在脑子里,新人查故障手册翻半天,客服接电话被问得哑口无言,售后工单又是另一个系统里的死数据,修完就存档,下次遇到类似问题还得从头摸索。这套项目就是奔着解决这个痛点去的——用 FastAPI 搭后端服务,用 Milvus 存向量知识,用 RAG 让大模型基于真实维修资料问答,同时把每一次问答自动关联到工单流程里,形成“提问—解决—沉淀”的闭环。
这个项目适合谁参考?首先是负责汽修门店数字化系统的后端工程师,其次是研究 RAG 落地但找不到具体场景的技术同学,还有想在生产环境用 Milvus 但对部署选型比较犹豫的人。我会把从环境搭建到接口联调、再到工单流转的完整路径都写清楚,包括实际踩过的坑。
很多人一提到智能问答就想到微调大模型,这是最常见的误区。汽修领域的故障现象、维修步骤、配件参数更新得很快,微调一次成本高不说,知识还会过期。RAG 的思路更聪明——把维修手册、技术通报、历史工单切块向量化,存进 Milvus,提问时先检索出最相关的知识片段,再把这些片段作为上下文丢给大模型生成答案。这样知识更新只需要重新灌库,模型本身不用动。加上工单闭环之后,系统就不再是“一次性问答机器人”,而是能持续从真实维修记录里学到新东西的知识引擎。
工单闭环是这个项目的灵魂。传统问答机器人答完就结束,用户问完还得自己对着答案去干活,干完了也没有反馈。我设计的闭环是:问答结果不仅要返回自然语言答复,还会拆解出建议的维修项目、工时、配件,然后自动生成一张草稿工单;维修技师实际完成维修后,工单状态变更为“已完成”并回写结论;系统定期把已完成的工单再向量化回 Milvus,下次遇到相似故障就能检索到真实维修案例。这套逻辑让知识库自己“生长”,越用越准。
2. 技术选型与架构拆解
2.1 FastAPI 作为服务层:异步和自动文档是最大红利
后端框架我选 FastAPI 没有太多犹豫。首先是异步支持,RAG 链路里有大量 IO 操作——调向量库查询、调大模型接口、读写数据库,用 async/await 能把并发吞吐撑起来,比如汽修门店高峰期同时十几个工位在查故障,同步框架很容易把线程池打满。其次是 FastAPI 自带 OpenAPI 文档,前端、安卓、小程序都能直接看接口契约,省掉了手动维护文档的时间。还有 Pydantic 做参数校验,工单数据结构复杂时,嵌套模型定义得清清楚楚,远比 Flask 手动校验来得稳妥。
在这个项目里,FastAPI 不只是做 HTTP 层,它还承担了流程编排的职责。一个典型的问答请求进来,先经过POST /api/rag/query,然后服务内部依次执行:查询 Milvus、拼装 prompt、调用 LLM、解析结构化结果、写工单草稿。每一步都是独立的 service 函数,用 FastAPI 的依赖注入把它们串起来。这样写的好处是方便测试,也方便以后替换组件——比如换 embedding 模型或者加一个多路召回,都只是在 service 内部改。
2.2 Milvus 向量数据库选型与版本坑
选 Milvus 而不是其他向量库,核心原因是它支持集合(collection)级别的动态 Schema,这对汽修知识库很重要。维修手册和工单的字段差异很大,有的需要存故障码,有的需要存车型,用固定 Schema 的库会很痛苦。Milvus 从 2.x 开始支持 JSON 字段和动态字段,我可以把各种元数据一股脑塞进去,查询时再用 filter 精确过滤,比纯向量检索加后置过滤要高效得多。
但这玩意儿的版本是个深坑。我最早用的是 Milvus 2.2.x,后来为了用新特性升级到 2.4.x,结果发现 Attu 客户端版本不一样,连接方式全变了。Attu 是一个 Milvus 的可视化管理工具,如果你只装了 Milvus 服务端而没装 Attu,可视化调试会非常痛苦。这里强调一下版本对应关系:Attu 2.3.0 及以前基本兼容 Milvus 2.2/2.3;Milvus 2.4 的 WebUI 其实是内置的,不再强烈依赖 Attu,但很多人还是习惯用 Attu 看数据。我是装了 Milvus 2.4.1,然后直接用内置 WebUI(默认端口 9091),再用 Attu 2.4.x 连接,流畅度还行。如果你刚上手,建议就装 Milvus 2.4.x 版本,然后 Access 方式别搞错,否则你会一头扎进“ETCD 报错”里出不来。
2.3 RAG 流程整体编排:从 query 到 answer 的完整链路
RAG 不是简单的“向量检索 + 大模型生成”,细拆有以下几步:
- query 预处理:对用户输入做去噪、同义词扩展(比如“车子启动不了”扩展为“无法启动”、“打不着火”),这些规则可以先用正则和词典做,不要一开始就上模型。
- embedding 落入向量库:用同一个 embedding 模型把 query 编码成向量,这一步必须保证 encode 模型和入库时一致,否则向量空间错位,检索结果一塌糊涂。
- 向量召回 + 元数据过滤:先按车型、系统(发动机/变速箱/电气)过滤,再在子集里算相似度,召回 top-k。
- 重排:如果召回结果多了,可以用简单的 RRF(倒数排名融合)加上关键词 overlap 调权,比直接依赖向量距离更稳。
- prompt 组装:把召回片段拼成上下文,加上系统提示词,要求模型只依据上下文回答,不要瞎编。
- 结构化抽取:让模型输出建议的维修项目、工时、配件,存到工单草稿里。
我用 langchain 还是直接手写?坦率讲,这个场景我建议手写 pipeline。LangChain 的抽象层确实方便,但版本升级太频繁,出了问题很难排查。RAG 核心链路其实不复杂,自己用 Python 写也就 100 多行。后面我会把关键代码贴出来。
3. 环境准备与 Milvus 本地部署(非 Docker 实测)
3.1 Windows / Linux 下 Milvus 安装细节
Milvus 官方推荐用 Docker Compose 部署,但是很多公司内网或者个人开发机没有 Docker 环境,尤其 Windows 下要装 Docker Desktop 一堆麻烦事。我实际测试过,Milvus 2.4.x 也提供了不带 Docker 的安装方式,这里给出几个路线。
路线一:Windows 下用 Docker Desktop 跑。虽然用到了 Docker,但只要你装好 Docker Desktop,docker-compose.yml一拉就能跑起来。这里有个坑:Windows 下 Milvus 默认挂载卷的路径如果包含中文或空格,容器会起不来,所以最好把目录设置成纯英文。路线二:直接上 Linux 裸机装。
以 Ubuntu 22.04 为例,不依赖 Docker 的安装步骤大概是这样:先装 etcd,再装 MinIO,然后再装 Milvus standalone(单机版)。Milvus standalone 虽然内部依赖 etcd 和 MinIO,但它有自己的启动脚本,只要配置里指向 etcd 和 MinIO 的端点就行。我用的版本组合是:etcd 3.5.9、MinIO RELEASE.2023-03-20T00-00-00Z、Milvus 2.4.1。顺序很重要,先启动 etcd,再 MinIO,最后启动 Milvus。
Windows 纯本机非 Docker 安装我没走通,官方对 Windows 原生支持不足,建议如果不想用 Docker 就装 WSL2 跑 Ubuntu,然后在 WSL 里按照 Linux 方式装。我测试下来这套组合最稳定。
3.2 Attu 客户端连接不同 Milvus 版本的问题
很多人第一次用 Attu 都懵,因为 Attu 和 Milvus 的版本没有严格一一对应。Attu 的 GitHub 仓库说支持 2.x 大版本,但实际连接时如果 Milvus 版本太新,接口路径变了,Attu 会显示一堆空数据或者直接报错。比如用 Attu 2.3.8 连 Milvus 2.4.1,集群信息能显示,但查询数据时可能拿不到向量字段的标量值。
我的经验是:Milvus 2.4.x 尽量用 Attu v2.4.0 以上版本,Milvus 2.2/2.3 则用 Attu 2.3.x。还有一个更省心的方法——Milvus 2.4 自带的 WebUI,启动后浏览器访问http://localhost:9091/webui,能看集合、做搜索、看日志。我后来基本都在 WebUI 里排查数据,Attu 只是偶尔用来做数据管理。
3.3 etcd 与存储配置要点
Milvus 的元数据、分片信息、索引状态都存在 etcd 里,etcd 挂了 Milvus 就废了。我有一个血泪教训:之前搭建时 etcd 用的默认端口 2379,结果跟本地另一个服务冲突,Milvus 启动过程一直在 etcd 连接重试,日志大量刷etcdserver: request timed out。排查了半天才找到元凶。
建议部署时把 etcd 的listen-client-urls和listen-peer-urls分开,客户端地址用本机 IP,而不要用0.0.0.0,这样安全也好排障。MinIO 的 bucket 名称可以自己定义,但 Milvus 启动时会自动建 bucket,如果你之前手动建过同名 bucket 且权限不对,初始化会失败,所以最省事的做法是让 Milvus 自动创建。
另外,Milvus 的配置文件milvus.yaml里有一个common.threadCount参数,默认是 CPU 核数。如果机器 CPU 核不多,又同时跑 embedding 模型,容易导致环境卡死。我调到 8,效果稳定。
4. 后端核心实现:FastAPI 接口与 RAG 链路
4.1 数据切分与向量化
汽修数据源五花八门:PDF 维修手册、Excel 零件表、历史工单文本。我统一先转成纯文本,再做结构化切分。直接按固定字符切是最蠢的,会把一句话砍成两半。我用的策略是分层切分:先按一级目录(比如“发动机系统”“变速箱系统”)分成大块,再按最小语义单元——段落和列表项——切成 chunk,每个 chunk 控制在 500 到 800 个 token,chunk 之间保留 50 个 token 的 overlap。
这里有一个小技巧:对于维修手册,把“故障码 + 可能原因 + 排查步骤”作为一个整体切,因为这三者是一个完整检索单元,拆开了检索噪音很大。对于历史工单,我字段里用fault_desc、solution、parts_used分别存,向量化时只对fault_desc + solution做 embedding,parts 作为过滤条件。
embedding 模型我选择了bge-large-zh-v1.5,中文场景效果不错,维度是 1024。Milvus 建立 collection 时,向量字段类型设为 FLOAT_VECTOR,dim 1024。如果用 OpenAI 的 embedding,API 有调用费用,内网部署不方便。bge 模型在本地用sentence-transformers加载,速度也挺快。
切分和向量化代码大概长这样:
from sentence_transformers import SentenceTransformer from pymilvus import Collection, FieldSchema, CollectionSchema, DataType, connections model = SentenceTransformer("BAAI/bge-large-zh-v1.5") def embed_texts(texts): return model.encode(texts, normalize_embeddings=True) fields = [ FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False), FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535), FieldSchema(name="car_model", dtype=DataType.VARCHAR, max_length=128), FieldSchema(name="system_tag", dtype=DataType.VARCHAR, max_length=64), FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=1024), FieldSchema(name="metadata", dtype=DataType.JSON), ] schema = CollectionSchema(fields, description="car repair knowledge") col = Collection(name="car_repair", schema=schema)4.2 检索逻辑(dense vector search 的踩坑)
检索最简单的方式就是col.search()传 query 向量,取 top_k。但如果直接全库搜索,会把完全不同的故障问混进来。比如用户问“怠速不稳”,你召回的是“怠速马达更换”,这算相关;但如果召回的是“空调不制冷”,那就是噪音。所以要先 filter 再 search,用expr参数把车型、系统限定住。
踩过最大的坑是metric_type的选择。Milvus 支持IP(内积)、L2(欧氏距离)、COSINE。我用 bge 模型的时候,normalize_embeddings=True之后,用 IP 和 COSINE 等价,但别用 L2。具体看模型说明,bge 官方建议用 COSINE。若 embedding 没归一化,IP 的分数范围不稳定,检索前几名容易错。
还有一个容易被忽略的是search_params里的ef参数(针对 HNSW 索引)。索引类型我用HNSW时,ef 越大召回越准但越慢。在线问答场景我设ef=128,这能兼顾精度与速度。另一个参数nprobe是 IVF 索引的,如果老版本用了 IVF 别搞混。
检索代码加上过滤条件后:
query_embedding = embed_texts([query])[0].tolist() search_params = {"metric_type": "COSINE", "params": {"ef": 128}} expr = 'system_tag in ["发动机", "电气"]' result = col.search(data=[query_embedding], anns_field="vector", param=search_params, limit=5, expr=expr, output_fields=["text", "car_model"])注意,expr里字符串要用单引号包着,字段名不能用反引号。我刚开始用习惯了 SQL 的语法,写着写着就报语法错误。Milvus 的 expr 语法基本兼容过滤表达式的子集,但 JSON 字段访问要用metadata["part_no"]这种用法,具体官方文档要看一眼。
4.3 生成与工单创建联动
召回得到 top-5 片段后,就要组装 prompt。我用了两个模型:一个是本地部署的 Qwen2.5-14B(通过 vLLM 部署),另一个是 OpenAI 兼容接口。考虑到大模型输出的稳定性,我把 prompt 写得非常死板:
你是汽修专家。只依据以下资料回答问题,不要编造。 资料: 1. [片段1] 2. [片段2] ... 问题:{user_query} 请输出 JSON,字段包括: - answer: 自然语言回答 - likely_causes: 可能原因数组 - suggestions: 建议操作列表 - parts: 建议配件列表,每个包含名称和数量 - estimated_hours: 预计工时(数字) 如果资料信息不足,answer 里明确说“资料中未找到,请人工核实”。为什么要让模型输出 JSON?因为我需要直接把生成结果映射到工单草稿。FastAPI 的响应模型天然支持 Pydantic,大模型输出 JSON 后我用json.loads解析,再存为工单对象的字段。如果允许模型自由发挥,那工单数据就是一堆垃圾。
在 FastAPI 里创建工单的接口和问答接口分开更清晰:
@app.post("/api/rag/query") async def rag_query(req: QueryRequest): contexts = search_milvus(req) messages = build_prompt(contexts, req.query) llm_resp = await call_llm(messages) parsed = parse_json(llm_resp) draft_id = await create_work_order_from_parsed(req, parsed) return {"answer": parsed["answer"], "draft_work_order_id": draft_id}这一步关联了问答和工单:问答结果不只是给用户看的,同时已经是半结构化工单。前端可以显示“已生成草稿工单,工单编号 WO-20250521-001”,用户确认后即可进入维修流程。
5. 工单闭环:从智能问答到执行反馈
5.1 工单数据结构设计
工单表我用 PostgreSQL 存储,但核心业务逻辑在 FastAPI 中处理,下面是关键的字段设计:
| 字段名 | 类型 | 说明 |
|---|---|---|
work_order_id | varchar | 工单号,格式 WO-YYYYMMDD-NNN |
query_text | text | 原始问题 |
answer_text | text | 大模型生成的答案 |
car_model | varchar | 车型 |
vin | varchar | 车架号 |
fault_code | varchar | 故障码,可为空 |
likely_causes | jsonb | 可能原因数组 |
suggestions | jsonb | 建议操作列表 |
parts | jsonb | 配件列表(名称、数量) |
estimated_hours | numeric | 预计工时 |
status | varchar | 状态:DRAFT/IN_PROGRESS/DONE/CLOSED |
actual_solution | text | 修理工实际处理方法(闭环关键) |
actual_parts | jsonb | 实际使用配件 |
created_at | timestamp | 创建时间 |
closed_at | timestamp | 关闭时间 |
设计时我特别加了actual_solution和actual_parts。这是跟普通问答系统最大的不同:问答结束后,系统并没有完事,它等着维修技师干完活把结果填回来。有了这两列,才能持续更新知识库。
5.2 问答结果如何映射为工单
在create_work_order_from_parsed里面,我把解析后的 JSON 映射到上面的表。这里有一个细节:如果用户问的是“这个故障代码 P0300 是什么意思”,生成结果里可能没有具体配件,estimated_hours 可能是 null。没关系,工单草稿允许空值,维修技师在确认草稿时可以补充。但是如果用户问的是“换机油”,系统应该能自动预测工时和机油滤芯配件,这就要靠 prompt 引导。
我在 prompt 里增加了规则:“如果问题明显是维修请求,必须输出 parts、estimated_hours;如果只是知识询问,parts 可以为空数组”。这样能减少垃圾工单的产生。另外,工单创建前,我会调用一个简单的去重函数,根据car_model + query_text + 时间范围(最近1小时)判断有没有重复工单,防止用户一直点同一个按钮刷出来一堆草稿。
5.3 闭环反馈与知识库更新
闭环的落地点在于:每天凌晨跑一个批处理脚本,把最近 24 小时状态为CLOSED且actual_solution非空的工单,再次切分向量化后写入 Milvus。写入的时候用text = "故障描述:" + query_text + " 实际解决:" + actual_solution,再附上实际的配件、车型等信息。这样知识库里会不断加入真实维修案例,而不是只有官方手册内容。
同时我会更新旧数据的knowledge_source字段,让它区分是官方手册还是历史工单。检索时可以把来源权重调高一点,比如官方手册的metadata["source"]为manual,工单的为work_order,在重排阶段对work_order的命中做一个小激励,因为真实维修案例往往更有参考价值。
这一套闭环跑起来后,系统越用越懂你们门店的常见问题。举个例子,某品牌车在店里经常出现某个通病,官方手册没写,但第一个维修师傅手工填了实际解决过程,之后第二个师傅再问就能检索到。这就是闭环的意义。
6. 常见问题与排查实录
6.1 Milvus 连接失败与 etcd 问题
最典型的现象是 FastAPI 启动时connections.connect(alias="default", host="localhost", port="19530")报连接超时。先别急着查 Milvus 本身,先看 etcd 是否正常。
排查步骤:
- 检查 etcd 进程:
ps aux | grep etcd。如果没有,启动/path/to/etcd --data-dir=/data/etcd --listen-client-urls=http://0.0.0.0:2379 --advertise-client-urls=http://0.0.0.0:2379。 - 检查 Milvus 日志:
/var/log/milvus/server.log。如果看到grpc: addrConn.createTransport failed,大概率是 Milvus 配置里的 etcd 地址写错了。 - 验证 etcd 连通性:
curl http://localhost:2379/health,返回{"health":"true"}才正常。
我遇到最扯的一次是 etcd 启动成功了,但防火墙把 2379 端口挡住了,Milvus 进程在另外一台机器上连不上。所以如果你把 etcd 和 Milvus 分开部署,请确保端口是通的。
6.2 Attu 版本不兼容
如果打开 Attu 后发现集合列表为空,但数据明明在里面,或者执行查询时报milvus collection not found,十有八九是 Attu 的 proto 版本比 Milvus 老。你可以在 Attu 页面右上角的设置里看连接的 Milvus 版本,或者看 Attu 的 Release Notes。
解决办法很简单:升级 Attu 到与 Milvus 匹配的版本。我整理了一张对照表,按这个来基本不会错:
| Milvus 版本 | Attu 推荐版本 | 备注 |
|---|---|---|
| 2.2.x | 2.2.x | 老项目常用 |
| 2.3.x | 2.3.x | 较稳定 |
| 2.4.x | 2.4.x 或内置 WebUI | 内置 WebUI 更舒服 |
另外注意,Milvus 2.4 开始,Attu 连接时默认端口 19530,我曾经在 web UI 之间切换过,结果同一个浏览器 session 混了,导致数据看不到,清理缓存或换个无痕窗口就好了。
6.3 RAG 效果不理想如何调优
很多刚入门的朋友调 RAG,搜索没结果就觉得是 embedding 模型问题,其实大概率是切分方式和过滤条件的问题。比如汽修故障码 P0300(缺火),如果按系统切分成“发动机系统”一个大块,再 embed 的时候整块太长,信息被稀释了。
我的调优顺序建议:
- 先看召回片段是否相关。直接在 Milvus WebUI 里手动查 query 的 top10。如果前 5 个都不相关,那就是切分粒度或 embedding 问题。如果相关的排在第 5 位之后,那就是重排问题。
- 检查 filter 条件是否太严。比如用户没填车型,你默认过滤
car_model == '',那必然什么都查不到。应该是“有车型就过滤,没有则跳过”。 - 调整 overlap。chunk 之间的 overlap 对跨句语义有影响,我测下来 50-100 token 的 overlap 对维修手册比较合适。
- 调整
ef参数。在线服务里 ef 可以设为 64 提升速度,但离线评估时用 256。如果你用 IVF 索引,nprobe 通常设为 8-16,数值太低召回很差。
评估方面,我建了一个 200 条真实问题的评测集,每条文成两部分:是否命中相关维修手册、大模型答案是否让修理工满意。计算基本指标如 hit@5、MRR,再加上人工打分。仅仅看向量相似度是不靠谱的,因为询问题目表述和文档差很远,相似度数值不能完全代表语义相关性。
6.4 FastAPI 并发与超时优化
RAG 链路里最慢的是调用大模型,我用 Qwen2.5-14B 单卡部署,单次生成平均 2-3 秒。如果用户在 FastAPI 接口等太久,前端会报超时。我这里做了两个优化:
第一,用asyncio+httpx.AsyncClient异步调用 LLM,不要用同步 requests。同步会阻塞事件循环,并发一高全部卡死。改成异步之后,能支撑 20 个并发请求同时问答。
第二,给 LLM 请求设置 timeout。我用 openai 的 async client 时,timeout=60秒,但实际大部分请求 10 秒内能完成。超时后做降级:不回工单,只返回一个固定提示“系统繁忙,请稍后再试”。这个兜底非常重要,不然大模型偶尔卡死了,接口就一直挂着,数据库连接也会被耗尽。
还有缓存。同一个问题一周内被问超过 3 次,完全可以走 Redis 缓存。我实现了一个简单逻辑:把 query 语义 hash 到rag:cache:{md5},TTL 7 天,命中了直接返回答案和之前的工单号。这样可以显著降低大模型压力,也提升用户感知速度。
7. 项目后续扩展与个人经验总结
这套系统已经在我们合作的一家连锁维修厂跑了三个月,从最初的知识库只有 200 份手册、一天几十次问答,到现在沉淀了 5000 多条真实工单,检索准确率大概提升了 15%。但这里有一个需要特别注意的问题:工单回写知识库一定要做质量过滤。有些技师随便写的解决方法可能并不正确,直接灌进知识库会污染数据源。我的建议是只在知识库里写入状态为“已闭环”且经过主管审核的工单,或者至少将工单数据权重调低,避免带偏后续检索结果。
另一个未来可以继续扩展的方向是视觉能力。很多汽修故障是需要看照片的,比如底盘漏油、电瓶腐蚀,只靠文本 RAG 有上限。目前我考虑再用 CLIP/ViT 把维修图片也向量化,存到 Milvus 的同一个 collection 里,然后做多模态搜索。FastAPI 这边只要增加一个图片上传接口,前端拍照就能查问题。虽然这个还没完全落地,但架构上已经预留了向量字段和 metadata 的冗余空间。
最后再分享一个运维里的小技巧:Milvus 数据备份不要只靠 etcd 快照。保险的做法是定期用milvus_backup这个官方工具把 collection 数据导出到本地磁盘或者 OSS,因为 etcd 只是元数据,真正向量数据在 MinIO,两者都要备份。曾经有一次我不小心删了 MinIO 里的一个 bucket,结果整个集合全毁了,恢复只能从备份中拉数据。现在脚本每天晚上自动备份,算是花钱买教训。
这套项目如果完全重做一遍,我仍然会坚持 FastAPI + Milvus + RAG 这个组合。FastAPI 的工程效率和类型安全对业务快速迭代太友好了,Milvus 虽然部署有些小坑,但生产可用的特性和成熟度在开源向量库里是第一梯队,RAG 则让 AI 能力真正贴合业务。希望这篇实操记录能帮少走几条弯路。