ADR 0053: Vector and full-text index steps in migrations¶
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. Extends the typed migration plans in schema and migrations. The GraphRAG toolkit's provisioning (ADR 0052) is the first consumer.
Context¶
Typed migration plans create and drop uniqueness, existence and property-type constraints and RANGE indexes. Each step has an offline preview, a journal and an exact-definition check, and each index step waits until the index is ONLINE. Vector and full-text indexes have none of this: - typed search requires them to exist (graph search); - Doctor checks them against declarations (Doctor); - but applications create them with hand-written DDL, outside any review or journal.
As checked on 24 October 2026:
- GraphMigrationSchemaKind has no vector or full-text kind.
- The schema inspector's SHOW INDEXES reads no index options, so it can't compare a vector index's dimensions or similarity function.
- A full-text index can cover several labels or relationship types, which the one-label GraphMigrationSchemaDefinition can't express.
- Typed search already reads options for its provenance (GraphSearchPreflight).
Decision¶
1. Two new schema kinds¶
public enum GraphMigrationSchemaKind { UniquenessConstraint, ExistenceConstraint, PropertyTypeConstraint, RangeIndex, VectorIndex, FullTextIndex }
public enum GraphMigrationCapability { NodeUniqueness, RelationshipUniqueness, PropertyExistence, PropertyType, RangeIndexes, VectorIndexes, FullTextIndexes }
public enum GraphVectorSimilarity { Cosine, Euclidean }
public sealed class GraphMigrationSchemaDefinition
{
// Existing constructor unchanged; it refuses VectorIndex and FullTextIndex, which need options.
public static GraphMigrationSchemaDefinition VectorIndex(string name, string entityType, string labelOrType,
string property, int dimensions, GraphVectorSimilarity similarity);
public static GraphMigrationSchemaDefinition FullTextIndex(string name, string entityType, IEnumerable<string> labelsOrTypes,
IEnumerable<string> properties, string analyzer = "standard-no-stop-words", bool eventuallyConsistent = false);
public IReadOnlyList<string> LabelsOrTypes { get; } // one entry except for full-text; LabelOrType is the first
public int? VectorDimensions { get; } // 1 to 4,096
public GraphVectorSimilarity? VectorSimilarity { get; }
public string? FullTextAnalyzer { get; } // set for full-text indexes only
public bool? FullTextEventuallyConsistent { get; }
}
IsIndexis true for both new kinds.GraphMigrationStep.CreateIndexandDropIndexaccept them, with the same preview, journal, precondition and postcondition as RANGE indexes.DefinitionTypeisVECTORorFULLTEXT, the typeSHOW INDEXESreports.- Validation:
- names, labels, types and properties are validated identifiers, and the reserved migration names stay reserved;
- a vector index has one label or type and one property;
- a full-text index has 1 to 16 distinct labels or types and 1 to 16 distinct properties;
- an analyzer name matches
[A-Za-z0-9_-]{1,64}. Whether the server knows it is checked when the step runs, by the server. - Checksums of existing plans don't change. The canonical JSON writes the new definition members (labels, dimensions, similarity, analyzer, consistency) only for the two new kinds, so a plan made only of the existing kinds keeps its canonical form and checksum, and journals of applied migrations still match.
- Impact:
GraphMigrationImpactgainsLabelsOrTypes, so a full-text step's preview names every label or type it covers.LabelOrTypestays the first. - Capabilities: both are Community features.
GraphSchemaCapabilitiesgainsVectorIndexesandFullTextIndexes, which the migration capability check reads.- They are Supported on recognised Neo4j 2025 and 2026 releases.
- Neo4j 5 releases report Unknown, because GraphBridge hasn't verified their index DDL.
- Unknown servers stay Unknown, as today, and a plan that needs an Unknown capability doesn't run.
2. Rendering¶
Options are rendered as literals from validated values. Recent Neo4j releases accept parameters in index options, but literals keep each step's preview and checksum self-contained, as for the existing schema steps, whose renderer already writes validated identifiers.
CREATE VECTOR INDEX `chunk_embedding` FOR (n:`Chunk`) ON (n.`embedding`)
OPTIONS {indexConfig: {`vector.dimensions`: 1536, `vector.similarity_function`: 'cosine'}}
CREATE FULLTEXT INDEX `chunk_text` FOR (n:`Chunk`|`Section`) ON EACH [n.`text`, n.`title`]
OPTIONS {indexConfig: {`fulltext.analyzer`: 'english', `fulltext.eventually_consistent`: false}}
Relationship forms use FOR ()-[r:TYPE]-(). The analyzer is always rendered: its default, standard-no-stop-words, is also Neo4j's default, and declaring it makes the result independent of the server's db.index.fulltext.default_analyzer setting. Options the definition doesn't have are left to the server, such as vector quantization, which Neo4j 2026.09 enables by default. They are reported by observation, not compared.
3. Observation and exact matching¶
- Options are now read. The inspector's index query also yields
options, andGraphObservedIndexgainsIndexConfig(IReadOnlyDictionary<string, object?>?), a copy ofoptions.indexConfig. - A second constructor takes it, with no optional parameters, so the existing constructor and its callers are unchanged, in source and in binaries.
- A row without an
optionscolumn, such as the scaffolding inspector's, gives a null configuration, which matches no vector or full-text definition. - A named vector index matches its definition when all of these hold:
- its type is
VECTOR; - its entity type, single label or type and single property are the declared ones;
vector.dimensionsequals the declared dimensions;vector.similarity_functionequals the declared similarity, case-insensitively.- A named full-text index matches its definition when all of these hold:
- its type is
FULLTEXTand its entity type is the declared one; - its labels or types are the same set;
- its properties are the same set, because their order doesn't change a full-text index (Doctor compares them the same way);
- the analyzer is the declared one, case-insensitively;
fulltext.eventually_consistentis the declared value.- Anything else under the step's name is a conflict, and the step stops for review, as it does today.
- Equivalents under another name refuse creation. An index of the same type, entity type, label or type set and property set refuses creation, whatever its options. The message names the other index.
- For vector indexes this is Neo4j's own rule: one per label and property, whatever the options.
- For full-text indexes Neo4j's check is narrower: it refuses the same labels and properties in the same order, whatever the options, and accepts a reordered list as a second index. GraphBridge refuses the reordered copy too, because it compares full-text schemas as sets everywhere (matching above, and Doctor) and the copy would index the same text twice.
- Recorded live on Neo4j 2026.09.0 (25 October 2026), where the first live run corrected this section's earlier claim that Neo4j refuses both. The same probe showed that
IF NOT EXISTSturns an equivalent create into a silent no-op, which is one more reason the rendered DDL never uses it. - Readiness uses the existing wait: POPULATING waits until
IndexWaitTimeout, ONLINE completes the step, and FAILED stops for review. - Eventual consistency: an eventually consistent full-text index can be ONLINE before recent writes are searchable. The docs say so; the migration doesn't wait for refreshes.
4. What stays the same¶
- No raw Cypher in plans, no DDL outside an explicit migration run, nothing at startup or in SaveChanges.
- The model manifest and
InspectGraphSchemaAsync's comparison don't gain search-index intent. Search indexes are declared for Doctor throughGraphQueryCatalog, and migrations create them. A later helper may build migration definitions from those declarations.
Not adopted¶
- Parameters in DDL: literals keep the preview and checksum self-contained (section 2).
- Leaving the analyzer to the server: the server's default is a setting the inspector doesn't read, so the step couldn't check what it created.
- Quantization and HNSW tuning options now: no caller needs them yet. They are added as optional definition members when one does, and are compared only when declared.
- Creating search indexes from EF model annotations: GraphBridge creates no schema from the model implicitly, and embeddings as EF properties (a separate ADR) decide their own index metadata.
Verification¶
- Unit tests:
- the exact Cypher for node and relationship vector and full-text steps;
- preview checksums, and unchanged canonical JSON and checksums for plans of the existing kinds;
- validation refusals: dimensions out of range, empty, duplicate or too many labels and properties, analyzer text, reserved names, the plain constructor with a new kind;
- observation parsing with and without
options; - matching and state for every rule in section 3;
- capability gating.
- Live tests, Neo4j 2026.09:
- a plan creates a node vector index and a two-label full-text index, waits for ONLINE and journals both;
- a rerun is idempotent;
- typed vector and full-text search work on the created indexes;
- a differently configured index under the step's name is a conflict;
- an equivalent under another name refuses creation;
- a drop with the expected definition removes the index;
- Doctor reports the declared indexes ONLINE.
Consequences¶
- Search indexes get the same review, journal and readiness guarantees as constraints.
- The GraphRAG toolkit's provisioning applies reviewed plans instead of its named DDL workaround.
GraphObservedIndexcarries options, which later drift checks can use.
Review¶
Reviewed on 24 October 2026 against the source, with an independent second reading, before acceptance. The review added:
- unchanged canonical JSON and checksums for plans of the existing kinds, pinned by a test;
- every label of a full-text step in the preview's impact, and the two capability properties;
- an always-declared analyzer, defaulting to standard-no-stop-words, and full-text properties compared as a set;
- a second GraphObservedIndex constructor that leaves the existing one unchanged;
- literals in index DDL as a choice, since recent Neo4j releases accept parameters there.