FastAPI+Milvus实现汽修RAG问答与工单闭环实践
2026/9/8 17:50:49 网站建设 项目流程

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 不是简单的“向量检索 + 大模型生成”,细拆有以下几步:

  1. query 预处理:对用户输入做去噪、同义词扩展(比如“车子启动不了”扩展为“无法启动”、“打不着火”),这些规则可以先用正则和词典做,不要一开始就上模型。
  2. embedding 落入向量库:用同一个 embedding 模型把 query 编码成向量,这一步必须保证 encode 模型和入库时一致,否则向量空间错位,检索结果一塌糊涂。
  3. 向量召回 + 元数据过滤:先按车型、系统(发动机/变速箱/电气)过滤,再在子集里算相似度,召回 top-k。
  4. 重排:如果召回结果多了,可以用简单的 RRF(倒数排名融合)加上关键词 overlap 调权,比直接依赖向量距离更稳。
  5. prompt 组装:把召回片段拼成上下文,加上系统提示词,要求模型只依据上下文回答,不要瞎编。
  6. 结构化抽取:让模型输出建议的维修项目、工时、配件,存到工单草稿里。

我用 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-urlslisten-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_descsolutionparts_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_idvarchar工单号,格式 WO-YYYYMMDD-NNN
query_texttext原始问题
answer_texttext大模型生成的答案
car_modelvarchar车型
vinvarchar车架号
fault_codevarchar故障码,可为空
likely_causesjsonb可能原因数组
suggestionsjsonb建议操作列表
partsjsonb配件列表(名称、数量)
estimated_hoursnumeric预计工时
statusvarchar状态:DRAFT/IN_PROGRESS/DONE/CLOSED
actual_solutiontext修理工实际处理方法(闭环关键)
actual_partsjsonb实际使用配件
created_attimestamp创建时间
closed_attimestamp关闭时间

设计时我特别加了actual_solutionactual_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 小时状态为CLOSEDactual_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 是否正常。

排查步骤:

  1. 检查 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
  2. 检查 Milvus 日志:/var/log/milvus/server.log。如果看到grpc: addrConn.createTransport failed,大概率是 Milvus 配置里的 etcd 地址写错了。
  3. 验证 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.x2.2.x老项目常用
2.3.x2.3.x较稳定
2.4.x2.4.x 或内置 WebUI内置 WebUI 更舒服

另外注意,Milvus 2.4 开始,Attu 连接时默认端口 19530,我曾经在 web UI 之间切换过,结果同一个浏览器 session 混了,导致数据看不到,清理缓存或换个无痕窗口就好了。

6.3 RAG 效果不理想如何调优

很多刚入门的朋友调 RAG,搜索没结果就觉得是 embedding 模型问题,其实大概率是切分方式和过滤条件的问题。比如汽修故障码 P0300(缺火),如果按系统切分成“发动机系统”一个大块,再 embed 的时候整块太长,信息被稀释了。

我的调优顺序建议:

  1. 先看召回片段是否相关。直接在 Milvus WebUI 里手动查 query 的 top10。如果前 5 个都不相关,那就是切分粒度或 embedding 问题。如果相关的排在第 5 位之后,那就是重排问题。
  2. 检查 filter 条件是否太严。比如用户没填车型,你默认过滤car_model == '',那必然什么都查不到。应该是“有车型就过滤,没有则跳过”。
  3. 调整 overlap。chunk 之间的 overlap 对跨句语义有影响,我测下来 50-100 token 的 overlap 对维修手册比较合适。
  4. 调整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 能力真正贴合业务。希望这篇实操记录能帮少走几条弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询