☰
Weaviate向量检索实战:建模、混合检索与RAG调优
2026/11/2 19:35:45 网站建设 项目流程

在做向量检索这条路上,我前后用过不少方案,前四篇里聊了索引结构、嵌入模型、分片与召回评估这些底层话题,这一篇落到一个具体的产品上:Weaviate。之所以单独拿它出来讲,是因为在我接触过的几个真实项目里,它的定位一直有点"不纯粹"——它既是一个向量数据库,又带着一层"语义搜索服务"的意思,内置向量化、内置关键词检索、内置混合排序,甚至还能直接调生成式模型把答案吐出来。对于手里有一堆文档、想快速搭出一个"能问能搜"的系统的团队来说,这套组合省掉了大量胶水代码;但反过来,如果你只想找一个纯粹存向量的 KV 存储,它的这层"厚度"可能会让你觉得别扭。所以这篇文章不打算写成一份接口手册,而是按我自己落地的顺序,把 Weaviate 的设计意图、建模方式、部署细节、检索调参和踩过的坑一条条摊开讲。适合已经了解向量检索基本概念、准备动手搭一套语义检索或 RAG 链路的中高级开发者,也适合刚接手的、需要判断"这东西到底该不该上"的技术负责人。

1. 我为什么把 Weaviate 放进这套检索链路

1.1 从"能搜到"到"搜得准":一个真实的需求缺口

先说清楚我遇到的是个什么场景。内部有一批产品文档和工单记录,格式很杂:Markdown、PDF、网页剪藏、表格导出,加起来几十万条 chunk。最早的方案特别土——Elasticsearch 加一个 BM25,配上同义词词典和一堆手工规则。上线第一个月还挺好,用户搜"怎么导出报表"能出结果;三个月后就不行了,用户开始问"我想把数据拿出来做分析",BM25 匹配不到"导出""CSV"这些词,因为原文写的是"支持以文件形式落地"。这类词汇鸿沟是关键词检索的天花板,靠词典堆不出来。

后来的做法是加一层向量召回:把 chunk 用嵌入模型编码,扔进一个向量索引里,查询时先向量搜、再关键词搜,两路结果合并。这个思路本身没问题,但落到工程上有三个特别烦的地方。第一,向量和原文得自己维护两份存储,删一条文档要同步删两处,稍微异步一点就脏数据。第二,过滤条件很难受——用户经常要"只看 2023 年之后、某个产品线下的文档",向量索引本身不擅长带条件检索,要么预过滤把候选集切得很小导致召回崩掉,要么后过滤导致返回条数不够。第三,融合排序要自己写,向量距离和 BM25 分数根本不在一个量纲上,归一化怎么做全靠拍脑袋。

Weaviate 是在这个背景下被我翻出来的。它把对象、属性、倒排索引和向量索引放在同一套数据结构里,写入一个对象时,属性进倒排、向量进 HNSW,删除也是原子的一起删——这一条直接解掉了上面第一个和第二个痛点。至于第三个,它内置了 hybrid 查询和两种融合策略,虽然不一定完美,但至少提供了一个可调的、有明确语义的起点,比自己瞎凑强。

1.2 Weaviate 到底解决了什么:五个我要的能力

从我的使用体感来看,Weaviate 真正有价值的点集中在五处,按重要性排:

一是"对象即文档"的模型。每一行数据是一个对象,有 UUID、有一组属性、有一个向量。属性可以声明成可过滤、可全文搜索、可范围过滤,也可以什么都不建索引,纯粹当返回值用。这个粒度比"纯向量 + metadata"要精细,因为你能控制每个字段的索引代价,而不是一股脑全建。

二是向量化模块的可插拔。你可以让 Weaviate 自己调模型(本地 transformers 容器、外部 API 都行),也可以在写入时自己算好向量传进去,声明vectorizer: none即可。这个自由度很关键——很多团队嵌入模型是自己微调的,或者有统一的模型网关,不希望数据出去。三种模式我都用过:本地 transformers 部署简单但吃显存;外部 API 省事但有网络抖动和成本问题;自备向量最可控,代价是你得自己管模型版本和维度一致性。

三是混合检索开箱即用。hybrid查询里一个alpha参数控制向量和关键词的权重,还有rankedFusion和relativeScoreFusion两种融合方式。前者按排名倒数融合(类似 RRF),后者按归一化分数加权。这个我后面会专门讲怎么调,因为默认值不一定适合你的数据分布。

四是带过滤的向量检索做了专门优化。它区分了"高选择性过滤"和"低选择性过滤"两条路径,前者会先缩小候选集再在子集上做 HNSW 搜索,后者则走正常的图遍历。这个机制不完美,但比大多数自研方案要认真。

五是生成式模块的位置很合理。它不是在检索之后让你自己去拼 prompt,而是在查询语句里直接声明generate,把检索到的上下文喂给模型,返回一个带_additional.generate的结果。对做 RAG 原型的团队来说,这一步省掉的是整条后处理链路的调试时间。当然生产上我一般还是会自己接管这一层,原因后面说。

1.3 选型对比与它不适合的场景

我不想把它吹成万能解。有三类情况我会明确不建议用它:

第一类,纯向量存储、极低延迟、超大规模。如果你的场景就是"给一亿条向量做 ANN 检索,不要文本、不要过滤、不要融合",那 Weaviate 的对象模型和倒排索引对你来说是纯粹的额外开销,写入吞吐和磁盘占用都会比专用方案差一截。这种时候更轻的索引库或自建 HNSW 更划算。

第二类,强事务、强一致的关系型需求。它是搜索引擎的思维方式,不是数据库的思维方式。跨对象的引用只是"ID 指针",没有级联删除、没有外键约束、没有多表 join。你要做订单系统,别往里塞。

第三类,团队的运维能力很薄。它的集群模式涉及分片、副本、租户、节点发现,参数不少。单机 docker 跑起来十分钟,但要上一个稳定的三节点集群,还是需要有人懂它在干什么。尤其是内存规划,HNSW 的图结构是常驻内存的,参数拍错直接 OOM。

反过来说,如果你的场景是"文档/商品/图像的有语义搜索需求,同时又要按业务字段过滤,还想试试混合排序和 RAG",那它在这个区间里是相当顺手的选择。它的"厚度"在这个区间里正好是优势,不是负担。

2. 先把数据模型想清楚:Schema、类与向量化模块

2.1 Class/Property/Cross-reference 这套建模思路

Weaviate 里的数据模型叫 Schema,基本单位是Class(可以理解成表或者集合),Class 下面有Property,每个 Property 有数据类型和索引配置。对象之间可以建cross-reference,也就是属性类型声明成另一个 Class 的引用,一个对象可以指向多个对象。这套东西很像图数据库的弱化版,好处是查询时可以顺着引用往下取一层关联对象的字段。

建模上我踩过一个很典型的坑:一开始我把"文档"和"段落"建成一个 Class,段落对象里冗余存了文档标题、文档 ID、发布时间这些元数据。这样查询方便,但代价是每个 chunk 都重复了一遍元数据,几十万 chunk 下来,倒排索引膨胀得厉害,磁盘占用比预期高了三成。后来改成两个 Class,段落通过 cross-reference 指向文档,元数据只存一份,过滤时用where里的引用路径去筛,磁盘立刻降下来了,而且文档元数据更新只需要改一个对象,不用批量刷。

但这里要提醒一句:cross-reference 的过滤性能不如同层属性。它需要先解析引用关系再判断,路径越深越慢。我的经验是——高频过滤字段冗余在同层(宁可多占一点空间),低频的、展示用的关联信息才用引用。别为了"模型干净"把所有关系都抽出去,检索系统的第一优先级永远是查询效率。

另外 Class 名字和属性名一旦建好就不好改,改属性类型基本等于重建。所以建模前一定要把字段清单列清楚:哪些是要过滤的、哪些是要全文搜的、哪些只是返回展示的、哪些是要建范围的。这个分类决定了后面索引怎么配。

2.2 向量化模块:内置、外部与自备向量三条路

这是 Weaviate 最需要提前决策的一块。三条路我按场景分别说:

内置本地模块。以text2vec-transformers为代表,Weaviate 会在启动时另起一个推理容器,把文本送进去编码。优点是数据不出内网,延迟稳定,缺点是吃显存、冷启动慢、模型换起来要重建整个集合的向量。我在一个离线环境的项目里用过,模型是几百 MB 级别的中文句向量,单卡跑起来 QPS 还行,但并发一高就排队,最终还是要独立部署推理服务做负载均衡,那还不如直接走自备向量。

外部 API 模块。配置好密钥就能用,省心,适合原型阶段或者数据量不大的场景。问题在于三件事:网络抖动会直接变成写入失败;批量导入时 API 有速率限制,你得分批限流;成本随数据量线性增长,几十万条重刷一次向量是很肉疼的。我一般的做法是——原型期用外部 API 快速验证效果,等模型选型定下来,切到自备向量。

自备向量(vectorizer: none)。这是我在生产环境里的默认选择。流程是自己拿模型编码,写入时把向量一起传进去。好处是模型完全自主、可以随时对比不同模型、可以离线批量重算、可以做向量版本管理。代价是你要自己保证三件事:维度一致(同一 Class 内向量长度必须相同,中途换模型必须重建集合)、归一化方式一致(模型输出是 L2 归一化还是未归一化,直接决定你用 cosine 还是 dot)、以及写入链路里向量的生成和存储是原子的。

提示:vectorizer是 Class 级别的配置,一旦集合里已经有数据,改这个配置基本等于重建。所以第一次建集合时就要想清楚,别指望后期平滑切换。

关于距离度量,我通常这样选:模型输出已经归一化的,用cosine或者dot都行,dot在归一化向量上和 cosine 排序等价但算得更快;未归一化的用l2-squared。这里有个细节容易翻车——很多人换模型时只改了维度,忘了归一化方式也变了,结果召回质量莫名其妙掉一截,排查半天以为是索引参数问题。

2.3 属性索引与倒排索引的取舍

Weaviate 的每个属性可以单独控制三件事:要不要进倒排做全文搜索(index_searchable)、要不要进倒排做过滤(index_filterable)、要不要进范围索引(index_range_filters)。默认配置是"能建就建",这在数据量小的时候没感觉,数据量一大就是灾难。

我做过一次对比测试,同一个集合、同样的 30 万条 chunk,属性全建索引和只建必要索引,磁盘占用差了将近一倍,批量导入耗时差了大概 40%。原因很直白:每个可搜索属性都要单独维护一份倒排表,每个可过滤属性还要维护一份映射,写入时这些结构都得更新。

所以我的配置原则是:

  • 文本内容字段:如果要用 BM25 或 hybrid,必须index_searchable=true;如果只做向量检索、不做关键词召回,直接关掉,省一大块空间。
  • 业务 ID、枚举类字段:index_filterable=true,但index_searchable=false。注意index_searchable在较新版本里对文本属性同时影响过滤能力,具体行为按你的版本实测确认,别想当然。
  • 时间、价格这类数值:如果需要范围过滤(大于、小于、区间),开index_range_filters。
  • 纯展示字段(长文本摘要、URL、原文件路径):全部关掉,只做返回值。

倒排索引本身还有两个 BM25 参数可调:bm25_k1(词频饱和,默认 1.2)和bm25_b(长度归一化,默认 0.75)。中文场景下我经常把b调低一点,因为中文短句多,长度归一化太强会压制短文档的分数。这个没有标准答案,靠你的评估集去试。

3. 部署与首个可用实例:从零到能查询

3.1 单机 docker compose 最小配置

本地验证我从来不用集群,一个单机容器足够。下面这份是我常用的最小配置,跑起来就能用:

services: weaviate: image: semitechnologies/weaviate:1.25.6 restart: on-failure ports: - "8080:8080" # REST / GraphQL - "50051:50051" # gRPC,批量导入走这个 environment: PERSISTENCE_DATA_PATH: "/var/lib/weaviate" DEFAULT_VECTORIZER_MODULE: "none" ENABLE_MODULES: "" AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: "true" QUERY_DEFAULTS_LIMIT: "25" LOG_LEVEL: "info" volumes: - ./data:/var/lib/weaviate

几个参数值得解释为什么这么设。DEFAULT_VECTORIZER_MODULE设成none是刻意的——如果留空或者设成某个模块名,而你又没在ENABLE_MODULES里启用它,写入时会直接报错"没有可用的向量化模块"。这个报错信息不太直观,我在第一次部署时在这上面浪费了半小时。PERSISTENCE_DATA_PATH必须挂卷,否则容器一删数据全没。50051这个 gRPC 端口一定要暴露,因为批量导入的性能差异主要来自这里——同样的数据,走 REST 批量接口和走 gRPC 批量接口,耗时能差好几倍。

如果你的数据里有中文,还要注意容器内不要依赖默认的文本处理。Weaviate 的 BM25 分词对中文支持依赖具体版本的处理方式,实测下来中文短词的匹配效果一般,很多时候 hybrid 里关键词那一路的贡献不如预期。这个后面在排查章节会细说。

启动后访问http://localhost:8080/v1/meta能看到版本信息,/v1/.well-known/ready返回 200 就是就绪了。我习惯在脚本里轮询这个就绪接口再开始导入,不然第一条写入会失败。

3.2 客户端接入与 Schema 落地

Python 客户端在 v4 之后换了一套写法,Collections 成了核心对象,和早期的client.schema那套 API 差别不小。我这里按 v4 的写法给例子,如果你用的是老版本,接口对应关系要自己查一下。

先建集合:

import weaviate from weaviate.classes.config import Configure, Property, DataType from weaviate.classes.config import VectorDistances client = weaviate.connect_to_local(host="localhost", port=8080, grpc_port=50051) if client.collections.exists("DocChunk"): client.collections.delete("DocChunk") client.collections.create( name="DocChunk", vectorizer_config=Configure.Vectorizer.none(), vector_index_config=Configure.VectorIndex.hnsw( distance_metric=VectorDistances.COSINE, ef_construction=256, max_connections=32, ef=-1, dynamic_ef_min=100, dynamic_ef_max=500, dynamic_ef_factor=8, quantizer=Configure.VectorIndex.Quantizer.pq( segments=128, centroids=256, training_limit=100000, rescore_limit=200, ), ), properties=[ Property(name="content", data_type=DataType.TEXT, index_searchable=True), Property(name="doc_id", data_type=DataType.TEXT, index_filterable=True), Property(name="source", data_type=DataType.TEXT, index_filterable=True), Property(name="page", data_type=DataType.INT), Property(name="created_at", data_type=DataType.DATE, index_range_filters=True), ], inverted_index_config=Configure.inverted_index(bm25_b=0.6, bm25_k1=1.2), ) client.close()

这段配置对应的是 768 维向量、30 万到 100 万量级的场景。ef_construction=256比默认的 128 高,建索引慢一些但图的连通性更好,召回更稳;ef=-1表示查询时用动态 ef,具体值由limit × dynamic_ef_factor算出来再夹在dynamic_ef_min和dynamic_ef_max之间。这个动态机制挺聪明——你查 5 条和查 100 条,需要的搜索深度本来就不一样,固定 ef 要么浪费要么不够。

PQ 那段是为了控内存,后面第 5 节会算清楚账。bm25_b=0.6是我在中文语料上试出来的,比默认的 0.75 更适合短 chunk。

3.3 批量导入与向量生成链路

导入这一段是整个工程里最容易被低估的部分。单条写入看起来很简单,但几十万条一起灌的时候,串行写入的速度能让你等到怀疑人生。

我的做法是把向量生成和写入解耦:离线用模型批量编码,把(uuid, properties, vector)落成一个中间文件或者一个迭代器,然后用客户端的批量接口灌进去。

import uuid from weaviate.util import generate_uuid5 collection = client.collections.get("DocChunk") with collection.batch.fixed_size(batch_size=200) as batch: for row in rows: # row: {"content":..., "doc_id":..., "vector":[...]} obj_uuid = generate_uuid5(f"{row['doc_id']}#{row['page']}#{row['content'][:64]}") batch.add_object( properties={ "content": row["content"], "doc_id": row["doc_id"], "source": row["source"], "page": row["page"], "created_at": row["created_at"], }, uuid=obj_uuid, vector=row["vector"], ) failed = collection.batch.failed_objects if failed: print(f"失败 {len(failed)} 条,首条原因:{failed[0].message}")

几个实操要点。第一,自己算 UUID 并保持幂等。用内容 + 来源算一个确定性 UUID,重复导入同一批数据不会产生副本,重跑任务也不用先清库。这在大批量导入出错需要重试时特别重要。第二,批量大小别超过 200。太大会把服务端内存顶起来,太小则网络往返开销压过实际工作。200 这个数在我的环境里比较稳,你们按自己的对象大小调。第三,一定要检查failed_objects。批量接口对单条失败是静默跳过的,不检查就等于丢数据,而且丢得悄无声息。第四,把index_searchable关掉能明显加速导入,如果关键词检索不是刚需,先关掉导完再考虑要不要建。

关于导入期间的一致性,我一般会配置写入和索引的异步行为,让写入先返回、索引慢慢建完。好处是导入吞吐高,坏处是刚导完的一段时间内查询召回不全。生产上我会在导入完成后等索引稳定再切流量,别在导入进行时对外提供服务。

4. 检索这一层:向量、关键词与混合

4.1 nearVector / nearText / nearObject

Weaviate 的查询接口有两套:REST 和 GraphQL。GraphQL 是它的原生查询语言,表达力强一点;Python 客户端 v4 则封装成了方法调用,日常用起来更顺手。

最基础的三种近邻查询是:

  • near_vector:给你一个向量,找最近的若干对象。这是最可控的方式,向量你自己生成,模型版本、归一化、查询改写全在你手里。生产环境我九成以上的查询走这个。
  • near_text:给一段文本,让 Weaviate 内部调向量化模块编码再搜。方便,但依赖服务端配好模块,而且你看不到查询向量长什么样,调不了。
  • near_object:给一个已有对象的 ID,找和它相似的其他对象。做"相关推荐"很顺手,省去一次编码。

返回结果里除了属性,还能通过return_metadata拿到distance、certainty、score这些附加信息。这里有个坑:不同距离度量下certainty的含义不一样,cosine 下它是 1 - distance,l2 下不是这个关系。如果你在业务里拿certainty做阈值卡点,先确认自己用的度量是什么,别跨集合复用同一个阈值。

还有个常见需求是"带条件过滤的近邻搜索"。Weaviate 的where过滤器写法不算简洁,但表达力够用,支持And/Or/Not嵌套,也支持按引用路径过滤。这里的设计取舍是:过滤发生在 HNSW 搜索内部还是外部,取决于过滤条件的选择性。条件筛掉 90% 数据的情况,会有专门的优化路径先缩小候选;条件只筛掉 1% 的情况就走正常遍历。这个自动判断有时候不准,遇到过滤后结果数明显少于预期,可以试试把条件写得更"窄"一点,或者干脆拆成两次查询。

4.2 BM25 与 hybrid 的 alpha 调法

hybrid是我在项目里用得最多的一个接口,因为它最贴近用户真实行为——用户输入的那句话,既有明确的词,也有模糊的意图。比如"退款流程多久到账","退款""到账"是硬词,BM25 能抓住;"流程"这类词向量能补上语义。

alpha的语义要记清楚:1 是纯向量,0 是纯关键词。默认是 0.5 或者 0.75,按版本不同。我的调法不是拍脑袋定一个数,而是分场景:

查询类型建议 alpha理由
专有名词、错误码、型号0.2 ~ 0.35硬匹配为主,向量容易飘到语义相近但错误的条目
自然语言提问0.6 ~ 0.75语义占主导,关键词做兜底
短词、口语化搜索0.5 左右两边都不可靠,靠融合互补
长句、带约束条件0.7 以上长句里关键词噪声多,向量更稳

融合方式也有讲究。rankedFusion是排名倒数相加,好处是对分数绝对量纲不敏感,坏处是丢掉了"第一名比第十名好多少"的信息;relativeScoreFusion是按归一化分数加权,好处是保留了分数差异,坏处是归一化本身可能被极端值带偏。我的经验是——数据里存在大量近似重复的短文本时,用 rankedFusion 更稳;数据质量比较均匀、长文本为主时,relativeScoreFusion 效果更好。这个结论只在我碰过的几批数据上成立,你们拿自己的评估集验证。

调 alpha 这件事最忌讳"看几个 case 就定"。我一般会标 50 到 100 条查询,每条人工标出期望的前三条,然后跑一个脚本遍历 alpha 从 0 到 1 步长 0.1,算 Recall@3 和 MRR,画一条曲线出来。基本上曲线的峰值区间就是可用的 alpha 范围,然后再在这个区间里挑一个偏保守的值上线。

4.3 生成式模块与重排:RAG 落地位置

Weaviate 有个generate能力,可以在查询里声明一个提示模板,把检索到的对象属性拼进去,调模型生成回答。Python 里对应generate.near_vector或者generate.hybrid这类方法。

这个东西的价值在于快速验证。你在半天内就能搭出一个"检索 + 生成"的完整闭环,看看数据够不够、召回准不准、模型能不能答对,而不用先写一整套 prompt 装配和上下文裁剪的逻辑。做技术验证的时候,这个速度优势很明显。

但在生产环境我会把这层拿掉,自己在应用侧做。原因有三个:一是可控性,上下文怎么拼、token 超了怎么截、引用编号怎么生成、失败怎么降级,这些都需要细粒度控制,塞在数据库查询里做反而别扭;二是可观测性,生成过程在数据库里是个黑盒,延迟拆不开、错了不知道错在哪;三是替换成本,模型换一版就要改数据库配置,模型侧的 A/B 测试完全没法做。

我的做法是让 Weaviate 只负责"检索 + 返回结构化结果",生成放在应用层。它返回的distance和score我会拿来做信号:分数过低就直接返回"没找到相关内容",而不是硬塞给模型让它编。这个拒答逻辑是 RAG 系统里最容易被忽略、但最能提升用户信任度的一环。

至于重排,Weaviate 支持接 rerank 模块,对初步召回的候选做二次排序。它对长 query + 短文档的场景提升明显。但代价是延迟,一次 rerank 调用往往是主检索耗时的好几倍。我的策略是只在"高质量请求"上开启——比如用户点开了详情页、或者明确用了"精准搜索"入口,普通列表页不开。

5. 性能与参数:HNSW、PQ 与内存账本

5.1 HNSW 四个参数怎么定

HNSW 的参数不多,但每个都直接影响内存、构建时间和召回,值得一个个算清楚。

max_connections(每个节点的最大连接数,默认 32)。这个决定了图的稠密度,也是内存占用的主要来源。粗略估算每向量图结构的开销是max_connections × 每连接字节数 × 层级系数,实践中把 32 提到 64,内存大概翻倍。除非你的数据分布特别复杂、召回怎么调都上不去,否则 32 是够的。往上调之前先确认瓶颈真的在图结构上,很多时候是嵌入模型的问题。

ef_construction(建索引时的搜索宽度,默认 128)。这个影响建索引的质量和耗时。调到 256,建索引时间大概长一倍多,但图的连通性更好,查询召回能稳一点。我的经验是百万级以下、维度 768 的场景,256 是个不错的平衡点。再高收益递减,而导入时间会变成实际瓶颈。

ef和动态 ef。查询时的搜索宽度。固定值的问题是——查 5 条和查 200 条,需要的 ef 差很多。所以我把ef设成 -1 启用动态机制,dynamic_ef_factor设 8 表示ef = limit × 8,再用 min 100 和 max 500 夹住。也就是查 10 条时 ef 是 100,查 50 条时 ef 是 400,查 100 条时封顶 500。这个配置在延迟和召回之间比较省心。如果你们的延迟预算特别紧,可以把 factor 降到 4 到 6,先看召回掉多少再说。

cleanup_interval_seconds。控制被删除节点的清理频率。删除频繁的场景下这个值太大会导致内存迟迟不释放。我在一个"每天全量重建索引"的项目里把它调小了。

一个反直觉的现象:召回率上不去,调 HNSW 参数往往解决不了。我遇到过好几次,以为 ef 太低,一路从 100 调到 800,Recall@10 只涨了两个点。真正的问题是嵌入模型对领域词汇不敏感,换模型之后召回直接上了十几个点。所以排查顺序永远是——先看模型和数据,再看索引参数。

5.2 PQ 压缩与 rescore

内存这块必须算账。以 100 万条 768 维 float32 向量为例:

  • 原始向量:1,000,000 × 768 × 4 字节 ≈2.94 GB
  • 加上 HNSW 图结构(max_connections=32):大概再占1 到 1.5 GB
  • 再加倒排索引和对象存储:取决于属性数量和文本长度,通常1 GB 以上

也就是说,光存数据就要 5 GB 左右的内存,还没算查询时的临时开销。如果你只有 8 GB 内存的机器,稍微一并发就 OOM 了。

PQ(乘积量化)就是用来压这一块的。它的思路是把 768 维切成若干段,每段单独聚类出一组质心,用质心的编号代替原来的浮点向量。配置里segments=128表示切成 128 段,每段 6 维(768 / 128 = 6),每段用 256 个质心编码占 8 bit 即 1 字节,那么每条向量从 3072 字节压到 128 字节,压缩比 24 倍。100 万条从 2.94 GB 降到 122 MB。

代价是精度损失。这么狠的压缩,直接拿 PQ 编码算距离,召回会掉得很明显。所以必须有rescore_limit——先用 PQ 近似算出候选集,再取前 N 条用原始向量重算精确距离。rescore_limit=200表示重算前 200 条。这个数越大越准、越慢。我的默认是 200,数据特别大、内存特别紧的时候降到 100,同时把limit控制在 20 以内,影响还能接受。

注意:PQ 的segments必须能整除向量维度。768 / 128 = 6 可以,768 / 100 就不行。训练阶段需要足够多的样本,如果集合里数据太少,PQ 训练不充分反而会拖累召回,小集合(几万条)就别开 PQ 了,不值得。

另外还有个容易忽略的参数是向量缓存上限。默认值设得非常大,等于是"能缓多少缓多少",理论上很快,但内存会一路吃满。生产上建议显式设一个和机器内存匹配的值,把内存占用变成可预测的。

5.3 批量导入吞吐、分片与持久化

导入速度这件事,除了批量大小和 gRPC,还有几个点值得说。

先关掉不必要的索引再导。我做过对比,同样 30 万条、10 个属性,全建索引和只建 4 个必要索引,导入耗时差了大约 40%。如果 BM25 不是核心需求,先把index_searchable关了,导完再看要不要加回来(代价是重建索引)。

不要让向量化成为瓶颈。如果用内置模块,导入时 CPU 或 GPU 是满的,写入反而在等编码。这时候要么扩推理实例,要么改成离线编码 + 自备向量。后者最省事,模型可以批处理,吞吐能上一个量级。

分片要提前规划。Weaviate 的 Class 可以设分片数,分片数在创建后不好改。分片多有利于并行写入和更大的数据量,但每个分片有自己的索引结构,查询时要汇总,分片太多反而增加开销。我的经验:百万级用默认的 1 到 2 个分片就够,千万级再考虑 4 个以上。盲目加分会让你在查询延迟上付出代价。

副本要等集群稳定了再加。单机没有副本概念,上了集群可以通过replication_factor配置。副本提升可用性和读吞吐,但写入要同步到多个节点,延迟会上去。我一般先在单副本上把数据灌完、验证召回,再调整副本数。

持久化路径必须挂盘,这个前面说过。还有一点——不同版本之间的数据格式可能不兼容,升级前一定先做备份,别指望直接换镜像就能跑起来。备份走它自带的备份接口,支持文件系统和对象存储两种后端,恢复的时候目标实例版本要和备份来源兼容,跨大版本恢复基本都会失败。

6. 常见问题速查与踩坑记录

6.1 启动与配置类问题

现象:写入报"没有配置向量化模块"。原因通常是DEFAULT_VECTORIZER_MODULE设了某个模块名,但ENABLE_MODULES里没启用,或者模块名拼写不一致。如果本来就打算自备向量,把DEFAULT_VECTORIZER_MODULE设成none,并且所有 Class 显式声明vectorizer: none。两个地方要一致,只改一处还是会报错。

现象:容器启动后立刻退出。八成是数据目录权限或者路径没挂对。看日志里第一行报的是什么错误,PERSISTENCE_DATA_PATH指向的目录必须可写。还有一种情况是老版本的数据目录被新版本镜像读到,格式不兼容,也会直接退出。这种时候不要硬修,换个干净目录重新导入更快。

现象:gRPC 端口连不上但 REST 正常。检查两件事:端口有没有暴露、客户端连接时有没有显式指定 gRPC 端口。很多客户端默认只连 REST,批量导入走了慢路径,你还以为是数据量问题。

6.2 写入与一致性类问题

现象:报向量维度不匹配。同一 Class 内的所有向量长度必须一致。中途换了嵌入模型,维度从 768 变成 1024,老对象写不进去。解决办法只有重建集合,没有平滑迁移的路子。所以换模型前一定要规划好,我一般会给集合名带上模型标识,比如DocChunk_v2_1024,新旧并存跑对比,验证完再切。

现象:批量导入部分失败但没报错。批量接口对单条失败是静默的,必须在结束时读failed_objects。常见的失败原因有:属性类型不对(日期格式、整数传了字符串)、引用的对象不存在、向量里含 NaN。我见过最隐蔽的一次是嵌入模型对某个空字符串输出了全零向量,写入成功但检索时永远排最后,查了很久才发现是数据侧的问题。

现象:删了对象但内存没降。HNSW 的删除是标记删除 + 定期清理,cleanup_interval_seconds决定了多久清一次。频繁增删的场景要把这个值调小,否则内存会持续上涨。另外注意,删除对象会同时删掉它的倒排条目和向量,这个过程是异步的,删完立刻查可能还能查到残留,别在删除后立刻做一致性校验。

现象:重复导入产生了重复数据。如果你让服务端自动生成 UUID,每次导入都会是新对象。解决办法是自己算确定性 UUID 传进去,这样重复导入会覆盖而不是新增。

6.3 检索质量与延迟类问题

现象:中文关键词搜索效果差。BM25 那一路对中文分词依赖具体实现,短词匹配经常不如预期。我的处理办法是——降低 hybrid 里关键词的权重(alpha 提到 0.65 以上),同时在自己的查询侧做一些同义词扩展。如果关键词检索是核心需求,就别指望数据库内置的分词,自己在前置环节做分词和扩展更可控。

现象:加了过滤条件后结果数远少于 limit。这是过滤和 HNSW 结合时的典型问题。过滤条件把候选集切得很碎,图遍历在子集里走不到足够的有效节点就停了。缓解手段有三个:把ef或动态 ef 的上限提上去;把过滤条件改成更 "宽" 的形式(比如把多个等值条件合并成一个枚举字段);或者拆成两次查询——先按过滤取 ID 集合,再在集合内做向量检索。

现象:查询延迟忽高忽低。先看是不是 PQ 的 rescore 在作怪,rescore_limit越大越不稳定。再看是不是有小集合的查询混在里头,HNSW 在小数据量上的表现和在大数据量上差异很大。还有就是冷启动——刚重启的实例,向量缓存是空的,前几百次查询都要从磁盘读,延迟会明显高于稳态。生产上要么预热,要么在负载均衡层面避开刚启动的节点。

现象:hybrid 的结果看起来"全是一边的"。检查 alpha 是不是被设成了 0 或者 1,或者检查融合方式。relativeScoreFusion在一路分数分布特别集中的时候会退化,看起来就像另一路的权重被吃掉了。换成rankedFusion试试,通常能看出问题在哪一路。

6.4 运维与升级类问题

多租户场景下忘传 tenant。Weaviate 的多租户是按租户隔离数据的,每个租户一个独立的分片。查询和写入都必须显式指定租户名,漏了直接报错。这个特性对 SaaS 场景很友好,隔离性好、单租户数据量小所以索引效率高,但要注意租户数量多了以后元数据开销会上去,几千个租户以上要评估一下管理成本。

升级前不做备份。这个不多说,吃过一次亏。升级的正确姿势是:备份 → 起新版本实例 → 恢复到新实例 → 验证检索效果和延迟 → 切流量。不要在原实例上原地升级,出问题回滚很麻烦。

监控指标看得太少。至少盯四个数:对象总数、索引大小、查询 P99 延迟、内存使用率。前两个看增长趋势,后两个看容量水位。我遇到过磁盘慢慢涨到满的情况,原因是一个定时任务反复导入同一批数据而没算确定性 UUID,对象数翻了好几倍。这种问题不监控根本发现不了。

以为删了集合磁盘就会立刻释放。删除是异步的,磁盘空间回收有延迟。急着腾空间的话,重启实例或者等后台任务跑完。

最后说个我自己的习惯。每次上线一个新的集合,我都会先灌 1000 条真实数据,跑 30 条典型查询,把召回结果和延迟记成一份基线文档,后面每次改参数、换模型、升级版本,都拿同样的查询跑一遍对比。这个基线文档看起来笨,但它是唯一能让我在"感觉变慢了"和"确实变慢了"之间做出判断的东西。向量检索这东西太容易凭感觉调参了,没有基线,你调的每一步都是在赌博。

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

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

立即咨询