这周刚把一个知识库问答项目的召回模块重新做了一遍,核心就是把Milvus向量数据库的混合检索能力真正用起来。之前我们只做纯向量检索,TopK一直加,可用户问“上个月华东区报修的打印机型号”这种带条件的问题时,结果总是不对。后来改成Milvus里的向量加标量混合检索,再叠加LangChain4j多路召回,整体召回率从62%提到了88%,线上误报率也降了不少。这篇就围绕这个案例,把从环境搭建到召回调优的完整过程记下来,给正在做向量数据库选型或者被召回率折磨的团队参考。
先交代一下项目背景。我们做的是一个企业内部知识库问答系统,文档经过切片之后全部灌进向量数据库,用户提问之后返回候选片段,再交给大模型生成答案。早期版本用的检索链路很朴素:Embedding模型算向量,Milvus里按余弦相似度召回TopK,把结果直接拼进Prompt。这种方案看起来简单,实际上非常挑问题类型,尤其是当问题里出现了部门、区域、时间、型号这些结构化约束时,TopK涨到20也救不回来。
1. 案例背景与核心需求拆解
1.1 纯向量检索的盲区在哪里
当时的检索链路是:用户问题先做Embedding,然后去Milvus里找向量距离最近的K条文本,再把文本片段送给大模型。这里用的距离度量就是常说的milvus余弦值,也就是metric_type设置成COSINE。语义相近的问题确实能召回,比如“报销流程是什么”和“报销单据怎么走”能匹配上。但问题一旦带上条件,纯向量检索就会出问题。
举个实际例子,用户问“上个月华东区报修的打印机型号有哪些”。这句话里,“上个月”是时间约束,“华东区”是区域约束,“打印机”是对象类型,“型号”是要求返回的字段。可向量检索算的是整句语义的相似度,它对“上个月”和“华东区”这种结构化条件没有感知。结果返回的前几条可能是其他区域的打印机维护记录,也可能是型号之外的耗材说明,单看语义都沾边,但就是不是用户要的那条答案。我们把TopK从5调到10,再到20,结果反而更差,因为召回了一大堆“看起来相关”的噪声片段,大模型的上下文被污染,答非所问的情况更严重了。
1.2 这次优化要解决什么
我们把问题拆成了三个可量化的目标。第一,建立一个带条件的评测集,不是随便找几十条问题拍脑袋测,而是让业务方整理了400条真实用户问题,每条都标注了标准答案ID和涉及的强条件字段。第二,TopK固定为10的情况下,召回率要从62%提升到至少85%,召回率按标准答案是否完整出现在返回结果里计算。第三,返回结果的排序要合理,正确答案的中位数排名不大于3,这样大模型才有更大几率从靠前位置读到正确答案。
这三个目标本质上是同一个问题:怎么在语义匹配之外,把业务条件也拉进检索过程。只靠向量算相似度做不到,所以必须上混合检索,也就是Milvus里向量字段和标量字段一起参与检索。另外,单靠向量检索本身也有盲区,关键词完全匹配、业务规则过滤这些路子也得补上,于是又引入了多路召回。这个思路和推荐系统里的多路召回很像,后面会详细说。
2. 环境搭建与向量数据库选型
2.1 为什么最终选了 Milvus
做选型之前我们对比了好几个方案,包括Elasticsearch、Faiss、Qdrant和Milvus。单纯比向量检索性能,Faiss和Milvus差别不大,但我们要的是混合检索,这个需求直接排除了纯ANN库。Elasticsearch的向量检索能力这些年进步很大,也能做过滤,但它在过滤条件复杂、数据量大时,性能衰减比专用向量库明显,而且我们已经有专门的关键词搜索服务,不希望ES同时扛两套重活。Qdrant也很优秀,Rust写的,性能好,但当时我们的运维体系对Kubernetes部署更熟,Milvus的分布式组件和Helm Chart更贴合现有环境。
最终选Milvus最核心的原因有三个。第一,它原生支持标量字段索引和布尔表达式过滤,可以在一次search里把向量相似度和SQL样式的过滤条件同时下推,这是混合检索的基础。第二,索引类型丰富,向量索引支持HNSW、IVF_FLAT、DISKANN,标量索引支持倒排、TRIE、STL_SORT,组合起来很灵活。第三,生态完整,有Python和Java的官方SDK,正好和我们Java后端对接。最近看到华为云码道检视修复智能体的案例分享,他们在代码缺陷检视场景里通过多路召回和重排把召回率做到了91.3%,虽然场景是代码不是文档,但验证了一个结论:召回率想突破,不能只靠单路向量,Milvus这类能支撑多路召回和混合检索的底座很关键。
2.2 在 Mac 上使用 Docker 安装 Milvus
开发环境用的是MacBook,最早我想偷懒直接用Milvus Lite,传一个.db文件路径就完事,但后来发现它不支持部分索引类型,也没法模拟生产环境的分布式行为,所以老老实实按官方标准用Docker跑单机Standalone。Milvus单机版依赖etcd和MinIO,分别负责元数据存储和日志/对象存储,完整起一套需要三个容器。我把docker-compose.yml精简了一下,本机测试够用。
services: etcd: image: quay.io/coreos/etcd:v3.5.5 environment: - ETCD_AUTO_COMPACTION_MODE=revision - ETCD_AUTO_COMPACTION_RETENTION=1000 - ETCD_QUOTA_BACKEND_BYTES=4294967296 volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls=http://0.0.0.0:2379 --data-dir /etcd minio: image: minio/minio:RELEASE.2023-03-20T20-16-18Z environment: MINIO_ACCESS_KEY: minioadmin MINIO_SECRET_KEY: minioadmin volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data command: minio server /minio_data standalone: image: milvusdb/milvus:v2.4.22 command: ["milvus", "run", "standalone"] environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 ports: - "19530:19530" - "9091:9091" volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus depends_on: - etcd - minio在这个目录下执行docker compose up -d,等三四个容器都起来后再检查连接。需要注意的是,我本机是Apple Silicon芯片,Docker Desktop内存设置至少给了8GB,否则Milvus Standalone启动时容易OOM,日志里会出现etcdserver: mvcc: database space exceeded这类误导性的报错。连接测试用一段简单的Python脚本就可以:
from pymilvus import connections, utility connections.connect(alias="default", host="127.0.0.1", port="19530") print(utility.get_server_version())能打印出版本号,说明Milvus服务已经可用了。生产环境我们后来部署在Linux服务器上,Compose文件基本没变,只是把MinIO的地址改成了内网域名,并加了数据卷的定期备份和监控。Milvus安装本身不复杂,复杂的是安装完之后怎么把表和索引设计对,这个坑比安装过程大得多。
2.3 本地模式与服务器部署的差异
这里额外说一个选型时的细节。Milvus有两种落地形态,一种是上面说的Standalone/分布式服务,另一种是Milvus Lite内嵌模式。后者直接通过本地文件路径启动,比如MilvusClient(uri="./data/milvus.db"),适合原型验证和小数据量场景。但它不是完整版,很多生产特性不支持,比如复杂的角色权限、多副本、部分索引类型和动态Schema都不太全。所以我们在开发环境用Docker跑完整版,和生产环境保持一致,避免“本地能跑,一上服务器就崩”的尴尬。
服务器Linux部署还有一个容易踩的点:容器里的工作目录和宿主机不一致。如果直接把宿主机上的相对路径挂载进容器,容易出现数据写不进预期目录的问题。我们当时的做法是统一用${DOCKER_VOLUME_DIRECTORY}环境变量指定宿主机绝对路径,这样本地和服务器用同一套Compose文件,只是环境变量不同。后面第5节会专门讲本地加载milvus.db时出现的一个典型报错,原理也和路径、容器隔离有关。
3. 混合检索实现与核心参数调优
3.1 Schema 设计:把业务条件变成标量字段
混合检索的第一步是设计好Collection的Schema。我们原来的表只有id、content、embedding三个字段,相当于把业务条件全丢掉了。改造后,我们把文档的基础属性都抽成独立字段,方便在检索时做过滤。最终Schema长这样:
from pymilvus import CollectionSchema, FieldSchema, DataType, Collection, utility fields = [ FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False), FieldSchema(name="title", dtype=DataType.VARCHAR, max_length=512), FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=8192), FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768), FieldSchema(name="department", dtype=DataType.VARCHAR, max_length=128), FieldSchema(name="region", dtype=DataType.VARCHAR, max_length=128), FieldSchema(name="model", dtype=DataType.VARCHAR, max_length=128), FieldSchema(name="create_ts", dtype=DataType.INT64), ] schema = CollectionSchema(fields, description="knowledge base docs") collection = Collection(name="kb_docs", schema=schema) collection.create_index("embedding", { "index_type": "HNSW", "metric_type": "COSINE", "params": {"M": 16, "efConstruction": 256} }) collection.create_index("department", {"index_type": "TRIE"}) collection.create_index("region", {"index_type": "TRIE"}) collection.create_index("create_ts", {"index_type": "STL_SORT"})这里的核心思想是:文档切片后不仅存文本,还要把这篇文档属于哪个部门、覆盖哪个区域、涉及什么型号、什么时间上线都作为标量字段存进去。这样用户问“华东区”的时候,问题里抽取出的条件可以直接映射到region == "华东"这个表达式。不少人做RAG时会忽略这一步,认为Embedding模型能理解一切,但实际上Embedding再强,也没有显式的字段过滤精确。时间字段用INT64存Unix时间戳,用STL_SORT索引,这样后面做“上个月”这种时间范围过滤时效率高很多。
3.2 向量加标量:两种检索姿势的取舍
Milvus支持在一次search里同时传向量和过滤表达式,但过滤条件放前面还是放后面,对结果影响很大。我们实践中试过两种姿势,各有适用场景。第一种是先向量召回再内存过滤,也就是搜索时不带表达式,先把TopK加大到200,然后把不符合业务条件的记录在应用层过滤掉。这种做法的好处是向量索引的检索范围是全量数据,不会因为过滤条件把潜在候选卡掉;坏处是如果过滤条件的选择性很强,比如“华中大区某型号”,Top200里可能压根没有符合条件的数据,过滤完就什么都没了。
第二种是检索时直接下推表达式,让Milvus先在满足条件的子集里做向量搜索。这种方式适合硬条件,比如部门、区域、时间范围,因为这些条件一旦不满足,答案就是错的,没有讨价还价的余地。我们最终选的是第二种为主,遇到一些“软条件”再做补充。实际搜索代码大概是这样的:
results = collection.search( data=[query_embedding], anns_field="embedding", param={"metric_type": "COSINE", "params": {"ef": 128}}, limit=10, expr="department == '销售' and region == '华东' and create_ts >= 1700000000", output_fields=["id", "title", "content", "department", "region", "create_ts"] )注意expr里的字符串字段必须用单引号包起来,时间戳是INT64所以直接比较数字。这里的metric_type是COSINE,Milvus内部会自动对向量做归一化,所以写入Milvus的Embedding和查询时的Embedding最好来自同一个模型,否则余弦值的分布会漂移。如果你们用的是其他距离度量,比如IP内积或L2欧氏距离,阈值和索引参数都要跟着调整,不能拿着COSINE的经验硬套。
3.3 召回率调优:别再无脑加大 TopK
把混合检索跑通之后,召回率从62%到了71%,但还不够。于是我们把注意力放在索引参数和TopK上。HNSW索引有两个关键参数:M控制每个节点的最大连接数,efConstruction控制建索引时的动态列表大小,查询时另一个参数ef控制搜索宽度。ef越大,召回越全,但耗时也越高。我们做了一组对比测试,固定TopK=10,用400条测试集跑:
| ef值 | 召回率 | 单次查询耗时 |
|---|---|---|
| 64 | 71% | 约12ms |
| 128 | 78% | 约18ms |
| 256 | 82% | 约27ms |
| 512 | 83% | 约45ms |
召回率从71%到82%,但ef从128加到256,耗时增加了50%,到512之后收益就很小了,所以线上我们取ef=256。这个结果说明一个问题:不要无脑加大TopK或者盲目堆索引参数,先定量测一版,找到曲线拐点。TopK也一样,我们试过TopK=20,召回率只涨了2%,但大模型要处理的上下文翻倍,回答质量反而下降。后来我们把精力放在多路召回和重排上,效果比特么堆参数明显得多。
4. 多路召回与 LangChain4j 集成
4.1 多路召回:从推荐系统借鉴的套路
“多路召回”这个词在推荐系统里早就不是新鲜事了。做推荐的团队经常会同时跑向量召回、双塔召回、物品协同过滤、热度召回,甚至还有像SWING这样的图算法召回。SWING算法的核心是利用用户行为图计算物品之间的相似度,它的思路和向量召回完全不同,向量看重语义,SWING看重共同行为关系。多路召回的意义就在于:每一路信号都有自己的盲区,向量召回擅长语义相似,但不擅长精确匹配;关键词召回擅长精确命中,但不理解同义改写;元数据过滤擅长处理硬条件,但需要先把条件抽取出来。走完多路召回之后再统一合并排序,比任何单一路单独跑都稳。
我们把这个思路套到RAG场景里,设计了三条检索路。第一路是Milvus混合检索,传入向量和结构化过滤表达式,主攻语义相关加业务硬条件。第二路是关键词检索,把用户问题里的专有名词、型号名、部门名抽出来,在标题和内容字段上做倒排匹配,主攻精确命中。第三路是元数据规则匹配,从问题里抽取时间、区域、部门等实体,直接查数据库或Milvus标量字段,主攻“上个月”“华东区”这类强约束。三条路召回的结果合并之后,再做一次重排,最终TopK送给大模型。
4.2 LangChain4j 的 Retriever 集成
我们的后端服务是Java技术栈,所以选了LangChain4j来做LLM应用层编排。LangChain4j提供了ContentRetriever接口,我们可以自定义多路召回的合并逻辑。它的MilvusEmbeddingStore封装了Milvus客户端,基本配置方式如下:
MilvusEmbeddingStore milvusStore = MilvusEmbeddingStore.builder() .uri("http://127.0.0.1:19530") .collectionName("kb_docs") .dimension(768) .build();不过MilvusEmbeddingStore默认封装的检索能力偏简单,直接用它做复杂表达式过滤不方便。所以我们没有把全部逻辑压在这个类上,而是自己写了一个HybridContentRetriever,实现ContentRetriever接口,在多路召回合并的逻辑里手动调用Milvus Java SDK和倒排索引服务。核心流程就是先分别拿到三路结果,然后用RRF算法合并排序。下面这段是合并排序的核心逻辑:
public List<Content> mergeByRRF(List<ScoredContent>... paths) { Map<String, Double> scoreMap = new HashMap<>(); Map<String, ScoredContent> contentMap = new HashMap<>(); int k = 60; for (List<ScoredContent> path : paths) { for (int rank = 0; rank < path.size(); rank++) { ScoredContent item = path.get(rank); String id = item.id(); scoreMap.merge(id, 1.0 / (k + rank + 1), Double::sum); contentMap.putIfAbsent(id, item); } } return scoreMap.entrySet().stream() .sorted(Map.Entry.<String, Double>comparingByValue().reversed()) .limit(10) .map(e -> contentMap.get(e.getKey())) .toList(); }RRF公式是score = sum(1 / (k + rank)),它不看各路分数的绝对值,只看排名,所以天然规避了向量分数和BM25分数量纲不一致的问题。k一般取60,实测效果比较稳。这一套做完,召回率从82%涨到了88%,虽然提升幅度不是最大的,但它是唯一一个不需要动Embedding模型和Milvus参数就能稳定增加召回率的手段。
4.3 结果合并与重排的细节
多路召回合并之后,还有一个容易被忽略的问题:重复内容去重。向量召回和关键词召回经常返回同一条内容,如果直接拼进Prompt,大模型会看到两遍一模一样的片段,浪费上下文不说,还可能干扰回答。我们在合并时以文档ID为key做了去重,同时保留每条内容来自哪一路的信息,方便后面调权重。
重排阶段我们先用RRF给了基础分,再加了两个业务规则。第一,如果用户问题中出现了时间范围词,比如“上个月”“最近三个月”,那么命中的时间字段落在范围内的记录统一加0.15分。第二,如果问题中出现了明确的区域词,且候选记录的region字段命中了,加0.1分。这两个规则看着土,但非常有效,因为RRF不感知业务语义,而时间、区域这些信息本身就是问题的核心约束。加上规则之后,正确答案的中位数排名从第5名提到了第2名,大模型的回答质量肉眼可见地稳了。
5. 常见问题与排查实录
5.1 本地加载 milvus.db 的坑
这个坑必须单独拿出来说,因为问的人太多了。有同事在Linux服务器上想用本地文件方式加载Milvus,代码里直接写milvus_uri: str = "./data/milvus.db",结果程序提示初始化失败,日志报错还截断了,只看到milv开头的几个字母,根本不知道是什么问题。
这里要先理清一个概念:MilvusClient(uri="./data/milvus.db")是Milvus Lite的使用方式,它会在本地创建一个SQLite风格的数据文件。这个模式不是完整版Milvus服务,不支持通过19530端口连接,也不能和Docker部署的Milvus混用。如果非要用Lite模式,请先确认pymilvus版本在2.4.2以上,且./data目录存在并有写权限。如果目录不存在,它会直接报Failed to open ...之类的错误。还有个常见问题:当前工作目录和代码目录不一致,相对路径解析错了。建议改写成绝对路径,或者先用pathlib把目录创建好。
如果你本来是想连接Docker里的Milvus,那就别用milvus.db这种本地文件路径,应该写MilvusClient(uri="http://127.0.0.1:19530")。跟在服务器上部署时类似,容器内的Milvus不会自动读取宿主机上的milvus.db文件,除非挂载数据卷。所以遇到这个报错,先反问自己一句:我现在到底用的是Server还是Lite?用对了模式,80%的问题都消失了。
5.2 召回结果不准的几个“元凶”
除了环境问题,召回结果不准基本都是下面的原因。第一,Collection忘了load()。Milvus的向量索引只有加载到内存之后才能被检索,很多新手在创建完索引后直接search,结果返回空。代码里要显式执行collection.load(),并可通过collection.load_progress()等待加载完成。第二,Embedding模型不一致。如果之前用text2vec-base-chinese生成了一批向量,后来换了bge-large-zh,新老数据全部混在一起,检索时不管COSINE阈值怎么调,结果都是乱的。必须统一Embedding模型并重新灌库。
第三,标量索引类型选错了。我们一开始给create_ts没有建索引,结果做时间范围过滤时特别慢,后来改成STL_SORT才好。第四,expr条件过严,比如写下region == "华东区"但库里存的是“华东”,匹配不上,需要先做实体归一化。第五,阈值设置不合理。很多人觉得余弦相似度0.8以上才算相关,实际Embedding模型不同,分数分布差别很大,我们项目里0.65就已经是高质量匹配了。最好先跑一批数据统计分数分布,再定阈值。
5.3 查询变慢与内存问题排查
Milvus单机版跑久了之后,最典型的问题是查询越来越慢。多数情况下不是索引失效,而是数据段Segment太多。Milvus数据是分Segment存储的,每次写入都可能产生新Segment,如果不做合并,查询时要扫描的Segment数量增多,性能自然下降。解决办法是定期执行collection.compact(),然后等get_compaction_state完成。
还有内存问题。HNSW索引是内存索引,数据量一大,加载之后占内存很可观。我们用8GB内存的测试机跑到300万条向量时,加载过程明显吃力。后来把不常用的旧数据单独放到一个Collection里,按需加载,才把压力降下来。并发的场景还要注意连接池,Java SDK默认连接数不高,压测时通过MilvusServiceClient的配置把最大连接数调大,能直接减少超时。
6. 项目复盘:从 62% 到 88% 我们做对了什么
6.1 关键优化项与收益
这轮优化不是某一项技术单点突破,而是多个手段叠加出来的结果。我把最终收益拆成了表,方便后面的人对齐预期:
| 优化项 | 做的事情 | 对召回率的贡献 |
|---|---|---|
| 混合检索 | 向量搜索中下推标量过滤表达式 | 62% → 71% |
| 索引参数调优 | 调整HNSW的ef,固定TopK=10 | 71% → 78% |
| 多路召回 | 增加倒排关键词路和元数据规则路 | 78% → 85% |
| 重排优化 | RRF合并后用业务规则加权 | 85% → 88% |
能看到最后的88%离我们一开始定的85%目标高一点,但离华为云码道检视修复智能体案例里提到的91.3%还有差距。不过那个场景是代码缺陷检视,有更明确的结构化特征和更多维度的静态分析信号,和纯文本知识库的召回难度不一样。我们学到的东西是通用的:召回率要突破,必须把“语义匹配”和“业务约束匹配”结合起来,用多路召回兜底,再用重排把正确答案顶上去。
6.2 真正的经验:评估集比算法更重要
最后说一个最想强调的经验。这次项目能做成,最大的功臣不是Milvus,不是LangChain4j,甚至不是某一个调参技巧,而是花了将近一周时间做的400条评测集。没有评测集的时候,所有人都凭感觉说“好像变好了”,一上评测集,就能看到哪一路召回在什么类型的问题上贡献大,哪些优化其实是自嗨。上线之后我们每周还会抽样新增问题,持续补充评测集,用来回归验证。
另外一个小手段是分层统计。只看整体召回率很容易被平均数蒙蔽,我们把“带强条件问题”和“开放性问题”分开统计,发现多路召回对带条件问题的提升很大,对开放性问题反而没什么帮助。这个结论直接影响了后续的优化方向,我们没有继续堆多路,而是开始换更强的Embedding模型。这里也想给同行提个建议:每次调完Milvus参数或多路召回权重,都分层看一眼结果,别只盯一个总数,否则很容易方向跑偏。