最近把个人日记系统做了一次比较大的重构,核心变化是引入了向量检索。原来我的日记搜索是纯关键词匹配,搜“端午那天心情很低落”这种带语义的查询基本无能为力,只能翻目录硬找。这次用 Milvus 搭了一套语义搜索,彻底把这个痛点解决了,顺手把从 Docker 部署到应用落地的完整过程记录下来。
这篇内容针对的是想用向量数据库做语义搜索、但又不太想啃官方文档的人。不管你是个人项目、独立开发者还是小团队,只要需要在文本、图片这种非结构化数据上做相似度检索,Milvus 都是目前性价比很高的方案。我用实际代码和数据链路说明白:它到底是什么、怎么部署、怎么从零跑通一个日记语义搜索。
1. 为什么是向量数据库:日记搜索的痛点与解法
1.1 关键词搜索记不住,语义才能“想起来”
传统数据库和 Elasticsearch 这类倒排索引方案,本质上是“字符匹配”。它要求你查询的词和文档里的词在字面上有交集。比如日记里写的是“今天加班到十点,整个人像被掏空”,你用关键词搜“疲惫”,大概率搜不到这条记录,因为“疲惫”这个字眼压根没出现过。
但对人脑来说,“加班到十点”和“疲惫”就是强关联。这就是语义搜索的典型场景:用户输入的是想法和感受,不是精确术语。
我一开始也考虑过用 SQL 的 LIKE 查询,或者直接在应用层做遍历匹配。翻了翻手头几万条日记,直接放弃了,遍历几万条文本再逐条做相似度计算,延迟会很难看,而且语义判断根本没法用规则实现。
后来把目光投向向量数据库,核心思路其实很简单:用深度学习模型把文本转成一个固定长度的数字向量,语义相近的文本在向量空间里距离也近。搜索的时候,把查询语句也转成向量,然后在库里找距离最近的若干条向量,返回对应的原文。
这就是为什么选向量数据库而不是继续堆关键词方案——人脑回忆日记是靠语义触发,不是靠字面匹配。
1.2 向量检索的直观原理
向量检索听起来高大上,其实理解起来没那么难。可以把每条日记想象成高维空间里的一个点,这个点的坐标就是模型生成的那一串数字。语义相近的文本,在空间里就挤在一起;语义不相关的,距离就远。
Milvus 在这里扮演的角色,就是一个专门处理海量高维向量的检索引擎。它内部用了一系列近似最近邻(ANN)算法,像 HNSW、IVF 这类,把“找最近邻”这件事的复杂度从暴力遍历的 O(n) 降到接近 O(log n) 级别。
一个生活化类比:几万条日记就是几万个住户,每条日记的向量是他们的家庭住址。你要找“住址最接近的邻居”,Milvus 先给你画好街区地图(索引),再按街区快速锁定目标,而不是挨家挨户敲门。
这个类比也能解释为什么索引设计很重要,我在后面会单独讲参数怎么调。
1.3 Milvus 和其他方案的取舍
当时手头还有几个备选,简单对比一下我为什么最终选了 Milvus。
| 方案 | 优势 | 短板 | 适合场景 |
|---|---|---|---|
| Milvus | 功能全、社区活跃、自带分布式能力 | 组件多,部署稍重 | 数据量大、对扩展性有要求的中长期项目 |
| Qdrant | 轻量,Rust 写性能好 | 生态相对小 | 快速原型、中小规模 |
| Chroma | 上手极快,代码量少 | 功能相对基础,性能上限有限 | 本地原型、学习 demo |
| Elasticsearch + 向量插件 | 复用已有 ES 技术栈 | 向量性能和数据量支持不够极致 | 已有 ES 为主的技术架构 |
我选 Milvus 很重要的一个原因是它提供了相对完整的客户端 SDK,Python 的 pymilvus 用起来顺手,而且和 LangChain 这类 RAG 框架的集成也很顺滑。对于个人项目来说短期可能有点“杀鸡用牛刀”,但考虑到日记数据会持续增长,后续也想把图片和语音笔记一起纳入语义搜索,Milvus 的可扩展性给我留了充足余量。
2. 技术选型与整体架构设计
2.1 嵌入模型的选择:中文场景我为什么用 text2vec
向量检索效果的底座其实不是数据库,而是生成向量的嵌入模型。模型不行,向量空间里的距离关系就是乱的,Milvus 再快也白搭。
中文日记场景下,我最终选了shibing624/text2vec-base-chinese,也就是 text2vec 系列。选它主要是三点考虑:一是中文语义理解效果好,在中文文本相似度任务上表现稳;二是模型体积适中,单机 CPU 也能跑,不需要 GPU;三是输出 768 维向量,检索精度和存储成本的平衡比较合理。
当然你也可以选其他的:
- 如果效果优先,机器也有 GPU,可以上
bge-large-zh - 如果追求轻量,
m3e-small也是不错的选择 - 如果日记里有大量英文,可以考虑
paraphrase-multilingual-MiniLM-L12-v2
需要特别强调一个坑:写入和查询用的嵌入模型必须是同一个,向量维度必须一致,否则检索结果就是乱的。我自己因为换过一次模型,维度从 768 变成 384,导致旧数据全部需要重建,这个成本在项目初期就要考虑进去。
2.2 集合字段与索引设计思路
Milvus 里的核心概念是“集合”(Collection),类比关系型数据库里的表。设计集合结构时,我采用了这样的字段规划:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT64 | 主键,对应日记的自增 ID |
| content | VARCHAR | 日记原文,用于结果展示 |
| mood | VARCHAR | 可选标签字段,按心情筛选 |
| created_at | INT64 | 时间戳,支持按时间范围过滤 |
| embedding | FLOAT_VECTOR | 向量字段,维度 768 |
这里有一个值得新手注意的设计细节:content原文存在数据库里,查出来直接展示用;embedding只是参与相似度计算,一般不会直接被人看懂。把原文和向量分离,既方便调试,也方便做后续的过滤查询。
索引方面,Milvus 2.4.x 默认推荐 HNSW(Hierarchical Navigable Small World)索引。HNSW 的优点是查询快、精度高,缺点是内存占用相对大、构建时间略长。对于日记这种百万级以下的数据规模,HNSW 是体验最好的选择,参数我用了M=16、efConstruction=200,这是准确率和构建速度都比较平衡的配置。
2.3 数据流向全景图
整个系统走通以后,数据流是这样一个链路:
文本日记 -> 嵌入模型(text2vec) -> 向量(768维) -> Milvus集合 查询语句 -> 同一个嵌入模型 -> 查询向量 -> Milvus ANN搜索 -> 相似日记注意这条链路里只有一个模型,查询和写入共用。任何一步换了模型,整个向量空间就变了,旧数据必须重建索引。
架构层面我用 Docker Compose 把 Milvus 拆成了三个组件:etcd(元数据存储)、MinIO(数据持久化)、Milvus standalone(核心引擎)。这是官方推荐的单机部署方式,既保留了分布式架构的组件形态,又能在个人电脑上跑起来,之后要扩展成集群也可以平滑迁移。
3. 环境准备与 Docker 部署 Milvus 单机版
3.1 部署前的资源预估与版本确认
Milvus 部署最关键的其实不是命令本身,而是部署前的资源规划。先说结论:单机部署 Milvus standalone + etcd + MinIO,最低 4GB 内存能跑,但建议 8GB 以上。
我实际跑下来,三个容器加起来内存占用大概在 2GB 到 3GB 左右,峰值会到 4GB。这还不算嵌入模型加载和向量检索时的内存开销。如果电脑是 8GB 内存的 Mac 或 Win 笔记本,建议 Docker 分配内存不小于 4GB。
版本选择上有一点要提前确认:Milvus 版本和 pymilvus 客户端版本、嵌入模型版本的兼容性。我现在用的是 Milvus 2.4.13 + pymilvus 2.4.x,这套组合比较稳定。尽量不要用最新的 2.5 当小白鼠,除非你有明确的需求点,否则等社区把坑填完再用更省心。
3.2 用 docker-compose 一键拉起 Milvus 集群
部署 Milvus 单机版,最稳妥的方式是直接使用官方提供的 docker-compose 文件。我不建议手动一个个容器启动,三个组件之间还有网络配置,手动操作容易漏。
我使用的 docker-compose.yml 核心内容如下(精简版):
version: '3.5' services: etcd: container_name: milvus-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://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd minio: container_name: milvus-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 healthcheck: test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] interval: 30s timeout: 20s retries: 3 standalone: container_name: milvus-standalone image: milvusdb/milvus:v2.4.13 command: ["milvus", "run", "standalone"] ports: - "19530:19530" - "9091:9091" volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus depends_on: - etcd - minio部署步骤就三行命令:
# 1. 在 docker-compose.yml 所在目录执行 docker-compose up -d # 2. 确认三个容器都正常启动 docker-compose ps # 3. 看 Milvus 容器日志确认启动完成 docker logs -f milvus-standalone第一次启动会拉取镜像,耗时取决于网络情况,大概五到十分钟。MinIO 容器有健康检查,Milvus 容器会一直等 etcd 和 MinIO 就绪后才真正启动,所以看到milvus-standalone还在启动中不要慌,等一会再看日志。
3.3 验证部署与最小写入测试
启动完成后,别急着写业务代码,先做一个最小化的连通性测试,确认 Milvus 服务正常。
安装 Python 客户端:
pip install pymilvus==2.4.*然后写一个最简单的脚本验证:
from pymilvus import connections, utility # 连接 Milvus 服务 connections.connect(alias="default", host="localhost", port="19530") # 检查连接状态 print("Milvus 连接状态:", utility.get_server_version())如果能正常打印出版本号,说明服务已经可用了,可以进入下一步。
4. 从部署到 AI 日记语义搜索的完整链路
4.1 日记数据建模与向量化写入
部署只是第一步,真正让系统“理解”语义的是数据写入链路。我整理了一套可供直接复用的流程。
先写一个嵌入模型的封装函数,负责把文本转成向量。我用的是text2vec-base-chinese,加载方式可以采用 sentence-transformers 库:
from sentence_transformers import SentenceTransformer model = SentenceTransformer('shibing624/text2vec-base-chinese') def embed_text(text: str) -> list: vec = model.encode(text, normalize_embeddings=True) return vec.tolist()注意这里有个细节:normalize_embeddings=True。归一化之后,向量内积和余弦相似度等价,检索时可以用 IP(内积)指标替代 COSINE,Milvus 上 IP 的检索性能更好。这个细节官方文档有提到,但很多人会忽略,影响到了实际查询速度。
接着在 Milvus 里创建集合:
from pymilvus import CollectionSchema, FieldSchema, DataType, Collection fields = [ FieldSchema(name="id", dtype=DataType.INT64, is_primary=True), FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=2000), FieldSchema(name="mood", dtype=DataType.VARCHAR, max_length=100), FieldSchema(name="created_at", dtype=DataType.INT64), FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768) ] schema = CollectionSchema(fields=fields, description="日记语义搜索集合") collection = Collection(name="diary_entries", schema=schema)把日记写入 Milvus:
import time from datetime import datetime def add_diary(content, mood): embedding = embed_text(content) row = { "id": int(time.time() * 1000), # 用时间戳作为 ID,粗略方案 "content": content, "mood": mood, "created_at": int(datetime.now().timestamp()), "embedding": embedding } collection.insert([row]) collection.flush()这里想提醒一个点:上例中collection.flush()很关键。Milvus 的 insert 操作是异步的,数据不会立刻变成可见状态,flush 才会把内存中的数据落盘分段,让后续搜索能查到。忘了 flush 是新手最容易碰到“写入成功但搜不到”的原因。不过也不能每条都 flush,频繁落盘会影响写入性能。我实际项目中是每凑够 100 条或者每隔 2 秒批量 flush 一次。
4.2 构建高效索引
集合刚创建时是“待索引”状态。没有索引时 Milvus 可以插入数据,但查询要么不支持要么极慢。所以数据写入后一定要建索引。
index_params = { "metric_type": "IP", # 内积检索,向量已归一化,等价于余弦相似度 "index_type": "HNSW", "params": {"M": 16, "efConstruction": 200} } collection.create_index(field_name="embedding", index_params=index_params)几个参数的选择逻辑说一下:
metric_type=IP:前面归一化后选内积,检索速度更快index_type=HNSW:百万级以下数据量的综合表现最优M=16:每个节点的最大连接数,越大召回率越高但内存和构建时间也增加efConstruction=200:索引构建时的动态列表大小,越大索引质量越高,构建越慢
建索引的过程需要一点时间,可以在代码里轮询索引状态:
collection.load()注意查数据前要collection.load(),把索引加载到内存。这个操作和建索引是两回事,刚建完索引不 load 的话,搜索时会报 “collection not loaded” 的错。
4.3 实现语义搜索查询
索引就绪后,最核心的功能就一行搜索调用:
collection.load() def semantic_search(query_text, top_k=5): query_vector = embed_text(query_text) results = collection.search( data=[query_vector], anns_field="embedding", param={"metric_type": "IP", "params": {"ef": 128}}, limit=top_k, output_fields=["content", "mood", "created_at"] ) hits = [] for hit in results[0]: hits.append({ "id": hit.id, "content": hit.entity.get("content"), "mood": hit.entity.get("mood"), "created_at": hit.entity.get("created_at"), "score": hit.score }) return hits这里param中的ef是查询时的动态候选集大小,值越大召回越准但延迟越高。128 是我测试下来比较均衡的值,如果追求速度可以调到 64,追求精度可以调到 256。
实际体验一下:
results = semantic_search("今天心情很低落,感觉做什么都没劲") for r in results: print(f"相似度: {r['score']:.4f}") print(f"内容: {r['content']}") print(f"心情: {r['mood']}") print("---")即使日记原文里写的是“整个人像被掏空了一样”,这个查询也能把它找出来,因为模型把它们映射到了相近的向量空间。这就是关键词搜索做不到的事情。
再加一层带条件的过滤查询,比如只搜某段时间、某个心情标签:
results = collection.search( data=[query_vector], anns_field="embedding", param={"metric_type": "IP", "params": {"ef": 128}}, limit=top_k, expr='mood == "低落"', output_fields=["content", "mood", "created_at"] )Milvus 支持在向量检索前先按标量字段过滤(即“混合检索”),这个能力让检索系统不再是单纯的“相似度排序”,而是能叠加业务规则,实用性一下子提升了一大截。
5. 常见问题与排查技巧实录
5.1 写入能成功但查不到数据
这是我在刚上手时耗时间最多的问题。现象是collection.insert()执行成功,但紧接着搜索返回空结果。
排查思路很简单:检查有没有flush()。Milvus 的写入流程是:写入 -> 内存 buffer -> flush 落盘 -> segment 可查。没有 flush,数据只在内存缓冲里,搜索看不到。
如果你用了批量写入后依然搜不到,再检查是不是忘记collection.load()。Milvus 设计里索引和 segment 加载到内存后才能真正进行向量计算,load没做,查询时会直接报错。
5.2 维度不一致、索引不生效等经典的坑
维度不一致是换嵌入模型后的典型问题。错误信息类似The dimension of query vector is not equal to the dimension of the field。这种问题只能强制统一模型和维度,别无他法。
索引不生效的表现是:数据量不大时搜着还行,数据量一大查询明显变慢。检查方法是查看集合的索引状态:
collection = Collection(name="diary_entries") print(collection.indexes)如果索引列表是空的,或者 index_type 和你设置的不一样,就要重新创建索引。另外,创建索引后一定要load(),否则索引没有加载到内存。
5.3 性能问题与资源占用排查
Milvus 单机版性能瓶颈通常在内存,而不是 CPU。HNSW 索引是把整个图结构加载到内存里的,数据量越大,内存占用越高。如果发现查询变慢,首先看内存使用率:
docker stats如果内存吃紧,可以考虑两个方向:
- 换成 IVF_FLAT 索引,牺牲一点查询速度换取更低内存占用
- 设置
resourceGroups或调整ef参数,但单机版下效果有限
另外,Milvus 运行时间久了会产生很多小 segment,查询性能会下降。可以做一次手动 compaction 合并小 segment:
collection.compact()这个操作把碎片化的数据文件合并,查询性能会明显改善。我一般按周跑一次 compaction,相当于给数据做一次“整理”。
5.4 部署与连接层面的坑
Windows 上部署最常碰到的是 Docker Desktop 虚拟化没开启,启动直接失败。这时候进 BIOS 开启虚拟化就好了。Mac 上一般比较顺畅,但注意 Docker Desktop 分配的内存不能太低。
连接不上 Milvus 时,先确认端口是不是通了:
nc -vz localhost 1953019530 是 gRPC 端口,如果 telnet 都通不了,大概率是容器没起来或者端口映射配错了。
还有个隐蔽但容易踩的问题:防火墙或杀毒软件拦截本地端口。有一次查了半天以为是 docker 网络问题,结果是 Windows 防火墙把 19530 给拦了,加入白名单就好。
写在最后的几个经验
这套日记语义搜索系统上线跑了几个月,说几个真实体验供参考。嵌入模型的选择和索引参数在项目初期就要果断定下来,中途换模型要重建全部向量,成本不低;数据规模如果在百万条以下,HNSW 加单机部署完全够用,不需要上分布式;搜索接口返回后,加一层基于规则的过滤比全交给向量搜更实用,比如敏感词过滤、时间范围限定等等。
Milvus 的部署和维护成本并没有想象中高,docker-compose 一把梭就能在个人项目里拥有工业级向量检索能力。目前我已经把图片标签、语音转文字后的文本陆续接进这个架构,数据量再上一个台阶后再来补一篇性能调优的实战记录。