ADR 0054: Embeddings as EF properties¶
Date: 2026-10-24. Status: Accepted on 2026-10-24, after the owner's review instruction ("if the ADRs make full sense, approve them"); the review's corrections are folded in below. The GraphRAG toolkit (ADR 0052) is the first consumer; until this lands, it writes vectors with a named native statement.
Context¶
Vector search in GraphBridge reads existing indexes (graph search), but nothing writes a vector through EF. As checked on 24 October 2026:
- Neo4jTypeMappingSource maps string, int, long, bool, double, DateTimeOffset, Guid and enums. It returns no mapping for arrays. EF Core 10.0.13 then falls back to a primitive-collection mapping, which needs an element mapping, and float has none either. So EF has no mapping for a float[] property, and the first unit test confirms how model building reports it.
- Applications write vectors with native Cypher by key, outside change tracking, upserts and value validation.
- Neo4j stores a vector either as a list of floats, which every supported server and Neo4j's Python GraphRAG library read, or, on servers with Bolt 6, as the native VECTOR value. Neo4j.Driver 6.4.0 exposes the latter as Vector<T>.
- Search compares an index's declared dimensions with the query, but nothing checks the stored values. A wrong-sized vector is silently left out of the index; Doctor's search-index checks warn about this after the fact.
Decision¶
1. Mapping¶
- CLR types:
float[]andReadOnlyMemory<float>, nullable or not.ReadOnlyMemory<float>is whatEmbedding<float>.Vectorreturns in Microsoft.Extensions.AI.double[]isn't mapped: embeddings are float32, and accepting doubles invites silent precision loss. - Opting in: a vector property is mapped only when the model says so:
modelBuilder.Entity<Chunk>().Property(c => c.Embedding).IsGraphVector(dimensions: 1536);
IsGraphVector(int dimensions) (1 to 4,096) records the dimensions as model metadata, and the type mapping source returns the vector mapping for a property that carries them. A float[] property without it stays unmapped, as today. The storage manifest includes the dimensions, so the fingerprint changes with them.
- Storage: a list of floats, written as the driver's double list built from the float32 values. Each value round-trips exactly, because every float32 is exactly representable as a double. Native VECTOR storage isn't the default; see "Not adopted".
- Reading: a stored list of numbers with exactly the declared length is read into float[] (or ReadOnlyMemory<float>), each element converted to the nearest float32, integers included, so vectors written natively by other tools are read too. A list of another length, a non-numeric element, a non-finite value and a native VECTOR value fail materialisation, naming the entity type and the property but no values.
- Model validation and the manifest: model validation accepts a non-scalar store type only for a property marked with IsGraphVector. The storage manifest records such a property as provider type LIST<FLOAT> with its dimensions; scalar properties keep their entries unchanged, so existing fingerprints don't move.
- Change tracking:
- comparer: a value comparer with structural equality (same length and the same bits for each element) and a hash over the length and the first elements;
- snapshot: a copied array, so mutating the array in place is detected as a change;
- ReadOnlyMemory<float> values: compared by content and snapshotted as a copy.
2. Validation before any write¶
The check runs where the vector mapping converts a value for storage, which SaveChanges, typed upserts and bulk setters all use, so nothing is sent. The message names the entity type and the property, and for an upsert the zero-based position, but never the values or the key. A vector is refused when: - its length differs from the declared dimensions; - it has a non-finite element; - it is all zeros. A cosine index can't score a zero vector, and it is never a real embedding. The rule holds whatever the index's similarity function.
A null vector behaves like any other null: an insert writes no property, and an update removes it (ADR 0051). An empty ReadOnlyMemory<float>, the default of a non-nullable property, has the wrong length and is refused; a property whose vector may be missing is declared nullable.
3. Queries¶
- Allowed: projecting the property (
Select(c => c.Embedding)), materialising entities that have it, and null tests (c.Embedding == null,!= null), which find entities that have no vector yet. - Refused: every other use, with the code of the shape's existing category and a message that points to typed search, whose results the application can join by key.
- predicates, ordering and equality on the property, and member access such as
.Length, indexers andAny()over it: GBQ120; Containswith a vector item or source: GBQ130;- aggregates over it: GBQ105;
- bulk copies from or to it (
SetProperty(c => c.Embedding, c => c.Other)): GBQ160, because a copy would move a stored list without the write checks. Setting it from a value is allowed and checked. - Fluent Cypher: returning the property or the whole entity reads it,
collectgathers it, andIsNull()tests it. A comparison,IN, a string operator,ORDER BYor another aggregate over it is refused with GBFC114, pointing to typed search.
4. Doctor and the index¶
- Doctor's search-index check, in
GraphBridge.Tooling, already has the context. For a declared vector expectation, it looks incontext.Modelfor the entity type whose label is the expectation's label and whose vector property's stored name is the expectation's property. When it finds one, Doctor also compares the declared dimensions with the model's and reports a mismatch as a new finding. The expectation API doesn't change. - No index is created implicitly. Migrations create it (ADR 0053).
Not adopted¶
- Native
VECTORstorage now. It is compact and the server checks its dimensions, but it needs Bolt 6, the Python stack and older servers read lists, and GraphBridge's type mapping would need two wire forms for one CLR type. It can be added later as an explicit option,IsGraphVector(dimensions, GraphVectorStorage.Native). - Mapping every
float[]by convention. An array property that isn't an embedding would silently become a vector. double[]andHalf[]: no common embedding client produces them.
Verification¶
- Unit tests:
- model building with and without
IsGraphVector; - the manifest's dimensions and fingerprint;
- comparer equality, hashing and snapshot isolation, including an in-place mutation detected as a change;
- each validation refusal and its message, including empty
ReadOnlyMemory<float>values and null on insert and update; - read conversion of float, double and integer lists, and the refusals for wrong lengths, non-numeric elements and native
VECTORvalues; - model validation of a non-scalar property with and without
IsGraphVector, and unchanged manifests for models without vectors; - the rendered Cypher and parameters for insert, update, upsert and bulk set;
- query refusals, with their codes.
- Live tests, Neo4j 2026.09:
- SaveChanges, upsert and LINQ materialisation round-trip a 1,536-dimension vector bit for bit, for both CLR types;
- typed vector search finds the written entity through a migration-created index;
- a wrong-sized vector is refused with nothing written;
- an update that changes only another property leaves the vector unchanged;
- native writes of the same property are read back by EF.
Consequences¶
- Embedding pipelines write vectors through EF, with change tracking and validation, and the GraphRAG toolkit drops its native vector write.
- The type mapping source gains its first non-scalar mapping, behind an explicit opt-in.
- Estimate: 3 to 4 days of human-equivalent effort, inside the plan's 5-day stop line.
Review¶
Reviewed on 24 October 2026 against the source, with an independent second reading, before acceptance. The review added: - read conversion and its refusals, and the model validation and manifest changes; - null and empty values on insert and update; - refusal codes per query shape, instead of one code for every use; - how Doctor finds the mapped vector property, without a new expectation API; - one zero-vector rule, shared with ADR 0052.