过去两三年,只要在碰AI应用落地,几乎绕不开“RAG该怎么搭”这道题。我前后给几个内部项目做过召回基础设施建设,也从“随便找个框架跑通demo”进化到“把每个环节的取舍弄明白”,中间被生产环境教育过好几轮。今天这篇想集中聊聊我学习Milvus向量数据库、并且把它作为RAG基础设施核心底座时的完整思路、踩过的坑和最后沉淀下来的做法。如果你正准备给团队搭一套能用的RAG系统,或者刚接触向量检索不知道从哪下手,这篇应该能帮你少走不少弯路。
1. 为什么RAG的召回层,偏偏离不开Milvus这种专用向量数据库?
1.1 RAG三条链路里,向量检索负责的是什么
RAG一句话说透:别让大模型凭空想。模型内部知识有边界,训练语料未必覆盖企业内部文档,直接问容易出现一本正经的胡说八道。RAG的做法是先建知识库,用户问题进来时先从库里捞出相关片段,再把这些片段连同问题一起交给大模型,让它基于材料作答。整个过程拆成三块:入库、召回、生成。很多人一开始只重视入库和生成,觉得“切一下文档然后丢给大模型”就结束了,结果做出来效果奇差。问题基本都出在召回环节——该捞的没捞上来,捞上来的又跟问题对不上,下游生成再强也白搭。
向量检索就是召回环节的核心实现。传统关键词搜索匹配的是字面,用户问“合同什么时候到期”,文档里写的是“本协议有效期至2026年5月”,字面上一个共同词都没有,机器怎么对应?Embedding模型把文本编码成几百维的稠密向量,“到期”和“有效期”这类语义相近的表达在向量空间里会彼此靠近,于是检索能突破字面限制,找到语义相关的片段。Milvus向量数据库在这个环节不是唯一能干的,但它把“大规模向量近邻搜索”做成了完整的基础设施,这正是RAG生产落地最需要的一层。
1.2 为什么搜索引擎和关系型数据库干不了这个活
有同学会问:不是听说ES也支持向量检索吗?pgvector不是也有kNN功能吗?为什么还要单独上一个Milvus?这个问题的答案,我建议从两个维度理解:性能和工程边界。
传统搜索引擎做向量检索,通常是在倒排索引之外再维护一套向量索引,工程复杂度高,而且向量维度到768甚至1024、数据量到百万条以上时,资源开销会非常难看。更关键的是,RAG场景对召回延迟极其敏感,用户已经要等大模型输出,检索如果还要花上百毫秒,叠加起来体验就会很糟糕。专用向量数据库从数据模型到存储引擎都围绕向量近邻搜索设计,查询路径短,索引结构专门优化,同样数据量下表现稳定得多。关系型数据库加pgvector就更是另一个量级了,小规模demo没问题,企业级数据规模和并发一起来,扩展性很快就撞墙。这不是说哪个方案垃圾,而是工具定位不同。几百条几万条数据你随便折腾都行,一旦目标是百万级文档切片、多业务方共享查询,面向水平扩展的专用向量库几乎是必须选项。
1.3 我最后选Milvus而不是其他方案,取舍逻辑是这样的
当时我把几个候选方案摆在一起对比过。Faiss是向量检索算法库,灵活是真灵活,但它是库不是服务,存储、并发、权限、监控全要自己搭,适合做研究实验或嵌在业务进程里,不适合当团队共用的基础设施。ES带向量插件,如果团队本来就维护ES、数据规模也不大,用它没有迁移成本,但混合查询的灵活度和大规模性能,后面会逐渐让人头疼。还有一些云上向量数据库,易用性确实好,但成本模型在某些数据量级下不够透明,也比较难下沉到有信创或私有化要求的场景。
Milvus这边是开源分布式向量数据库,独立部署,数据模型允许把文档的标量信息(来源、时间、权限)和向量字段放在同一条记录里,查询时既支持先向量召回再标量过滤,也支持过滤后做向量检索。这个能力对RAG非常重要,企业内部文档天然带各种业务维度,没有混合查询就只能“先捞一堆再在业务侧过滤”,效率低而且容易漏。再加上它是完整的分布式系统,写入、索引、查询、持久化、访问控制拆得很清楚,团队其他人接手也容易。学一个组件的成本是固定的,但长期维护收益很大。
2. RAG基础设施整体架构设计与关键选型思路
2.1 先拆两条流水线:写入链路和查询链路
动手之前别急着装服务写代码,先把系统画成两条流水线。第一条是写入链路:源文档进来,先做格式解析和清洗,再切分成合适大小的块,每块过一遍Embedding模型生成向量,连同原文和元数据一起写进Milvus。第二条是查询链路:用户提问,把问题向量化,到Milvus里做近邻检索,拿到候选结果,可选做Rerank精排,然后把命中的文本块按模板组装成Prompt,交给大模型生成回答。
两条链路的节奏完全不一样。写入是异步的、低频的,查询是在线的、要低延迟的,所以架构上尽量让它们解耦。我自己的做法是写入侧挂一个任务队列,文档多的时候慢慢灌;查询侧只关心Milvus里有没有数据、有没有加载到内存。这套链路里,Milvus的角色是知识库的在线存储与检索引擎。写入链路往里面加数据,查询链路每来一个问题打一次检索请求。它不负责生成,也不负责算Embedding,但它是串联两条链路的中间件。
很多教程一上来就让人跑LangChain的“内存向量库”demo,东西能跑,但一关进程数据就没了,更没有权限和监控,属于玩具级基础设施。真正要接业务,向量存储必须是独立服务,所以把Milvus放在链路图正中间这件事,建议大家一开始就对齐。
2.2 文本切分与Embedding模型怎么选才对路
写入链路里最容易翻车的就是两个选择:切分策略和Embedding模型。
先说切分。默认按固定窗口切分,比如每300个token切一块,块与块之间重叠50个token,这个方案最简单也最不容易出错。为什么必须有重叠?因为语义可能刚好被一刀切成两段,“合同有效期至”和“2026年5月”分到两块,检索时就可能捞不到完整信息。重叠窗口等于给边界留了缓冲。如果文档章节结构明显,比如技术手册、规章制度,我建议优先按章节标题切分,再把每章内部切成小块,这样上下文边界天然贴合语义。块大小控制在300到500个token之间比较好,太小则上下文不足,太大则噪声多、大模型窗口浪费。
再说Embedding模型。中文场景强烈建议优先选针对中文优化过的开源向量模型,比如BGE系列,它在中文检索任务上表现稳定,维度一般768,配合余弦相似度很自然。也可以接商业API的Embedding接口,几百维都行。这里有一个必须提前想清楚的约束:Embedding一旦定了,Milvus里的Collection维度就锁死了,后面换模型必须重建Collection重新灌数据。所以这个决策要在建库之前做,而且要用真实业务数据小成本验证一遍。我见过好几个项目建好库跑了一个月才发现模型选得不合适,只能推倒重来,非常伤。
2.3 必须补上的模块:标量过滤与Rerank精排
绝大多数demo教程都会漏掉一个事:RAG在真实业务里不可能只做纯向量检索。用户的数据有来源、有时间、有部门、有权限范围,不加过滤的话,检索结果就可能跨部门、跨时间,甚至捞出已失效的数据。Milvus支持把标量字段和向量字段一起定义在Collection里,查询时用expr参数写过滤条件,比如“source in ['内部Wiki'] and version >= '2024'”,这样向量召回和业务筛选能在一次查询里完成。注意,标量字段如果想要参与过滤,记得在字段上建普通索引,否则查询效率会掉得非常明显。
另一个生产必做模块是Rerank。向量检索的本职是快速从几百万条里捞出候选,但它的排序能力其实一般,很多“相似但实际不相关”的内容会排在很前面。解决办法是加一个Rerank模型,把候选集压缩到几十条甚至十条,用交叉编码器做精排,让真正相关的片段浮上来。整体思路就是大漏斗:向量库负责粗筛,Rerank负责精排。Milvus返回TopK时,我一般取100到200条,再扔给Rerank选前5到10条。没有这一层,RAG的回答质量方差会非常大。实际测试里,加与不加Rerank,用户可感知的准确率经常差一大截。
3. Milvus部署方式对比与Collection核心设计实操
3.1 学习、单机生产、集群三种部署怎么选
Milvus的部署形态,我实际用下来分成三档。第一档是Milvus Lite,一个Python包里内置的文件级向量库,适合学习概念和验证思路,数据量不超过百万条时跑起来很轻松,我已经习惯用它做本地方案验证。第二档是Docker Compose启动的单机Milvus,生产环境最推荐从这档起步,部署不难,运维可控,一个节点也能支撑千万级向量。第三档是基于Kubernetes的集群,当数据量到亿级、并发高、需要弹性扩缩容时再考虑,运维复杂度明显上升,不建议一上来就上集群。
Milvus Lite用起来极简单,装好pymilvus之后直接写:
from pymilvus import MilvusClient client = MilvusClient("milvus_demo.db")它会把所有数据落到本地一个文件里,用来学Collection、Schema、索引这些概念完全够用。等要接真实数据,再切到Docker Compose,连接方式从文件路径换成服务地址,代码改动很小。Docker Compose部署Milvus比较直接,写好compose文件后一条命令起来,默认端口19530。走一遍这两种形态,你会发现从Lite到单机只是换了uri,其他API基本一致。这个设计对新手很友好,大大降低了上手的心理门槛。
3.2 手写Schema:字段规划与主键策略
Milvus里的Collection相当于传统数据库的表,每一行是一条数据,里面至少有一个向量字段和若干标量字段。我建Collection的习惯是:主键用字符串,值设为“文档ID_块序号”,一条主键就能同时溯源到原文档和块位置,比纯uuid更好调试。正文放一个长文本字段存原始切分块,来源、标题、上传时间、部门这些业务属性各放一个标量字段,再单独放一个float向量字段保存Embedding输出。这样设计的好处是,查询想展示引用来源、做权限过滤都不用额外查库。
一个常见误区是只建向量字段,其他什么都不存,结果召回之后还要回原库找原文,链路长还容易出错。我建议所有信息都在一条Milvus记录里带全。另一个容易踩的坑是主键生成方式,我以前用过自增数字,后面做数据清洗、去重、按批次更新时就特别痛苦,同一个文档重新切分后主键对不上,旧数据没删掉就重复了。后来固定用“来源标识_章节路径_序号”这种模式,稳定多了。字段一多,写入前要确认和Embedding维度对齐,比如BGE系列的768维,别手滑写成1024,不然建索引时报错才反应过来,又得排查半天。
3.3 索引类型与核心检索参数配置指南
Milvus的检索性能一半靠索引类型,一半靠查询参数。最常用的两大家族,一个是HNSW,基于图的近似最近邻索引,召回率高、延迟低,参数直观,适合绝大多数RAG场景;另一个是IVF系列,类似聚类分桶的思路,内存更省,但需要调nprobe来换效果。我自己默认用HNSW,参数先给保守组:M设16到32,控制每个节点的邻居数量,太大内存涨;efConstruction设在200到400之间,控制建索引时的搜索宽度,太高建索引时间变长;查询时efSearch设在100到200左右,越高召回越好,但延迟也升。实际项目里,索引参数的可调空间就这几个,先用默认值跑通,发现问题再针对性微调。
相似度度量同样重要。RAG场景我推荐COSINE,主流Embedding模型训练时大多基于余弦相似度,语义距离和余弦距离对应关系更自然。如果用IP内积,需要确认向量已经归一化,否则分数语义不稳定。L2距离不是不能用,但语义上“向量近”和“文本像”的对应不如余弦直观。建索引的代码大致长这样:
index_params = { "index_type": "HNSW", "metric_type": "COSINE", "params": {"M": 32, "efConstruction": 300} } client.create_index(collection_name, index_params) client.load_collection(collection_name)注意,Milvus查询之前必须先load_collection,把索引和数据加载进内存,这一步很多新手会漏,漏了要么直接报错,要么查不到数据。另外,数据量特别大、内存有限时,可以改用IVF_PQ这种量化索引,把原始向量压缩存储,牺牲一点精度换内存,等数据量大到需要优化内存再研究也不迟。
4. RAG核心链路从零实现:入库、检索、生成全流程
4.1 文档预处理与批量写入Milvus的细节
链路的第一步是数据处理。我的处理顺序是:先把文档解析成纯文本,做一轮清洗,去掉无意义的页眉页脚、连续空行、脚本注释等噪声;然后按章节或固定窗口切块;给每个块生成向量;最后和原文、元数据一起批量写入。清洗这步很多人会跳过,但真实世界文档乱七八糟的东西特别多,不洗的话噪声会进入向量空间,拖低整体召回质量,做RAG图省事往往得不偿失。
写入代码不复杂,但有一个性能铁律:用批量insert,不要一条一条insert。我本地实测,批量512条写入比逐条插入快好几倍,尤其首次灌库时数据量动辄几十万条,逐条插就是灾难。批量写入示例:
rows = [] for i, chunk in enumerate(chunks): rows.append({ "id": f"{doc_id}_{i}", "text": chunk, "source": source, "embedding": embedding_model.encode(chunk).tolist() }) client.insert(collection_name, rows)写入之后还有一件事容易被忽略:Milvus的索引构建是异步的,数据落盘不等于马上能查。首次灌库数据量大时,索引要构建一段时间,查询还没有快速路可走。我的习惯是灌完数据看一眼Collection的状态,等它build index和load完成再开始压测。这个节奏对初次使用者来说尤其重要,省掉那几分钟的等待,换来的却是后面排查问题时的大把时间。
4.2 检索请求的正确写法与召回参数调优
查询链路的入口,是把用户问题嵌入成向量,然后用同一个Metric写进search。Milvus 2.x里search的入参看着简单,实际有几个参数值得逐个抠:top_k控制返回条数,expr是标量过滤表达式,search_params里放的是检索相关参数,HNSW就传ef_search,IVF就传nprobe。
filter的用法要小心一个反直觉现象:数据总量不大时,filter表达式写复杂反而拖慢速度;但数据量大、过滤能卡掉大部分内容时,filter能显著提前收敛。我通常把硬性业务条件(权限、来源白名单)放filter里,相关性交给排序去解决。查询示例:
query_vec = embedding_model.encode(user_query).tolist() results = client.search( collection_name, data=[query_vec], limit=100, expr='source in ["内部Wiki", "技术文档"] and status == "active"', search_params={"metric_type": "COSINE", "params": {"ef_search": 200}} )返回结果里有distance字段。新手常问:distance越小越好还是越大越好?注意,COSINE在Milvus里返回的其实是“1减去余弦相似度”,所以是越小越相似。这个细节不搞清楚,阈值就写反了。实际调优时我不建议过度依赖距离阈值,更稳的做法是:先按TopK固定数量捞,再用Rerank精排,最后设一个宽松阈值过滤掉太离谱的结果。召回参数调整顺序我也总结过:先确认数据有没有load,再看ef_search是不是太小,再看过滤表达式有没有生效,最后才考虑换索引类型,这个顺序能帮你少做很多无用功。
4.3 把召回结果组装成大模型上下文
检索完,最后一步是把命中的片段拼成给大模型的上下文。这里有两个工程细节:一是控制上下文总量,比如模型支持8K窗口,别把一万多token的文本全塞进去,上下文越长,噪声越大,成本也越高;二是保留引用信息,让大模型回答时带上来源,用户能追溯,系统出错也好debug。
我常用的做法是把每个命中的块和来源打包,用类似“【来源:内部Wiki/合同管理,第2章】内容……”的格式拼进Prompt。有了这一步,用户看到的回答不再是无根之木,追问时也能直接定位到具体文档。如果检索结果为空,不要硬编一个答案,我一般会让模型明确说“知识库中没有找到相关内容”,这比瞎猜强得多。Prompt模板本身不用写多花哨,关键是上下文组织得干净、有序、带来源,模型输出的稳定性会高不少。
5. 生产环境常见问题与排障技巧实录
5.1 召回结果答非所问?从五个方向排查
这是RAG项目上线后最常收到的反馈:“明明文档里就有答案,为什么系统答不上来?”我在复盘时基本按这个顺序查。第一,切分是不是有问题,比如块太小语义被切断,或者块太大混入太多无关内容,小批量改切分参数重新验证,往往立竿见影。第二,Embedding模型和领域匹配度不够,通用模型在专业领域通常表现平平,换领域数据调优过的模型提升往往很明显。第三,索引参数没调优,默认参数能跑通不等于效果好,适当加大ef_search再试试。第四,召回TopK太小,候选太少,真正相关的块排在后面没捞上来,我一般至少先取50到100条再做精排。第五,距离阈值设错,COSINE“越小越相似”这个反直觉点踩过的人非常多。
5.2 写入慢、查询卡、内存涨的经验解法
写入慢最常见的坑是逐条insert,改成批量后速度能上一个量级。另一个容易被忽视的问题是,索引在写入的同时就触发了重建,两者互相抢资源。我的做法是首次灌库时先别急着建索引,等数据全部写完再统一create_index,整体耗时能省下不少。
查询卡顿,先查内存是不是快满了。Milvus的数据查询主要在内存里跑,Collection没load或者内存不足,查询延迟会非常难看。需要控制内存时,把不常用的大Collection停掉load,只保留高频部分常驻。内存持续上涨还有一个隐藏点:大批量写入后,老版本备份数据没有及时清理,或者关闭了分区却没有确认状态。这些问题单个看都不大,积累一段时间后会让服务逐渐变慢。遇到这类情况,先看Milvus自己的监控和日志,再对应做清理。在真实项目里,我还会把写入和查询分集群部署,避免两者的资源互相污染,这也是后期优化时比较管用的手段。
5.3 常见故障速查表与兜底策略
整理一个速查表,对应到实际操作中遇到最多的情况:
| 现象 | 可能原因 | 建议处理 |
|---|---|---|
| 查询返回空 | Collection未load或数据未落盘 | 确认load_collection已执行,索引build完成 |
| 结果相关性差 | 切分粒度不对或Embedding模型不匹配 | 调整chunk大小/重叠,换领域模型小样本对比 |
| 查询延迟高 | ef_search/nprobe设置过大、数据未load到内存 | 降低参数,检查资源占用,启用监控 |
| 写入慢 | 逐条insert、与索引构建冲突 | 批量写入,批量完成后统建索引 |
| 过滤没效果 | 标量字段没建索引 | 为过滤字段创建普通索引 |
| 内存溢出 | 全部Collection同时load、容量超限 | 按需load,小Collection定期释放 |
这个表基本覆盖了我接手的RAG项目里最常见的问题。兜底策略方面,我的建议永远是先保住查询链路稳定,再优化召回质量:召回质量差最多答得不好,链路不稳定则是完全不可用。线上出问题优先回滚到上一版本,别在服务抖动时调试参数,这是我被实际教训出来的习惯。
最后再分享一点个人体会。我学Milvus和搭RAG基础设施,如果重走一遍,一定会改掉两个毛病:一是不要上来就追求大而全的架构,从Milvus Lite加几十个文档的小数据集把链路跑通,远比空谈设计有用;二是把召回质量当成持续迭代的工程来做,每一条用户反馈的bad case都记录、分析、改进,RAG系统的效果就是靠这么一点点磨出来的。工具上我最终选Milvus做底座,不只是因为它性能符合预期,更因为它的数据模型、索引体系和运维边界足够清晰,让团队能在一个稳定的地基上长期迭代。希望这篇分享能让你在搭自己的RAG时,少走我走过的那些弯路。