Skip to content

Enhancing VectorStoreWriter for better RAG support#7396

Open
Copilot wants to merge 16 commits intodata-ingestion-preview2from
copilot/fix-vectorstorewriter-injection
Open

Enhancing VectorStoreWriter for better RAG support#7396
Copilot wants to merge 16 commits intodata-ingestion-preview2from
copilot/fix-vectorstorewriter-injection

Conversation

Copy link
Contributor

Copilot AI commented Mar 15, 2026

  • Fix unnecessary using System in TestChunkRecordWithMetadata.cs (CI failure S1128)
  • Remove stale using System.Text.Json.Serialization from all 5 snapshot IngestedChunk.cs files (template snapshot test failures)
  • Rename CreateCollectionDefinition to CreateDefaultCollectionDefinition
  • Make VectorStoreWriter non-sealed and move SetMetadata from IngestedChunkRecord to VectorStoreWriter
  • Rename IngestedChunkRecord to IngestionChunkVectorRecord everywhere
  • Apply varVectorStoreCollectionDefinition fix in VectorStoreExtensions.cs
  • Add simplified GetIngestionRecordCollection overload without generic type parameters
  • Add XML remarks to GetIngestionRecordCollection and extend README.md with code samples
  • Fix README.md to use explicit type names instead of var (use using VectorStoreWriter writer = new syntax)
  • Build with -warnaserror — 0 warnings, 0 errors
  • Run tests — 123 passed, 11 skipped, 0 failed

📍 Connect Copilot coding agent with Jira, Azure Boards or Linear to delegate work to Copilot in one click without leaving your project management tool.

Copilot AI and others added 5 commits March 14, 2026 20:50
…tedChunkRecord base type

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
…toreWriter refactoring

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
…ion property names

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
@github-actions github-actions bot added the area-ai-templates Microsoft.Extensions.AI.Templates label Mar 15, 2026
@adamsitnik adamsitnik added area-data-ingestion and removed area-ai-templates Microsoft.Extensions.AI.Templates labels Mar 15, 2026
Copy link
Member

@adamsitnik adamsitnik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot address my feedback

Copilot stopped work on behalf of adamsitnik due to an error March 16, 2026 11:57
…al, throw in SetMetadata, make consts protected, remove JSON attributes

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
Copilot AI requested a review from adamsitnik March 16, 2026 12:09
Copy link
Member

@adamsitnik adamsitnik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot please address my feedback

…oc reference, remove JsonPropertyName, add custom schema test

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 16, 2026

@copilot please address my feedback

All four feedback items addressed in c3c57e0: storage name consts made private, VectorStoreWriterOptions doc fixed, [JsonPropertyName] removed from test, and new CanWriteChunksWithCustomDefinition test added.

Copilot AI requested a review from adamsitnik March 16, 2026 12:36
Copy link
Member

@adamsitnik adamsitnik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot address my feedback

… custom storage names in test

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
Copilot AI requested a review from adamsitnik March 16, 2026 12:45
Copy link
Member

@adamsitnik adamsitnik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot please address my feedback

… for consistency

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
@adamsitnik adamsitnik marked this pull request as ready for review March 16, 2026 15:42
@adamsitnik adamsitnik requested a review from a team as a code owner March 16, 2026 15:42
Copy link
Member

@adamsitnik adamsitnik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, @roji the PR is ready for review.

/// </summary>
/// <typeparam name="TChunk">The type of the chunk content.</typeparam>
/// <remarks>
/// When the vector dimension count is not known at compile time, use the <see cref="CreateCollectionDefinition"/>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To other reviewers: this is very important. My idea was following:

  • introduce a non-sealed IngestedChunkRecord<TChunk> type that comes with all the default properties. It gives us the ability to perform the query to get chunks that point to the same document but also allows the users for an easy RAG (they don't need to provide their own type)
  • those who don't need any custom schema, can just use this type and call CreateCollectionDefinition to create the definition (it's required, because they need so somehow provide the dimensionCount).
  • those who need a custom schema, can create it and pass to VectoreStoreCollection ctor
  • the other way to customize the schema is to derive from IngestedChunkRecord<TChunk> and override selected properties.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK... It has been a while since we discussed all this so I may have forgotten some considerations we talked about.

So If I understand correctly, this moves away from the previous dynamic approach (Dictionary<string, object?>) to a typed approach. Some comments:

  • Importantly, typed mapping with MEVD is (currently) not trimming (and therefore NativeAOT)-compatible... Serializing/deserializing a dictionary is easy enough, but doing it with a .NET type requires a source generator which we don't yet have. I think that's going to be a problem.
  • I admit it took me quite a while to understand how custom metadata is supposed to work (with SetMetadata()) and why; having a partly typed, partly dynamic story seems to introduce quite a bit of complexity/weirdness. Here's my understanding:
    • A user that wants custom metadata must extend IngestedChunkRecord (this already feels a bit heavy compared to just having a Dictionary<string, object?> as before).
    • On their CustomIngestedChunkRecord, they add .NET properties for the extra metadata.
    • But they must also override SetMetadata(), to copy the dynamic metadata properties from the incoming IngestionChunk to the strongly-typed .NET properties on CustomIngestedChunkRecord. That's some various boilerplate-y, tedious glue between the dynamic nature of IngestionChunk and the static nature of IngestedChunkRecord.
    • (aside from all the above, they must also call CreateCollectionDefinition() to get the VectorStoreCollectionDefinition, and mutate that to add their custom properties. But that's unrelated)
  • Note that their code in SetMetadata() can get out of sync... Like I can see someone adding a new static property on CustomIngestedChunkRecord, but then forgetting to update SetMetadata() (and then we silently write empty properties).
    * In other words, users need to keep (1) the incoming IngestionChunk's metadata (wherever it's populated), (2) CustomIngestedChunkRecord's .NET properties, (3) SetMetadata() and (4) the record definition in sync, which seems quite brittle... With the previous, fully dynamic model only (1) and (4) needed to be kept in sync (which is the absolute minimum)
  • Because of all this, I'm trying to understand the value we get out of this partly static/partly dynamic design, compared to simply continuing to map Dictionary<string, object?> as before, and whether it's worth it...

/// <summary>
/// Gets or sets the name of the collection. When not provided, "chunks" will be used.
/// </summary>
public string CollectionName
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To other reviewers: These settings are no longer configured by the writer, they are part of the collection creation process.

[VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction, StorageName = "embedding")]
[JsonPropertyName("embedding")]
public string? Vector => Text;
[VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction, StorageName = EmbeddingStorageName)]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To other reviewers: this is the simplest way of configuring the dimension count (overriding virtual property and annotating it with the right attribute)


// User creates their own definition without using CreateCollectionDefinition,
// using custom storage names to prove they can map to a pre-existing collection schema.
VectorStoreCollectionDefinition definition = new()
Copy link
Member

@adamsitnik adamsitnik Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To other reviewers: this is an example of providing custom schema (without the need to provide a dedicated mapper)

/// When the vector dimension count is known at compile time, derive from this class and add
/// the <see cref="VectorStoreVectorAttribute"/> to the <see cref="Embedding"/> property.
/// </remarks>
public class IngestedChunkRecord<TChunk>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To other reviewers: I am going to work on removing this generic argument very soon (I want IngestionChunk to be able to represent any input without using generic argument). But it's out of the scope of this PR.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know you're going to remove the generic argument, but FYI the TChunk name is causing me a bit of confusion, also in IngestionChunk<TChunk> (as if it's a chunk over itself). When reading this code I wasn't sure if with IngestedChunkRecord<TChunk>, TChunk should be string or IngestionChunk<string>.

So maybe consider renaming TChunk to just T (or TContent) everywhere.

Copy link
Member

@adamsitnik adamsitnik Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I really want to remove it and now I think I even know how ( #7404)

Please keep in mind it's Preview2 branch, so whatever gets merged does not automatically gets released to nuget.org. So I would prefer to keep TChunk here and just remove it completely in next PR.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the plan to simply replace TChunk with AIContent? If so, it might be good to think about this a bit together.

For one thing, IEmbeddingGenerator doesn't restrict its input to be AIContent - it can be any TInput, so restricting IngestionChunk to be over AIContent may hinder some scenarios. For example, one of the design ideas behind IEmbeddingGenerator was to allow user-specific types (e.g. Product) to be passed as input, and then the custom IEmbeddingGenerator implementation knows how to generate embeddings from the Product's different fields. I'm not sure how relevant that is for the ingestion pipeline, but maybe worth thinking about (obviously a Product cannot just be written via MEVD in any case).

That also brings the question of whether we want to store (or allow storing) the media type for binary data in the vector database. For example, for an ingestion pipeline handling images, presumably chunks will have a DataContent as their content, which has a MediaType (to distinguish PNG vs. JPG). It would make sense to have that media type in the database, so that when the image is loaded on the consumption side, we know what the raw bytes actually represent...

Anyway, some things to think about...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the plan to simply replace TChunk with AIContent?

I don't have a very clear plan in mind. I know that if we just replace TChunk with AIContent, plenty of things are going to stop working (for example, the embedding generation done by MEVD during upserts because I suppose it gets IEmbeddingGenerator<string> from vector store write options).

All of that could be done with some extra mapping, but one thing that is not clear to me is whether we can support a chunker that returns chunks of different types (for example text or image) and then store it into a single vector property. I suspect that text and image would use different models or at least having different dimension count.

And overall supporting the ability to return chunks of different types is one of our preview 2 goals.

Copy link
Member

@roji roji left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @adamsitnik here are some first thoughts/questions about the new design... We should probably get past these before me reviewing the rest of the PR in detail. Feels like maybe we should jump into a call to discuss this stuff.

/// When the vector dimension count is known at compile time, derive from this class and add
/// the <see cref="VectorStoreVectorAttribute"/> to the <see cref="Embedding"/> property.
/// </remarks>
public class IngestedChunkRecord<TChunk>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know you're going to remove the generic argument, but FYI the TChunk name is causing me a bit of confusion, also in IngestionChunk<TChunk> (as if it's a chunk over itself). When reading this code I wasn't sure if with IngestedChunkRecord<TChunk>, TChunk should be string or IngestionChunk<string>.

So maybe consider renaming TChunk to just T (or TContent) everywhere.

/// </summary>
/// <typeparam name="TChunk">The type of the chunk content.</typeparam>
/// <remarks>
/// When the vector dimension count is not known at compile time, use the <see cref="CreateCollectionDefinition"/>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK... It has been a while since we discussed all this so I may have forgotten some considerations we talked about.

So If I understand correctly, this moves away from the previous dynamic approach (Dictionary<string, object?>) to a typed approach. Some comments:

  • Importantly, typed mapping with MEVD is (currently) not trimming (and therefore NativeAOT)-compatible... Serializing/deserializing a dictionary is easy enough, but doing it with a .NET type requires a source generator which we don't yet have. I think that's going to be a problem.
  • I admit it took me quite a while to understand how custom metadata is supposed to work (with SetMetadata()) and why; having a partly typed, partly dynamic story seems to introduce quite a bit of complexity/weirdness. Here's my understanding:
    • A user that wants custom metadata must extend IngestedChunkRecord (this already feels a bit heavy compared to just having a Dictionary<string, object?> as before).
    • On their CustomIngestedChunkRecord, they add .NET properties for the extra metadata.
    • But they must also override SetMetadata(), to copy the dynamic metadata properties from the incoming IngestionChunk to the strongly-typed .NET properties on CustomIngestedChunkRecord. That's some various boilerplate-y, tedious glue between the dynamic nature of IngestionChunk and the static nature of IngestedChunkRecord.
    • (aside from all the above, they must also call CreateCollectionDefinition() to get the VectorStoreCollectionDefinition, and mutate that to add their custom properties. But that's unrelated)
  • Note that their code in SetMetadata() can get out of sync... Like I can see someone adding a new static property on CustomIngestedChunkRecord, but then forgetting to update SetMetadata() (and then we silently write empty properties).
    * In other words, users need to keep (1) the incoming IngestionChunk's metadata (wherever it's populated), (2) CustomIngestedChunkRecord's .NET properties, (3) SetMetadata() and (4) the record definition in sync, which seems quite brittle... With the previous, fully dynamic model only (1) and (4) needed to be kept in sync (which is the absolute minimum)
  • Because of all this, I'm trying to understand the value we get out of this partly static/partly dynamic design, compared to simply continuing to map Dictionary<string, object?> as before, and whether it's worth it...

@adamsitnik
Copy link
Member

Because of all this, I'm trying to understand the value we get out of this partly static/partly dynamic design, compared to simply continuing to map Dictionary<string, object?> as before, and whether it's worth it...

So far, there was only one schema (created by us), so we knew how to do the mapping. If we continue to map to Dictionary<string, object?> but also accept a custom schema, the user needs to tell us how exactly we should perform the mapping.

And once they want to do RAG, they need to re-map the dictionary using these magic names.

With strongly typed approach, they don't need to. And RAG is much easier. But at a cost of complicated metadata story (which is not very common scenario).

Let's have a call later today and discuss it.

@roji
Copy link
Member

roji commented Mar 19, 2026

If we continue to map to Dictionary<string, object?> but also accept a custom schema, the user needs to tell us how exactly we should perform the mapping.

Isn't that still the case even after this PR? The vector collection definition still needs to be tweaked by the user to include the custom metadata properties, and these must correspond 100% to what will actually be coming in on IngestionChunk, right?

With strongly typed approach, they don't need to. And RAG is much easier. But at a cost of complicated metadata story (which is not very common scenario).

Right, I can see that.

The basic problem here - and I think the source of the complexity - is that we have a dynamic metadata scheme on the ingestion framework side (on IngestionChunk), and we're trying to shoehorn that into a static, typed scheme for MEVD.

Maybe an alternative here is to say that the built-in VectorStoreWriter only works with the universal/built-in fields (no custom metadata), and if you want custom metadata you need to do your own VectorStoreWriter; this effectively moves the complexity (like SetMetadata()) from IngestedChunkRecord into the writer. We may be able to think of making VectorStoreWriter extensible with hooks for this (again, instead of having an extensible IngestedChunkRecord with its SetMetadata() hook).

Just some ideas I haven't fully thought through yet. Let's discuss.

Copy link
Member

@adamsitnik adamsitnik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot please address my feedback

…n, move SetMetadata to non-sealed VectorStoreWriter

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
Copilot AI requested a review from adamsitnik March 19, 2026 16:08
/// <returns>A vector store collection configured for ingested chunk records.</returns>
[RequiresDynamicCode("This API is not compatible with NativeAOT. You can implement your own IngestionChunkWriter that uses dynamic mapping via VectorStore.GetCollectionDynamic().")]
[RequiresUnreferencedCode("This API is not compatible with trimming. You can implement your own IngestionChunkWriter that uses dynamic mapping via VectorStore.GetCollectionDynamic().")]
public static VectorStoreCollection<Guid, TRecord> GetIngestionRecordCollection<TRecord, TChunk>(this VectorStore vectorStore,
Copy link
Member

@adamsitnik adamsitnik Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To other reviewers: This is an alternative to exposing entire schema. We can just expose a factory method that does the right thing.

The advantages;

  • one method call instead of two
  • clear message what needs to happen when you need NativeAOT.

The disadvantages:

/// Override this method in derived classes to store metadata as typed properties with
/// <see cref="VectorStoreDataAttribute"/> attributes.
/// </remarks>
protected virtual void SetMetadata(TRecord record, string key, object? value)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To other reviewers: So far, we were optimized for very easy ingestion. Now, the RAG is way simpler but when you need to use metadata, you need to create a derived type and handle it on your own. We throw here to avoid silent errors.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks.

Below are some design thoughts I'm dumping here, not necessarily actionable; just stuff that's in my mind that we can discuss if we want.

There's still a slight nagging question in my head - if we need/want the custom metadata to be strongly-typed, does it make sense to consider making it strongly typed IngestionChunk as well? In other words, it's slightly weird that before the writing phase, the ingestion pipeilne treats metadata as a dynamic Dictionary property bag, but then at the very end suddenly needs to convert it to a static .NET type via a custom user-provided hook (especially since I'm assuming most usages of MEDI will end in VectorStoreWriter).

Though to argue against myself, since we're proposing to have two distinct types - IngestionChunk (in the pipeline) and IngestedChunkRecord (only in the MEVD writer, representing the database record), there would need to be some custom hook in any case, to transfer the custom metadata from the former to the latter. So at that point it maybe doesn't matter whether the input (IngestionChunk) is dynamic or not.

The only way this becomes really simple, is if we have a single (strongly-typed, inheritable) IngestionChunk type rather than two; this would allow us to both use it in the ingestion pipeline and to directly map it to the database in the MEVD writer. At that point no more custom data transformation hook is needed between the two types.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to provide an abstraction and a default implementation. Having two different types makes it possible and if for some reason our default implementation is not enough for given scenario, the user can implement their own writer.

So the idea is that all the chunk processors use IngestionChunk and just insert any metadata they want and it's the job of the writer to somehow persist this information.

@adamsitnik adamsitnik requested a review from roji March 20, 2026 11:04
Copy link
Member

@roji roji left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here you go @adamsitnik, we can chat again on some of the stuff below if you want.

/// When the vector dimension count is known at compile time, derive from this class and add
/// the <see cref="VectorStoreVectorAttribute"/> to the <see cref="Embedding"/> property.
/// </remarks>
public class IngestedChunkRecord<TChunk>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the plan to simply replace TChunk with AIContent? If so, it might be good to think about this a bit together.

For one thing, IEmbeddingGenerator doesn't restrict its input to be AIContent - it can be any TInput, so restricting IngestionChunk to be over AIContent may hinder some scenarios. For example, one of the design ideas behind IEmbeddingGenerator was to allow user-specific types (e.g. Product) to be passed as input, and then the custom IEmbeddingGenerator implementation knows how to generate embeddings from the Product's different fields. I'm not sure how relevant that is for the ingestion pipeline, but maybe worth thinking about (obviously a Product cannot just be written via MEVD in any case).

That also brings the question of whether we want to store (or allow storing) the media type for binary data in the vector database. For example, for an ingestion pipeline handling images, presumably chunks will have a DataContent as their content, which has a MediaType (to distinguish PNG vs. JPG). It would make sense to have that media type in the database, so that when the image is loaded on the consumption side, we know what the raw bytes actually represent...

Anyway, some things to think about...

/// Override this method in derived classes to store metadata as typed properties with
/// <see cref="VectorStoreDataAttribute"/> attributes.
/// </remarks>
protected virtual void SetMetadata(TRecord record, string key, object? value)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks.

Below are some design thoughts I'm dumping here, not necessarily actionable; just stuff that's in my mind that we can discuss if we want.

There's still a slight nagging question in my head - if we need/want the custom metadata to be strongly-typed, does it make sense to consider making it strongly typed IngestionChunk as well? In other words, it's slightly weird that before the writing phase, the ingestion pipeilne treats metadata as a dynamic Dictionary property bag, but then at the very end suddenly needs to convert it to a static .NET type via a custom user-provided hook (especially since I'm assuming most usages of MEDI will end in VectorStoreWriter).

Though to argue against myself, since we're proposing to have two distinct types - IngestionChunk (in the pipeline) and IngestedChunkRecord (only in the MEVD writer, representing the database record), there would need to be some custom hook in any case, to transfer the custom metadata from the former to the latter. So at that point it maybe doesn't matter whether the input (IngestionChunk) is dynamic or not.

The only way this becomes really simple, is if we have a single (strongly-typed, inheritable) IngestionChunk type rather than two; this would allow us to both use it in the ingestion pipeline and to directly map it to the database in the MEVD writer. At that point no more custom data transformation hook is needed between the two types.

Copy link
Member

@adamsitnik adamsitnik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot please address my feedback

…ied overload, fix typo, update README

- Renamed IngestedChunkRecord<TChunk> to IngestionChunkVectorRecord<TChunk> across all
  source, test, template, and snapshot files
- Fixed typo 'additiveDefintion' -> 'additiveDefinition' in VectorStoreExtensions.cs
- Added simplified non-generic GetIngestionRecordCollection overload for the common
  IngestionChunkVectorRecord<string> case
- Added XML remarks to generic GetIngestionRecordCollection method documenting usage
  patterns (simple, custom metadata, custom schema)
- Extended README.md with code samples for basic usage, custom metadata, and custom
  collection schema

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI requested a review from adamsitnik March 21, 2026 12:01
Copy link
Member

@adamsitnik adamsitnik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot please address my feedback

Copilot AI requested a review from adamsitnik March 21, 2026 12:13
@adamsitnik adamsitnik requested a review from roji March 21, 2026 13:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants