From 08456ebc6d393f1fe363230103ba208de75329ba Mon Sep 17 00:00:00 2001 From: Florian Bernd Date: Tue, 25 Nov 2025 15:46:33 +0100 Subject: [PATCH 1/4] Add custom vector data converters for high performance ingest scenarios --- .../ElasticsearchClientSettings.cs | 16 + .../IElasticsearchClientSettings.cs | 31 +- .../_Shared/Next/JsonWriterExtensions.cs | 15 + .../_Shared/Next/VectorConverters.cs | 298 ++++++++++++++++++ 4 files changed, 356 insertions(+), 4 deletions(-) create mode 100644 src/Elastic.Clients.Elasticsearch/_Shared/Next/VectorConverters.cs diff --git a/src/Elastic.Clients.Elasticsearch/_Shared/Core/Configuration/ElasticsearchClientSettings.cs b/src/Elastic.Clients.Elasticsearch/_Shared/Core/Configuration/ElasticsearchClientSettings.cs index a8444286571..b313c76fd89 100644 --- a/src/Elastic.Clients.Elasticsearch/_Shared/Core/Configuration/ElasticsearchClientSettings.cs +++ b/src/Elastic.Clients.Elasticsearch/_Shared/Core/Configuration/ElasticsearchClientSettings.cs @@ -115,6 +115,8 @@ public abstract class ElasticsearchClientSettingsBase : private readonly Serializer _sourceSerializer; private BeforeRequestEvent? _onBeforeRequest; private bool _experimentalEnableSerializeNullInferredValues; + private FloatVectorDataEncoding _floatVectorDataEncoding = Elasticsearch.FloatVectorDataEncoding.Base64; + private ByteVectorDataEncoding _byteVectorDataEncoding = Elasticsearch.ByteVectorDataEncoding.Base64; private ExperimentalSettings _experimentalSettings = new(); private bool _defaultDisableAllInference; @@ -165,6 +167,8 @@ protected ElasticsearchClientSettingsBase( FluentDictionary IElasticsearchClientSettings.RouteProperties => _routeProperties; Serializer IElasticsearchClientSettings.SourceSerializer => _sourceSerializer; BeforeRequestEvent? IElasticsearchClientSettings.OnBeforeRequest => _onBeforeRequest; + FloatVectorDataEncoding IElasticsearchClientSettings.FloatVectorDataEncoding => _floatVectorDataEncoding; + ByteVectorDataEncoding IElasticsearchClientSettings.ByteVectorDataEncoding => _byteVectorDataEncoding; ExperimentalSettings IElasticsearchClientSettings.Experimental => _experimentalSettings; bool IElasticsearchClientSettings.ExperimentalEnableSerializeNullInferredValues => _experimentalEnableSerializeNullInferredValues; @@ -198,6 +202,18 @@ public TConnectionSettings DefaultFieldNameInferrer(Func fieldNa public TConnectionSettings ExperimentalEnableSerializeNullInferredValues(bool enabled = true) => Assign(enabled, (a, v) => a._experimentalEnableSerializeNullInferredValues = v); + /// + /// The default vector data encoding to use. + /// This settings instance for chaining. + public TConnectionSettings FloatVectorDataEncoding(FloatVectorDataEncoding encoding) => + Assign(encoding, (a, v) => a._floatVectorDataEncoding = v); + + /// + /// The default vector data encoding to use. + /// This settings instance for chaining. + public TConnectionSettings ByteVectorDataEncoding(ByteVectorDataEncoding encoding) => + Assign(encoding, (a, v) => a._byteVectorDataEncoding = v); + public TConnectionSettings Experimental(ExperimentalSettings settings) => Assign(settings, (a, v) => a._experimentalSettings = v); diff --git a/src/Elastic.Clients.Elasticsearch/_Shared/Core/Configuration/IElasticsearchClientSettings.cs b/src/Elastic.Clients.Elasticsearch/_Shared/Core/Configuration/IElasticsearchClientSettings.cs index ffda6461b83..376b8d41da8 100644 --- a/src/Elastic.Clients.Elasticsearch/_Shared/Core/Configuration/IElasticsearchClientSettings.cs +++ b/src/Elastic.Clients.Elasticsearch/_Shared/Core/Configuration/IElasticsearchClientSettings.cs @@ -116,14 +116,37 @@ public interface IElasticsearchClientSettings : ITransportConfiguration BeforeRequestEvent? OnBeforeRequest { get; } /// - /// This is an advanced setting which controls serialization behaviour for inferred properies such as ID, routing and index name. - /// When enabled, it may reduce allocations on serialisation paths where the cost can be more significant, such as in bulk operations. + /// This is an advanced setting which controls serialization behaviour for inferred properties such as ID, routing and index name. + /// When enabled, it may reduce allocations on serialization paths where the cost can be more significant, such as in bulk operations. /// As a by-product it may cause null values to be included in the serialized data and impact payload size. This will only be a concern should some - /// typed not have inferrence mappings defined for the required properties. + /// typed not have inference mappings defined for the required properties. /// - /// This is marked as experiemental and may be removed or renamed in the future once its impact is evaluated. + /// This is marked as experimental and may be removed or renamed in the future once its impact is evaluated. bool ExperimentalEnableSerializeNullInferredValues { get; } + /// + /// Controls the vector data encoding to use for properties + /// in documents during ingestion when the is used. + /// + /// + /// Setting this value to provides backwards + /// compatibility when talking to Elasticsearch servers with a version older than 9.3.0 + /// (required for ). + /// + FloatVectorDataEncoding FloatVectorDataEncoding { get; } + + /// + /// Controls the vector data encoding to use for properties + /// in documents during ingestion when the is used. + /// + /// + /// Setting this value to provides backwards + /// compatibility when talking to Elasticsearch servers with a version older than 8.14.0 + /// (required for ) or older than 9.3.0 (required + /// for ). + /// + ByteVectorDataEncoding ByteVectorDataEncoding { get; } + /// /// Experimental settings. /// diff --git a/src/Elastic.Clients.Elasticsearch/_Shared/Next/JsonWriterExtensions.cs b/src/Elastic.Clients.Elasticsearch/_Shared/Next/JsonWriterExtensions.cs index 926a284fb90..2258026d8c0 100644 --- a/src/Elastic.Clients.Elasticsearch/_Shared/Next/JsonWriterExtensions.cs +++ b/src/Elastic.Clients.Elasticsearch/_Shared/Next/JsonWriterExtensions.cs @@ -250,6 +250,21 @@ public static void WriteUnionValue(this Utf8JsonWriter writer, JsonSeria ); } + public static void WriteSpanValue(this Utf8JsonWriter writer, JsonSerializerOptions options, ReadOnlySpan span, + JsonWriteFunc? writeElement) + { + writeElement ??= static (w, o, v) => WriteValue(w, o, v); + + writer.WriteStartArray(); + + foreach (var element in span) + { + writeElement(writer, options, element); + } + + writer.WriteEndArray(); + } + #endregion Delegate Based Write Methods #region Specialized Write Methods diff --git a/src/Elastic.Clients.Elasticsearch/_Shared/Next/VectorConverters.cs b/src/Elastic.Clients.Elasticsearch/_Shared/Next/VectorConverters.cs new file mode 100644 index 00000000000..f6a6bd2aebe --- /dev/null +++ b/src/Elastic.Clients.Elasticsearch/_Shared/Next/VectorConverters.cs @@ -0,0 +1,298 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Elastic.Clients.Elasticsearch.Serialization; + +namespace Elastic.Clients.Elasticsearch; + +/// +/// The encoding to use when serializing vector data using the converter. +/// +public enum FloatVectorDataEncoding +{ + /// + /// Legacy (JSON array) vector encoding for backwards compatibility. + /// + Legacy, + + /// + /// Base64 vector encoding. + /// + /// + /// Base64 encoding is available starting from Elasticsearch 9.3.0. + /// + Base64 +} + +public sealed class FloatVectorDataConverter : + JsonConverter> +{ + private FloatVectorDataEncoding? _encoding; + + public override ReadOnlyMemory Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.StartArray => new(reader.ReadCollectionValue(options, null)!.ToArray()), + JsonTokenType.String => ReadBase64VectorData(ref reader), + _ => throw reader.UnexpectedTokenException(JsonTokenType.StartArray, JsonTokenType.String) + }; + } + + public override void Write(Utf8JsonWriter writer, ReadOnlyMemory value, JsonSerializerOptions options) + { + var encoding = _encoding; + if (encoding is null) + { + var settings = ContextProvider.GetContext(options); + _encoding = settings.FloatVectorDataEncoding; + } + + switch (_encoding) + { + case FloatVectorDataEncoding.Legacy: + writer.WriteSpanValue(options, value.Span, null); + break; + + case FloatVectorDataEncoding.Base64: + WriteBase64VectorData(writer, value); + break; + + default: + throw new NotSupportedException(); + } + } + + private static ReadOnlyMemory ReadBase64VectorData(ref Utf8JsonReader reader) + { + var bytes = reader.GetBytesFromBase64(); + + if ((bytes.Length & 3) != 0) + { + throw new ArgumentException("Decoded vector data length is not a multiple of 4 (not valid 32-bit floats)."); + } + + var span = bytes.AsSpan(); + + if (BitConverter.IsLittleEndian) + { + // Host is little-endian. We must swap the byte order. + + var intSourceDest = MemoryMarshal.Cast(span); + + for (var i = 0; i < intSourceDest.Length; i++) + { + intSourceDest[i] = BinaryPrimitives.ReverseEndianness(intSourceDest[i]); + } + } + + var result = new float[bytes.Length / 4]; + Buffer.BlockCopy(bytes, 0, result, 0, bytes.Length); + + return new(result); + } + + private static void WriteBase64VectorData(Utf8JsonWriter writer, ReadOnlyMemory value) + { + if (value.IsEmpty) + { + writer.WriteStringValue(string.Empty); + return; + } + + // If the host is big-endian we can reinterpret the memory as bytes without copying. + if (!BitConverter.IsLittleEndian) + { + writer.WriteBase64StringValue(MemoryMarshal.AsBytes(value.Span)); + } + + // Host is little-endian. We must swap the byte order. + + var pool = MemoryPool.Shared; + var required = checked(value.Length * sizeof(float)); + var owner = pool.Rent(required); + + try + { + var dest = owner.Memory.Span[..required]; + + var intSource = MemoryMarshal.Cast(value.Span); + var intDest = MemoryMarshal.Cast(dest); + + for (var i = 0; i < intSource.Length; i++) + { + intDest[i] = BinaryPrimitives.ReverseEndianness(intSource[i]); + } + + writer.WriteBase64StringValue(dest); + } + finally + { + owner.Dispose(); + } + } +} + +/// +/// The encoding to use when serializing vector data using the converter. +/// +public enum ByteVectorDataEncoding +{ + /// + /// Legacy (JSON array) vector encoding for backwards compatibility. + /// + Legacy, + + /// + /// Hexadecimal string vector encoding. + /// + /// + /// Hexadecimal encoding is available starting from Elasticsearch 8.14.0. + /// + Hex, + + /// + /// Base64 vector encoding. + /// + /// + /// Base64 encoding is available starting from Elasticsearch 9.3.0. + /// + Base64 +} + +public sealed class ByteVectorDataConverter : + JsonConverter> +{ + private ByteVectorDataEncoding? _encoding; + + public override ReadOnlyMemory Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.StartArray => new(reader.ReadCollectionValue(options, (ref r, _) => unchecked((byte)r.GetSByte()))!.ToArray()), + JsonTokenType.String => ReadStringVectorData(ref reader), + _ => throw reader.UnexpectedTokenException(JsonTokenType.StartArray, JsonTokenType.String) + }; + } + + public override void Write(Utf8JsonWriter writer, ReadOnlyMemory value, JsonSerializerOptions options) + { + if (_encoding is null) + { + var settings = ContextProvider.GetContext(options); + _encoding = settings.ByteVectorDataEncoding; + } + + switch (_encoding) + { + case ByteVectorDataEncoding.Legacy: + writer.WriteSpanValue(options, value.Span, (w, _, b) => w.WriteNumberValue(unchecked((sbyte)b))); + break; + + case ByteVectorDataEncoding.Hex: + WriteHexVectorData(writer, value); + break; + + case ByteVectorDataEncoding.Base64: + writer.WriteBase64StringValue(value.Span); + break; + + default: + throw new NotSupportedException(); + } + } + + private static ReadOnlyMemory ReadStringVectorData(ref Utf8JsonReader reader) + { + if (reader.TryGetBytesFromBase64(out var result)) + { + return result; + } + + return ReadHexVectorData(ref reader); + } + + private static ReadOnlyMemory ReadHexVectorData(ref Utf8JsonReader reader) + { +#if NET5_0_OR_GREATER + var data = Convert.FromHexString(reader.GetString()!); +#else + var data = FromHex(reader.GetString()!); +#endif + + return new(data); + } + + private static void WriteHexVectorData(Utf8JsonWriter writer, ReadOnlyMemory value) + { + if (value.IsEmpty) + { + writer.WriteStringValue(string.Empty); + return; + } + + // We don't use Convert.ToHexString even for .NET 5.0+ to be able to use pooled memory. + + var pool = MemoryPool.Shared; + var required = checked(value.Length * 2); + var owner = pool.Rent(required); + + try + { + var source = value.Span; + var dest = owner.Memory.Span[..required]; + + byte b; + + for(int bx = 0, cx = 0; bx < source.Length; ++bx, ++cx) + { + b = ((byte)(source[bx] >> 4)); + dest[cx] = (char)(b > 9 ? b + 0x37 : b + 0x30); + b = ((byte)(source[bx] & 0x0F)); + dest[++cx]=(char)(b > 9 ? b + 0x37 : b + 0x30); + } + + writer.WriteStringValue(dest); + } + finally + { + owner.Dispose(); + } + } + +#if !NET5_0_OR_GREATER + public static byte[] FromHex(string data) + { + if (data.Length is 0) + { + return []; + } + + if (data.Length % 2 != 0) + { + throw new ArgumentException("Decoded vector data length is not a multiple of 2 (not valid 8-bit hex niblets)."); + } + + var buffer = new byte[data.Length / 2]; + char c; + + for (int bx = 0, sx = 0; bx < buffer.Length; ++bx, ++sx) + { + c = data[sx]; + buffer[bx] = (byte)((c > '9' ? (c > 'Z' ? (c - 'a' + 10) : (c - 'A' + 10)) : (c - '0')) << 4); + c = data[++sx]; + buffer[bx] |= (byte)(c > '9' ? (c > 'Z' ? (c - 'a' + 10) : (c - 'A' + 10)) : (c - '0')); + } + + return buffer; + } +#endif +} From f8287e3527035e754b7299ea1b187253b965ef76 Mon Sep 17 00:00:00 2001 From: Florian Bernd Date: Tue, 2 Dec 2025 14:59:31 +0100 Subject: [PATCH 2/4] Add documentation --- docs/reference/source-serialization.md | 58 +++++++++++++++++++ .../ElasticsearchClientSettings.cs | 4 +- .../IElasticsearchClientSettings.cs | 3 + .../_Shared/Next/VectorConverters.cs | 4 +- 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/docs/reference/source-serialization.md b/docs/reference/source-serialization.md index 41d6fb074c1..f8c2c14c8f6 100644 --- a/docs/reference/source-serialization.md +++ b/docs/reference/source-serialization.md @@ -16,6 +16,9 @@ Source serialization refers to the process of (de)serializing POCO types in cons - [Registering custom `System.Text.Json` converters](#registering-custom-converters) - [Creating a custom `Serializer`](#creating-custom-serializers) - [Native AOT](#native-aot) +- [Vector data serialization](#vector-data-serialization) + - [Opt‑in on document properties](#optin-on-document-properties) + - [Configure encodings globally](#configure-encodings-globally) ## Modeling documents with types [modeling-documents-with-types] @@ -451,3 +454,58 @@ static void ConfigureOptions(JsonSerializerOptions o) o.TypeInfoResolver = UserTypeSerializerContext.Default; } ``` + +## Vector data serialization [vector-data-serialization] + +Efficient ingestion of high-dimensional vectors often benefits from compact encodings rather than verbose JSON arrays. The client provides opt‑in converters for vector properties in your source documents that serialize to either hexadecimal or `base64` strings, depending on the vector type and the Elasticsearch version you target. + +- Float vectors can use `base64` starting from Elasticsearch 9.3.0. +- Byte/bit vectors can use hexadecimal strings starting from Elasticsearch 8.14.0 and `base64` starting from Elasticsearch 9.3.0. +- The legacy representation (JSON arrays) remains available for backwards compatibility. + +Base64 is the preferred format for high‑throughput indexing because it minimizes payload size and reduces JSON parsing overhead. + +### Opt‑in on document properties [optin-on-document-properties] + +Vector encodings are opt‑in. Apply a `System.Text.Json` `JsonConverter` attribute on the vector property of your POCO. For best performance, model the properties as `ReadOnlyMemory`. + +```csharp +using System; +using System.Text.Json.Serialization; +using Elastic.Clients.Elasticsearch.Serialization; + +public class ImageEmbedding +{ + [JsonConverter(typeof(FloatVectorDataConverter))] <1> + public ReadOnlyMemory Vector { get; set; } +} + +public class ByteSignature +{ + [JsonConverter(typeof(ByteVectorDataConverter))] <2> + public ReadOnlyMemory Signature { get; set; } +} +``` + +1. `FloatVectorDataConverter` enables `base64` encoding for float vectors. +2. `ByteVectorDataConverter` enables `base64` encoding for byte vectors. + +Without these attributes, vectors are serialized using the default source serializer behavior. + +### Configure encodings globally [configure-encodings-globally] + +When the opt‑in attributes are present, you can control the actual wire encoding globally via `ElasticsearchClient` settings on a per‑type basis: + +- `FloatVectorDataEncoding`: controls float vector encoding (legacy arrays or `base64`). +- `ByteVectorDataEncoding`: controls byte/bit vector encoding (legacy arrays, hexadecimal, or `base64`). + +These settings allow a single set of document types to work against mixed clusters. For example, a library using the 8.19.x client can talk to both 8.x and 9.x servers and dynamically opt out of `base64` on older servers without maintaining duplicate POCOs (with/without converter attributes). + +::::{note} + +Set the encoding based on your effective server version: + +- Float vectors: use `base64` for 9.3.0+; otherwise use legacy arrays. +- Byte/bit vectors: prefer `base64` for 9.3.0+; use hexadecimal for 8.14.0–9.2.x; otherwise use legacy arrays. + +:::: diff --git a/src/Elastic.Clients.Elasticsearch/_Shared/Core/Configuration/ElasticsearchClientSettings.cs b/src/Elastic.Clients.Elasticsearch/_Shared/Core/Configuration/ElasticsearchClientSettings.cs index b313c76fd89..5b92ec7379d 100644 --- a/src/Elastic.Clients.Elasticsearch/_Shared/Core/Configuration/ElasticsearchClientSettings.cs +++ b/src/Elastic.Clients.Elasticsearch/_Shared/Core/Configuration/ElasticsearchClientSettings.cs @@ -115,8 +115,8 @@ public abstract class ElasticsearchClientSettingsBase : private readonly Serializer _sourceSerializer; private BeforeRequestEvent? _onBeforeRequest; private bool _experimentalEnableSerializeNullInferredValues; - private FloatVectorDataEncoding _floatVectorDataEncoding = Elasticsearch.FloatVectorDataEncoding.Base64; - private ByteVectorDataEncoding _byteVectorDataEncoding = Elasticsearch.ByteVectorDataEncoding.Base64; + private FloatVectorDataEncoding _floatVectorDataEncoding = Serialization.FloatVectorDataEncoding.Base64; + private ByteVectorDataEncoding _byteVectorDataEncoding = Serialization.ByteVectorDataEncoding.Base64; private ExperimentalSettings _experimentalSettings = new(); private bool _defaultDisableAllInference; diff --git a/src/Elastic.Clients.Elasticsearch/_Shared/Core/Configuration/IElasticsearchClientSettings.cs b/src/Elastic.Clients.Elasticsearch/_Shared/Core/Configuration/IElasticsearchClientSettings.cs index 376b8d41da8..36261666485 100644 --- a/src/Elastic.Clients.Elasticsearch/_Shared/Core/Configuration/IElasticsearchClientSettings.cs +++ b/src/Elastic.Clients.Elasticsearch/_Shared/Core/Configuration/IElasticsearchClientSettings.cs @@ -5,7 +5,10 @@ using System; using System.Collections.Generic; using System.Reflection; + using Elastic.Clients.Elasticsearch.Requests; +using Elastic.Clients.Elasticsearch.Serialization; + using Elastic.Transport; namespace Elastic.Clients.Elasticsearch; diff --git a/src/Elastic.Clients.Elasticsearch/_Shared/Next/VectorConverters.cs b/src/Elastic.Clients.Elasticsearch/_Shared/Next/VectorConverters.cs index f6a6bd2aebe..4a54d60f1b1 100644 --- a/src/Elastic.Clients.Elasticsearch/_Shared/Next/VectorConverters.cs +++ b/src/Elastic.Clients.Elasticsearch/_Shared/Next/VectorConverters.cs @@ -9,9 +9,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Elastic.Clients.Elasticsearch.Serialization; - -namespace Elastic.Clients.Elasticsearch; +namespace Elastic.Clients.Elasticsearch.Serialization; /// /// The encoding to use when serializing vector data using the converter. From 73d07a285af9c9dd15cf3a17f45dde2f07d2e0e2 Mon Sep 17 00:00:00 2001 From: Florian Bernd Date: Mon, 8 Dec 2025 10:54:41 +0100 Subject: [PATCH 3/4] Minor adjustments --- .../_Shared/Core/LazyJsonConverter.cs | 5 +++-- .../_Shared/Next/JsonWriterExtensions.cs | 19 +++++++++++++++++-- .../_Shared/Next/VectorConverters.cs | 13 ++++++++++--- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/Elastic.Clients.Elasticsearch/_Shared/Core/LazyJsonConverter.cs b/src/Elastic.Clients.Elasticsearch/_Shared/Core/LazyJsonConverter.cs index 5291695a874..079a46e305c 100644 --- a/src/Elastic.Clients.Elasticsearch/_Shared/Core/LazyJsonConverter.cs +++ b/src/Elastic.Clients.Elasticsearch/_Shared/Core/LazyJsonConverter.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; @@ -14,13 +15,13 @@ public sealed class LazyJsonConverter : JsonConverter { private IElasticsearchClientSettings? _settings; + [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute'", Justification = "Always using explicit TypeInfoResolver")] + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute'", Justification = "Always using explicit TypeInfoResolver")] public override LazyJson Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { InitializeSettings(options); -#pragma warning disable IL2026, IL3050 // The `TypeInfoResolver` for `RequestResponseConverter` knows how to handle `JsonElement`. return new LazyJson(JsonSerializer.Deserialize(ref reader, options), _settings!); -#pragma warning restore IL2026, IL3050 } private void InitializeSettings(JsonSerializerOptions options) diff --git a/src/Elastic.Clients.Elasticsearch/_Shared/Next/JsonWriterExtensions.cs b/src/Elastic.Clients.Elasticsearch/_Shared/Next/JsonWriterExtensions.cs index 2258026d8c0..af2d739d71b 100644 --- a/src/Elastic.Clients.Elasticsearch/_Shared/Next/JsonWriterExtensions.cs +++ b/src/Elastic.Clients.Elasticsearch/_Shared/Next/JsonWriterExtensions.cs @@ -250,13 +250,28 @@ public static void WriteUnionValue(this Utf8JsonWriter writer, JsonSeria ); } - public static void WriteSpanValue(this Utf8JsonWriter writer, JsonSerializerOptions options, ReadOnlySpan span, + public static void WriteMemoryValue(this Utf8JsonWriter writer, JsonSerializerOptions options, ReadOnlyMemory memory, JsonWriteFunc? writeElement) { - writeElement ??= static (w, o, v) => WriteValue(w, o, v); + if (writeElement is null) + { + var converter = options.GetConverter(null); + + writeElement = (w, o, v) => + { + if ((v is null) && !converter.HandleNull) + { + w.WriteNullValue(); + return; + } + + converter.Write(w, v, o); + }; + } writer.WriteStartArray(); + var span = memory.Span; foreach (var element in span) { writeElement(writer, options, element); diff --git a/src/Elastic.Clients.Elasticsearch/_Shared/Next/VectorConverters.cs b/src/Elastic.Clients.Elasticsearch/_Shared/Next/VectorConverters.cs index 4a54d60f1b1..0bd48794c7a 100644 --- a/src/Elastic.Clients.Elasticsearch/_Shared/Next/VectorConverters.cs +++ b/src/Elastic.Clients.Elasticsearch/_Shared/Next/VectorConverters.cs @@ -5,6 +5,7 @@ using System; using System.Buffers; using System.Buffers.Binary; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Serialization; @@ -57,7 +58,7 @@ public override void Write(Utf8JsonWriter writer, ReadOnlyMemory value, J switch (_encoding) { case FloatVectorDataEncoding.Legacy: - writer.WriteSpanValue(options, value.Span, null); + writer.WriteMemoryValue(options, value, null); break; case FloatVectorDataEncoding.Base64: @@ -69,6 +70,7 @@ public override void Write(Utf8JsonWriter writer, ReadOnlyMemory value, J } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static ReadOnlyMemory ReadBase64VectorData(ref Utf8JsonReader reader) { var bytes = reader.GetBytesFromBase64(); @@ -98,6 +100,7 @@ private static ReadOnlyMemory ReadBase64VectorData(ref Utf8JsonReader rea return new(result); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void WriteBase64VectorData(Utf8JsonWriter writer, ReadOnlyMemory value) { if (value.IsEmpty) @@ -192,7 +195,7 @@ public override void Write(Utf8JsonWriter writer, ReadOnlyMemory value, Js switch (_encoding) { case ByteVectorDataEncoding.Legacy: - writer.WriteSpanValue(options, value.Span, (w, _, b) => w.WriteNumberValue(unchecked((sbyte)b))); + writer.WriteMemoryValue(options, value, (w, _, b) => w.WriteNumberValue(unchecked((sbyte)b))); break; case ByteVectorDataEncoding.Hex: @@ -208,6 +211,7 @@ public override void Write(Utf8JsonWriter writer, ReadOnlyMemory value, Js } } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static ReadOnlyMemory ReadStringVectorData(ref Utf8JsonReader reader) { if (reader.TryGetBytesFromBase64(out var result)) @@ -218,6 +222,7 @@ private static ReadOnlyMemory ReadStringVectorData(ref Utf8JsonReader read return ReadHexVectorData(ref reader); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static ReadOnlyMemory ReadHexVectorData(ref Utf8JsonReader reader) { #if NET5_0_OR_GREATER @@ -229,6 +234,7 @@ private static ReadOnlyMemory ReadHexVectorData(ref Utf8JsonReader reader) return new(data); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void WriteHexVectorData(Utf8JsonWriter writer, ReadOnlyMemory value) { if (value.IsEmpty) @@ -267,7 +273,8 @@ private static void WriteHexVectorData(Utf8JsonWriter writer, ReadOnlyMemory Date: Wed, 10 Dec 2025 11:12:42 +0100 Subject: [PATCH 4/4] Commit benchmark code --- src/Playground/Program.cs | 241 ++++++++++++++++++++++++++++++++------ 1 file changed, 206 insertions(+), 35 deletions(-) diff --git a/src/Playground/Program.cs b/src/Playground/Program.cs index 805d1ff45e6..e5f2aec6e75 100644 --- a/src/Playground/Program.cs +++ b/src/Playground/Program.cs @@ -2,48 +2,219 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information. -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; using Elastic.Clients.Elasticsearch; +using Elastic.Clients.Elasticsearch.Mapping; using Elastic.Clients.Elasticsearch.Serialization; using Elastic.Transport; -using Elastic.Transport.Extensions; - -using Playground; - -var pool = new SingleNodePool(new Uri("https://primary.es.europe-west3.gcp.cloud.es.io")); -var settings = new ElasticsearchClientSettings(pool, - sourceSerializer: (_, settings) => - new DefaultSourceSerializer(settings, PlaygroundJsonSerializerContext.Default) - ) - .Authentication(new BasicAuthentication("elastic", "Oov35Wtxj5DzpZNzYAzFb0KZ")) - .DisableDirectStreaming() - .EnableDebugMode(cd => - { - //var request = System.Text.Encoding.Default.GetString(cd.RequestBodyInBytes); - Console.WriteLine(cd.DebugInformation); - }); -var client = new ElasticsearchClient(settings); +var baseOptions = new VectorIngestOptions +{ + DatasetFile = "C:\\Users\\Florian\\Desktop\\bench\\open_ai_corpus-initial-indexing-1k.json", + Repetitions = 1, + MaxDegreeOfParallelism = 1, + WarmupPasses = 5, + MeasuredPasses = 3, + ElasticsearchEndpoint = new Uri("http://192.168.100.87:9200"), + ElasticsearchUsername = "elastic", + ElasticsearchPassword = "julCIvcZ" +}; -var person = new Person +var cases = new VectorIngestOptions[] { - FirstName = "Steve", - LastName = "Jobs", - Age = 35, - IsDeleted = false, - Routing = "1234567890", - Id = 1, - Enum = DateTimeKind.Utc, + baseOptions with { UseBase64VectorEncoding = false, ChunkSize = 100 }, + baseOptions with { UseBase64VectorEncoding = false, ChunkSize = 250 }, + baseOptions with { UseBase64VectorEncoding = false, ChunkSize = 500 }, + baseOptions with { UseBase64VectorEncoding = false, ChunkSize = 1000 }, + baseOptions with { UseBase64VectorEncoding = true , ChunkSize = 100 }, + baseOptions with { UseBase64VectorEncoding = true , ChunkSize = 250 }, + baseOptions with { UseBase64VectorEncoding = true , ChunkSize = 500 }, + baseOptions with { UseBase64VectorEncoding = true , ChunkSize = 1000 } }; -var id = client.Infer.Id(person); -var idByType = IdByType(person.GetType(), person); -Console.WriteLine(id); -Console.WriteLine(idByType); -// This still errors on AOT compilation -Console.WriteLine(client.SourceSerializer.SerializeToString(person)); +foreach (var testcase in cases) +{ + Console.Write($"Base64: {(testcase.UseBase64VectorEncoding ? '1' : '0')}, Chunk size: {testcase.ChunkSize,4} == "); + await VectorIngest.Ingest(testcase); +} + +public sealed record VectorIngestOptions +{ + /// + /// The path to the dataset file. + /// + public required string DatasetFile { get; init; } + + /// + /// The number of times the dataset is repeated. + /// + public int Repetitions { get; init; } = 1; + + /// + /// The chunk size for the individual bulk requests. + /// + public int ChunkSize { get; init; } = 100; + + /// + /// Configures whether vector data is encoded in base64 format. + /// + public bool UseBase64VectorEncoding { get; init; } = true; + + /// + /// The maximum number of concurrent bulk operations allowed when processing tasks in parallel. + /// + public int MaxDegreeOfParallelism { get; init; } = 1; + + /// + /// The number of warmup passes to perform before measurements begin. + /// + public int WarmupPasses { get; init; } = 5; + + /// + /// The number of measurement passes to perform. + /// + public int MeasuredPasses { get; init; } = 3; + + public required Uri ElasticsearchEndpoint { get; init; } + public required string ElasticsearchUsername { get; init; } + public required string ElasticsearchPassword { get; init; } +} + +public static class VectorIngest +{ + public static async Task Ingest(VectorIngestOptions options) + { + var docs = LoadDataset(options.DatasetFile, options.Repetitions); + + var sw = new Stopwatch(); + var elapsedSeconds = 0.0d; + + var client = CreateClient(options); + var indexName = $"bench-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; + + for (var i = 0; i < options.WarmupPasses + options.MeasuredPasses; i++) + { + await InitializeIndex(client, indexName, docs.First().Embedding.Length); + + sw.Restart(); + client + .BulkAll(docs, x => x + .Index(indexName) + .MaxDegreeOfParallelism(options.MaxDegreeOfParallelism) + .Size(options.ChunkSize) + ) + .Wait(TimeSpan.FromHours(1), _ => {}); + sw.Stop(); + + if (i >= options.WarmupPasses) + { + elapsedSeconds += sw.Elapsed.TotalSeconds; + } + + await client.Indices.RefreshAsync(x => x.Indices(indexName)).ConfigureAwait(false); + + var count = await client.CountAsync(x => x.Indices(indexName)).ConfigureAwait(false); + if (count.Count != docs.Length) + { + throw new Exception($"Document count mismatch: expected {docs.Length}, got {count.Count}"); + } + } + + await client.Indices.DeleteAsync(indexName).ConfigureAwait(false); + + elapsedSeconds /= options.MeasuredPasses; + var docsPerSec = docs.Length / elapsedSeconds; + + Console.WriteLine($"{((int)(elapsedSeconds * 1000)),4}ms / {((int)docsPerSec),4} docs/s"); + } + + private static OpenAiBenchmarkDocument[] LoadDataset(string filename, int repetitions) + { + return Enumerable.Repeat(0, repetitions).Select((_, i) => Load(filename, i)).Aggregate((a, b) => a.Union(b)).ToArray(); + + static IEnumerable Load(string filename, int i) + { + using var fs = File.OpenRead(filename); + return JsonSerializer + .DeserializeAsyncEnumerable(fs, BenchmarkJsonSerializerContext.Default.OpenAiBenchmarkDocument, true) + .ToBlockingEnumerable() + .Select(x => x! with { Id = $"{i}_{x.Id}"}) + .ToArray(); + } + } + + private static ElasticsearchClient CreateClient(VectorIngestOptions options) + { + var pool = new SingleNodePool(options.ElasticsearchEndpoint); + + var settings = new ElasticsearchClientSettings(pool, + sourceSerializer: (_, settings) => + new DefaultSourceSerializer(settings, BenchmarkJsonSerializerContext.Default/*, x => x.Converters.Remove(x.Converters.OfType>().Single())*/) + ) + .Authentication(new BasicAuthentication(options.ElasticsearchUsername, options.ElasticsearchPassword)) + .MemoryStreamFactory(new RecyclableMemoryStreamFactory()) + .EnableHttpCompression(false) + .FloatVectorDataEncoding(options.UseBase64VectorEncoding ? FloatVectorDataEncoding.Base64 : FloatVectorDataEncoding.Legacy); + + return new ElasticsearchClient(settings); + } + + private static async Task InitializeIndex(ElasticsearchClient client, string indexName, int vectorDimensions) + { + if (await client.Indices.ExistsAsync(indexName).ConfigureAwait(false) is { Exists: true }) + { + await client.Indices.DeleteAsync(indexName).ConfigureAwait(false); + } + + await client.Indices + .CreateAsync(x => x + .Index(indexName) + .Mappings(x => x + .Properties(x => x + .Keyword(x => x.Id) + .DenseVector(x => x.Embedding, x => x + .Dims(vectorDimensions) + .ElementType(DenseVectorElementType.Float) + .IndexOptions(x => x. + Type(DenseVectorIndexOptionsType.Flat) + ) + ) + .Text(x => x.Title) + .Text(x => x.Text) + ) + ) + .WaitForActiveShards(1) + ) + .ConfigureAwait(false); + + await client.Indices.RefreshAsync(x => x.Indices(indexName)).ConfigureAwait(false); + } +} + +[JsonSerializable(typeof(OpenAiBenchmarkDocument))] +[JsonSerializable(typeof(OpenAiBenchmarkDocument[]))] +[JsonSerializable(typeof(object))] +public sealed partial class BenchmarkJsonSerializerContext : + JsonSerializerContext +{ + +} + +public sealed record OpenAiBenchmarkDocument +{ + [JsonPropertyName("docid")] + public required string Id { get; init; } + + [JsonPropertyName("title")] + public required string Title { get; init; } + + [JsonPropertyName("text")] + public required string Text { get; init; } -[UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "Can only annotate our implementation")] -[UnconditionalSuppressMessage("Trimming", "IL2067", Justification = "Can only annotate our implementation")] -string? IdByType(Type type, object instance) => client.Infer.Id(type, instance); + [JsonConverter(typeof(FloatVectorDataConverter))] + [JsonPropertyName("emb")] + public ReadOnlyMemory Embedding { get; init; } +}