docs/design-docs/design_docs/20260129-search-orderby.md
The Order By feature enables users to sort vector search results by scalar fields instead of (or in addition to) the default distance/similarity score ordering. This is useful for scenarios where users want to prioritize results based on business-relevant attributes like price, timestamp, rating, etc.
Vector search results are typically sorted by similarity score (distance). However, users often need to:
order_by_fieldsorder_by_fields in main search paramsCross-link: Query ORDER BY is documented separately in:
design_docs/20260203-query-orderby.mdorder_by is not supported when using search iteratorsUsers specify order by fields via the order_by_fields search parameter:
# PyMilvus Example
collection.search(
data=[[0.1, 0.2, 0.3, ...]],
anns_field="embedding",
param={"metric_type": "L2", "params": {"nprobe": 10}},
limit=10,
order_by_fields=[
{"field": "price", "order": "asc"}
]
)
order_by_fields = [
{
"field": "field_name", # Required: field name or JSON path
"order": "asc" | "desc" # Optional: default is "asc"
},
# ... additional fields for multi-field sorting
]
Field specification formats:
{"field": "price", "order": "asc"}{"field": "age", "order": "desc"} (automatically resolved from dynamic field storage){"field": "metadata[\"price\"]", "order": "asc"} (nested access in dynamic field){"field": "user[\"profile\"][\"score\"]", "order": "desc"}Note: JSON path is only supported for dynamic fields. Regular JSON fields (defined in schema) cannot be used for ordering.
Order options:
"asc" or "ascending": Sort in ascending order (default)"desc" or "descending": Sort in descending order| Field Type | Supported | Notes |
|---|---|---|
| Bool | Yes | false < true |
| Int8/Int16/Int32/Int64 | Yes | Numeric comparison |
| Float/Double | Yes | Numeric comparison (NaN rejected at insert) |
| String/VarChar | Yes | Lexicographic comparison |
| JSON | No | Comparing JSON values on bytes is meaningless |
| Dynamic field subpath | Yes | Access via JSON path (e.g., age or metadata["price"]) |
| Array | No | Not sortable |
| Vector types | No | Not sortable |
Dynamic fields are fully supported. Access them directly by field name:
# Access dynamic field directly
order_by_fields=[
{"field": "age", "order": "desc"} # age is a dynamic field
]
# Multiple dynamic fields work the same as scalar fields
order_by_fields=[
{"field": "rating", "order": "desc"}, # dynamic field
{"field": "timestamp", "order": "asc"} # mixing with schema-defined scalar fields
]
Note: Dynamic fields are automatically resolved. If a field name is not found in the schema, Milvus will look for it in the dynamic field storage.
JSON path is only supported for dynamic fields, not for regular JSON fields. This is because Milvus currently only supports JSON path expressions for dynamic field storage. Ordering by a regular JSON field is not supported as comparing raw JSON bytes is meaningless.
Dynamic field subpaths can be accessed using bracket notation:
# Access dynamic field subpath
order_by_fields=[
{"field": "metadata[\"price\"]", "order": "asc"} # metadata is a dynamic field key
]
# Nested path in dynamic field
order_by_fields=[
{"field": "user[\"profile\"][\"score\"]", "order": "desc"} # user is a dynamic field key
]
# Multiple order by fields (mixing simple and nested dynamic fields)
order_by_fields=[
{"field": "price", "order": "asc"}, # simple dynamic field
{"field": "timestamp", "order": "desc"} # another dynamic field
]
Note: If you have a regular JSON field (defined in schema with JSON type), you cannot use it for ordering. Only dynamic fields support JSON path access for ordering.
┌─────────────────────────────────────────────────────────────────────────────┐
│ Proxy (Search Task) │
├─────────────────────────────────────────────────────────────────────────────┤
│ 1. Parse order_by_fields from search params │
│ 2. Validate field names against schema │
│ 3. Add order_by fields to output_fields for requery │
│ 4. Execute search on QueryNodes │
│ 5. Run search pipeline with order_by operator │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Search Pipeline │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌─────────┐ ┌───────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Reduce │ → │ Requery │ → │ Organize │ → │ Order By │ → │ End │ │
│ └──────────┘ └─────────┘ └───────────┘ └──────────┘ └──────────┘ │
│ │
│ For Hybrid Search: │
│ ┌──────────┐ ┌────────┐ ┌─────────┐ ┌───────────┐ ┌──────────┐ │
│ │ Reduce │ → │ Rerank │ → │ Requery │ → │ Order By │ → │ End │ │
│ └──────────┘ └────────┘ └─────────┘ └───────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
// internal/proxy/search_util.go
type OrderByField struct {
FieldName string // Top-level field name for result lookup
FieldID int64 // Field ID for validation
JSONPath string // JSON Pointer format: "/price" or "/user/age"
Ascending bool // true for ASC, false for DESC
OutputFieldName string // Field name to request in requery
IsDynamicField bool // true if field is resolved from dynamic field storage
}
// internal/proxy/search_util.go
type SearchInfo struct {
planInfo *planpb.QueryInfo
offset int64
isIterator bool
collectionID int64
orderByFields []OrderByField // NEW: Order by specifications
}
The orderByOperator is a pipeline operator that sorts search results:
// internal/proxy/search_pipeline.go
type orderByOperator struct {
orderByFields []OrderByField
groupByFieldId int64
groupSize int64
}
Location: internal/proxy/search_util.go:parseOrderByFields()
order_by_fields parameter from search params (list of dictionaries)"field" key as field name (required)"order" key as direction (optional, default: "asc")metadata["price"])OrderByField struct with parsed valuesLocation: internal/proxy/search_pipeline.go
Two new pipeline definitions are added:
searchWithOrderByPipe (for common search):
reduce → merge_ids → requery → gen_ids → organize → pick → order_by
hybridSearchWithOrderByPipe (for hybrid search):
reduce → rerank → pick_ids → requery → organize → result → order_by
Location: internal/proxy/search_pipeline.go:orderByOperator.run()
sort.SliceStable to maintain original order for equal valuesNote: Regular JSON fields (defined in schema) are not supported for ordering because comparing JSON values on raw bytes is meaningless.
The implementation uses NULLS FIRST semantics:
| Error Condition | Error Message |
|---|---|
| Missing "field" key | "missing 'field' key in order_by_fields entry" |
| Empty field name | "empty field name in order_by_fields" |
| Invalid direction | "invalid order direction 'X' for field 'Y' (must be 'asc', 'desc', 'ascending', or 'descending')" |
| Field not found | "order_by field 'X' does not exist in collection schema" |
| Unsortable type | "order_by field 'X' has unsortable type Y" |
| Regular JSON field | "order_by is not supported for JSON field 'X', use dynamic field with JSON path instead" |
| Iterator conflict | "order_by is not supported when using search iterator" |
| Function score conflict | "order_by and function_score cannot be used together" |
| Invalid JSON path | "invalid JSON path in order_by field 'X'" |
| Operation | Time Complexity | Space Complexity |
|---|---|---|
| Parsing | O(f * k) | O(f) |
| JSON Extraction | O(n * f) | O(n * f) |
| Sorting | O(n log n * f) | O(n) |
| Reordering | O(n * d) | O(n * d) |
Where:
Location: internal/proxy/search_pipeline_test.go
Coverage includes:
| File | Changes |
|---|---|
internal/proxy/search_util.go | OrderByField struct, parseOrderByFields() for parsing dictionary entries |
internal/proxy/search_pipeline.go | orderByOperator, pipeline definitions, comparison functions |
internal/proxy/task_search.go | orderByFields field, pipeline integration |
internal/proxy/task.go | OrderByFieldsKey constant |
internal/proxy/search_pipeline_test.go | Unit tests for dictionary-based order_by_fields |
The Query ORDER BY design has been separated into:
design_docs/20260203-query-orderby.mdprice * quantity)For dynamic fields, JSON Pointer (RFC 6901) is converted to gjson path format:
JSON Pointer uses / as separator:
/user/name → user.name~0 → ~ (escaped tilde)~1 → / (escaped slash)gjson uses . as separator:
\.Example: /key~1with~1slash → key\/with\/slash (single key with slashes)
Note: This conversion only applies to dynamic fields. Regular JSON fields (defined in schema) are not supported for ordering.
The requeryOperator unions order_by fields with output fields:
for _, orderByField := range t.orderByFields {
outputFieldNames.Insert(orderByField.OutputFieldName)
}
This ensures order_by field data is fetched even if not explicitly requested.