From 85b0eba176b94a619a840b5ecee47dd811461943 Mon Sep 17 00:00:00 2001 From: "lancedb-gatefixer[bot]" <313497061+lancedb-gatefixer[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:00:52 -0500 Subject: [PATCH] fix(python): support typed embedding vector fields (#3826) ## Summary - infer fixed-size float32 Arrow vectors from `VectorField` metadata when a model uses the standard `list[float]` annotation - document the mypy-compatible embedding-field declaration while preserving the existing `Vector(dim)` runtime API - add regression coverage for schema conversion and model construction ## Root cause `Vector(dim)` is a runtime constrained-type factory, but Python's static typing rules do not permit call expressions in annotation positions. Mypy's suggested `Vector[...]` spelling also cannot represent a dimension computed at runtime and fails because `Vector` is a function. `VectorField` already carries the embedding function and its dimension, but schema conversion previously ignored that metadata for a statically valid `list[float]` annotation. The runtime factory remains available for dimension-constrained Pydantic validation. Embedding-backed models that require mypy compatibility can now use `list[float] = embedding.VectorField()`; LanceDB derives the same nullable fixed-size float32 Arrow type from the field metadata. ## Validation - `cd python && uv run --extra tests pytest python/tests/test_pydantic.py -q` (23 passed) - focused mypy check reproducing the original error and accepting the new `LanceModel` declaration - isolated schema checks with Pydantic 1.10.24 and 2.11.7 - `ruff format .` - `ruff check .` Fixes #1982 Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com> --- python/python/lancedb/embeddings/base.py | 6 +++- python/python/lancedb/pydantic.py | 39 ++++++++++++++++++++++++ python/python/tests/test_pydantic.py | 20 ++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/python/python/lancedb/embeddings/base.py b/python/python/lancedb/embeddings/base.py index 149b9c089..fbe43860d 100644 --- a/python/python/lancedb/embeddings/base.py +++ b/python/python/lancedb/embeddings/base.py @@ -182,7 +182,11 @@ class EmbeddingFunction(BaseModel, ABC): def VectorField(self, **kwargs): """ Creates a pydantic Field that can automatically annotate - the target vector column for this embedding function + the target vector column for this embedding function. + + The field can be annotated as ``list[float]`` for compatibility with + static type checkers. LanceDB will infer the fixed vector dimension from + this embedding function. """ return Field(json_schema_extra={"vector_column_for": self}, **kwargs) diff --git a/python/python/lancedb/pydantic.py b/python/python/lancedb/pydantic.py index 528d865d7..313c514c3 100644 --- a/python/python/lancedb/pydantic.py +++ b/python/python/lancedb/pydantic.py @@ -88,6 +88,17 @@ def Vector( ... pa.field("url", pa.utf8(), False), ... pa.field("embeddings", pa.list_(pa.float32(), 768)) ... ]) + + Notes + ----- + ``Vector`` creates a type dynamically, so calls such as ``Vector(768)`` are + not valid static type annotations. For an embedding field, use the standard + ``list[float]`` annotation when running mypy; ``VectorField`` supplies the + fixed dimension to LanceDB:: + + class MyModel(LanceModel): + text: str = embeddings.SourceField() + vector: list[float] = embeddings.VectorField() """ # TODO: make a public parameterized type. @@ -313,6 +324,10 @@ def _unwrap_optional_annotation(annotation: Any) -> Any | None: def _pydantic_to_arrow_type(field: FieldInfo) -> pa.DataType: """Convert a Pydantic FieldInfo to Arrow DataType""" + embedding_vector_type = _embedding_vector_to_arrow_type(field) + if embedding_vector_type is not None: + return embedding_vector_type + unwrapped = _unwrap_optional_annotation(field.annotation) if unwrapped is not None: return _pydantic_type_to_arrow_type(unwrapped, field) @@ -326,8 +341,32 @@ def _pydantic_to_arrow_type(field: FieldInfo) -> pa.DataType: return _pydantic_type_to_arrow_type(field.annotation, field) +def _embedding_vector_to_arrow_type(field: FieldInfo) -> pa.DataType | None: + """Infer a fixed-size vector type from ``VectorField`` metadata.""" + if not _is_embedding_vector_annotation(field): + return None + + function = get_extras(field, "vector_column_for") + return pa.list_(pa.float32(), function.ndims()) + + +def _is_embedding_vector_annotation(field: FieldInfo) -> bool: + if get_extras(field, "vector_column_for") is None: + return False + + annotation = _unwrap_optional_annotation(field.annotation) + if annotation is None: + annotation = field.annotation + + origin = getattr(annotation, "__origin__", None) + args = getattr(annotation, "__args__", ()) + return origin is list and args == (float,) + + def is_nullable(field: FieldInfo) -> bool: """Check if a Pydantic FieldInfo is nullable.""" + if _is_embedding_vector_annotation(field): + return True if _unwrap_optional_annotation(field.annotation) is not None: return True if isinstance(field.annotation, (_GenericAlias, GenericAlias)): diff --git a/python/python/tests/test_pydantic.py b/python/python/tests/test_pydantic.py index ca03940f2..81674d735 100644 --- a/python/python/tests/test_pydantic.py +++ b/python/python/tests/test_pydantic.py @@ -9,6 +9,7 @@ from typing import List, Optional, Tuple import pyarrow as pa import pydantic import pytest +from lancedb.conftest import MockTextEmbeddingFunction from lancedb.pydantic import ( LanceModel, MultiVector, @@ -425,6 +426,25 @@ def test_bare_vector_raises_clear_error(): exec("class TestModel(LanceModel):\n vector: Vector", namespace) +def test_embedding_vector_list_annotation(): + embedding = MockTextEmbeddingFunction.create() + + class StaticTypingModel(LanceModel): + text: str = embedding.SourceField() + vector: list[float] = embedding.VectorField() + + schema = pydantic_to_schema(StaticTypingModel) + assert schema == pa.schema( + [ + pa.field("text", pa.utf8(), False), + pa.field("vector", pa.list_(pa.float32(), embedding.ndims()), True), + ] + ) + + model = StaticTypingModel(text="hello", vector=[0.0] * embedding.ndims()) + assert model.vector == [0.0] * embedding.ndims() + + def test_fixed_size_list_field(): class TestModel(pydantic.BaseModel): vec: Vector(16)