docs/design-docs/design_docs/20260521-external-table-function-output.md
7a00320998cc3b5dabbe5b3a1e7ceb5a8968a65020260105-external_table.md, 20260129-add-function-field-design.md, 20260226-manifest-format.mdExternal collections can now define function output fields and text-match fields. Function outputs are computed during external-table refresh and persisted as StorageV3 packed column groups beside the external source column groups. External source columns remain referenced in-place; Milvus only materializes the derived function output columns.
The implementation supports:
enable_match text index generation on external text fields.take() output for user-visible non-BM25 function output fields.BM25 function output is intentionally not raw-retrievable by users;
CanRetrieveRawFieldData returns false for BM25 outputs.
External collections originally exposed only columns that already existed in the external data source. That prevented common lakehouse search patterns:
text_match(...) filters.Copying complete external data into Milvus would defeat the external-table model. The core design is therefore to compute and store only derived columns while keeping original source columns as external file references.
external_field.external_field on function output fields because outputs are
produced by Milvus, not read from the external source.take() path.A normal user field in an external collection must set external_field. The
mapping binds a Milvus field name to a physical column name in the external
source.
Example:
schema.add_field("doc", DataType.VARCHAR, external_field="doc_text")
A function output field must not set external_field. The field is declared in
Milvus schema and listed as a function output.
Example:
schema.add_field("sparse", DataType.SPARSE_FLOAT_VECTOR)
schema.add_function(
name="bm25_fn",
input_fields=["doc"],
output_fields=["sparse"],
function_type=FunctionType.BM25,
)
During CreateCollection, Proxy validates functions first so output fields are
marked as IsFunctionOutput=true before external schema validation runs.
External validation then skips source-column checks for those fields.
External varchar fields may enable analyzer and text match:
schema.add_field(
"doc",
DataType.VARCHAR,
external_field="doc",
enable_analyzer=True,
enable_match=True,
)
After refresh creates external segments, DataCoord may schedule text-index stats tasks for external collections, and DataNode persists text index stats into the segment manifest.
Client
|
| CreateCollection(schema: external_source + functions + enable_match)
v
Proxy
| validateFunction() marks function outputs
| NormalizeAndValidateExternalCollectionSchema()
v
RootCoord / DataCoord
| store schema and external source metadata
| no segment data or function output is generated here
Client
|
| RefreshExternalCollection()
v
DataCoord
| scan external source metadata
| allocate refresh work
| trigger DataNode refresh task
v
DataNode External Refresh
| scan external source fragments
| keep reusable segments when fragments and function-output columns match
| build new segments for orphan fragments
| ExecuteFunctionsForSegment()
v
StorageV3 Manifest
| external source column groups: external_field column names
| function output column group: numeric field-id column names
| stats: bm25.<fieldID>, text_index.<fieldID>
v
QueryNode / Segcore
| load external manifest reader
| resolve manifest columns to FieldId
| search/query/retrieve via chunk readers and take()
External schema validation uses two field classes:
| Field class | external_field | Source data check | Storage column name |
|---|---|---|---|
| External input field | required | yes | external_field |
| Function output field | forbidden | no | decimal fieldID |
| Virtual/system field | forbidden | no | computed internally |
Proxy create flow:
validateFunction() first.external_field mappings.external_source and external_spec pair.This ordering matters. If external validation ran before function validation,
function output fields would look like ordinary unmapped fields and fail the
external_field requirement.
RefreshExternalCollectionTask organizes external fragments into segments. For
each segment that needs creation or rebuild:
FFIPackedReader over only function input columns.InsertData.embedding.RunAll().SegmentInfo.ManifestPath.The pipeline is streaming. Peak memory is bounded by one Arrow batch plus function output buffers, not by full segment size.
embedding.RunAll() executes functions in this order:
This order avoids confusing the function planner with pre-populated BM25 output fields while still sharing one canonical function execution path with import.
Refresh reuses an existing segment only when:
Function output columns are checked by numeric field-id column names. Old segments created before function output support are invalidated and rebuilt.
StorageV3 manifests store column groups by column name. External collections use two naming rules in the same manifest:
| Data kind | Manifest column name | Reason |
|---|---|---|
| External source column | external_field value | matches Parquet/source schema |
| Function output column | decimal fieldID string | matches internal StorageV3 convention |
Example manifest composition:
segment base path/
_metadata/manifest-N.avro # input manifest: external columns only
_metadata/manifest-N+1.avro # function output column group appended
_metadata/manifest-N+2.avro # BM25 stats registered, if needed
_metadata/manifest-N+k.avro # text index stats registered later, if needed
_data/... # Milvus-owned function output packed files
_stats/bm25.<fieldID>/0 # BM25 stats blob, if needed
_stats/text_index.<fieldID>/...# text index files, if needed
Important rules:
IsFunctionOutput fields.ManifestReader and Segcore request external_field for external input
fields and fieldID strings for generated fields.Schema::ResolveColumnFieldId() maps external column names back through
external_field, then falls back to parsing numeric field IDs.Schema::get_storage_column_name() is the shared rule used by take().External segments are loaded through the external manifest path. QueryNode uses StorageV3 metadata and builds a schemaless milvus-storage reader so Parquet source types can be normalized into Milvus internal types.
For external collections, Segcore:
external_source and
external_spec;FieldId;Search and retrieve output paths use take() for external input fields and
user-visible non-BM25 function output fields. For each requested field, Segcore
uses get_storage_column_name(fieldID), reads Arrow data from the manifest
reader, normalizes Arrow types, and fills Milvus DataArray results.
Virtual primary key is still computed from segment ID and row offset. It is not stored in external files or function output column groups.
BM25 function output needs per-segment stats for IDF. During refresh, DataNode accumulates BM25 output over all streamed batches and serializes one stats blob per BM25 output field.
Stats key format:
bm25.<fieldID>
File layout:
_stats/bm25.<fieldID>/0
The stats blob is written before manifest registration. The manifest remains the
visibility commit point. A retry may overwrite the same deterministic path, but
readers only see stats after AddStatsToManifest() commits a new manifest.
External collections may use enable_match on varchar fields. Refresh creates
the external segments first; it does not synchronously build the text index.
After segments are registered, DataCoord allows external TextIndexJob tasks.
It also allows JsonKeyIndexJob for StorageV3 external segments that already
have a non-empty manifest path, so JSON key stats can be written back to the
manifest. BM25 stats inspector jobs are still skipped for external collections.
DataNode builds text index stats for every EnableMatch() field and registers
manifest stats with key:
text_index.<fieldID>
The result is also dual-written to segment metadata as a placeholder so the stats inspector does not re-trigger text-index jobs for StorageV3 segments that already have manifest stats.
QueryNode load requires these persisted text-index stats for enable-match fields.
If they are not ready yet, load fails with TextIndexNotFound and can be retried
after the stats task commits a manifest containing text_index.<fieldID>.
Function output vector fields are normal schema fields after refresh. Index building reads them from the manifest by numeric field-id column name.
Covered cases:
MINHASH_LSH.External segments use synthetic StorageV3 binlogs so downstream components can process them like regular packed segments. The fake binlog includes all child field IDs and a conservative memory estimate:
MemorySize = (sampledExternalBytesPerRow + functionOutputBytesPerRow) * rowCount
External bytes are sampled from external source fields. Function output bytes are estimated from output field schemas because those columns do not exist in source files. If every external sampling attempt fails or produces non-positive bytes, refresh fails instead of writing zero-sized fake binlogs. This avoids QueryNode resource estimation collapse and possible OOM during load.
Refresh fails loudly when:
external_field;Partially written output files or stats blobs are not visible until a manifest commit references them. Retrying refresh recomputes outputs for the same segment base path and commits a new manifest version.
| Feature | External collection behavior |
|---|---|
| Declare BM25 output at create time | supported; data is generated by manual refresh |
| Declare MinHash output at create time | supported; data is generated by manual refresh |
| Declare TextEmbedding output at create time | supported; data is generated by manual refresh |
enable_match on varchar field | supported; text index becomes available after stats task |
| Raw retrieve BM25 output | not supported |
| Raw retrieve MinHash/TextEmbedding output | supported |
| Insert/delete/upsert | not supported |
| Refresh after source changes | manual trigger required |
| AddField/schema evolution | not supported |
Key implementation points:
internal/proxy/task.go: validates functions before external schema checks
so create-time function output fields are marked before external validation.pkg/util/typeutil/schema.go: external schema validator skips function output
fields and rejects external_field on them.internal/datanode/external/function_executor.go: streams external input
batches, runs functions, writes output column groups, and registers BM25 stats.internal/datanode/external/task_update.go: invalidates old segments missing
output columns and routes manifest creation through the function executor.internal/storagev2/packed/utils.go: excludes function output fields from
external source column list.internal/storage/record_reader.go: reads external_field columns for source
fields and numeric field-id columns for generated fields.internal/core/src/common/Schema.cpp: maps external column names and function
output field-id column names to FieldId.internal/core/src/segcore/ChunkedSegmentSealedImpl.cpp: loads external
manifests and uses take() for external and function output fields.internal/datacoord/stats_inspector.go: permits external TextIndexJob and
StorageV3 manifest-backed JsonKeyIndexJob, while skipping BM25 stats
inspector jobs for external collections.internal/datanode/index/task_stats.go: builds text index stats and registers
them into the manifest.Unit and integration coverage includes:
external_field requirement;external_field;external_field names;