Milvus 3.0 开源解读|如何借助原生 TEXT 类型和 LOB 高效管理原始文本
Milvus 3.0 引入 DataType.TEXT 和 LOB 存储路径,使长文本成为数据库一等公民。TEXT 支持全文检索、BM25 和混合搜索;LOB 实现短文本内联、大文本外置,避免内存压力、写放大与 I/O 冗余。存储策略对上层透明,用户仍以普通字段方式操作。
原始文本为何必须与向量共存
在 AI 检索系统中,Embedding 可以帮助我们快速找到语义相近的内容。但从 TEXT_MATCH、BM25 等词法检索,到 reranking、答案生成、高亮和审计,系统仍然需要保存并访问原始文本。
也是因此,如何让原始文本可以和索引一起被存储、检索和管理,也是现代数据库建设中的关键命题。
早期,行业普遍采用向量数据库 + 外部存储的解耦架构来解决这一问题:向量数据库仅存放 Embedding 和简单的 Metadata,而长文档正文、代码段或日志则存放在 S3、MongoDB 或 Elasticsearch 中。
在这种架构中,一旦我们需要更新或删除文档,就必须同时操作外部存储与向量数据库。然而,在分布式网络下,这种方式极易引发数据不一致,导致向量检索命中但取不到正文(或者取错版本)。与此同时,混合检索又进一步放大了这个问题:如果原始文本不在向量数据库内,BM25 倒排索引的构建与全文本过滤就必须依赖外部组件,链路冗长且难以保证原子性。

TEXT + LOB:让长文本成为一等公民
既然如此,为什么不直接把正文放进数据库中?
原因在于,如果只是简单地将巨量原始文本作为普通字符串列塞进向量数据库,却仍然按照普通内联字符串处理,大 Payload 又会进入内存、flush、compaction 和 I/O 的每一个环节:百 KB 到数MB的长文本会迅速挤爆内存缓冲区,并在数据合并重构(Compaction)时引发极其严重的磁盘与网络写放大。
Milvus 3.0 引入 DataType.TEXT 和 LOB 存储路径,所要解决的,就是这个问题:让长文本像稠密向量、稀疏向量和标量字段一样,成为 Milvus 中的一等公民,并纳入对象存储的完整生命周期管理。
- TEXT 可以用来存储文档正文、RAG chunk、日志、源代码、对话等较长的文本内容。
- 同一个 TEXT 字段可以与 analyzer、text_match、BM25、稠密向量以及混合搜索配合使用。
- 较短的文本仍然直接存放在 Segment 中;较大的文本则转为 LOB 文件,并在 Segment 中保存引用。
- Compaction 时,如果现有 LOB 文件仍然值得保留,可以直接复用,而不必重新写入全部正文。
- DataCoord 中的 LOB GC 会根据 Manifest 的引用关系和安全时间窗口,清理已经失去引用的孤儿文件。
为什么不能把 TEXT 当成一个更大的 VARCHAR
先区分两个容易混淆的概念。
对于标签、状态、名称、类别、ID 等短元数据,VARCHAR 依然是更合适的选择。
TEXT 则适合面向更长的内容,例如:RAG chunk 文本;文档正文;源代码;日志;客服或支持对话;多语言文本;需要参与全文检索或混合检索的字段。
例如,我们可以这样定义 Schema:
schema.add_field(
field_name="content",
datatype=DataType.TEXT,
nullable=True,
enable_analyzer=True,
enable_match=True,
analyzer_params={"tokenizer": "standard"},
)
这里的 content 不仅可以正常写入和返回,还可以参与 analyzer、text_match、BM25 等文本检索能力。
这意味着从数据模型来看,正文终于可以和向量、标量字段放在同一个 Collection 里。
为何需为 TEXT 单独设计 LOB 存储
为什么 Milvus 还需要为 TEXT 单独设计 LOB,而不是继续像普通 Segment 列一样保存它?原因主要有四个:
1、Growing Segment 的内存压力会迅速放大
Milvus 要让刚写入的数据立即可查,QueryNode 中的 Growing Segment 就必须能够访问最近写入的数据。
对于普通标量字段,这部分数据通常不大。
但如果一行数据携带几百 KB 甚至数 MB 的正文,情况就完全不同了。此时决定内存占用的可能不再是向量,而是文本 Payload 本身。
2、写入链路可能重复保存同一份大文本
传统写入链路可以简化为:
WAL -> StreamingNode write buffer -> object storage
与此同时,为了查询 Growing Data,QueryNode 本身也需要访问刚写入的数据。
于是,对于大文本来说,一个明显的问题出现了:如果 StreamingNode 为了等待 flush 再保留一份完整 Payload,而查询侧已经持有一份,相当于同一份正文在内存中被重复保存。
3、Compaction 带来的写放大
Compaction 的目的通常是整理 Segment、处理删除记录并重组数据。
但如果长文本和其他字段始终捆绑在同一个 Segment 文件中,哪怕真正发生变化的只有少数几行,Compaction 仍然可能不得不重新搬运大量完全没有变化的正文。
例如,一个 Segment 中有几 GB 的原始文本,而这次 Compaction 实际只删除了其中很少一部分记录。
理想状态下,我们只需要更新这些记录的引用关系。如果正文完全内联,则可能不得不把几 GB 数据重新写一遍。
4、与文本无关的查询也承担了额外 I/O
并不是每一次向量搜索都需要返回全文。很多查询只需要向量、主键、标量过滤字段,或者少量指定的输出字段。如果大文本始终内联在 Segment 列文件中,这些文件会因为正文而变得更大,即便一次查询根本不会访问这些内容。
基于以上背景,如何让大文本进入 Milvus,又尽量不干扰原有的向量和标量数据路径,正是我们引入 LOB 存储的原因。
混合存储:短文本内联,大文本走 LOB
为了在“读取性能”与“存储开销”之间取得最佳平衡,Milvus 3.0 并没有把所有 TEXT 都无条件拆成独立文件,而是在存储底层通过混合存储布局引入了动态的分层处理机制。
系统会根据文本 Payload 体积自动切换保存策略:如果一段文本本身很短,把它放在 Segment 里直接读取通常更简单;只有当 Payload 足够大时,才把它从主数据路径中拆出去。
(当前默认阈值为 64 KiB,具体参数可配置)
small TEXT value -> inline bytes
large TEXT value -> LOB file + reference
也就是说,TEXT 是应用层的数据类型,而 inline 和 LOB 是底层根据 Payload 大小采用的两种存储方式。
从概念上可以理解成:
segment manifest
normal column groups:
pk, vector, scalar fields
TEXT reference column:
row 1 -> inline bytes
row 2 -> LOB ref(file_id, row_offset)
row 3 -> LOB ref(file_id, row_offset)
partition-level lobs/
{field_id}/_data/{file_id}.vx
如此一来,小文本继续保持 Inline 状态,直接保存在 Segment 中,因此常规读取仍然轻量;大文本则移到独立的 LOB 文件中,Segment 只保留对应引用。
内存中的 Growing Segment 和写缓冲区不必再常驻巨量 Payload,显著降低了内存压力。同时也为后续的 LOB 文件复用和生命周期管理提供了基础。
Compaction:能复用正文,就不要重新写一遍
数据落盘以后,Segment 仍然会发生删除和 Compaction。如果每次 Compaction 又把所有 LOB 重写一遍,会造成较大的 I/O 放大。
假设一个 Segment 中保存了大量文档。现在其中 5% 的记录被删除,需要进行 Compaction。
对于主键、标量和向量数据,生成一个新的 Segment 很正常。但对于几百 MB 甚至几 GB 的正文来说,如果剩下 95% 的内容实际上完全没有变化,再复制一次就没有太大意义。
LOB 将文本 Payload 从普通 Segment 文件中拆出去之后,Milvus 就可以实现让新的 Segment 可以被重新组织引用,而原来的 LOB 文件继续使用。
当然,并不是所有情况下都值得复用。如果一个 LOB 文件中绝大多数记录已经被删除,继续保留整个文件反而会浪费空间。
因此, Milvus 引入了一个重要指标:hole ratio。
hole_ratio = unused_lob_rows_or_bytes / total_lob_rows_or_bytes
它描述的是一个 LOB 文件中已经不再使用的数据比例。
根据实际情况,Compaction 可以采取不同策略:
- REUSE_ALL:如果 hole ratio 较低,继续使用已有 LOB 文件,只复制或更新引用。
- REWRITE_ALL:如果 hole ratio 较高,只把仍然有效的文本重新写入新的 LOB 文件。
- SKIP:对于仅处理删除数据的 L0 compaction,不需要移动 LOB 文件。
这样就避免了一个典型的写放大场景:明明只删除了少数几行,却不得不重新写入数 MB,甚至数 GB 的文档内容。
Read Path:对查询层保持透明
把 TEXT 分成 inline 和 LOB 两种布局,会让存储层复杂一些。
但这种复杂度不应该暴露给应用。
在 Milvus 3.0 中,查询执行层仍然是按照行读取字段。至于这一行文本究竟直接存在 Segment 中,还是要从 LOB 中获取,由底层存储层处理:
if reference is inline:
return inline bytes
else:
decode file_id + row_offset
read from LOB Vortex file
当一次查询需要读取多条 LOB 数据时,Milvus 还可以先按照 file_id 对引用进行分组。这样,同一个 LOB 文件对应的请求可以复用 Reader,再根据不同的 row_offset 找到对应文本,而不是每读取一行都重新打开文件。
从用户和应用程序的角度看,TEXT 依然可以像普通字段一样出现在搜索结果中,不需要修改原有业务逻辑。
GC:文件要能复用,也要能够安全删除
一旦 LOB 文件可以脱离单个 Segment 独立存在,新的问题也随之出现:
什么时候可以确定一个 LOB 文件已经没有任何人需要,可以安全删除?
这里不能简单地看“最近有没有 Segment 使用它”。这与分布式系统中的提交顺序有关。LOB 文件会先写入对象存储,随后对应的 Segment Manifest 才会提交并变得可见。
因此,如果一个节点恰好在 LOB 文件写入之后、Manifest 提交之前崩溃,对象存储中就可能残留没有任何引用的孤儿文件。
Milvus 在 DataCoord 中提供了专门的 LOB GC 来处理这类情况:
- 扫描仍然有效的 Segment Manifest 和引用 Binlog。
- 构建当前可达的 LOB file_id 集合。
- 枚举对象存储中的 LOB 文件。
- 删除已经没有引用、并且早于安全时间窗口的文件。
安全时间窗口的作用,是避免误删仍处于 flush 或 compaction 过程中的文件——这些文件的数据可能已经写入,但元数据尚未来得及提交。
这样,即使发生节点崩溃、重试或部分写入,LOB 文件的生命周期仍然可以被安全管理。
对用户来说,TEXT 仍然是普通字段
前面讨论了很多底层机制,但这些机制有一个共同目标:不要把存储复杂度传递给使用 Milvus 的应用。
应用不需要判断某条 TEXT 是 inline 还是 LOB,也不需要自己创建、维护或者删除 LOB 文件。
正常定义字段即可。
存储并返回 TEXT
schema.add_field(field_name="id", datatype=DataType.INT64, is_primary=True)
schema.add_field(field_name="content", datatype=DataType.TEXT, nullable=True)
schema.add_field(field_name="embedding", datatype=DataType.FLOAT_VECTOR, dim=768)
client.insert(
collection_name="docs",
data=[
{
"id": 1,
"content": "A long document body ...",
"embedding": embedding,
}
],
)
搜索时可以像其他字段一样返回正文:
client.search(
collection_name="docs",
data=[query_embedding],
anns_field="embedding",
limit=10,
output_fields=["content"],
)
使用 text_match
如果字段启用了对应的 analyzer 和 match 能力,还可以直接进行文本匹配:
client.query(
collection_name="docs",
filter='text_match(content, "vector database")',
output_fields=["id", "content"],
)
使用 BM25 全文搜索
同一个 TEXT 字段也可以作为 BM25 Function 的输入:
schema.add_field(field_name="content_sparse", datatype=DataType.SPARSE_FLOAT_VECTOR)
schema.add_function(Function(
name="content_bm25",
function_type=FunctionType.BM25,
input_field_names=["content"],
output_field_names=["content_sparse"],
))
index_params.add_index(
field_name="content_sparse",
index_type="SPARSE_INVERTED_INDEX",
metric_type="BM25",
)
client.search(
collection_name="docs",
data=["vector database full text search"],
anns_field="content_sparse",
search_params={"metric_type": "BM25"},
limit=10,
output_fields=["content"],
)
总而言之,TEXT 是用户的数据类型,LOB 是 Milvus 的存储策略。用户管理的是文本字段,而不是 LOB 文件。
从内联字符串到 TEXT + LOB,到底改变了什么?

回到文章开头的问题。Milvus 过去并非完全不能保存字符串。只不过,我们还需要考虑,当字符串变成长文档之后,如何避免它一路放大 Growing Data、写缓冲区、Compaction 和对象存储的成本。
TEXT + LOB 的设计,本质上是把大文本 Payload 从 Segment 主数据路径中拆出来,同时又不改变上层的数据模型。

对很多需要混合检索的系统来说,它意味着应用不再需要为了不同的检索方式,把同一份文本拆到多套存储和索引系统中分别维护。这样一来,向量、文本和 Metadata 可以围绕同一条数据完成写入、更新、查询和删除,整个检索链路也会更简单。


JOTO 企业落地观察
- 企业若计划部署 RAG 系统,TEXT + LOB 意味着无需再为 chunk 正文单独搭建外部存储服务,可统一在 Milvus 中完成向量索引、BM25 全文检索与原文召回,降低架构复杂度与跨系统一致性风险。
- 在智能体工程中,若 agent 需频繁调用长上下文(如会议纪要、合同全文),TEXT 字段的透明读取能力可避免额外的存储跳转逻辑,使 prompt 构建与 context 注入更贴近单一数据源范式。
- 对于知识工程团队,TEXT 字段启用 analyzer 后支持专业词典注入与多语言分词,意味着非结构化知识库的预处理环节可收敛至数据库层,减少 ETL 流水线中定制化文本清洗模块的开发负担。
- 在 AI 安全治理场景下,LOB 文件的 GC 机制依赖 Manifest 引用与安全时间窗口,这对企业审计原始数据生命周期提出了新要求:需确保 DataCoord 的 GC 策略与合规保留期对齐,避免因过早清理导致取证缺失。
立即咨询 JOTO
JOTO 提供覆盖企业智能体规划与搭建、AI 平台私有化部署、RAG 知识工程、AI 安全治理、FDE 驻场共创及持续运营优化的全周期 AI 落地服务,帮助企业把验证中的 AI 能力转化为安全、可控、可持续迭代的生产力。 联系 JOTO 获取 AI 落地咨询
想把这些做法用到你的业务里?
留下你的场景和痛点,我们帮你判断从哪一步开始。
联系我们


