去年做内部知识库问答系统时,团队用了很短时间就搭出第一个 demo:把一批技术文档切成小块,做向量化,塞进一个轻量向量数据库,再让大模型基于检索结果回答。演示效果不错,大家一度以为这事已经结束了。
真正的问题出现在一周之后。同事上传了更多资料,文档结构从“几十个小文件”变成“几千个长文档”;有人要求按部门隔离知识范围;需要按时间筛选最新版本;还要支持增量同步。这时候,原先那套“单机 Demo”开始频繁暴露问题:检索结果不稳定、数据重复、过滤条件很弱、索引重建慢,更别提并发和权限。我们重新审视架构,最后把向量存储切到了 Milvus 2.6,整套 RAG 流程才逐步稳定下来。
这篇文章不是 Milvus 的官方文档翻译,也不是一套可以直接抄走的代码。我的目标是把“Milvus 2.6 + RAG 实战”这件事拆开来讲:为什么向量数据库在 RAG 项目里更像“记忆系统”而不是“搜索引擎”,为什么 Milvus 这类重一点的基础设施适合放到企业项目中,以及从单机原型到可维护系统,有哪些环节必须补齐。
1. 向量数据库在 RAG 项目里的角色,比你想的更接近“记忆”
1.1 RAG 效果好不好,一半取决于“知识回取”
RAG 的基本链路并不复杂:文档切块 → 向量化 → 向量检索 → 拼接上下文 → 大模型生成。很多团队把注意力放在最后一步,也就是提示词和大模型本身,结果却总在“检索不到”或“检索到的东西不对”上翻车。
我现在的判断是:RAG 的效果上限,很大程度上由“知识回取”决定。大模型能回答得多准确,取决于它有没有从向量数据库里拿到足够相关的上下文。如果向量库里根本没有合适内容,提示词写得再精细也没用。
这里有一个容易误解的地方:向量检索不是“关键词搜索”,它比较的是语义相似度。用户问“这个接口怎么鉴权”,系统应该召回“登录认证流程”相关段落,而不是只召回包含“鉴权”两个字的片段。这是向量数据库的价值,但也带来一个代价:召回结果天然有“概率性”,不像传统数据库那样严格。因此在生产系统里,我们不仅要关心能不能检索到,还要关心召回质量、排序和过滤条件是不是可预期。
1.2 轻量实现与重基础设施:先别急着给团队上“重武器”
如果你只是做几十个文件的个人知识库,选择一个轻量向量数据库、一个 notebook 脚本,完全够用。因为没有并发、没有权限、没有增量更新,也没有“系统性失败”需要处理。
但企业项目是另一回事。它的特征通常是:
- 文档数量大,且持续增长;
- 文档有部门、标签、时间、版本等属性;
- 需要多人同时使用,检索链路可能出现慢查询;
- 需要定期更新向量库,而不是每次清空重建;
- 有时候还需要跨文档回答,以及结果可溯源。
这时选型就非常关键。轻量方案往往赢在“上手快”,但输在“工程能力”。Milvus、Elasticsearch 这类系统虽然部署更重、学习曲线更陡,但它们具备数据模型、索引管理、过滤条件、分布式扩展、监控备份这些能力。换句话说,它们是“数据系统”,而不是“内存里的相似度计算工具”。
1.3 选型前先问三个问题
在决定用 Milvus 还是其他向量数据库之前,我会让团队先过一遍三个问题:
- 数据量大概会涨到多少?百万条以上时,索引和查询性能会开始拉开差距;
- 需不需要和结构化成对筛选?如果知识库带部门、标签、时间范围过滤,向量库的标量过滤能力就很重要;
- 是想解决“今天的问题”,还是想解决“半年的问题”?如果只是验证想法,轻量方案更合适;如果要放进业务系统,就要从第一天考虑运维和扩展。
我见过不少团队,先用了轻量方案做演示,三个月后因为权限和增量更新重构。也有团队一上来就搭三台机器跑 Milvus,结果数据只有几百条,很多运维能力完全用不上。
这里没有绝对的对错,只有阶段不匹配。
2. Milvus 2.6 的关键设计:不只是向量搜索引擎,而是数据系统
2.1 从集合(Collection)到分区(Partition):数据模型决定了工程边界
Milvus 2.6 的数据模型继承自 Milvus 2.x 体系,核心概念包括:
- Collection:可以理解为关系数据库中的表,同一套字段结构的数据放在一个集合里;
- Field:字段,除了向量字段,还可以定义主键、字符串、整数、JSON 等标量字段;
- Partition:集合内部的分区,可以按业务维度拆分数据,比如按月份或部门分区。
这个设计对 RAG 项目非常重要。因为在纯向量检索方案里,你通常只有一个“向量列 + 文本列”,过滤能力很弱。但在 Milvus 里,你可以把doc_id、doc_type、department、publish_date、page_index等字段一起存进去,然后在检索时通过表达式过滤。
我最常用的一种结构是:
| 字段 | 类型 | 用途 |
|---|---|---|
id | INT64 主键 | 唯一标识 |
doc_id | VARCHAR | 文档 ID,方便关联原始文档 |
chunk_id | VARCHAR | 切块 ID |
content | VARCHAR | 文本片段 |
department | VARCHAR | 所属部门或知识域 |
publish_date | INT64 | 发布时间戳 |
page_index | INT64 | 来源页码,方便溯源 |
embedding | FLOAT_VECTOR | 向量字段,维度对齐 Embedding 模型 |
这样设计的好处是,检索时不做“全库暴力扫描”,而是在指定分区或过滤条件下做向量相似度查找。企业场景中,最常见的过滤是“只看本部门资料”或“只看 2024 年以后发布的文档”。如果没有标量字段,这个需求会变成一场灾难。
2.2 索引不是越复杂越好,先理解参数
Milvus 2.6 支持多种索引类型,比如 FLAT、IVF_FLAT、IVF_PQ、HNSW,以及 GPU 索引等。对 RAG 项目来说,最常遇到的是 IVF_FLAT 和 HNSW。
- FLAT:暴力计算,最准,但数据量大了之后很慢,适合小数据验证;
- IVF_FLAT:先聚类再检索,需要调
nlist和nprobe参数; - HNSW:基于图的近似最近邻,召回质量高,查询快,但内存占用更高,构建时间也更长。
很多人一上来就想选 HNSW,觉得“更先进”。但我会建议先看数据量:十万条以内,IVF_FLAT 足够;百万级以后再考虑 HNSW。原因很简单,HNSW 召回好,代价是内存和参数调优复杂度,工程上必须同时考虑部署资源。
另一个容易被忽略的是距离度量方式。常见的有 L2、IP、COSINE。文本向量通常用 COSINE 更直观,因为向量化模型很多已经对向量做了归一化处理。如果模型没有归一化,COSINE 还能避免向量长度对相似度产生干扰。落地时一定要确认两件事:Embedding 模型的输出是否归一化,向量字段和查询时的metric_type是否一致。不一致的后果是:从结果表面看有时正常,有时明显偏差。
2.3 为什么标量过滤在 RAG 系统里如此重要
很多 RAG 项目的初始版本没有权限和维度过滤,因为大家在做一个“搜索框”。但企业知识库天然带组织边界:销售不应看到研发内部文档,实习生不应看到薪资流程。你要么在文档切块时就按权限分区存储,要么在向量检索时带过滤条件。
Milvus 2.6 的查询支持表达式过滤,比如:
expr = 'department == "rd" and publish_date > 20240101'这个能力让知识库从“一个所有内容混在一起的大仓库”变成“可按业务维度隔离的知识系统”。但也带来一个工程要求:文档入库时,必须把元数据提取正确。很多团队把向量化写好了,却在“元数据质量”上栽了跟头——字段缺失、部门统计口径不一致、正文中提取的标题和文件名不匹配。这些都会直接影响检索过滤效果。
2.4 可扩展性:从单机到集群的平滑路径
Milvus 的架构分成了多个组件,包括接入层、查询节点、数据节点、索引节点等。单机模式下,用 Docker Compose 就能启动一套环境;数据量上来后,可以扩展为分布式集群模式。
对普通开发团队,这意味着一个很重要的好处:你不需要在项目初期就决策“我到底要部署多大集群”。先跑单机,数据量增长后再把组件拆分,这个路径比“轻量方案做大了再整体迁移”要平滑得多。
当然,分布式不是免费的,它引入了更多运维组件,需要监控 query node 的负载、segment 的数量、索引构建的进度等等。所以如果数据量很小,我仍然建议先单机,不要为了“高可用”而过度设计。
3. 一次跑通:Milvus 2.6 + RAG 的最小落地流程
3.1 先规划环境,再写代码
我建议先做一张最小环境清单,避免中途被依赖问题打断。
| 环节 | 建议 | 说明 |
|---|---|---|
| Docker | 已安装 Docker 和 Docker Compose | Milvus 2.6 可以用 Docker Compose 启动 |
| Milvus 版本 | 以官方最新稳定版为准 | 写文章时 2.6 是大版本,但实际落地要确认当前版本 |
| Embedding 模型 | 先选一个中文效果稳定的模型 | 比如常见开源 Embedding 模型或 API 模型,输出维度要记下来 |
| Python 版本 | Python 3.9+ | 兼容性更好 |
| SDK | 安装pymilvus | 以 Milvus 官方 SDK 为准 |
如果是本地验证,Docker Compose 启动是成本最低的方式:
# 拉取项目仓库或配置文件后启动 docker compose up -d具体配置文件以官方仓库为准,这里不展开。启动后检查端口19530是否监听,然后开始写 Python 脚本。
3.2 设计集合结构并创建集合
举个例子,假设我们有一套产品手册,需要按文档类型过滤。我们先连接 Milvus,创建集合。
from pymilvus import ( connections, CollectionSchema, FieldSchema, DataType, Collection, utility ) connections.connect(host="localhost", port="19530") files = [ FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False), FieldSchema(name="doc_id", dtype=DataType.VARCHAR, max_length=256), FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=65535), FieldSchema(name="doc_type", dtype=DataType.VARCHAR, max_length=64), FieldSchema(name="publish_date", dtype=DataType.INT64), FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1024), ] schema = CollectionSchema(files, description="rag_docs") collection_name = "doc_kb" if utility.has_collection(collection_name): collection = Collection(collection_name) else: collection = Collection(collection_name, schema)这里最需要确认的是dim=1024必须和 Embedding 模型输出维度一致。不同模型可能是 384、768、1024 或更高,不一致时插入数据会直接报错。
3.3 文档切块和向量化
文档切块是另外一个非常重要的话题,后面专门讲。现在我们先假设已经得到若干文本块,每个文本块对应一行记录。
from sentence_transformers import SentenceTransformer model = SentenceTransformer("BAAI/bge-m3") # 仅示例,具体模型按实际情况选择 chunks = [ {"doc_id": "manual_001", "content": "系统登录支持账号密码和 SSO 两种方式。", "doc_type": "manual", "publish_date": 20240101}, {"doc_id": "manual_001", "content": "调用鉴权接口时,需要先将 token 放入 Header。", "doc_type": "manual", "publish_date": 20240101}, ] batch_embeddings = [] for chunk in chunks: vector = model.encode(chunk["content"]).tolist() batch_embeddings.append({ **chunk, "embedding": vector, }) collection.insert(batch_embeddings) collection.flush() print(collection.num_entities)这里有一个工程常识:不要逐条插入和flush,尤其数据量大时性能会很难看。正确的做法是积攒一批向量,批量插入;需要可见时再 flush,或者用定期脚本统一刷新。
3.4 检索并拼接上下文
检索时,先做查询向量化,再调用search接口,最后把返回的content拼接到上下文里。
question = "如何获取鉴权 token?" q_vector = model.encode(question).tolist() collection.load() results = collection.search( data=[q_vector], anns_field="embedding", param={"metric_type": "COSINE", "params": {"nprobe": 16}}, limit=5, expr='doc_type == "manual"', output_fields=["doc_id", "content", "publish_date"], )返回结果中,每条记录有id、distance和entity。entity里可以看到content等字段。再把content按顺序拼接,生成最终的大模型提示词。
context = "\n\n".join( hit.entity.get("content") for hit in results[0] ) prompt = f"请基于以下资料回答问题:\n\n{context}\n\n问题:{question}"到这一步,最小的 RAG 链路已经跑通了。
3.5 这段流程最容易踩的几个坑
第一,忘记load()。创建索引后必须先把集合加载进内存,才能执行查询。否则会报“collection not loaded”或类似错误。
第二,向量维度不匹配。常见来源是换了一个 Embedding 模型但没改dim。
第三,COSINE 归一化没确认。可以打印两个向量直接计算余弦相似度,和 Milvus 返回的distance对比,如果出现明显偏差,就要检查模型是否归一化、距离度量是否一致。
第四,limit不是越大越好。RAG 场景里,检索 5 到 10 块文本通常已经足够,超出后不仅上下文过长,还会引入噪声,大模型反而更容易答偏。
注意:先跑通“单条查询”再处理批量任务,不要一上来就把所有文档灌进去。先确认 3 条测试数据能查到,再扩展到全量。
4. 文档切块:决定 RAG 质量的上限,但最容易被低估
4.1 切块到底在做什么
向量化之前要把长文档切块,这是 RAG 链路里最影响效果但最容易被忽略的环节。
切块的目的不是简单地把文件按长度截断。它要做的是:尽量保证每个块是一个语义完整、可独立理解的知识单元。
为什么不能直接把整篇文档塞给模型?因为当文档超过模型上下文限制,或者里面包含多个互不相关的话题时,检索系统很难返回“最相关的那段”。反过来,如果切得太碎,每一块包含的信息量太少,模型可能缺少上下文。
4.2 常见切块策略对比
| 策略 | 做法 | 适合场景 | 风险 |
|---|---|---|---|
| 固定长度切块 | 按 token 或字符数切,带重叠 | 通用场景,快速验证 | 容易截断句子,语义断裂 |
| 递归字符切块 | 先按段落分,再处理大块 | Markdown、纯文本 | 需要按文档类型调整分隔符 |
| 按文档结构切块 | 根据标题、章节、表格切分 | PDF、Word、Markdown | 依赖解析质量 |
| 父子块 | 父块保存更大语义,子块用于检索 | 长文档问答 | 实现复杂度高,索引量更大 |
| 语义切块 | 用 Embedding 判断边界 | 内容主题跳跃明显 | 计算成本高,边界仍不确定 |
从我自己的经验看,大多数 RAG 项目不需要一开始就用高深的语义切块。先把固定长度 + 重叠跑通,再观察哪些文档类型表现差,再针对性地改成结构切块或父子块,这个顺序更稳。
如果用递归字符切块,常见配置是:
from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?"], )注意,chunk_size是按字符还是 token,不同库定义不同;而且不同语言、不同文档结构的最佳值差异很大。与其抄一个参数,不如先抽 20 条有代表性的文档人工验证切割结果,看有没有把关键句子拆断。
4.3 切块质量如何验证
一个很实用的验证方法:把切完的块打印出来,随机抽取,问三个问题:
- 这个块里的信息是否完整?是不是一句话被截成两半?
- 这个块的主题是否单一?如果一段包含三个不同的功能点,检索时很容易“答非所问”;
- 如果只靠这一个块回答用户问题,上下文够不够?
很多人会在这里偷懒,原因是很直接:切块结果肉眼看起来似乎没毛病。但检索系统的问题往往出现在边界,比如一个流程的“步骤 2”被切断,或者某个表格被截断成一个残缺的竖列。这类问题是提示词工程解决不了的。
还有一个点值得注意:切块时最好在元数据里保留来源信息,比如page_index、doc_id、标题路径。当大模型引用错误内容或用户质疑结果时,系统能快速定位到原始文档。这也是企业级 RAG 和 Demo 的重要区别。
5. 从 Demo 到企业项目:还需要补的工程拼图
5.1 增量更新与数据一致性
做好一次全量导入并不难,难的是每天都有新文档、文档有修改、文档有删除。这时你需要一个“内容管线”:
- 检测源文件是否变化;
- 对新增文件切块并向量化;
- 对修改过的文档,删除旧块,插入新块;
- 对已删除的文档,级联清理向量数据;
- 定期重建部分索引,处理碎片。
Milvus 支持按表达式删除数据,比如根据doc_id删除:
collection.delete(f'doc_id in ["manual_001"]')但删除之后要意识到一个问题:删除操作不会立刻重排所有索引,数据段会有碎片。如果频繁增删,性能可能会退化,需要定期执行 compact 合并数据段。这个操作在生产环境中要有运维计划,不能等到查询慢到不可忍受才处理。
5.2 权限过滤与租户隔离
RAG 系统一旦对外开放使用,权限和租户隔离就不可避免。
有两种常见思路:
- 所有文档存在同一个集合,通过标量字段做过滤;
- 每个租户或部门用独立 Collection 或 Partition。
第一种方式优点是一条查询路径,缺点是过滤条件一旦写错,可能发生越权;第二种方式隔离更彻底,但需要管理更多集合,资源占用更大。
在多数中大型项目里,我更建议先按业务域做 Partition,再在 Partition 内部用字段过滤。比如按部门分区,查询时先锁定分区,再通过department字段进一步过滤。这样即使过滤条件漏掉,横向越权的大面积泄露风险也会更低。
5.3 可观测性:不能让向量库变成“黑盒”
企业项目里,你迟早会遇到“用户说检索不到”的问题。如果系统没有日志和指标,排查会非常痛苦。
至少要采集这几类信息:
- 查询耗时、召回数量、候选数量;
distance分布,判断相似度得分整体是偏高还是偏低;- Collection 的实体数量、索引状态、segment 数量;
- 导入任务的成功率、失败原因;
- 过滤条件是否命中预期的分区。
有一个很容易被忽视的监控点:相似度阈值。很多团队没有记录每次查询的相似度分数,一旦用户反馈“结果不对”,只能重新运行脚本猜测。如果从一开始就把distance写进日志,就能很快发现“结果不相关是因为相似度普遍偏低”还是“ 过滤条件写错导致排除了正确结果”。
5.4 混合检索与重排:不是每个项目都需要,但值得知道
典型问题:用户问“怎么配置 Nginx 反向代理”,向量检索可能召回一些语义接近但不精确的内容。因为纯向量检索对“专有名词、编号、代码片段”这类精确匹配并不敏感。
解决思路是做混合检索:向量检索负责语义召回,关键词/全文检索负责精确匹配,再用 RRF(Reciprocal Rank Fusion)或重排模型把结果融合排序。在一些需要精确引用的场景,比如产品文档、法务条款、代码示例,混合检索的效果通常比纯向量好。
不过混合检索会引入更多组件,比如一个全文搜索引擎或者额外的索引。如果数据量不大、问题以开放式问答为主,纯向量也够用。我的建议是“先有监控,再做增强”:等确实出现召回不足的案例,再决定是否上混合检索。
5.5 一个可复用的落地顺序
把前面的经验收束成一个框架,我通常建议按下面顺序推进:
- 先做 20 条数据的“最小链路验证”,确认能插入、能检索、能拼上下文;
- 再选 200 条业务真实文档,人工检查切块质量和检索结果;
- 设计元数据字段、权限过滤口径、更新策略;
- 跑通全量导入,记录耗时和失败率;
- 上线后只做小流量验证,同时记录查询日志和召回分数;
- 根据日志暴露的问题,反向调整切块策略、过滤条件和索引参数。
这个顺序的原则是:不要在一开始追求完整的企业级架构,但也不要带着 Demo 心态上线。先让链路可控,再逐步补齐工程能力。
6. 常见问题排查链路
6.1 检索不到任何数据,先判断是哪一层断了
遇到“查不到结果”,不要立刻怀疑向量数据库。按顺序排查:
- 检查集合里有没有数据:
collection.num_entities是否为 0; - 检查
flush()是否执行过:Milvus 中数据插入后会先进入内存,未 flush 时有些查询可能看不到; - 检查向量维度:插入时报错维度不匹配,查询时可能报错;
- 检查
expr过滤条件:如果元数据字段名或值写错,过滤会清空结果; - 检查相似度阈值:如果阈值设置得过于苛刻,比如要求
distance < 0.1,但真实数据普遍在 0.3 左右,也会查不到; - 检查模型是否加载:没有调用
load()或load()失败,查询也会失败。
这套顺序的关键是“先看数据,再看条件,最后看阈值”。很多新手第一反应是调索引参数,结果数据根本没进去。
6.2 检索到了,但结果明显不相关
这是更棘手的问题,因为链路通了,但质量不对。我一般按这个顺序排查:
| 可能原因 | 检查方式 | 优先调整方向 |
|---|---|---|
| 切块粒度不合适 | 打印几个检索命中块,看语义是否完整 | 调整 chunk_size 或换结构切块 |
| 查询词本身太复杂 | 拆成子问题测试 | 考虑 query 改写或分解 |
| Embedding 模型不适合领域 | 在领域文档上做召回人工评测 | 换模型或对比多种模型 |
| 过滤条件过强 | 去掉过滤条件对比结果 | 调整权限过滤口径 |
| 相似度计算方式不符 | 检查 metric_type 和归一化 | 统一 COSINE 或根据模型调整 |
另外一个很常见的原因是索引参数和查询参数不匹配。比如索引用的 IVF_FLAT 但查询参数里缺失nprobe,或索引构建时nlist设置得过小,在小数据集上导致聚类效果不好。遇到这种情况,先用 FLAT 索引做一个基线:如果 FLAT 下召回明显更好,说明问题大概率出在近似索引参数上。
6.3 查询变慢,不是“加机器”能直接解决的
查询变慢的原因通常有这么几类:
- 数据量增长,但没有重新 compact,segment 碎片太多;
- 查询中使用了不带索引的标量字段,实际上在做全量扫描;
expr过滤条件没命中分区,导致跨 segment 扫描;- 并发高,节点资源已经打满;
- 索引类型和查询参数不匹配,导致搜索范围过大。
排查顺序建议:先看查询日志的耗时分位数,再看节点 CPU 和内存,再看 segment 数量,然后看慢查询对应的过滤表达式。不要一慢就加节点,先把资源浪费的根源找到。
Milvus 提供了一些性能监控指标,比如查询耗时、数据段数量、索引构建进度等。项目上线前,就把这些指标接到告警平台,否则出了问题只能靠猜。
提醒:生产环境里,任何“优化”都要先记录改动前后的查询日志。没有基线,优化就是碰运气。
写在最后:工具最终为流程服务
从 Milvus 2.6 的选型,到 RAG 的完整落地,我一直警惕一件事:不要为了用一个更“重”的组件而制造复杂。
Milvus 2.6 的价值,只有在“知识量增长、检索条件复杂、需要长期维护”的背景下才会显现。如果你的项目还处在验证阶段,用轻量方案完全合理;如果你的知识库已经进入生产,或者即将承接核心业务,那么从数据模型、索引参数、元数据设计、监控日志这些角度提前规划,才是更稳妥的做法。
这篇文章里提到的最有价值的方法,不是某个代码片段,而是一套推进节奏:先跑通最小链路,再验证真实数据的切块和召回,再补权限和监控,最后根据日志反向迭代。这套顺序适用于大多数 RAG 项目,也适用于你下一次接到的知识库需求。
把向量数据库当成“长期记忆系统”,把 RAG 当成“记忆与生成之间的协作流程”,很多选择就变得清晰了。剩下的,是持续迭代。