From dd5da195093d390799a4bf085ea149a858dbc579 Mon Sep 17 00:00:00 2001 From: Haiyang Xu Date: Wed, 22 Apr 2026 13:38:04 -0700 Subject: [PATCH 1/7] Inline filtering --- Directory.Packages.props | 4 +- libs/server/Resp/Vector/AttributeExtractor.cs | 289 +++++++++++- libs/server/Resp/Vector/DiskANNService.cs | 11 +- .../Resp/Vector/VectorFilterExpression.cs | 6 - .../Resp/Vector/VectorManager.Callbacks.cs | 9 +- .../Resp/Vector/VectorManager.Filter.cs | 102 ++++- .../Resp/Vector/VectorManager.Locking.cs | 6 +- .../Resp/Vector/VectorManager.Migration.cs | 2 +- libs/server/Resp/Vector/VectorManager.cs | 302 +++++++++---- website/docs/dev/filtered-search-design.md | 417 ++++++++++++++++++ 10 files changed, 1053 insertions(+), 95 deletions(-) create mode 100644 website/docs/dev/filtered-search-design.md diff --git a/Directory.Packages.props b/Directory.Packages.props index bbfd3a695fc..f4c9c70891a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -32,7 +32,7 @@ - + - \ No newline at end of file + diff --git a/libs/server/Resp/Vector/AttributeExtractor.cs b/libs/server/Resp/Vector/AttributeExtractor.cs index 0fcecbb43a0..db0503d1e9e 100644 --- a/libs/server/Resp/Vector/AttributeExtractor.cs +++ b/libs/server/Resp/Vector/AttributeExtractor.cs @@ -375,5 +375,292 @@ internal static ReadOnlySpan TrimWhiteSpace(ReadOnlySpan s) private static bool IsNumberChar(byte b) => IsDigit(b) || b == (byte)'-' || b == (byte)'+' || b == (byte)'.' || b == (byte)'e' || b == (byte)'E'; + + // ======================== Binary attribute format ======================== + // + // Pre-extracted binary format for fast filter evaluation: + // [0xFF marker] + // [num_fields: u8] + // For each field: + // [field_name_len: u8] + // [field_name: N bytes] ← raw UTF-8 + // [value_type: u8] ← 0=string, 1=number, 2=bool_true, 3=bool_false, 4=null + // [value_len: u16 LE] + // [value_bytes: N bytes] ← UTF-8 string or 8-byte f64 LE + + internal const byte BinaryMarker = 0xFF; + + private const byte BinTypeString = 0; + private const byte BinTypeNumber = 1; + private const byte BinTypeBoolTrue = 2; + private const byte BinTypeBoolFalse = 3; + private const byte BinTypeNull = 4; + + /// + /// Convert a top-level JSON object to pre-extracted binary format. + /// Returns total bytes written, or -1 if output is too small. + /// + public static int ConvertJsonToBinary(ReadOnlySpan json, Span output) + { + var s = TrimWhiteSpace(json); + if (s.IsEmpty || s[0] != (byte)'{') return -1; + s = s[1..]; + + if (output.Length < 2) return -1; + output[0] = BinaryMarker; + // output[1] = num_fields — written at the end + var pos = 2; + byte fieldCount = 0; + + while (true) + { + s = TrimWhiteSpace(s); + if (s.IsEmpty) return -1; + if (s[0] == (byte)'}') break; + + if (s[0] != (byte)'"') return -1; + + // Parse key + var afterOpenQuote = s[1..]; + if (!SkipString(ref s)) return -1; + var keyContent = afterOpenQuote[..(afterOpenQuote.Length - s.Length - 1)]; + + // Check for escape sequences in key (rare) + var keyHasEscape = false; + for (var ki = 0; ki < keyContent.Length; ki++) + { + if (keyContent[ki] == (byte)'\\') { keyHasEscape = true; break; } + } + if (keyHasEscape) return -1; // keys with escapes not supported + + // Write field_name_len + field_name + if (keyContent.Length > 255) return -1; + if (pos + 1 + keyContent.Length + 1 + 2 > output.Length) return -1; + output[pos++] = (byte)keyContent.Length; + keyContent.CopyTo(output[pos..]); + pos += keyContent.Length; + + // Skip colon + s = TrimWhiteSpace(s); + if (s.IsEmpty || s[0] != (byte)':') return -1; + s = s[1..]; + + // Parse value + s = TrimWhiteSpace(s); + if (s.IsEmpty) return -1; + + var c = s[0]; + if (c == (byte)'"') + { + // String value — need to unescape + s = s[1..]; // skip opening quote + var body = s; + var hasEscape = false; + while (!s.IsEmpty) + { + if (s[0] == (byte)'\\') { hasEscape = true; s = s[2..]; continue; } + if (s[0] == (byte)'"') break; + s = s[1..]; + } + if (s.IsEmpty) return -1; + var strContent = body[..(body.Length - s.Length)]; + s = s[1..]; // skip closing quote + + output[pos++] = BinTypeString; + + if (!hasEscape) + { + // No escapes — direct copy + if (pos + 2 + strContent.Length > output.Length) return -1; + output[pos] = (byte)(strContent.Length & 0xFF); + output[pos + 1] = (byte)((strContent.Length >> 8) & 0xFF); + pos += 2; + strContent.CopyTo(output[pos..]); + pos += strContent.Length; + } + else + { + // Unescape into output + var valueLenPos = pos; + pos += 2; // reserve for value_len + var valueStart = pos; + for (var si = 0; si < strContent.Length; si++) + { + if (pos >= output.Length) return -1; + if (strContent[si] == (byte)'\\' && si + 1 < strContent.Length) + { + si++; + output[pos++] = strContent[si] switch + { + (byte)'n' => (byte)'\n', + (byte)'r' => (byte)'\r', + (byte)'t' => (byte)'\t', + _ => strContent[si], // \", \\, \/ etc. + }; + } + else + { + output[pos++] = strContent[si]; + } + } + var valueLen = pos - valueStart; + output[valueLenPos] = (byte)(valueLen & 0xFF); + output[valueLenPos + 1] = (byte)((valueLen >> 8) & 0xFF); + } + } + else if (IsDigit(c) || c == (byte)'-' || c == (byte)'+') + { + // Number value — store as 8-byte f64 LE + var numStart = s; + while (!s.IsEmpty && IsNumberChar(s[0])) s = s[1..]; + var numSpan = numStart[..(numStart.Length - s.Length)]; + if (!Utf8Parser.TryParse(numSpan, out double numVal, out var consumed) || consumed != numSpan.Length) + return -1; + + output[pos++] = BinTypeNumber; + if (pos + 2 + 8 > output.Length) return -1; + output[pos] = 8; + output[pos + 1] = 0; + pos += 2; + System.BitConverter.TryWriteBytes(output[pos..], numVal); + pos += 8; + } + else if (c == (byte)'t') + { + if (!s.StartsWith("true"u8)) return -1; + s = s[4..]; + output[pos++] = BinTypeBoolTrue; + if (pos + 2 > output.Length) return -1; + output[pos] = 0; output[pos + 1] = 0; + pos += 2; + } + else if (c == (byte)'f') + { + if (!s.StartsWith("false"u8)) return -1; + s = s[5..]; + output[pos++] = BinTypeBoolFalse; + if (pos + 2 > output.Length) return -1; + output[pos] = 0; output[pos + 1] = 0; + pos += 2; + } + else if (c == (byte)'n') + { + if (!s.StartsWith("null"u8)) return -1; + s = s[4..]; + output[pos++] = BinTypeNull; + if (pos + 2 > output.Length) return -1; + output[pos] = 0; output[pos + 1] = 0; + pos += 2; + } + else + { + // Nested objects/arrays — not supported in binary format + return -1; + } + + fieldCount++; + + // Next field or end + s = TrimWhiteSpace(s); + if (s.IsEmpty) return -1; + if (s[0] == (byte)',') { s = s[1..]; continue; } + if (s[0] == (byte)'}') break; + return -1; + } + + output[1] = fieldCount; + return pos; + } + + /// + /// Extract fields from pre-extracted binary attribute data. + /// Same contract as ExtractFields but ~10x faster (no JSON parsing). + /// + public static int ExtractFieldsBinary( + ReadOnlySpan binary, + ReadOnlySpan filterBytes, + ReadOnlySpan<(int Start, int Length)> selectorRanges, + Span results, + ref ExprProgram program) + { + for (var i = 0; i < selectorRanges.Length; i++) + results[i] = default; + + if (binary.Length < 2 || binary[0] != BinaryMarker) + return 0; + + var numFields = binary[1]; + var pos = 2; + var found = 0; + var needed = selectorRanges.Length; + + for (var f = 0; f < numFields && pos < binary.Length; f++) + { + // Read field name + if (pos >= binary.Length) break; + var nameLen = binary[pos++]; + if (pos + nameLen > binary.Length) break; + var fieldName = binary.Slice(pos, nameLen); + pos += nameLen; + + // Read value type + if (pos >= binary.Length) break; + var valueType = binary[pos++]; + + // Read value length + if (pos + 2 > binary.Length) break; + var valueLen = (int)(binary[pos] | (binary[pos + 1] << 8)); + pos += 2; + + // Read value bytes + if (pos + valueLen > binary.Length) break; + + // Match against selectors + var matchIndex = -1; + for (var i = 0; i < selectorRanges.Length; i++) + { + if (results[i].IsNone && + fieldName.SequenceEqual(filterBytes.Slice(selectorRanges[i].Start, selectorRanges[i].Length))) + { + matchIndex = i; + break; + } + } + + if (matchIndex >= 0) + { + switch (valueType) + { + case BinTypeString: + // Create a Str token referencing the binary buffer offsets + results[matchIndex] = ExprToken.NewStr(pos, valueLen, hasEscape: false); + break; + case BinTypeNumber: + if (valueLen == 8) + { + var numVal = System.BitConverter.ToDouble(binary[pos..]); + results[matchIndex] = ExprToken.NewNum(numVal); + } + break; + case BinTypeBoolTrue: + results[matchIndex] = ExprToken.NewNum(1); + break; + case BinTypeBoolFalse: + results[matchIndex] = ExprToken.NewNum(0); + break; + case BinTypeNull: + results[matchIndex] = ExprToken.NewNull(); + break; + } + + found++; + if (found == needed) return found; + } + + pos += valueLen; + } + + return found; + } } -} \ No newline at end of file +} diff --git a/libs/server/Resp/Vector/DiskANNService.cs b/libs/server/Resp/Vector/DiskANNService.cs index 14ecdf28fc7..56f72e90028 100644 --- a/libs/server/Resp/Vector/DiskANNService.cs +++ b/libs/server/Resp/Vector/DiskANNService.cs @@ -18,7 +18,7 @@ internal sealed unsafe class DiskANNService internal const byte Attributes = 3; private const byte Metadata = 4; internal const byte InternalIdMap = 5; - private const byte ExternalIdMap = 6; + internal const byte ExternalIdMap = 6; #if DEBUG /// @@ -44,6 +44,7 @@ public nint CreateIndex( delegate* unmanaged[Cdecl] writeCallback, delegate* unmanaged[Cdecl] deleteCallback, delegate* unmanaged[Cdecl] readModifyWriteCallback, + delegate* unmanaged[Cdecl] filterCallback, out bool quantizationRequested ) { @@ -55,7 +56,7 @@ out bool quantizationRequested unsafe { - var ret = NativeDiskANNMethods.create_index(context, dimensions, reduceDims, quantType, distanceMetric, buildExplorationFactor, numLinks, (nint)readCallback, (nint)writeCallback, (nint)deleteCallback, (nint)readModifyWriteCallback); + var ret = NativeDiskANNMethods.create_index(context, dimensions, reduceDims, quantType, distanceMetric, buildExplorationFactor, numLinks, (nint)readCallback, (nint)writeCallback, (nint)deleteCallback, (nint)readModifyWriteCallback, (nint)filterCallback); Debug.Assert(ret != 0, "create_index failed, returning a null pointer - this shouldn't be possible"); @@ -75,9 +76,10 @@ public nint RecreateIndex( delegate* unmanaged[Cdecl] writeCallback, delegate* unmanaged[Cdecl] deleteCallback, delegate* unmanaged[Cdecl] readModifyWriteCallback, + delegate* unmanaged[Cdecl] filterCallback, out bool quantizationRequested ) - => CreateIndex(context, dimensions, reduceDims, quantType, buildExplorationFactor, numLinks, distanceMetricType, readCallback, writeCallback, deleteCallback, readModifyWriteCallback, out quantizationRequested); + => CreateIndex(context, dimensions, reduceDims, quantType, buildExplorationFactor, numLinks, distanceMetricType, readCallback, writeCallback, deleteCallback, readModifyWriteCallback, filterCallback, out quantizationRequested); public void DropIndex(ulong context, nint index) { @@ -344,7 +346,8 @@ public static partial nint create_index( nint readCallback, nint writeCallback, nint deleteCallback, - nint readModifyWriteCallback + nint readModifyWriteCallback, + nint filterCallback ); [LibraryImport(DISKANN_GARNET)] diff --git a/libs/server/Resp/Vector/VectorFilterExpression.cs b/libs/server/Resp/Vector/VectorFilterExpression.cs index b5984042bd5..af0afce8ef9 100644 --- a/libs/server/Resp/Vector/VectorFilterExpression.cs +++ b/libs/server/Resp/Vector/VectorFilterExpression.cs @@ -354,18 +354,12 @@ internal ref struct ExprProgram /// The compiled postfix instruction sequence. public Span Instructions; - /// Number of instructions in the program. - public int Length; - /// /// Flat pool of tuple element tokens. Tuple tokens in /// store (StartIndex, Count) into this span. /// public Span TuplePool; - /// Number of elements used in . - public int TuplePoolLength; - /// /// Runtime tuple pool for extracted JSON array elements. /// Reused across candidate evaluations. Runtime elements are appended diff --git a/libs/server/Resp/Vector/VectorManager.Callbacks.cs b/libs/server/Resp/Vector/VectorManager.Callbacks.cs index 301f159b504..1c7e7d3062b 100644 --- a/libs/server/Resp/Vector/VectorManager.Callbacks.cs +++ b/libs/server/Resp/Vector/VectorManager.Callbacks.cs @@ -18,7 +18,7 @@ public sealed partial class VectorManager { public unsafe #if NET9_0_OR_GREATER - ref + ref #endif struct VectorReadBatch : IReadArgBatch { @@ -186,6 +186,7 @@ internal readonly void CompletePending(ref VectorBasicContext objectContext) private unsafe delegate* unmanaged[Cdecl] WriteCallbackPtr { get; } = &WriteCallbackUnmanaged; private unsafe delegate* unmanaged[Cdecl] DeleteCallbackPtr { get; } = &DeleteCallbackUnmanaged; private unsafe delegate* unmanaged[Cdecl] ReadModifyWriteCallbackPtr { get; } = &ReadModifyWriteCallbackUnmanaged; + private unsafe delegate* unmanaged[Cdecl] InlineFilterCallbackPtr { get; } = &FilterCallbackUnmanaged; /// /// Used to thread the active across p/invoke and reverse p/invoke boundaries into DiskANN. @@ -274,6 +275,12 @@ private static byte ReadModifyWriteCallbackUnmanaged(ulong context, nint keyData return status.IsCompletedSuccessfully ? (byte)1 : default; } + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static byte FilterCallbackUnmanaged(ulong context, uint internalId) + { + return EvaluateCandidateFilter(context, internalId); + } + private static unsafe bool ReadSizeUnknown(ulong context, bool forceAlignment, ReadOnlySpan key, ref SpanByteAndMemory value) { #pragma warning disable IDE0302 // [...]-style collection initialization doesn't actually _guarantee_ stackalloc (or inline arrays), which we need here diff --git a/libs/server/Resp/Vector/VectorManager.Filter.cs b/libs/server/Resp/Vector/VectorManager.Filter.cs index 52b25ae8f43..86979a845fb 100644 --- a/libs/server/Resp/Vector/VectorManager.Filter.cs +++ b/libs/server/Resp/Vector/VectorManager.Filter.cs @@ -3,7 +3,9 @@ using System; using System.Buffers.Binary; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; +using Tsavorite.core; namespace Garnet.server { @@ -135,9 +137,7 @@ internal static int ApplyPostFilter( var program = new ExprProgram { Instructions = instrBuf[..instrCount], - Length = instrCount, TuplePool = tuplePoolBuf[..tupleCount], - TuplePoolLength = tupleCount, RuntimePool = runtimePoolBuf, RuntimePoolLength = 0, }; @@ -146,7 +146,7 @@ internal static int ApplyPostFilter( filterBitmap.Clear(); // ── Collect unique selectors ────────────────────────────── - var selectorCount = GetSelectorRanges(program.Instructions, program.Length, filter, selectorBuf); + var selectorCount = GetSelectorRanges(program.Instructions, program.Instructions.Length, filter, selectorBuf); var selectorRanges = selectorBuf[..selectorCount]; // Slice extractedFields to actual selector count @@ -215,5 +215,101 @@ internal static int GetSelectorRanges( } return count; } + + // ── Inline filter callback infrastructure ───── + // + // These types allow the Rust DiskANN pipeline to call + // back into C# for per-candidate filter evaluation, avoiding the need + // to over-fetch candidates and filter them afterwards. + // + // The compiled filter program and scratch buffers are stored in + // [ThreadStatic] fields before the FFI call. The callback runs on the + // same thread, so it reads the pre-compiled state directly — no need + // to marshal pointers through the FFI boundary. + + /// + /// Thread-static state for the inline filter callback. + /// Set before the FFI call into Rust, read by . + /// + [ThreadStatic] + internal static unsafe void* t_inlineFilterStatePtr; + + + /// + /// Per-query filter state maintained on the C# side. + /// Populated before calling into Rust; the callback reads it from thread-static storage. + /// All Span/pointer fields reference pinned scratch-buffer memory that remains + /// valid for the duration of the FFI call. + /// + internal ref struct InlineFilterState + { + // Pointers into scratch buffer (pinned for FFI duration): + public Span InstrBuf; + public Span TuplePoolBuf; + public Span RuntimePoolBuf; + public Span ExtractedFields; + public Span StackBuf; + public Span<(int Start, int Length)> SelectorRanges; + + /// Pointer to the filter expression bytes. + public ReadOnlySpan FilterBytes; + } + + /// + /// Shared filter evaluation logic for both single and batch callbacks. + /// Reads the candidate's external ID and attributes, then evaluates the compiled filter. + /// + private static unsafe byte EvaluateCandidateFilter(ulong context, uint internalId) + { + ref var state = ref Unsafe.As(ref Unsafe.AsRef(t_inlineFilterStatePtr)); + + // 1. Read external ID for this internal_id via ExtMap + Span iidKey = stackalloc byte[sizeof(uint)]; + BinaryPrimitives.WriteUInt32LittleEndian(iidKey, internalId); + + Span eidBuf = stackalloc byte[128]; + var eidMem = SpanByteAndMemory.FromPinnedSpan(eidBuf); + try + { + if (!ReadSizeUnknown(context | DiskANNService.ExternalIdMap, true, iidKey, ref eidMem)) + return 0; // can't find external ID → exclude + + // 2. Read attributes by external ID + Span attrBuf = stackalloc byte[256]; + var attrMem = SpanByteAndMemory.FromPinnedSpan(attrBuf); + try + { + if (!ReadSizeUnknown(context | DiskANNService.Attributes, true, eidMem.ReadOnlySpan, ref attrMem)) + return 0; // no attributes → exclude + + // 3. Rebuild ExprProgram from thread-static state pointers + var program = new ExprProgram + { + Instructions = state.InstrBuf, + TuplePool = state.TuplePoolBuf, + RuntimePool = state.RuntimePoolBuf, + RuntimePoolLength = 0, + }; + + program.ResetRuntimePool(); + + AttributeExtractor.ExtractFields(attrMem.ReadOnlySpan, state.FilterBytes, state.SelectorRanges, state.ExtractedFields, ref program); + + var stack = new ExprStack(state.StackBuf); + var pass = ExprRunner.Run(ref program, attrMem.ReadOnlySpan, state.FilterBytes, state.SelectorRanges, state.ExtractedFields, ref stack); + + return pass ? (byte)1 : (byte)0; + } + finally + { + attrMem.Memory?.Dispose(); + } + } + finally + { + eidMem.Memory?.Dispose(); + } + } + } } \ No newline at end of file diff --git a/libs/server/Resp/Vector/VectorManager.Locking.cs b/libs/server/Resp/Vector/VectorManager.Locking.cs index 2c53ae1865a..463ffe1c195 100644 --- a/libs/server/Resp/Vector/VectorManager.Locking.cs +++ b/libs/server/Resp/Vector/VectorManager.Locking.cs @@ -171,7 +171,7 @@ internal VectorSetLock ReadVectorIndex(StorageSession storageSession, ReadOnlySp bool requestQuantization; unsafe { - newlyAllocatedIndex = Service.RecreateIndex(indexContext, dims, reduceDims, quantType, buildExplorationFactor, numLinks, distanceMetric, ReadCallbackPtr, WriteCallbackPtr, DeleteCallbackPtr, ReadModifyWriteCallbackPtr, out requestQuantization); + newlyAllocatedIndex = Service.RecreateIndex(indexContext, dims, reduceDims, quantType, buildExplorationFactor, numLinks, distanceMetric, ReadCallbackPtr, WriteCallbackPtr, DeleteCallbackPtr, ReadModifyWriteCallbackPtr, InlineFilterCallbackPtr, out requestQuantization); } input.header.cmd = RespCommand.VADD; @@ -365,7 +365,7 @@ out GarnetStatus status unsafe { - newlyAllocatedIndex = Service.RecreateIndex(indexContext, dims, reduceDims, quantType, buildExplorationFactor, numLinks, distanceMetric, ReadCallbackPtr, WriteCallbackPtr, DeleteCallbackPtr, ReadModifyWriteCallbackPtr, out requestQuantization); + newlyAllocatedIndex = Service.RecreateIndex(indexContext, dims, reduceDims, quantType, buildExplorationFactor, numLinks, distanceMetric, ReadCallbackPtr, WriteCallbackPtr, DeleteCallbackPtr, ReadModifyWriteCallbackPtr, InlineFilterCallbackPtr, out requestQuantization); } input.parseState.EnsureCapacity(12); @@ -397,7 +397,7 @@ out GarnetStatus status unsafe { - newlyAllocatedIndex = Service.CreateIndex(indexContext, dims, reduceDims, quantizer, buildExplorationFactor, numLinks, distanceMetric, ReadCallbackPtr, WriteCallbackPtr, DeleteCallbackPtr, ReadModifyWriteCallbackPtr, out requestQuantization); + newlyAllocatedIndex = Service.CreateIndex(indexContext, dims, reduceDims, quantizer, buildExplorationFactor, numLinks, distanceMetric, ReadCallbackPtr, WriteCallbackPtr, DeleteCallbackPtr, ReadModifyWriteCallbackPtr, InlineFilterCallbackPtr, out requestQuantization); } input.parseState.EnsureCapacity(12); diff --git a/libs/server/Resp/Vector/VectorManager.Migration.cs b/libs/server/Resp/Vector/VectorManager.Migration.cs index a1188054f38..cc2c19e587c 100644 --- a/libs/server/Resp/Vector/VectorManager.Migration.cs +++ b/libs/server/Resp/Vector/VectorManager.Migration.cs @@ -176,7 +176,7 @@ public void HandleMigratedIndexKey( bool requestQuantization; unsafe { - newlyAllocatedIndex = Service.RecreateIndex(context, dimensions, reduceDims, quantType, buildExplorationFactor, numLinks, distanceMetric, ReadCallbackPtr, WriteCallbackPtr, DeleteCallbackPtr, ReadModifyWriteCallbackPtr, out requestQuantization); + newlyAllocatedIndex = Service.RecreateIndex(context, dimensions, reduceDims, quantType, buildExplorationFactor, numLinks, distanceMetric, ReadCallbackPtr, WriteCallbackPtr, DeleteCallbackPtr, ReadModifyWriteCallbackPtr, InlineFilterCallbackPtr, out requestQuantization); } var ctxArg = PinnedSpanByte.FromPinnedSpan(MemoryMarshal.Cast(MemoryMarshal.CreateSpan(ref context, 1))); diff --git a/libs/server/Resp/Vector/VectorManager.cs b/libs/server/Resp/Vector/VectorManager.cs index 7affdc610b9..b782b4ae034 100644 --- a/libs/server/Resp/Vector/VectorManager.cs +++ b/libs/server/Resp/Vector/VectorManager.cs @@ -120,6 +120,27 @@ private static void EnsureIdBufferSize(ref SpanByteAndMemory buffer, int retriev } } + /// + /// Ensures the VSIM filter bitmap buffer has at least one bit per result + /// ( bits, rounded up to whole bytes). + /// Rents from if the current buffer is too small. + /// + private static void EnsureFilterBitmapSize(ref SpanByteAndMemory buffer, int resultCount) + { + var sizeBytes = (resultCount + 7) >> 3; + if (sizeBytes > buffer.Length) + { + if (!buffer.IsSpanByte) + { + buffer.Memory.Dispose(); + } + + buffer = new SpanByteAndMemory(MemoryPool.Shared.Rent(sizeBytes), sizeBytes); + } + + buffer.Length = sizeBytes; + } + /// /// This managers instance of . /// @@ -590,7 +611,7 @@ internal static void ClearIndexPointer(Span value) /// /// Perform a similarity search given a vector to compare against. /// - internal VectorManagerResult ValueSimilarity( + internal unsafe VectorManagerResult ValueSimilarity( scoped ReadOnlySpan indexValue, VectorValueType valueType, scoped ReadOnlySpan values, @@ -612,25 +633,10 @@ ref SpanByteAndMemory filterBitmap ReadIndex(indexValue, out var context, out var dimensions, out _, out var quantType, out _, out _, out _, out var indexPtr); - // When a filter is present, over-retrieve candidates from DiskANN so that - // post-filtering has enough results to fill the requested count. - // - // FILTER-EF controls both the graph exploration breadth and the output - // buffer size when a filter is active, allowing it to be tuned independently - // from EF (which is used for unfiltered searches). - var retrieveCount = !filter.IsEmpty ? maxFilteringEffort : count; - var effectiveEF = !filter.IsEmpty - ? Math.Max(searchExplorationFactor, maxFilteringEffort) - : searchExplorationFactor; - - // No point in asking for more data than the effort we'll put in - if (retrieveCount > effectiveEF) - { - retrieveCount = effectiveEF; - } + var effectiveEF = Math.Max(searchExplorationFactor, count); - EnsureDistanceBufferSize(ref outputDistances, retrieveCount); - EnsureIdBufferSize(ref outputIds, retrieveCount); + EnsureDistanceBufferSize(ref outputDistances, count); + EnsureIdBufferSize(ref outputIds, count); int found; nint continuation; @@ -651,20 +657,105 @@ ref SpanByteAndMemory filterBitmap return VectorManagerResult.BadParams; } - found = - Service.SearchVector( - context, - indexPtr, - vectorData.ReadOnlySpan, - vectorData.ElementCount, - delta, - effectiveEF, - filter, - maxFilteringEffort, - outputIds, - outputDistances, - out continuation - ); + if (!filter.IsEmpty) + { + // ── Inline filtered search path ───────── + // Compile the filter, set up callback state, and let Rust + // evaluate per-candidate via InlineFilterCandidateCallbackImpl. + // Only passing candidates are written to the output buffer, + // so we size it for the desired count, not the overfetch. + + // Borrow scratch space for compiled filter program + var bufferSlice = ActiveThreadSession.scratchBufferBuilder.CreateArgSlice( + TotalPoolTokens * ExprToken.Size + MaxSelectors * 2 * sizeof(int)); + var span = MemoryMarshal.Cast(bufferSlice.Span); + var selectorBuf = MemoryMarshal.Cast( + bufferSlice.Span.Slice(TotalPoolTokens * ExprToken.Size)); + + try + { + span.Clear(); + + var offset = 0; + var instrBuf = span.Slice(offset, MaxInstructions); offset += MaxInstructions; + var tuplePoolBuf = span.Slice(offset, MaxTuplePool); offset += MaxTuplePool; + var tokensBuf = span.Slice(offset, MaxInstructions); offset += MaxInstructions; + var opsStackBuf = span.Slice(offset, MaxInstructions); offset += MaxInstructions; + var runtimePoolBuf = span.Slice(offset, MaxRuntimePool); offset += MaxRuntimePool; + var extractedFields = span.Slice(offset, MaxSelectors); offset += MaxSelectors; + var stackBuf = span.Slice(offset, StackCapacity); + + var instrCount = ExprCompiler.TryCompile(filter, instrBuf, tuplePoolBuf, tokensBuf, opsStackBuf, out var tupleCount, out _); + if (instrCount < 0) + { + // Compile failed — return zero results + outputDistances.Length = 0; + filterBitmap.Length = 0; + outputIdFormat = VectorIdFormat.I32LengthPrefixed; + errorMsg = "ERR Compiling filter failed"u8; + return VectorManagerResult.BadParams; + } + + var selectorCount = GetSelectorRanges(instrBuf[..instrCount], instrCount, filter, selectorBuf); + + // Pin filter bytes and scratch buffer pointers, then populate thread-static state + fixed (byte* filterPtr = filter) + fixed (ExprToken* instrPtr = instrBuf, tuplePtr = tuplePoolBuf, runtimePtr = runtimePoolBuf, fieldsPtr = extractedFields, stackPtr = stackBuf) + fixed ((int, int)* selPtr = selectorBuf) + { + t_inlineFilterStatePtr = new InlineFilterState + { + InstrCount = instrCount, + TupleCount = tupleCount, + SelectorCount = selectorCount, + InstrBuf = instrPtr, + TuplePoolBuf = tuplePtr, + RuntimePoolBuf = runtimePtr, + ExtractedFields = fieldsPtr, + StackBuf = stackPtr, + SelectorRanges = selPtr, + FilterBytes = filterPtr, + FilterBytesLen = filter.Length, + }; + + found = Service.SearchVector( + context, + indexPtr, + vectorData.ReadOnlySpan, + vectorData.ElementCount, + delta, + effectiveEF, + filter, + maxFilteringEffort, + outputIds, + outputDistances, + out continuation + ); + } + } + finally + { + ActiveThreadSession.scratchBufferBuilder.RewindScratchBuffer(bufferSlice); + t_inlineFilterStatePtr = default; + } + } + else + { + found = + Service.SearchVector( + context, + indexPtr, + vectorData.ReadOnlySpan, + vectorData.ElementCount, + delta, + effectiveEF, + filter, + maxFilteringEffort, + outputIds, + outputDistances, + out continuation + ); + } } if (found < 0) @@ -683,18 +774,6 @@ out continuation // Apply post-filtering if filter is specified if (!filter.IsEmpty) { - // Ensure bitmap is large enough for the over-retrieved result set - var requiredBitmapBytes = (found + 7) >> 3; - if (requiredBitmapBytes > filterBitmap.Length) - { - if (!filterBitmap.IsSpanByte) - { - filterBitmap.Memory.Dispose(); - } - - filterBitmap = new SpanByteAndMemory(MemoryPool.Shared.Rent(requiredBitmapBytes), requiredBitmapBytes); - } - _ = ApplyPostFilter(filter, found, outputAttributes.ReadOnlySpan, filterBitmap.Span, ActiveThreadSession.scratchBufferBuilder); } @@ -716,7 +795,7 @@ out continuation /// /// Perform a similarity search given a vector to compare against. /// - internal VectorManagerResult ElementSimilarity( + internal unsafe VectorManagerResult ElementSimilarity( ReadOnlySpan indexValue, ReadOnlySpan element, int count, @@ -736,34 +815,109 @@ ref SpanByteAndMemory filterBitmap ReadIndex(indexValue, out var context, out _, out _, out var quantType, out _, out _, out _, out var indexPtr); - // When a filter is present, over-retrieve candidates from DiskANN - var retrieveCount = !filter.IsEmpty ? maxFilteringEffort : count; - var effectiveEF = !filter.IsEmpty - ? Math.Max(searchExplorationFactor, maxFilteringEffort) - : searchExplorationFactor; - - // No point in asking for more data than the effort we'll put in - if (retrieveCount > effectiveEF) - { - retrieveCount = effectiveEF; - } - - EnsureDistanceBufferSize(ref outputDistances, retrieveCount); - EnsureIdBufferSize(ref outputIds, retrieveCount); - - var found = - Service.SearchElement( - context, - indexPtr, - element, - delta, - effectiveEF, - filter, - maxFilteringEffort, - outputIds, - outputDistances, - out var continuation - ); + var effectiveEF = Math.Max(searchExplorationFactor, count); + + EnsureDistanceBufferSize(ref outputDistances, count); + EnsureIdBufferSize(ref outputIds, count); + + int found; + nint continuation; + + if (!filter.IsEmpty) + { + // ── Inline-filtered search path ────────────────────────── + // Size output buffers for desired result count + EnsureDistanceBufferSize(ref outputDistances, count); + EnsureIdBufferSize(ref outputIds, count); + + // Borrow scratch space for compiled filter program + var bufferSlice = ActiveThreadSession.scratchBufferBuilder.CreateArgSlice( + TotalPoolTokens * ExprToken.Size + MaxSelectors * 2 * sizeof(int)); + var span = MemoryMarshal.Cast(bufferSlice.Span); + var selectorBuf = MemoryMarshal.Cast( + bufferSlice.Span.Slice(TotalPoolTokens * ExprToken.Size)); + + try + { + span.Clear(); + + var offset = 0; + var instrBuf = span.Slice(offset, MaxInstructions); offset += MaxInstructions; + var tuplePoolBuf = span.Slice(offset, MaxTuplePool); offset += MaxTuplePool; + var tokensBuf = span.Slice(offset, MaxInstructions); offset += MaxInstructions; + var opsStackBuf = span.Slice(offset, MaxInstructions); offset += MaxInstructions; + var runtimePoolBuf = span.Slice(offset, MaxRuntimePool); offset += MaxRuntimePool; + var extractedFields = span.Slice(offset, MaxSelectors); offset += MaxSelectors; + var stackBuf = span.Slice(offset, StackCapacity); + + var instrCount = ExprCompiler.TryCompile(filter, instrBuf, tuplePoolBuf, tokensBuf, opsStackBuf, out var tupleCount, out _); + if (instrCount < 0) + { + outputDistances.Length = 0; + filterBitmap.Length = 0; + outputIdFormat = VectorIdFormat.I32LengthPrefixed; + return VectorManagerResult.OK; + } + + var selectorCount = GetSelectorRanges(instrBuf[..instrCount], instrCount, filter, selectorBuf); + + fixed (byte* filterPtr = filter) + fixed (ExprToken* instrPtr = instrBuf, tuplePtr = tuplePoolBuf, runtimePtr = runtimePoolBuf, fieldsPtr = extractedFields, stackPtr = stackBuf) + fixed ((int, int)* selPtr = selectorBuf) + { + t_inlineFilterStatePtr = new InlineFilterState + { + Context = context, + InstrCount = instrCount, + TupleCount = tupleCount, + SelectorCount = selectorCount, + InstrBuf = instrPtr, + TuplePoolBuf = tuplePtr, + RuntimePoolBuf = runtimePtr, + ExtractedFields = fieldsPtr, + StackBuf = stackPtr, + SelectorRanges = selPtr, + FilterBytes = filterPtr, + FilterBytesLen = filter.Length, + }; + + found = Service.SearchElement( + context, + indexPtr, + element, + delta, + effectiveEF, + filter, + maxFilteringEffort, + outputIds, + outputDistances, + out continuation + ); + + } + } + finally + { + ActiveThreadSession.scratchBufferBuilder.RewindScratchBuffer(bufferSlice); + } + } + else + { + found = + Service.SearchElement( + context, + indexPtr, + element, + delta, + effectiveEF, + filter, + maxFilteringEffort, + outputIds, + outputDistances, + out continuation + ); + } + if (found < 0) { diff --git a/website/docs/dev/filtered-search-design.md b/website/docs/dev/filtered-search-design.md new file mode 100644 index 00000000000..a77b49eeac1 --- /dev/null +++ b/website/docs/dev/filtered-search-design.md @@ -0,0 +1,417 @@ +# Filtered Vector Search — End-to-End Design Document + +## 1. Motivation + +Garnet's vector search (`VSIM` command family) supports similarity search over DiskANN graph indexes. Users frequently need to combine similarity search with metadata filtering (e.g., "find the 10 nearest images where `year > 2020 AND genre IN ['action', 'comedy']`"). + +### Problem with post-filtering + +The naive approach — fetch K results, then discard non-matching ones — suffers from two issues: + +1. **Overfetch waste**: To return K filtered results, you must fetch K×(1/selectivity) candidates. At 1% selectivity, that's 100× overfetch. +2. **Recall loss**: Even with overfetch, the final result set may contain fewer than K results or miss closer matches that were pruned before the filter was applied. + +### Solution: Inline filtering + +Evaluate the filter predicate *during* graph traversal so that non-matching candidates never occupy result slots. This eliminates overfetch and improves recall for selective filters. This requires changes on both the Garnet side (attribute storage design) and the DiskANN library side (search algorithm). + +--- + +## 2. Garnet-Side: Attribute Storage Design for Inline Filtering (Current Change) + +### Existing Attribute Store + +The existing Garnet attribute store was designed for general-purpose access — attributes are stored as **raw JSON keyed by external (user-facing) ID**. This is the natural choice for a key-value store: the user inserts a vector with key `"doc:42"` and attributes `{"year": 2021, "genre": "action"}`, so the attributes are stored under that same key. This store serves RESP command operations (e.g., `VGETATTR`) and remains unchanged. + +However, this store creates a mismatch with how DiskANN's graph traversal operates during inline filtering. DiskANN works entirely in **internal ID space** — every candidate is a `uint32` internal ID. To evaluate a filter using only the existing store, the callback must: + +1. **Read `ExternalIdMap[internal_id]`** → translate the internal ID to the external key (one Garnet store read) +2. **Read `Attributes[external_key]`** → fetch the raw JSON payload (second Garnet store read) +3. **Parse JSON at query time** → `ExtractFields()` runs a JSON tokenizer to locate and parse the fields referenced by the filter expression + +With inline filtering, this callback runs on **every candidate the graph traversal considers** (potentially thousands per query). The two store reads and JSON parsing per candidate become the dominant cost on the hot path. + +### Solution: Add a second attribute store optimized for query-time filter evaluation + +The current change **adds a new attribute store** alongside the existing one. The two stores serve different purposes: + +| Store | Keyed by | Format | Purpose | +|-------|----------|--------|---------| +| Existing | External ID (user key) | Raw JSON | RESP command operations (`VGETATTR`, `VSETATTR`, etc.) | +| **New** | Internal ID (DiskANN ID) | Binary | Inline filter evaluation at query time | + +The existing external ID keyed JSON store is untouched — it continues to serve all RESP command operations. The new internal ID keyed binary store is a **write-time derived projection** of the same data, optimized purely for the inline filter callback's access pattern. + +### Why key by internal ID + +DiskANN hands the callback an internal ID; the existing attribute store expects an external key. Bridging this gap requires reading the `ExternalIdMap` — a store read that exists purely because of the keying mismatch. By adding a store keyed by internal ID, the filter callback can look up attributes directly without any ID translation. This eliminates the `ExternalIdMap` read entirely — one fewer store read per candidate. + +### Why store in binary format + +Raw JSON forces parsing on every candidate at query time. Extracting a numeric field like `.year` requires scanning for the key, skipping whitespace, and parsing a number string into a double. This work is repeated identically for every candidate, every query. The JSON structure does not change between queries — this is wasted work. + +The binary store **shifts the cost of JSON parsing from query time to ingestion time:** + +- **At ingestion** (vector insert/update): JSON is parsed once and converted to binary via `ConvertJsonToBinary()`. The binary format is `[0xFF marker][field count][per-field: name_len, name, type_tag, value_len, value_bytes]`, with numbers pre-converted to 8-byte LE f64. This is a one-time cost, written to the new store alongside the existing JSON store. +- **At query time** (per-candidate): `ExtractFieldsBinary()` performs a direct scan over length-prefixed fields. No JSON tokenizer. Field names compared as raw byte spans. Numbers read directly as f64 — no string parsing. ~10× faster than JSON extraction. + +Since each vector is inserted once but may be evaluated as a candidate across thousands of queries, this tradeoff — pay more at write, pay less at read — is the correct one for a read-heavy similarity search workload. + +### Per-candidate callback comparison + +``` +Without binary attribute store (2 store reads + JSON parse per candidate): + 1. Read ExternalIdMap[internal_id] → external key ← ID translation + 2. Read Attributes[external_key] → JSON bytes ← existing JSON store + 3. ExtractFields(json, selectors) → field values ← JSON parse at query time + 4. ExprRunner.Run(program) → bool + +With binary attribute store (1 store read + binary scan per candidate): + 1. Read BinaryAttributes[internal_id] → binary bytes ← new store, direct lookup + 2. ExtractFieldsBinary(binary, selectors) → field values ← pre-parsed, ~10× faster + 3. ExprRunner.Run(program) → bool +``` + +### Summary of inline filter per-candidate cost + +| Aspect | Only external ID keyed JSON attribute store | Current change (internal ID keyed binary attribute) | Further optimization (co-locate binary attribute with vector data) | +|--------|---------------------------------------------|---------------------------------------|----------------------------------------------| +| Store reads per candidate | 2 (ExternalIdMap + Attributes) | 1 (Attributes only) | 0 (already accessible during traversal) | +| ID translation | Required (internal → external) | Eliminated (keyed by internal ID) | Eliminated | +| Field extraction | JSON parse at query time | Binary scan (~10× faster) | Binary scan (~10× faster) | +| Parse cost paid at | Query time (per candidate, per query) | Ingestion time (once per insert) | Ingestion time (once per insert) | +| Total per-candidate overhead | 2 reads + JSON parse + eval | 1 read + binary scan + eval | Binary scan + eval | + +### Further optimization: Co-locate attributes with vector data + +The current change still requires one Garnet store read per candidate to fetch the binary attributes by internal ID. A further optimization is to **co-locate the binary attribute payload directly after the vector data** in the same Garnet record. + +During graph traversal, DiskANN already accesses the vector record for each candidate to compute distances. If the binary attributes are stored as trailing bytes in the same record, the callback can read them from the data DiskANN already has a reference to — no additional store read required. + +``` +Current change (1 store read per candidate): + 1. Read Attributes[internal_id] → binary bytes ← still a separate read + 2. ExtractFieldsBinary(binary, selectors) → field values + 3. ExprRunner.Run(program) → bool + +Co-located (0 extra store reads per candidate): + 1. Read trailing bytes from vector record[internal_id] ← already accessible during traversal + 2. ExtractFieldsBinary(binary, selectors) → field values + 3. ExprRunner.Run(program) → bool +``` + +This would reduce the per-candidate cost to **zero extra store reads** — the only remaining overhead is the binary field scan and expression evaluation. + +### Further with attibute index: Pre-built attribute index to replace per-candidate filter evaluation + +If an attribute index is available (e.g., inverted indexes or roaring bitmaps built over attribute values), the filter predicate can be evaluated **at query planning time** rather than per-candidate during graph traversal. The index would produce a pre-computed set of matching internal IDs (e.g., a bitmap), which can be fed directly into DiskANN as a `GarnetFilter::Bitmap`. This replaces the per-candidate FFI callback entirely — DiskANN checks the bitmap with a single bit lookup instead of reading attributes and running the expression evaluator. + +This would shift the filter cost from O(candidates_visited) callback invocations to a single O(matching_vectors) bitmap construction at query start, eliminating per-candidate attribute reads and expression evaluation altogether. + +--- + +## 3. DiskANN-Side: Filtered Search Algorithms + +The DiskANN library provides multiple search algorithms for filtered queries. All receive a filter predicate via the `QueryLabelProvider` trait and differ in how they integrate filtering into graph traversal. + +### 3.1 Comparison of DiskANN Filtered Search Algorithms + +| Aspect | MultihopSearch | BetaFilter | TwoQueueSearch | +|--------|---------------|------------|----------------| +| Filter integration | Evaluate during standard single-queue search | Scale distances by beta factor for non-matching nodes | Separate explore queue (unfiltered) and result queue (filtered only) | +| Data structures | `NeighborPriorityQueue` (sorted array) | Wraps any search strategy | `candidates` min-heap + `filtered_results` max-heap | +| Exploration breadth at low selectivity | Limited — non-matching nodes occupy result slots | Moderate — non-matching nodes appear farther but still compete | Broad — all neighbors enter explore queue regardless of filter | +| Convergence | Standard greedy convergence | Standard greedy convergence | Converges only when closest unexplored candidate is farther than worst *filtered* result | +| Adaptive budget | No | No | Yes — doubles hop budget when fewer than K results found | + +#### Performance Comparison (TBD) + +Benchmark results on the 100K YFCC dataset comparing recall and latency across MultihopSearch, BetaFilter, and TwoQueueSearch at various selectivity levels are pending. + +### 3.2 TwoQueueSearch Algorithm (Current Choice) + +**File**: `DiskANN/diskann/src/graph/search/two_queue_search.rs` + +#### Data Structures + +| Queue | Type | Purpose | +|-------|------|---------| +| `candidates` | `BinaryHeap>` (min-heap) | Exploration frontier — all neighbors regardless of filter | +| `filtered_results` | `BinaryHeap` (max-heap) | Result accumulator — only filter-passing neighbors | + +#### Algorithm + +``` +Initialize: insert start_point into candidates and visited set + +while candidates is not empty AND hops < max_candidates: + Pop up to beam_width closest candidates + + Convergence check: + if |filtered_results| >= result_cap + AND closest_candidate.distance > worst_filtered_result.distance: + → Converged, stop + + For each popped candidate: + Expand neighbors via graph adjacency + For each neighbor not yet visited: + Compute distance to query + if |filtered_results| < result_cap OR distance < worst_filtered.distance: + Insert into candidates + + Call filter_provider.on_visit(neighbor): + Accept → insert into filtered_results + Reject → skip + Terminate → abort immediately + + Prune filtered_results to result_cap (= k × RESULT_SIZE_FACTOR) + + Adaptive budget: if |filtered_results| < k after budget exhausted: + Double budget to 2 × max_candidates + +Return filtered_results sorted by distance, truncated to k +``` + +#### Key Parameters + +| Parameter | Source | Description | +|-----------|--------|-------------| +| `beam_width` | `search_l` (ef) | Number of candidates to expand per iteration | +| `max_candidates` | `max(ef, maxFilteringEffort)` | Hop budget before stopping | +| `result_cap` | `k × RESULT_SIZE_FACTOR` | Max size of filtered_results before pruning | +| `RESULT_SIZE_FACTOR` | Constant | Overallocation factor for result queue | + +#### Termination Modes + +- **Exhausted**: candidates queue empty +- **MaxCandidates**: hop budget reached +- **Converged**: closest unexplored candidate is farther than worst result +- **FilterTerminated**: filter callback returned `Terminate` + +#### Why TwoQueueSearch over MultihopSearch + +The key advantage of TwoQueueSearch is the **separation of exploration from result collection**. In MultihopSearch, non-matching candidates occupy slots in the single priority queue, limiting how far the search can explore. At low selectivity (e.g., 1% match rate), the queue fills with non-matching nodes and the search converges prematurely, missing closer matches that lie further in the graph. + +TwoQueueSearch solves this by maintaining two separate heaps: all neighbors enter the explore queue (keeping exploration broad), but only matching neighbors enter the result queue. The convergence check compares against the worst *filtered* result, not the worst candidate overall. This allows the search to keep exploring through non-matching regions of the graph until it finds enough filtered results. + +### 3.3 Filter Mode Dispatch (Rust) + +**File**: `DiskANN/diskann-garnet/src/labels.rs`, `dyn_index.rs` + +```rust +enum GarnetFilter { + Bitmap(GarnetQueryLabelProvider, f32), // pre-computed bitmap + beta factor + Callback(GarnetFilterProvider, u32), // per-candidate FFI callback + max_effort + None, +} +``` + +| Filter Mode | Search Algorithm | When Used | +|-------------|-----------------|-----------| +| `None` | Standard greedy KNN | No filter specified | +| `Bitmap` | BetaFilter (scale distances) | Pre-computed bitmap available (future/alternative path) | +| `Callback` | **TwoQueueSearch** | Filter expression provided in VSIM command | + +The `Callback` variant creates a `GarnetFilterProvider` that wraps the FFI callback. The `TwoQueueSearch` calls `on_visit()` which invokes the callback for each candidate. + +--- + +## 4. Architecture Overview + +``` +┌──────────────────────────────────────────────────────┐ +│ Client (RESP) │ +│ VSIM key 10 VALUES vec... FILTER ".year > 2020" │ +│ MAXFILTERINGEFFORT 2000 │ +└──────────┬───────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────┐ +│ Garnet Server (C#) │ +│ │ +│ VectorManager.ValueSimilarity() │ +│ ├─ ExprCompiler.TryCompile(filter) → postfix pgm │ +│ ├─ Pin scratch buffers, set t_inlineFilterState │ +│ └─ DiskANNService.SearchVector( │ +│ ..., filterData, filterLen, maxFilterEffort) │ +└──────────┬───────────────────────────────────────────┘ + │ P/Invoke (FFI) + ▼ +┌──────────────────────────────────────────────────────┐ +│ DiskANN (Rust, diskann-garnet) │ +│ │ +│ search_vector() │ +│ ├─ GarnetFilter::Callback → TwoQueueSearch │ +│ │ ├─ candidates: min-heap (explore) │ +│ │ └─ filtered_results: max-heap (results) │ +│ │ │ +│ │ For each candidate node: │ +│ │ ├─ Insert into candidates (unfiltered) │ +│ │ ├─ Call filterCallback(ctx, internal_id)──┐ │ +│ │ │ ┌────────────────────┘ │ +│ │ │ ▼ │ +│ │ │ ┌─────────────────────────────────────┐ │ +│ │ │ │ C# InlineFilterCandidateCallback │ │ +│ │ │ │ ├─ Read BinaryAttrs[internal_id] │ │ +│ │ │ │ ├─ ExtractFieldsBinary(selectors) │ │ +│ │ │ │ └─ ExprRunner.Run(program)→0/1 │ │ +│ │ │ └─────────────────────────────────────┘ │ +│ │ │ │ +│ │ └─ If pass: insert into filtered_results │ +│ └─ Return top-K from filtered_results │ +└──────────────────────────────────────────────────────┘ +``` + +--- + +## 5. Filter Compilation (C#) + +**File**: `libs/server/Resp/Vector/VectorManager.Filter.cs` + +### Expression Language + +Supports boolean expressions over JSON attributes: + +``` +.year > 2020 AND .genre IN ["action", "comedy"] AND NOT .archived +``` + +Operators: `=`, `!=`, `<`, `<=`, `>`, `>=`, `IN`, `NOT IN`, `AND`, `OR`, `NOT` + +### Compilation Pipeline + +1. **Tokenize** — extract field selectors (`.field`), operators, literals +2. **Shunting-yard** — convert infix to postfix via `ExprCompiler.TryCompile` +3. **Output** — array of `ExprToken` (instruction stream) + selector ranges (unique field names referenced) + +### Zero-Allocation Design + +All compilation and evaluation buffers come from a session-local `ScratchBufferBuilder` with a fixed ~9 KB layout: + +| Buffer | Size | Purpose | +|--------|------|---------| +| `instrBuf` | 2048 B | Compiled instructions | +| `tuplePoolBuf` | 2048 B | Tuple literal storage | +| `tokensBuf` | 1024 B | Tokenizer workspace | +| `opsStackBuf` | 512 B | Shunting-yard operator stack | +| `runtimePoolBuf` | 1024 B | IN-operator array expansion | +| `extractedFields` | 1024 B | Field extraction output | +| `stackBuf` | 1024 B | Expression evaluation stack | + +No heap allocations occur during filter compilation or evaluation. + +--- + +## 6. FFI Callback Protocol + +### Registration + +At index creation (`CreateIndex` / `RecreateIndex`), C# passes `InlineFilterCallbackPtr` to Rust: + +```csharp +delegate* unmanaged[Cdecl] InlineFilterCallbackPtr + = &InlineFilterCandidateCallbackImpl; +``` + +Rust stores this in its `Callbacks` struct alongside read/write/delete callbacks. + +### Per-Search Setup (C# side) + +Before each FFI search call: + +1. Compile filter expression +2. Pin all scratch buffers +3. Populate `[ThreadStatic] t_inlineFilterState` with pointers to: + - Compiled instructions + - Tuple pool + - Selector ranges + - Filter bytes + - Garnet storage context +4. Call `Service.SearchVector(...)` with `filter_data`, `filter_len`, `max_filtering_effort` + +### Per-Candidate Callback (Rust → C#) + +``` +Rust calls: filterCallback(context: u64, internal_id: u32) → u8 + └─ 1 = pass, 0 = reject + +C# InlineFilterCandidateCallbackImpl: + 1. Read BinaryAttributes[internal_id] → binary bytes (via ReadSizeUnknown) + 2. ExtractFieldsBinary(binary, selectors) → field values + 3. ExprRunner.Run(instructions, fields) → bool + 4. Return 1 or 0 +``` + +### Thread Safety + +- DiskANN search is single-threaded per query +- `[ThreadStatic]` state ensures no cross-query interference +- `ActiveThreadSession` is set before FFI and cleared on lock release + +--- + +## 7. Attribute Extraction + +**File**: `libs/server/Resp/Vector/AttributeExtractor.cs` + +Two storage formats are supported: + +### JSON Format + +Default format for the existing external ID keyed store. Attributes stored as raw JSON (e.g., `{"year": 2021, "genre": "action"}`). `ExtractFields()` performs a single-pass scan, matching field names against selectors and parsing values into `ExprToken`. + +### Binary Format + +Used by the new internal ID keyed store. Pre-extracted binary layout: `[0xFF marker][field count][per-field: name_len, name, type_tag, value_len, value_bytes]`. Numbers stored as 8-byte LE f64. `ExtractFieldsBinary()` is ~10× faster than JSON extraction. Conversion via `ConvertJsonToBinary()`. + +Both paths are zero-allocation, operating on `ReadOnlySpan`. + +--- + +## 8. End-to-End Data Flow + +``` +1. VSIM command parsed → filter bytes + maxFilteringEffort extracted + +2. VectorManager.ValueSimilarity() + ├─ filter non-empty → inline filtered path + ├─ ExprCompiler.TryCompile(filter) → postfix program + ├─ Pin buffers, populate t_inlineFilterState + └─ DiskANNService.SearchVector(query, k, ef, filterData, filterLen, maxEffort) + +3. P/Invoke → Rust search_vector() + ├─ Detect GarnetFilter::Callback + ├─ Create TwoQueueSearch with GarnetFilterProvider + └─ Run two-queue algorithm: + For each candidate: + ├─ Compute distance + ├─ Insert into candidates min-heap + ├─ FFI callback → C# evaluates filter → accept/reject + └─ If accepted → insert into filtered_results max-heap + +4. Return top-K internal IDs + distances (only matching candidates) + +5. Back in C# VectorManager: + ├─ Map internal IDs → external keys via ExternalIdMap + ├─ Optionally fetch attributes for results + └─ Serialize RESP response to client +``` + +--- + +## 9. Performance Characteristics + +### Compared to Post-Filtering + +| Aspect | Post-Filter | Two-Queue Inline | +|--------|-------------|------------------| +| Overfetch required | Yes (K/selectivity) | No | +| Recall at low selectivity | Poor (misses nearby matches) | High (explores broadly) | +| Per-candidate cost | Distance only | Distance + FFI callback + attribute read + filter eval | +| Memory | Large result buffers | Fixed-size heaps | + +### Tuning + +- **`maxFilteringEffort`** — Controls the hop budget. Higher values improve recall for selective filters at the cost of latency. Recommended: 2-10× the `ef` (search_l) parameter. +- **`RESULT_SIZE_FACTOR`** — Overallocates the result queue to improve result quality during pruning. +- **Adaptive budget doubling** — When fewer than K results are found within the initial budget, the algorithm automatically doubles exploration depth. From ada1c756d61756218f242a72104992f92b7f93e6 Mon Sep 17 00:00:00 2001 From: Kevin Montrose Date: Fri, 19 Jun 2026 16:25:32 -0400 Subject: [PATCH 2/7] something like this --- .../Resp/Vector/VectorManager.Filter.cs | 16 ++++- libs/server/Resp/Vector/VectorManager.cs | 67 +++++++++---------- 2 files changed, 46 insertions(+), 37 deletions(-) diff --git a/libs/server/Resp/Vector/VectorManager.Filter.cs b/libs/server/Resp/Vector/VectorManager.Filter.cs index 86979a845fb..562b94fd58a 100644 --- a/libs/server/Resp/Vector/VectorManager.Filter.cs +++ b/libs/server/Resp/Vector/VectorManager.Filter.cs @@ -3,7 +3,10 @@ using System; using System.Buffers.Binary; +using System.Diagnostics; +#if NET9_0_OR_GREATER using System.Runtime.CompilerServices; +#endif using System.Runtime.InteropServices; using Tsavorite.core; @@ -232,8 +235,9 @@ internal static int GetSelectorRanges( /// Set before the FFI call into Rust, read by . /// [ThreadStatic] - internal static unsafe void* t_inlineFilterStatePtr; - +#pragma warning disable CS8500 // InlineFilterState only contains unmanaged types or spans of pinned arrays, this is safe + internal static unsafe InlineFilterState* InlineFilterStatePtr; +#pragma warning restore CS8500 /// /// Per-query filter state maintained on the C# side. @@ -261,7 +265,13 @@ internal ref struct InlineFilterState /// private static unsafe byte EvaluateCandidateFilter(ulong context, uint internalId) { - ref var state = ref Unsafe.As(ref Unsafe.AsRef(t_inlineFilterStatePtr)); + Debug.Assert(InlineFilterStatePtr != null, "Shouldn't call without pinning a filter state"); + ref var state +#if NET9_0_OR_GREATER + = ref Unsafe.AsRef(InlineFilterStatePtr); +#else + = ref *InlineFilterStatePtr; +#endif // 1. Read external ID for this internal_id via ExtMap Span iidKey = stackalloc byte[sizeof(uint)]; diff --git a/libs/server/Resp/Vector/VectorManager.cs b/libs/server/Resp/Vector/VectorManager.cs index b782b4ae034..292f2c04487 100644 --- a/libs/server/Resp/Vector/VectorManager.cs +++ b/libs/server/Resp/Vector/VectorManager.cs @@ -6,6 +6,7 @@ using System.Buffers.Binary; using System.Collections.Concurrent; using System.Diagnostics; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; using System.Threading.Channels; @@ -698,45 +699,43 @@ ref SpanByteAndMemory filterBitmap var selectorCount = GetSelectorRanges(instrBuf[..instrCount], instrCount, filter, selectorBuf); - // Pin filter bytes and scratch buffer pointers, then populate thread-static state - fixed (byte* filterPtr = filter) - fixed (ExprToken* instrPtr = instrBuf, tuplePtr = tuplePoolBuf, runtimePtr = runtimePoolBuf, fieldsPtr = extractedFields, stackPtr = stackBuf) - fixed ((int, int)* selPtr = selectorBuf) + var filterState = new InlineFilterState { - t_inlineFilterStatePtr = new InlineFilterState - { - InstrCount = instrCount, - TupleCount = tupleCount, - SelectorCount = selectorCount, - InstrBuf = instrPtr, - TuplePoolBuf = tuplePtr, - RuntimePoolBuf = runtimePtr, - ExtractedFields = fieldsPtr, - StackBuf = stackPtr, - SelectorRanges = selPtr, - FilterBytes = filterPtr, - FilterBytesLen = filter.Length, - }; - - found = Service.SearchVector( - context, - indexPtr, - vectorData.ReadOnlySpan, - vectorData.ElementCount, - delta, - effectiveEF, - filter, - maxFilteringEffort, - outputIds, - outputDistances, - out continuation - ); + InstrBuf = instrBuf, + TuplePoolBuf = tuplePoolBuf, + RuntimePoolBuf = runtimePoolBuf, + ExtractedFields = extractedFields, + StackBuf = stackBuf, + SelectorRanges = selectorBuf, + FilterBytes = filter, + }; + + // InlineFilterState is a ref struct, so will remain on stack for the SearchVector call. + // + // Save a pointer off so it's easy to grab InlineFilterState in callbacks. + unsafe + { + InlineFilterStatePtr = (InlineFilterState*)Unsafe.AsPointer(ref filterState); } + + found = Service.SearchVector( + context, + indexPtr, + vectorData.ReadOnlySpan, + vectorData.ElementCount, + delta, + effectiveEF, + filter, + maxFilteringEffort, + outputIds, + outputDistances, + out continuation + ); } finally { ActiveThreadSession.scratchBufferBuilder.RewindScratchBuffer(bufferSlice); - t_inlineFilterStatePtr = default; + InlineFilterStatePtr = null; } } else @@ -865,7 +864,7 @@ ref SpanByteAndMemory filterBitmap fixed (ExprToken* instrPtr = instrBuf, tuplePtr = tuplePoolBuf, runtimePtr = runtimePoolBuf, fieldsPtr = extractedFields, stackPtr = stackBuf) fixed ((int, int)* selPtr = selectorBuf) { - t_inlineFilterStatePtr = new InlineFilterState + InlineFilterStatePtr = new InlineFilterState { Context = context, InstrCount = instrCount, From 6186f21cc1e6244bb8c6e788d66e8c5a402687cc Mon Sep 17 00:00:00 2001 From: Kevin Montrose Date: Fri, 19 Jun 2026 16:28:19 -0400 Subject: [PATCH 3/7] follow through to the rest --- libs/server/Resp/Vector/ExprRunner.cs | 2 +- libs/server/Resp/Vector/VectorManager.cs | 93 ++++++++++++------------ 2 files changed, 48 insertions(+), 47 deletions(-) diff --git a/libs/server/Resp/Vector/ExprRunner.cs b/libs/server/Resp/Vector/ExprRunner.cs index 69e14008adb..53195c86b7b 100644 --- a/libs/server/Resp/Vector/ExprRunner.cs +++ b/libs/server/Resp/Vector/ExprRunner.cs @@ -51,7 +51,7 @@ public static bool Run( { stack.Clear(); - for (var i = 0; i < program.Length; i++) + for (var i = 0; i < program.Instructions.Length; i++) { var inst = program.Instructions[i]; diff --git a/libs/server/Resp/Vector/VectorManager.cs b/libs/server/Resp/Vector/VectorManager.cs index 292f2c04487..5b6f1c911fc 100644 --- a/libs/server/Resp/Vector/VectorManager.cs +++ b/libs/server/Resp/Vector/VectorManager.cs @@ -6,7 +6,6 @@ using System.Buffers.Binary; using System.Collections.Concurrent; using System.Diagnostics; -using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; using System.Threading.Channels; @@ -715,7 +714,9 @@ ref SpanByteAndMemory filterBitmap // Save a pointer off so it's easy to grab InlineFilterState in callbacks. unsafe { - InlineFilterStatePtr = (InlineFilterState*)Unsafe.AsPointer(ref filterState); +#pragma warning disable CS8500 // InlineFilterState only contains unmanaged types or spans of pinned arrays, this is safe + InlineFilterStatePtr = &filterState; +#pragma warning restore CS8500 } found = Service.SearchVector( @@ -860,64 +861,64 @@ ref SpanByteAndMemory filterBitmap var selectorCount = GetSelectorRanges(instrBuf[..instrCount], instrCount, filter, selectorBuf); - fixed (byte* filterPtr = filter) - fixed (ExprToken* instrPtr = instrBuf, tuplePtr = tuplePoolBuf, runtimePtr = runtimePoolBuf, fieldsPtr = extractedFields, stackPtr = stackBuf) - fixed ((int, int)* selPtr = selectorBuf) + var filterState = new InlineFilterState { - InlineFilterStatePtr = new InlineFilterState - { - Context = context, - InstrCount = instrCount, - TupleCount = tupleCount, - SelectorCount = selectorCount, - InstrBuf = instrPtr, - TuplePoolBuf = tuplePtr, - RuntimePoolBuf = runtimePtr, - ExtractedFields = fieldsPtr, - StackBuf = stackPtr, - SelectorRanges = selPtr, - FilterBytes = filterPtr, - FilterBytesLen = filter.Length, - }; + InstrBuf = instrBuf, + TuplePoolBuf = tuplePoolBuf, + RuntimePoolBuf = runtimePoolBuf, + ExtractedFields = extractedFields, + StackBuf = stackBuf, + SelectorRanges = selectorBuf, + FilterBytes = filter, + }; + + // InlineFilterState is a ref struct, so will remain on stack for the SearchVector call. + // + // Save a pointer off so it's easy to grab InlineFilterState in callbacks. + unsafe + { +#pragma warning disable CS8500 // InlineFilterState only contains unmanaged types or spans of pinned arrays, this is safe + InlineFilterStatePtr = &filterState; +#pragma warning restore CS8500 + } - found = Service.SearchElement( - context, - indexPtr, - element, - delta, - effectiveEF, - filter, - maxFilteringEffort, - outputIds, - outputDistances, - out continuation - ); + found = Service.SearchElement( + context, + indexPtr, + element, + delta, + effectiveEF, + filter, + maxFilteringEffort, + outputIds, + outputDistances, + out continuation + ); - } } finally { ActiveThreadSession.scratchBufferBuilder.RewindScratchBuffer(bufferSlice); + InlineFilterStatePtr = null; } } else { found = - Service.SearchElement( - context, - indexPtr, - element, - delta, - effectiveEF, - filter, - maxFilteringEffort, - outputIds, - outputDistances, - out continuation - ); + Service.SearchElement( + context, + indexPtr, + element, + delta, + effectiveEF, + filter, + maxFilteringEffort, + outputIds, + outputDistances, + out continuation + ); } - if (found < 0) { logger?.LogWarning("Error indicating response from vector service {found}", found); From 56d244cae674fd0b5ce1fcf55a9eb709ecfe36cc Mon Sep 17 00:00:00 2001 From: Jack Moffitt Date: Fri, 19 Jun 2026 16:22:58 -0500 Subject: [PATCH 4/7] Fix filter program related lengths --- .../Resp/Vector/RespServerSessionVectors.cs | 6 +- libs/server/Resp/Vector/VectorManager.cs | 35 ++-- .../RespVectorSetTests.cs | 30 +--- website/docs/commands/vector-sets.md | 5 +- website/docs/dev/filtered-search-design.md | 152 ++++-------------- 5 files changed, 58 insertions(+), 170 deletions(-) diff --git a/libs/server/Resp/Vector/RespServerSessionVectors.cs b/libs/server/Resp/Vector/RespServerSessionVectors.cs index a45384c4f4d..7aab64ea7c7 100644 --- a/libs/server/Resp/Vector/RespServerSessionVectors.cs +++ b/libs/server/Resp/Vector/RespServerSessionVectors.cs @@ -811,9 +811,9 @@ private bool NetworkVSIM(ref TGarnetApi storageApi) return AbortWithWrongNumberOfArguments("VSIM"); } - if (!parseState.TryGetInt(curIx, out var maxFilteringEffortNonNull) || maxFilteringEffortNonNull < 0 || maxFilteringEffortNonNull > VectorManager.MaxRetrieveCount) + if (!parseState.TryGetInt(curIx, out var maxFilteringEffortNonNull) || maxFilteringEffortNonNull < 4 || maxFilteringEffortNonNull > VectorManager.MaxFilteringScaleFactor) { - return AbortWithErrorMessage($"ERR FILTER-EF must be an integer between 0 and {VectorManager.MaxRetrieveCount}"); + return AbortWithErrorMessage($"ERR FILTER-EF must be an integer between 4 and {VectorManager.MaxFilteringScaleFactor}"); } maxFilteringEffort = maxFilteringEffortNonNull; @@ -860,7 +860,7 @@ private bool NetworkVSIM(ref TGarnetApi storageApi) delta ??= 2f; searchExplorationFactor ??= 100; filter ??= default; - maxFilteringEffort ??= (int)Math.Min((long)count.Value * 200, VectorManager.MaxRetrieveCount); + maxFilteringEffort ??= 16; // TODO: these stackallocs are dangerous, need logic to avoid stack overflow Span idSpace = stackalloc byte[(DefaultResultSetSize * DefaultIdSize) + (DefaultResultSetSize * sizeof(int))]; diff --git a/libs/server/Resp/Vector/VectorManager.cs b/libs/server/Resp/Vector/VectorManager.cs index 5b6f1c911fc..461a639c19e 100644 --- a/libs/server/Resp/Vector/VectorManager.cs +++ b/libs/server/Resp/Vector/VectorManager.cs @@ -71,6 +71,11 @@ public sealed partial class VectorManager : IDisposable /// internal const int MaxRetrieveCount = 100_000_000; + /// + /// Maximum scale factor for adaptive-L inline filtering. + /// + internal const int MaxFilteringScaleFactor = 256; + /// /// Maximum exploration factor (EF) for build and search operations. /// Matches Redis's hardcoded limit of 1,000,000. @@ -700,12 +705,12 @@ ref SpanByteAndMemory filterBitmap var filterState = new InlineFilterState { - InstrBuf = instrBuf, - TuplePoolBuf = tuplePoolBuf, + InstrBuf = instrBuf[..instrCount], + TuplePoolBuf = tuplePoolBuf[..tupleCount], RuntimePoolBuf = runtimePoolBuf, - ExtractedFields = extractedFields, + ExtractedFields = extractedFields[..Math.Max(selectorCount, 1)], StackBuf = stackBuf, - SelectorRanges = selectorBuf, + SelectorRanges = selectorBuf[..selectorCount], FilterBytes = filter, }; @@ -774,6 +779,8 @@ out continuation // Apply post-filtering if filter is specified if (!filter.IsEmpty) { + EnsureFilterBitmapSize(ref filterBitmap, found); + _ = ApplyPostFilter(filter, found, outputAttributes.ReadOnlySpan, filterBitmap.Span, ActiveThreadSession.scratchBufferBuilder); } @@ -863,12 +870,12 @@ ref SpanByteAndMemory filterBitmap var filterState = new InlineFilterState { - InstrBuf = instrBuf, - TuplePoolBuf = tuplePoolBuf, + InstrBuf = instrBuf[..instrCount], + TuplePoolBuf = tuplePoolBuf[..tupleCount], RuntimePoolBuf = runtimePoolBuf, - ExtractedFields = extractedFields, + ExtractedFields = extractedFields[..Math.Max(selectorCount, 1)], StackBuf = stackBuf, - SelectorRanges = selectorBuf, + SelectorRanges = selectorBuf[..selectorCount], FilterBytes = filter, }; @@ -934,17 +941,7 @@ out continuation // Apply post-filtering if filter is specified if (!filter.IsEmpty) { - // Ensure bitmap is large enough for the over-retrieved result set - var requiredBitmapBytes = (found + 7) >> 3; - if (requiredBitmapBytes > filterBitmap.Length) - { - if (!filterBitmap.IsSpanByte) - { - filterBitmap.Memory.Dispose(); - } - - filterBitmap = new SpanByteAndMemory(MemoryPool.Shared.Rent(requiredBitmapBytes), requiredBitmapBytes); - } + EnsureFilterBitmapSize(ref filterBitmap, found); _ = ApplyPostFilter(filter, found, outputAttributes.ReadOnlySpan, filterBitmap.Span, ActiveThreadSession.scratchBufferBuilder); } diff --git a/test/standalone/Garnet.test.vectorset/RespVectorSetTests.cs b/test/standalone/Garnet.test.vectorset/RespVectorSetTests.cs index cfb6b50dc4c..e71a214da31 100644 --- a/test/standalone/Garnet.test.vectorset/RespVectorSetTests.cs +++ b/test/standalone/Garnet.test.vectorset/RespVectorSetTests.cs @@ -879,9 +879,9 @@ public void VSIMErrors() var res1 = db.Execute("VADD", ["foo", "VALUES", "3", "1.0", "2.0", "3.0", new byte[] { 0, 0, 0, 0 }, "CAS", "NOQUANT", "EF", "16", "M", "32", "SETATTR", "{\"year\":1980}"]); ClassicAssert.AreEqual(1, (int)res1); - // FILTER-EF exceeding MaxRetrieveCount must be rejected + // FILTER-EF exceeding MaxFilteringScaleFactor must be rejected var exc1 = ClassicAssert.Throws(() => db.Execute("VSIM", ["foo", "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", ".year > 1950", "FILTER-EF", "999999999", "COUNT", "3", "WITHATTRIBS"])); - ClassicAssert.AreEqual("ERR FILTER-EF must be an integer between 0 and 100000000", exc1.Message); + ClassicAssert.AreEqual("ERR FILTER-EF must be an integer between 4 and 256", exc1.Message); // COUNT exceeding MaxRetrieveCount must be rejected var exc2 = ClassicAssert.Throws(() => db.Execute("VSIM", ["foo", "VALUES", "3", "0.0", "0.0", "0.0", "COUNT", "999999999"])); @@ -896,32 +896,6 @@ public void VSIMErrors() ClassicAssert.AreEqual("ERR EF must be an integer between 1 and 1000000", exc4.Message); } - [Test] - public void VSIMWithDefaultFilterEFOverflowDoesNotCrash() - { - using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); - var db = redis.GetDatabase(0); - - _ = db.KeyDelete("foo"); - - // Add a vector with attributes so FILTER can be used - var res1 = db.Execute("VADD", ["foo", "VALUES", "3", "1.0", "2.0", "3.0", new byte[] { 0, 0, 0, 0 }, "CAS", "NOQUANT", "EF", "16", "M", "32", "SETATTR", "{\"year\":1980}"]); - ClassicAssert.AreEqual(1, (int)res1); - - // Verify that a moderate COUNT with FILTER (no explicit FILTER-EF) works correctly. - // The default maxFilteringEffort = count*200. With count=1000, that's 200,000 which is safe. - // This validates the code path through the (long) cast fix without hitting resource limits. - var res = (byte[][])db.Execute("VSIM", ["foo", "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", ".year > 1950", "COUNT", "1000", "WITHATTRIBS"]); - ClassicAssert.AreEqual(2, res.Length, "Should return 1 result (1 pair of id+attribute) for year > 1950"); - - // Verify that COUNT values which would overflow count*200 in int32 are rejected. - // 10,737,419 * 200 = 2,147,483,800 > int32.MaxValue. - // Our (long) cast prevents the overflow, but MaxRetrieveCount caps COUNT itself. - // Any COUNT above MaxRetrieveCount (~178M) is rejected at parse time. - var ex = Assert.Throws(() => db.Execute("VSIM", ["foo", "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", ".year > 1950", "COUNT", "999999999", "WITHATTRIBS"])); - ClassicAssert.IsTrue(ex.Message.Contains("COUNT must be an integer between"), $"Expected COUNT validation error, got: {ex.Message}"); - } - private static byte[] SeedMoviesForAdvancedFiltering(IDatabase db) { _ = db.KeyDelete("movies"); diff --git a/website/docs/commands/vector-sets.md b/website/docs/commands/vector-sets.md index c4f1583bea6..02dcf84ad62 100644 --- a/website/docs/commands/vector-sets.md +++ b/website/docs/commands/vector-sets.md @@ -382,7 +382,7 @@ The query's effective dimension must match the index's `input-vector-dimensions` | `EPSILON delta` | `2.0` | DiskANN `L_search` epsilon — controls how aggressively the graph is explored beyond the current best. | | `EF n` | `100` | Search-time exploration factor (`L_search` candidate-list size). Must be in `[1, 1000000]`. | | `FILTER expr` | _none_ | Post-filter results by an attribute expression (see [Filter Expressions](#filter-expressions)). | -| `FILTER-EF n` | `min(COUNT * 200, 100000000)` | Maximum number of nearest neighbors to **inspect** before filtering. Must be in `[0, 100000000]`. | +| `FILTER-EF n` | `16` | Scale factor for adaptive inline filter search. Must be in `[4, 256]`. This controls how high the EF will scale based on selectivity. | | `TRUTH` | _off_ | Accepted for compatibility; exact / brute-force search is not yet wired up. | | `NOTHREAD` | _off_ | Accepted for compatibility; currently ignored (search always runs on the calling thread). | @@ -634,7 +634,8 @@ Or in `garnet.conf`: |-------|-------|--------| | Maximum vector dimensions | 65,536 | `VectorManager.MaxVectorDimensions` | | Maximum build / search EF | 1,000,000 | `VectorManager.MaxExplorationFactor` | -| Maximum `COUNT` / `FILTER-EF` | 100,000,000 | `VectorManager.MaxRetrieveCount` | +| Maximum `COUNT` | 100,000,000 | `VectorManager.MaxRetrieveCount` | +| Maximum `FILTER-EF` | 256 | `VectorManager.MaxFilteringScaleFactor` | | Maximum elements per Vector Set | 2³² − 1 | DiskANN limit | | Concurrent Vector Sets per instance | ~15 | Internal context metadata limit | | Empty Vector Set keys | not allowed | Returns `ERR Vector Set key cannot be empty` (preview restriction) | diff --git a/website/docs/dev/filtered-search-design.md b/website/docs/dev/filtered-search-design.md index a77b49eeac1..dd87ff65075 100644 --- a/website/docs/dev/filtered-search-design.md +++ b/website/docs/dev/filtered-search-design.md @@ -112,109 +112,33 @@ This would shift the filter cost from O(candidates_visited) callback invocations ## 3. DiskANN-Side: Filtered Search Algorithms -The DiskANN library provides multiple search algorithms for filtered queries. All receive a filter predicate via the `QueryLabelProvider` trait and differ in how they integrate filtering into graph traversal. +The DiskANN library provides multiple search algorithms for filtered queries. All receive a filter predicate and differ in how they integrate filtering into graph traversal. ### 3.1 Comparison of DiskANN Filtered Search Algorithms -| Aspect | MultihopSearch | BetaFilter | TwoQueueSearch | -|--------|---------------|------------|----------------| -| Filter integration | Evaluate during standard single-queue search | Scale distances by beta factor for non-matching nodes | Separate explore queue (unfiltered) and result queue (filtered only) | -| Data structures | `NeighborPriorityQueue` (sorted array) | Wraps any search strategy | `candidates` min-heap + `filtered_results` max-heap | -| Exploration breadth at low selectivity | Limited — non-matching nodes occupy result slots | Moderate — non-matching nodes appear farther but still compete | Broad — all neighbors enter explore queue regardless of filter | -| Convergence | Standard greedy convergence | Standard greedy convergence | Converges only when closest unexplored candidate is farther than worst *filtered* result | -| Adaptive budget | No | No | Yes — doubles hop budget when fewer than K results found | +| Aspect | Inline (w adaptive L) | BetaFilter | +|----------------------------------------|-----------------------------------------------------------------------------|----------------------------------------------------------------| +| Filter integration | Evaluate filter during search, scaling Lsearch based on sampled selectivity | Scale distances by beta factor for non-matching nodes | +| Data structures | `NeighborPriorityQueue` (sorted array) | Wraps any search strategy | +| Exploration breadth at low selectivity | Bounded by the adaptive Lsearch | Moderate — non-matching nodes appear farther but still compete | +| Convergence | Standard greedy convergence | Standard greedy convergence | +| Adaptive budget | Yes | No | #### Performance Comparison (TBD) -Benchmark results on the 100K YFCC dataset comparing recall and latency across MultihopSearch, BetaFilter, and TwoQueueSearch at various selectivity levels are pending. +Benchmark results on the 100K YFCC dataset comparing recall and latency are pending, but our choice of inline with adaptive-L was based on DiskANN benchmarks with in-mem providers across +a range of selectivities and across various datasets. -### 3.2 TwoQueueSearch Algorithm (Current Choice) +### 3.2 Inline w/ Adaptive-L Algorithm (Current Choice) -**File**: `DiskANN/diskann/src/graph/search/two_queue_search.rs` - -#### Data Structures - -| Queue | Type | Purpose | -|-------|------|---------| -| `candidates` | `BinaryHeap>` (min-heap) | Exploration frontier — all neighbors regardless of filter | -| `filtered_results` | `BinaryHeap` (max-heap) | Result accumulator — only filter-passing neighbors | - -#### Algorithm - -``` -Initialize: insert start_point into candidates and visited set - -while candidates is not empty AND hops < max_candidates: - Pop up to beam_width closest candidates - - Convergence check: - if |filtered_results| >= result_cap - AND closest_candidate.distance > worst_filtered_result.distance: - → Converged, stop - - For each popped candidate: - Expand neighbors via graph adjacency - For each neighbor not yet visited: - Compute distance to query - if |filtered_results| < result_cap OR distance < worst_filtered.distance: - Insert into candidates - - Call filter_provider.on_visit(neighbor): - Accept → insert into filtered_results - Reject → skip - Terminate → abort immediately - - Prune filtered_results to result_cap (= k × RESULT_SIZE_FACTOR) - - Adaptive budget: if |filtered_results| < k after budget exhausted: - Double budget to 2 × max_candidates - -Return filtered_results sorted by distance, truncated to k -``` - -#### Key Parameters - -| Parameter | Source | Description | -|-----------|--------|-------------| -| `beam_width` | `search_l` (ef) | Number of candidates to expand per iteration | -| `max_candidates` | `max(ef, maxFilteringEffort)` | Hop budget before stopping | -| `result_cap` | `k × RESULT_SIZE_FACTOR` | Max size of filtered_results before pruning | -| `RESULT_SIZE_FACTOR` | Constant | Overallocation factor for result queue | - -#### Termination Modes - -- **Exhausted**: candidates queue empty -- **MaxCandidates**: hop budget reached -- **Converged**: closest unexplored candidate is farther than worst result -- **FilterTerminated**: filter callback returned `Terminate` - -#### Why TwoQueueSearch over MultihopSearch - -The key advantage of TwoQueueSearch is the **separation of exploration from result collection**. In MultihopSearch, non-matching candidates occupy slots in the single priority queue, limiting how far the search can explore. At low selectivity (e.g., 1% match rate), the queue fills with non-matching nodes and the search converges prematurely, missing closer matches that lie further in the graph. - -TwoQueueSearch solves this by maintaining two separate heaps: all neighbors enter the explore queue (keeping exploration broad), but only matching neighbors enter the result queue. The convergence check compares against the worst *filtered* result, not the worst candidate overall. This allows the search to keep exploring through non-matching regions of the graph until it finds enough filtered results. +Please see the algorithm description in DiskANN. ### 3.3 Filter Mode Dispatch (Rust) -**File**: `DiskANN/diskann-garnet/src/labels.rs`, `dyn_index.rs` - -```rust -enum GarnetFilter { - Bitmap(GarnetQueryLabelProvider, f32), // pre-computed bitmap + beta factor - Callback(GarnetFilterProvider, u32), // per-candidate FFI callback + max_effort - None, -} -``` - -| Filter Mode | Search Algorithm | When Used | -|-------------|-----------------|-----------| -| `None` | Standard greedy KNN | No filter specified | -| `Bitmap` | BetaFilter (scale distances) | Pre-computed bitmap available (future/alternative path) | -| `Callback` | **TwoQueueSearch** | Filter expression provided in VSIM command | +**File**: `DiskANN/diskann-garnet/src/provider.rs`, `dyn_index.rs` -The `Callback` variant creates a `GarnetFilterProvider` that wraps the FFI callback. The `TwoQueueSearch` calls `on_visit()` which invokes the callback for each candidate. +A filter callback is provided which DiskANN will invoke to check whether vectors match the filter expression. ---- ## 4. Architecture Overview @@ -222,7 +146,7 @@ The `Callback` variant creates a `GarnetFilterProvider` that wraps the FFI callb ┌──────────────────────────────────────────────────────┐ │ Client (RESP) │ │ VSIM key 10 VALUES vec... FILTER ".year > 2020" │ -│ MAXFILTERINGEFFORT 2000 │ +│ FILTER-EF 32 │ └──────────┬───────────────────────────────────────────┘ │ ▼ @@ -241,24 +165,18 @@ The `Callback` variant creates a `GarnetFilterProvider` that wraps the FFI callb │ DiskANN (Rust, diskann-garnet) │ │ │ │ search_vector() │ -│ ├─ GarnetFilter::Callback → TwoQueueSearch │ -│ │ ├─ candidates: min-heap (explore) │ -│ │ └─ filtered_results: max-heap (results) │ -│ │ │ -│ │ For each candidate node: │ -│ │ ├─ Insert into candidates (unfiltered) │ -│ │ ├─ Call filterCallback(ctx, internal_id)──┐ │ -│ │ │ ┌────────────────────┘ │ -│ │ │ ▼ │ -│ │ │ ┌─────────────────────────────────────┐ │ -│ │ │ │ C# InlineFilterCandidateCallback │ │ -│ │ │ │ ├─ Read BinaryAttrs[internal_id] │ │ -│ │ │ │ ├─ ExtractFieldsBinary(selectors) │ │ -│ │ │ │ └─ ExprRunner.Run(program)→0/1 │ │ -│ │ │ └─────────────────────────────────────┘ │ -│ │ │ │ -│ │ └─ If pass: insert into filtered_results │ -│ └─ Return top-K from filtered_results │ +│ │ For each candidate node: │ +│ │ ├─ Call filterCallback(ctx, internal_id)──┐ │ +│ │ │ ┌────────────────────┘ │ +│ │ │ ▼ │ +│ │ │ ┌─────────────────────────────────────┐ │ +│ │ │ │ C# InlineFilterCandidateCallback │ │ +│ │ │ │ ├─ Read BinaryAttrs[internal_id] │ │ +│ │ │ │ ├─ ExtractFieldsBinary(selectors) │ │ +│ │ │ │ └─ ExprRunner.Run(program)→0/1 │ │ +│ │ │ └─────────────────────────────────────┘ │ +│ │ │ +│ └─ Return top-K │ └──────────────────────────────────────────────────────┘ ``` @@ -403,15 +321,13 @@ Both paths are zero-allocation, operating on `ReadOnlySpan`. ### Compared to Post-Filtering -| Aspect | Post-Filter | Two-Queue Inline | -|--------|-------------|------------------| -| Overfetch required | Yes (K/selectivity) | No | -| Recall at low selectivity | Poor (misses nearby matches) | High (explores broadly) | -| Per-candidate cost | Distance only | Distance + FFI callback + attribute read + filter eval | -| Memory | Large result buffers | Fixed-size heaps | +| Aspect | Post-Filter | Inline with Adaptive-L | +|---------------------------|------------------------------|--------------------------------------------------------| +| Overfetch required | Yes (K/selectivity) | No | +| Recall at low selectivity | Poor (misses nearby matches) | High (explores broadly) | +| Per-candidate cost | Distance only | Distance + FFI callback + attribute read + filter eval | +| Memory | Large result buffers | Fixed-size heaps | ### Tuning -- **`maxFilteringEffort`** — Controls the hop budget. Higher values improve recall for selective filters at the cost of latency. Recommended: 2-10× the `ef` (search_l) parameter. -- **`RESULT_SIZE_FACTOR`** — Overallocates the result queue to improve result quality during pruning. -- **Adaptive budget doubling** — When fewer than K results are found within the initial budget, the algorithm automatically doubles exploration depth. +- Use FILTER-EF to control the scaling of Lsearch when selectivity is low. Defaults to 16. From b38d2d67c6e3846bb0d2d111318b39d0bff0347a Mon Sep 17 00:00:00 2001 From: Kevin Montrose Date: Tue, 23 Jun 2026 10:30:07 -0400 Subject: [PATCH 5/7] address style feedback --- libs/server/Resp/Vector/AttributeExtractor.cs | 89 ++++++++++++++----- .../Resp/Vector/VectorManager.Filter.cs | 7 +- libs/server/Resp/Vector/VectorManager.cs | 21 +++-- 3 files changed, 84 insertions(+), 33 deletions(-) diff --git a/libs/server/Resp/Vector/AttributeExtractor.cs b/libs/server/Resp/Vector/AttributeExtractor.cs index db0503d1e9e..1a0df3e93d2 100644 --- a/libs/server/Resp/Vector/AttributeExtractor.cs +++ b/libs/server/Resp/Vector/AttributeExtractor.cs @@ -28,8 +28,7 @@ public static int ExtractFields( Span results, ref ExprProgram program) { - for (var i = 0; i < selectorRanges.Length; i++) - results[i] = default; + results[..selectorRanges.Length].Clear(); var s = TrimWhiteSpace(json); if (s.IsEmpty || s[0] != (byte)'{') return 0; @@ -81,7 +80,11 @@ public static int ExtractFields( s = TrimWhiteSpace(s); if (s.IsEmpty) return found; - if (s[0] == (byte)',') { s = s[1..]; continue; } + if (s[0] == (byte)',') + { + s = s[1..]; + continue; + } if (s[0] == (byte)'}') return found; return found; } @@ -126,7 +129,11 @@ public static ExprToken ExtractField(ReadOnlySpan json, ReadOnlySpan s = TrimWhiteSpace(s); if (s.IsEmpty) return default; - if (s[0] == (byte)',') { s = s[1..]; continue; } + if (s[0] == (byte)',') + { + s = s[1..]; + continue; + } if (s[0] == (byte)'}') return default; return default; } @@ -162,7 +169,7 @@ private static ExprToken ParseValueToken(ReadOnlySpan json, ref ReadOnlySp var c = s[0]; if (c == (byte)'"') return ParseStringToken(json, ref s); - if (c == (byte)'[') return ParseArrayTokenNoPool(json, ref s); + if (c == (byte)'[') return ParseArrayTokenNoPool(ref s); if (c == (byte)'{') return default; // Nested objects not supported if (c == (byte)'t') return ParseLiteralToken(ref s, "true"u8, ExprTokenType.Num, 1); if (c == (byte)'f') return ParseLiteralToken(ref s, "false"u8, ExprTokenType.Num, 0); @@ -245,7 +252,11 @@ internal static ExprToken ParseArrayToken(ReadOnlySpan json, ref ReadOnlyS s = TrimWhiteSpace(s); // Empty array - if (!s.IsEmpty && s[0] == (byte)']') { s = s[1..]; return ExprToken.NewTuple(0, 0); } + if (!s.IsEmpty && s[0] == (byte)']') + { + s = s[1..]; + return ExprToken.NewTuple(0, 0); + } Span localBuf = stackalloc ExprToken[MaxArrayElements]; var count = 0; @@ -254,7 +265,11 @@ internal static ExprToken ParseArrayToken(ReadOnlySpan json, ref ReadOnlyS { s = TrimWhiteSpace(s); if (s.IsEmpty) return default; - if (count >= MaxArrayElements) { SkipBracketed(ref s, (byte)'[', (byte)']'); return ExprToken.NewNull(); } + if (count >= MaxArrayElements) + { + _ = SkipBracketed(ref s, (byte)'[', (byte)']'); + return ExprToken.NewNull(); + } var elem = ParseValueToken(json, ref s); if (elem.IsNone) return default; @@ -262,7 +277,11 @@ internal static ExprToken ParseArrayToken(ReadOnlySpan json, ref ReadOnlyS s = TrimWhiteSpace(s); if (s.IsEmpty) return default; - if (s[0] == (byte)']') { s = s[1..]; break; } + if (s[0] == (byte)']') + { + s = s[1..]; + break; + } if (s[0] != (byte)',') return default; s = s[1..]; } @@ -283,7 +302,7 @@ internal static ExprToken ParseArrayToken(ReadOnlySpan json, ref ReadOnlyS return ExprToken.NewNull(); } - private static ExprToken ParseArrayTokenNoPool(ReadOnlySpan json, ref ReadOnlySpan s) + private static ExprToken ParseArrayTokenNoPool(ref ReadOnlySpan s) { // Standalone extraction without a program — just skip the array if (!SkipValue(ref s)) return default; @@ -315,8 +334,16 @@ private static bool SkipString(ref ReadOnlySpan s) s = s[1..]; while (!s.IsEmpty) { - if (s[0] == (byte)'\\') { s = s[2..]; continue; } - if (s[0] == (byte)'"') { s = s[1..]; return true; } + if (s[0] == (byte)'\\') + { + s = s[2..]; + continue; + } + if (s[0] == (byte)'"') + { + s = s[1..]; + return true; + } s = s[1..]; } return false; @@ -333,8 +360,15 @@ private static bool SkipBracketed(ref ReadOnlySpan s, byte opener, byte cl if (!SkipString(ref s)) return false; continue; } - if (s[0] == opener) depth++; - else if (s[0] == closer) depth--; + + if (s[0] == opener) + { + depth++; + } + else if (s[0] == closer) + { + depth--; + } s = s[1..]; } return depth == 0; @@ -429,7 +463,11 @@ public static int ConvertJsonToBinary(ReadOnlySpan json, Span output var keyHasEscape = false; for (var ki = 0; ki < keyContent.Length; ki++) { - if (keyContent[ki] == (byte)'\\') { keyHasEscape = true; break; } + if (keyContent[ki] == (byte)'\\') + { + keyHasEscape = true; + break; + } } if (keyHasEscape) return -1; // keys with escapes not supported @@ -458,7 +496,12 @@ public static int ConvertJsonToBinary(ReadOnlySpan json, Span output var hasEscape = false; while (!s.IsEmpty) { - if (s[0] == (byte)'\\') { hasEscape = true; s = s[2..]; continue; } + if (s[0] == (byte)'\\') + { + hasEscape = true; + s = s[2..]; + continue; + } if (s[0] == (byte)'"') break; s = s[1..]; } @@ -522,7 +565,7 @@ public static int ConvertJsonToBinary(ReadOnlySpan json, Span output output[pos] = 8; output[pos + 1] = 0; pos += 2; - System.BitConverter.TryWriteBytes(output[pos..], numVal); + _ = BitConverter.TryWriteBytes(output[pos..], numVal); pos += 8; } else if (c == (byte)'t') @@ -563,7 +606,11 @@ public static int ConvertJsonToBinary(ReadOnlySpan json, Span output // Next field or end s = TrimWhiteSpace(s); if (s.IsEmpty) return -1; - if (s[0] == (byte)',') { s = s[1..]; continue; } + if (s[0] == (byte)',') + { + s = s[1..]; + continue; + } if (s[0] == (byte)'}') break; return -1; } @@ -580,11 +627,9 @@ public static int ExtractFieldsBinary( ReadOnlySpan binary, ReadOnlySpan filterBytes, ReadOnlySpan<(int Start, int Length)> selectorRanges, - Span results, - ref ExprProgram program) + Span results) { - for (var i = 0; i < selectorRanges.Length; i++) - results[i] = default; + results[..selectorRanges.Length].Clear(); if (binary.Length < 2 || binary[0] != BinaryMarker) return 0; @@ -609,7 +654,7 @@ public static int ExtractFieldsBinary( // Read value length if (pos + 2 > binary.Length) break; - var valueLen = (int)(binary[pos] | (binary[pos + 1] << 8)); + var valueLen = binary[pos] | (binary[pos + 1] << 8); pos += 2; // Read value bytes diff --git a/libs/server/Resp/Vector/VectorManager.Filter.cs b/libs/server/Resp/Vector/VectorManager.Filter.cs index 562b94fd58a..dfe9d105d1e 100644 --- a/libs/server/Resp/Vector/VectorManager.Filter.cs +++ b/libs/server/Resp/Vector/VectorManager.Filter.cs @@ -265,12 +265,13 @@ internal ref struct InlineFilterState /// private static unsafe byte EvaluateCandidateFilter(ulong context, uint internalId) { - Debug.Assert(InlineFilterStatePtr != null, "Shouldn't call without pinning a filter state"); - ref var state + + Debug.Assert(InlineFilterStatePtr != null, "Shouldn't call without pinning a filter state"); + ref var state #if NET9_0_OR_GREATER = ref Unsafe.AsRef(InlineFilterStatePtr); #else - = ref *InlineFilterStatePtr; + = ref *InlineFilterStatePtr; #endif // 1. Read external ID for this internal_id via ExtMap diff --git a/libs/server/Resp/Vector/VectorManager.cs b/libs/server/Resp/Vector/VectorManager.cs index 461a639c19e..f3fdda8d04a 100644 --- a/libs/server/Resp/Vector/VectorManager.cs +++ b/libs/server/Resp/Vector/VectorManager.cs @@ -135,10 +135,7 @@ private static void EnsureFilterBitmapSize(ref SpanByteAndMemory buffer, int res var sizeBytes = (resultCount + 7) >> 3; if (sizeBytes > buffer.Length) { - if (!buffer.IsSpanByte) - { - buffer.Memory.Dispose(); - } + buffer.Memory?.Dispose(); buffer = new SpanByteAndMemory(MemoryPool.Shared.Rent(sizeBytes), sizeBytes); } @@ -616,7 +613,7 @@ internal static void ClearIndexPointer(Span value) /// /// Perform a similarity search given a vector to compare against. /// - internal unsafe VectorManagerResult ValueSimilarity( + internal VectorManagerResult ValueSimilarity( scoped ReadOnlySpan indexValue, VectorValueType valueType, scoped ReadOnlySpan values, @@ -741,7 +738,11 @@ out continuation finally { ActiveThreadSession.scratchBufferBuilder.RewindScratchBuffer(bufferSlice); - InlineFilterStatePtr = null; + + unsafe + { + InlineFilterStatePtr = null; + } } } else @@ -802,7 +803,7 @@ out continuation /// /// Perform a similarity search given a vector to compare against. /// - internal unsafe VectorManagerResult ElementSimilarity( + internal VectorManagerResult ElementSimilarity( ReadOnlySpan indexValue, ReadOnlySpan element, int count, @@ -906,7 +907,11 @@ out continuation finally { ActiveThreadSession.scratchBufferBuilder.RewindScratchBuffer(bufferSlice); - InlineFilterStatePtr = null; + + unsafe + { + InlineFilterStatePtr = null; + } } } else From b73303969624e62fc9774aeaa0b61c1e46fa37a6 Mon Sep 17 00:00:00 2001 From: Kevin Montrose Date: Tue, 23 Jun 2026 10:34:50 -0400 Subject: [PATCH 6/7] formatting --- libs/server/Resp/Vector/AttributeExtractor.cs | 76 +++++++++---------- .../Resp/Vector/VectorManager.Filter.cs | 7 +- 2 files changed, 41 insertions(+), 42 deletions(-) diff --git a/libs/server/Resp/Vector/AttributeExtractor.cs b/libs/server/Resp/Vector/AttributeExtractor.cs index 1a0df3e93d2..0e572ea2ee0 100644 --- a/libs/server/Resp/Vector/AttributeExtractor.cs +++ b/libs/server/Resp/Vector/AttributeExtractor.cs @@ -80,10 +80,10 @@ public static int ExtractFields( s = TrimWhiteSpace(s); if (s.IsEmpty) return found; - if (s[0] == (byte)',') - { - s = s[1..]; - continue; + if (s[0] == (byte)',') + { + s = s[1..]; + continue; } if (s[0] == (byte)'}') return found; return found; @@ -129,10 +129,10 @@ public static ExprToken ExtractField(ReadOnlySpan json, ReadOnlySpan s = TrimWhiteSpace(s); if (s.IsEmpty) return default; - if (s[0] == (byte)',') + if (s[0] == (byte)',') { - s = s[1..]; - continue; + s = s[1..]; + continue; } if (s[0] == (byte)'}') return default; return default; @@ -252,9 +252,9 @@ internal static ExprToken ParseArrayToken(ReadOnlySpan json, ref ReadOnlyS s = TrimWhiteSpace(s); // Empty array - if (!s.IsEmpty && s[0] == (byte)']') - { - s = s[1..]; + if (!s.IsEmpty && s[0] == (byte)']') + { + s = s[1..]; return ExprToken.NewTuple(0, 0); } @@ -265,10 +265,10 @@ internal static ExprToken ParseArrayToken(ReadOnlySpan json, ref ReadOnlyS { s = TrimWhiteSpace(s); if (s.IsEmpty) return default; - if (count >= MaxArrayElements) - { - _ = SkipBracketed(ref s, (byte)'[', (byte)']'); - return ExprToken.NewNull(); + if (count >= MaxArrayElements) + { + _ = SkipBracketed(ref s, (byte)'[', (byte)']'); + return ExprToken.NewNull(); } var elem = ParseValueToken(json, ref s); @@ -277,10 +277,10 @@ internal static ExprToken ParseArrayToken(ReadOnlySpan json, ref ReadOnlyS s = TrimWhiteSpace(s); if (s.IsEmpty) return default; - if (s[0] == (byte)']') - { - s = s[1..]; - break; + if (s[0] == (byte)']') + { + s = s[1..]; + break; } if (s[0] != (byte)',') return default; s = s[1..]; @@ -334,15 +334,15 @@ private static bool SkipString(ref ReadOnlySpan s) s = s[1..]; while (!s.IsEmpty) { - if (s[0] == (byte)'\\') - { - s = s[2..]; - continue; + if (s[0] == (byte)'\\') + { + s = s[2..]; + continue; } - if (s[0] == (byte)'"') - { - s = s[1..]; - return true; + if (s[0] == (byte)'"') + { + s = s[1..]; + return true; } s = s[1..]; } @@ -463,9 +463,9 @@ public static int ConvertJsonToBinary(ReadOnlySpan json, Span output var keyHasEscape = false; for (var ki = 0; ki < keyContent.Length; ki++) { - if (keyContent[ki] == (byte)'\\') + if (keyContent[ki] == (byte)'\\') { - keyHasEscape = true; + keyHasEscape = true; break; } } @@ -496,11 +496,11 @@ public static int ConvertJsonToBinary(ReadOnlySpan json, Span output var hasEscape = false; while (!s.IsEmpty) { - if (s[0] == (byte)'\\') - { - hasEscape = true; - s = s[2..]; - continue; + if (s[0] == (byte)'\\') + { + hasEscape = true; + s = s[2..]; + continue; } if (s[0] == (byte)'"') break; s = s[1..]; @@ -606,10 +606,10 @@ public static int ConvertJsonToBinary(ReadOnlySpan json, Span output // Next field or end s = TrimWhiteSpace(s); if (s.IsEmpty) return -1; - if (s[0] == (byte)',') - { - s = s[1..]; - continue; + if (s[0] == (byte)',') + { + s = s[1..]; + continue; } if (s[0] == (byte)'}') break; return -1; @@ -708,4 +708,4 @@ public static int ExtractFieldsBinary( return found; } } -} +} \ No newline at end of file diff --git a/libs/server/Resp/Vector/VectorManager.Filter.cs b/libs/server/Resp/Vector/VectorManager.Filter.cs index dfe9d105d1e..562b94fd58a 100644 --- a/libs/server/Resp/Vector/VectorManager.Filter.cs +++ b/libs/server/Resp/Vector/VectorManager.Filter.cs @@ -265,13 +265,12 @@ internal ref struct InlineFilterState /// private static unsafe byte EvaluateCandidateFilter(ulong context, uint internalId) { - - Debug.Assert(InlineFilterStatePtr != null, "Shouldn't call without pinning a filter state"); - ref var state + Debug.Assert(InlineFilterStatePtr != null, "Shouldn't call without pinning a filter state"); + ref var state #if NET9_0_OR_GREATER = ref Unsafe.AsRef(InlineFilterStatePtr); #else - = ref *InlineFilterStatePtr; + = ref *InlineFilterStatePtr; #endif // 1. Read external ID for this internal_id via ExtMap From f024774cebb7b8b5baddaad121c8262309ee6c81 Mon Sep 17 00:00:00 2001 From: Jack Moffitt Date: Tue, 23 Jun 2026 13:52:49 -0500 Subject: [PATCH 7/7] more tests --- libs/server/Resp/Vector/AttributeExtractor.cs | 5 +- libs/server/Resp/Vector/VectorManager.cs | 2 +- .../DiskANN/DiskANNServiceTests.cs | 22 +- .../RespVectorSetTests.cs | 230 ++++++++++++++++++ website/docs/commands/vector-sets.md | 6 +- website/docs/dev/filtered-search-design.md | 19 +- 6 files changed, 262 insertions(+), 22 deletions(-) diff --git a/libs/server/Resp/Vector/AttributeExtractor.cs b/libs/server/Resp/Vector/AttributeExtractor.cs index 0e572ea2ee0..412cd68b889 100644 --- a/libs/server/Resp/Vector/AttributeExtractor.cs +++ b/libs/server/Resp/Vector/AttributeExtractor.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. using System; +using System.Buffers.Binary; using System.Buffers.Text; namespace Garnet.server @@ -565,7 +566,7 @@ public static int ConvertJsonToBinary(ReadOnlySpan json, Span output output[pos] = 8; output[pos + 1] = 0; pos += 2; - _ = BitConverter.TryWriteBytes(output[pos..], numVal); + BinaryPrimitives.WriteDoubleLittleEndian(output[pos..], numVal); pos += 8; } else if (c == (byte)'t') @@ -683,7 +684,7 @@ public static int ExtractFieldsBinary( case BinTypeNumber: if (valueLen == 8) { - var numVal = System.BitConverter.ToDouble(binary[pos..]); + var numVal = BinaryPrimitives.ReadDoubleLittleEndian(binary[pos..]); results[matchIndex] = ExprToken.NewNum(numVal); } break; diff --git a/libs/server/Resp/Vector/VectorManager.cs b/libs/server/Resp/Vector/VectorManager.cs index f3fdda8d04a..8a94e707de0 100644 --- a/libs/server/Resp/Vector/VectorManager.cs +++ b/libs/server/Resp/Vector/VectorManager.cs @@ -864,7 +864,7 @@ ref SpanByteAndMemory filterBitmap outputDistances.Length = 0; filterBitmap.Length = 0; outputIdFormat = VectorIdFormat.I32LengthPrefixed; - return VectorManagerResult.OK; + return VectorManagerResult.BadParams; } var selectorCount = GetSelectorRanges(instrBuf[..instrCount], instrCount, filter, selectorBuf); diff --git a/test/standalone/Garnet.test.extensions/DiskANN/DiskANNServiceTests.cs b/test/standalone/Garnet.test.extensions/DiskANN/DiskANNServiceTests.cs index 67db2b1b7a8..0e9f12c72ad 100644 --- a/test/standalone/Garnet.test.extensions/DiskANN/DiskANNServiceTests.cs +++ b/test/standalone/Garnet.test.extensions/DiskANN/DiskANNServiceTests.cs @@ -23,6 +23,7 @@ public class DiskANNServiceTests : TestBase private delegate byte WriteCallbackDelegate(ulong context, nint keyData, nuint keyLength, nint writeData, nuint writeLength); private delegate byte DeleteCallbackDelegate(ulong context, nint keyData, nuint keyLength); private delegate byte ReadModifyWriteCallbackDelegate(ulong context, nint keyData, nuint keyLength, nuint writeLength, nint dataCallback, nint dataCallbackContext); + private delegate byte InlineFilterCallbackDelegate(ulong context, uint internalId); private sealed class ContextAndKeyComparer : IEqualityComparer<(ulong Context, byte[] Data)> { @@ -158,17 +159,24 @@ unsafe byte ReadModifyWriteCallback(ulong context, nint keyData, nuint keyLength return 1; } + unsafe byte InlineFilterCallback(ulong context, uint internalId) + { + return 1; + } + ReadCallbackDelegate readDel = ReadCallback; WriteCallbackDelegate writeDel = WriteCallback; DeleteCallbackDelegate deleteDel = DeleteCallback; ReadModifyWriteCallbackDelegate rmwDel = ReadModifyWriteCallback; + InlineFilterCallbackDelegate filterDel = InlineFilterCallback; var readFuncPtr = Marshal.GetFunctionPointerForDelegate(readDel); var writeFuncPtr = Marshal.GetFunctionPointerForDelegate(writeDel); var deleteFuncPtr = Marshal.GetFunctionPointerForDelegate(deleteDel); var rmwFuncPtr = Marshal.GetFunctionPointerForDelegate(rmwDel); + var filterFuncPtr = Marshal.GetFunctionPointerForDelegate(filterDel); - var rawIndex = NativeDiskANNMethods.create_index(Context, 75, 0, VectorQuantType.XNoQuant_U8, VectorDistanceMetricType.L2, 10, 10, readFuncPtr, writeFuncPtr, deleteFuncPtr, rmwFuncPtr); + var rawIndex = NativeDiskANNMethods.create_index(Context, 75, 0, VectorQuantType.XNoQuant_U8, VectorDistanceMetricType.L2, 10, 10, readFuncPtr, writeFuncPtr, deleteFuncPtr, rmwFuncPtr, filterFuncPtr); Span id = [0, 1, 2, 3]; Span elem = Enumerable.Range(0, 75).Select(static x => (byte)x).ToArray(); @@ -353,17 +361,25 @@ unsafe byte ReadModifyWriteCallback(ulong context, nint keyData, nuint keyLength return 1; } + unsafe byte InlineFilterCallback(ulong context, uint internalId) + { + return 1; + } + + ReadCallbackDelegate readDel = ReadCallback; WriteCallbackDelegate writeDel = WriteCallback; DeleteCallbackDelegate deleteDel = DeleteCallback; ReadModifyWriteCallbackDelegate rmwDel = ReadModifyWriteCallback; + InlineFilterCallbackDelegate filterDel = InlineFilterCallback; var readFuncPtr = Marshal.GetFunctionPointerForDelegate(readDel); var writeFuncPtr = Marshal.GetFunctionPointerForDelegate(writeDel); var deleteFuncPtr = Marshal.GetFunctionPointerForDelegate(deleteDel); var rmwFuncPtr = Marshal.GetFunctionPointerForDelegate(rmwDel); + var filterFuncPtr = Marshal.GetFunctionPointerForDelegate(filterDel); - var rawIndex = NativeDiskANNMethods.create_index(Context, 75, 0, VectorQuantType.XNoQuant_U8, VectorDistanceMetricType.L2, 10, 10, readFuncPtr, writeFuncPtr, deleteFuncPtr, rmwFuncPtr); + var rawIndex = NativeDiskANNMethods.create_index(Context, 75, 0, VectorQuantType.XNoQuant_U8, VectorDistanceMetricType.L2, 10, 10, readFuncPtr, writeFuncPtr, deleteFuncPtr, rmwFuncPtr, filterFuncPtr); Span id = [0, 1, 2, 3]; Span elem = Enumerable.Range(0, 75).Select(static x => (byte)x).ToArray(); @@ -408,7 +424,7 @@ unsafe byte ReadModifyWriteCallback(ulong context, nint keyData, nuint keyLength { NativeDiskANNMethods.drop_index(Context, rawIndex); - rawIndex = NativeDiskANNMethods.create_index(Context, 75, 0, VectorQuantType.XNoQuant_U8, VectorDistanceMetricType.L2, 10, 10, readFuncPtr, writeFuncPtr, deleteFuncPtr, rmwFuncPtr); + rawIndex = NativeDiskANNMethods.create_index(Context, 75, 0, VectorQuantType.XNoQuant_U8, VectorDistanceMetricType.L2, 10, 10, readFuncPtr, writeFuncPtr, deleteFuncPtr, rmwFuncPtr, filterFuncPtr); } // Search value diff --git a/test/standalone/Garnet.test.vectorset/RespVectorSetTests.cs b/test/standalone/Garnet.test.vectorset/RespVectorSetTests.cs index e71a214da31..98f7f555f0e 100644 --- a/test/standalone/Garnet.test.vectorset/RespVectorSetTests.cs +++ b/test/standalone/Garnet.test.vectorset/RespVectorSetTests.cs @@ -867,6 +867,236 @@ public void VSIMWithAdvancedFilteringELEWithoutWithAttribs() ClassicAssert.AreEqual(2, res3.Length, "ELE + FILTER without WITHATTRIBS: arithmetic and comparison"); } + [Test] + public void VSIMBadFilters() + { + const string VectorSet = "vs"; + const string CompileErr = "ERR Compiling filter failed"; + + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + _ = db.KeyDelete(VectorSet); + + // Seed: + // ids 0..2 -> valid JSON attributes (year + genre) + // id 3 -> malformed JSON attribute + // id 4 -> no SETATTR at all + var add0 = db.Execute("VADD", [VectorSet, "VALUES", "3", "1.0", "2.0", "3.0", new byte[] { 0, 0, 0, 0 }, "NOQUANT", "SETATTR", "{\"year\":1980,\"genre\":\"action\"}"]); + ClassicAssert.AreEqual(1, (int)add0); + var add1 = db.Execute("VADD", [VectorSet, "VALUES", "3", "1.1", "2.1", "3.1", new byte[] { 0, 0, 0, 1 }, "NOQUANT", "SETATTR", "{\"year\":1990,\"genre\":\"drama\"}"]); + ClassicAssert.AreEqual(1, (int)add1); + var add2 = db.Execute("VADD", [VectorSet, "VALUES", "3", "1.2", "2.2", "3.2", new byte[] { 0, 0, 0, 2 }, "NOQUANT", "SETATTR", "{\"year\":2000,\"genre\":\"sci-fi\"}"]); + ClassicAssert.AreEqual(1, (int)add2); + var add3 = db.Execute("VADD", [VectorSet, "VALUES", "3", "1.3", "2.3", "3.3", new byte[] { 0, 0, 0, 3 }, "NOQUANT", "SETATTR", "{not-valid-json"]); + ClassicAssert.AreEqual(1, (int)add3); + var add4 = db.Execute("VADD", [VectorSet, "VALUES", "3", "1.4", "2.4", "3.4", new byte[] { 0, 0, 0, 4 }, "NOQUANT"]); + ClassicAssert.AreEqual(1, (int)add4); + + // ── Section A: compile-time errors ───────────────────────────────── + // Every entry below must surface as "ERR Compiling filter failed". + (string Filter, string Why)[] badFilters = + [ + (" ", "whitespace-only filter (compiler sees zero tokens)"), + ("(.year > 1980", "unclosed opening paren"), + (".year > 1980)", "extra closing paren"), + ("()", "empty parens with no expression"), + (".genre == \"action", "unterminated double-quoted string"), + (".genre == 'action", "unterminated single-quoted string"), + (". > 1", "bare-dot selector with no field name"), + ("> 1980", "binary operator with no left operand"), + (".year >", "binary operator with no right operand"), + (".year > > 1980", "two consecutive binary operators"), + (".year 1980", "two consecutive operands with no operator"), + (".year > 1.2.3", "malformed number literal"), + ("foobar", "unknown identifier"), + ("@ > 1", "character not allowed in any token"), + (".x in [1, 2", "unterminated tuple literal"), + (".x in [1 2]", "tuple elements without a comma separator"), + ("not", "unary 'not' with no operand"), + ("in [1, 2]", "'in' operator with no left operand"), + (".x in", "'in' operator with no right operand"), + (">", "naked binary operator"), + ]; + + foreach (var (filter, why) in badFilters) + { + var exc = ClassicAssert.Throws( + () => db.Execute("VSIM", [VectorSet, "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", filter, "COUNT", "10"]), + $"Expected compile failure for filter '{filter}' ({why})"); + ClassicAssert.AreEqual(CompileErr, exc.Message, $"Wrong error message for filter '{filter}' ({why})"); + } + + // ── Section B: documented "skip silently" behavior ───────────────── + // Per the filter-expressions docs: "If a field is missing or invalid, + // the element is skipped without error." None of the queries below + // should raise an exception. + + // Empty FILTER string is treated as no filter at all by the VSIM + // parser (length-0 check before compile), so it returns all elements. + var emptyFilter = (byte[][])db.Execute("VSIM", [VectorSet, "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", "", "COUNT", "10"]); + ClassicAssert.AreEqual(5, emptyFilter.Length, "Empty FILTER string should behave as no filter"); + + // Filter referencing a field no element has -> 0 results, no error. + var missingField = (byte[][])db.Execute("VSIM", [VectorSet, "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", ".nonexistent > 5", "COUNT", "10"]); + ClassicAssert.AreEqual(0, missingField.Length, "Filter on a non-existent field should return zero results, not an error"); + + // Type-mismatched comparisons must not raise. Exact result count + // depends on whether the runner skips or coerces, which the spec + // leaves unspecified, so we only assert "no error" and that the + // result stays within the seeded population. + var numCmpString = (byte[][])db.Execute("VSIM", [VectorSet, "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", ".genre > 5", "COUNT", "10"]); + ClassicAssert.IsNotNull(numCmpString, "Numeric comparison against a string field must not raise"); + ClassicAssert.LessOrEqual(numCmpString.Length, 5); + + var stringEqOnNum = (byte[][])db.Execute("VSIM", [VectorSet, "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", ".year == \"hello\"", "COUNT", "10"]); + ClassicAssert.IsNotNull(stringEqOnNum, "Comparing a numeric field to a string literal must not raise"); + ClassicAssert.LessOrEqual(stringEqOnNum.Length, 5); + + // A permissive valid filter should match the 3 well-formed elements + // and silently skip the malformed-JSON (id 3) and no-attr (id 4) + // elements, demonstrating both documented skip cases at once. + var validFilter = (byte[][])db.Execute("VSIM", [VectorSet, "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", ".year >= 1980", "COUNT", "10"]); + ClassicAssert.AreEqual(3, validFilter.Length, "Only the 3 well-formed elements should match; malformed-JSON and no-attr elements must be skipped silently"); + var matchedIds = new HashSet(validFilter, ByteArrayComparer.Instance); + ClassicAssert.IsTrue(matchedIds.Contains([0, 0, 0, 0]), "id 0 (valid attrs) should be in results"); + ClassicAssert.IsTrue(matchedIds.Contains([0, 0, 0, 1]), "id 1 (valid attrs) should be in results"); + ClassicAssert.IsTrue(matchedIds.Contains([0, 0, 0, 2]), "id 2 (valid attrs) should be in results"); + ClassicAssert.IsFalse(matchedIds.Contains([0, 0, 0, 3]), "id 3 (malformed JSON) should be silently skipped"); + ClassicAssert.IsFalse(matchedIds.Contains([0, 0, 0, 4]), "id 4 (no SETATTR) should be silently skipped"); + } + + [Test] + public void VSIMComplexJsonAttributes() + { + const string VectorSet = "vs"; + + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + _ = db.KeyDelete(VectorSet); + + // Seed 11 elements covering nested objects, booleans, null, arrays, + // non-object top-level JSON, empty objects, dash-in-field-name, and + // same-named top-level vs nested fields. + // + // id 0 -> top-level year + nested meta.director + // id 1 -> year only exists at nested depth + // id 2 -> top-level boolean true + // id 3 -> top-level boolean false + // id 4 -> top-level null + // id 5 -> top-level number array + // id 6 -> non-object top-level JSON (whole attr is an array) + // id 7 -> empty object + // id 8 -> field name contains a dash + // id 9 -> same-named field both top-level (1980) and nested (2020) + // id 10 -> top-level string array + nested object value + (byte[] Id, string Attr)[] seed = + [ + ([0, 0, 0, 0], "{\"year\":1980,\"meta\":{\"director\":\"Spielberg\"}}"), + ([0, 0, 0, 1], "{\"meta\":{\"year\":1980}}"), + ([0, 0, 0, 2], "{\"active\":true}"), + ([0, 0, 0, 3], "{\"active\":false}"), + ([0, 0, 0, 4], "{\"year\":null}"), + ([0, 0, 0, 5], "{\"scores\":[1,2,3]}"), + ([0, 0, 0, 6], "[1,2,3]"), + ([0, 0, 0, 7], "{}"), + ([0, 0, 0, 8], "{\"year-old\":1980}"), + ([0, 0, 0, 9], "{\"year\":1980,\"nested\":{\"year\":2020}}"), + ([0, 0, 0, 10], "{\"tags\":[\"classic\"],\"director\":{\"name\":\"Spielberg\"}}"), + ]; + + for (var i = 0; i < seed.Length; i++) + { + var (id, attr) = seed[i]; + // Spread the vectors slightly so cosine/L2 doesn't collapse them on top of each other. + var v0 = (1.0f + i * 0.1f).ToString(); + var v1 = (2.0f + i * 0.1f).ToString(); + var v2 = (3.0f + i * 0.1f).ToString(); + var res = db.Execute("VADD", [VectorSet, "VALUES", "3", v0, v1, v2, id, "NOQUANT", "SETATTR", attr]); + ClassicAssert.AreEqual(1, (int)res, $"VADD for id {i} should succeed even with unusual attribute shape"); + } + + // Sanity: all 11 elements made it into the set. + var info = (RedisValue[])db.Execute("VINFO", [VectorSet]); + var infoMap = new Dictionary(); + for (var i = 0; i < info.Length; i += 2) + infoMap[info[i]] = info[i + 1]; + ClassicAssert.AreEqual("11", infoMap["size"], "All 11 elements must be present"); + + // ── Case 1 + 9: top-level .year is visible; nested .year is not ─── + // Filter .year > 1900 should match id 0 and id 9 (both have top-level + // year 1980). It must NOT match id 1 (nested-only) or id 4 (null). + var byYear = MatchedIds(db.Execute("VSIM", [VectorSet, "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", ".year > 1900", "COUNT", "20"])); + AssertSameIds(ExpectIds([0, 0, 0, 0], [0, 0, 0, 9]), byYear, "Top-level .year > 1900 should match only ids 0 and 9"); + + // ── Case 9 specifically: nested .year=2020 must be invisible ────── + var byYear2000 = MatchedIds(db.Execute("VSIM", [VectorSet, "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", ".year > 2000", "COUNT", "20"])); + ClassicAssert.AreEqual(0, byYear2000.Count, ".year > 2000 must not see the nested year=2020 in id 9"); + + var byYearRange = MatchedIds(db.Execute("VSIM", [VectorSet, "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", ".year > 1900 and .year < 2000", "COUNT", "20"])); + AssertSameIds(ExpectIds([0, 0, 0, 0], [0, 0, 0, 9]), byYearRange, "Range filter should still see only top-level .year for ids 0 and 9"); + + // ── Case 1 sub: top-level field whose value is an object is unusable + // id 0's .meta and id 1's .meta are objects. Comparing to a string + // must yield 0 matches without raising. + var metaEq = (byte[][])db.Execute("VSIM", [VectorSet, "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", ".meta == \"Spielberg\"", "COUNT", "20"]); + ClassicAssert.AreEqual(0, metaEq.Length, "Equality against an object-valued top-level field must yield 0 results"); + + // Same idea for case 10: .director is an object on id 10. + var directorEq = (byte[][])db.Execute("VSIM", [VectorSet, "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", ".director == \"Spielberg\"", "COUNT", "20"]); + ClassicAssert.AreEqual(0, directorEq.Length, "Equality against object-valued .director must yield 0 results"); + + // ── Case 3: top-level booleans coerce to 1 / 0 ──────────────────── + var activeTrue = MatchedIds(db.Execute("VSIM", [VectorSet, "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", ".active == 1", "COUNT", "20"])); + AssertSameIds(ExpectIds([0, 0, 0, 2]), activeTrue, ".active == 1 should match only the element whose JSON value is true"); + + var activeFalse = MatchedIds(db.Execute("VSIM", [VectorSet, "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", ".active == 0", "COUNT", "20"])); + AssertSameIds(ExpectIds([0, 0, 0, 3]), activeFalse, ".active == 0 should match only the element whose JSON value is false"); + + // ── Case 4: top-level null does not match numeric > comparisons ─── + // (.year > 5 with year=null: id 4 must NOT appear; ids 0 and 9 do.) + var yearGt5 = MatchedIds(db.Execute("VSIM", [VectorSet, "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", ".year > 5", "COUNT", "20"])); + AssertSameIds(ExpectIds([0, 0, 0, 0], [0, 0, 0, 9]), yearGt5, ".year > 5 must skip the null-valued id 4"); + + // ── Case 5: top-level number array works with `in`, fails > silently + var inHit = MatchedIds(db.Execute("VSIM", [VectorSet, "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", "2 in .scores", "COUNT", "20"])); + AssertSameIds(ExpectIds([0, 0, 0, 5]), inHit, "2 in .scores should match only id 5"); + + var inMiss = (byte[][])db.Execute("VSIM", [VectorSet, "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", "99 in .scores", "COUNT", "20"]); + ClassicAssert.AreEqual(0, inMiss.Length, "99 in .scores should match nothing"); + + var arrAsNum = (byte[][])db.Execute("VSIM", [VectorSet, "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", ".scores > 5", "COUNT", "20"]); + ClassicAssert.AreEqual(0, arrAsNum.Length, "Numeric comparison against an array-valued field must yield 0 results without raising"); + + // ── Case 8: selector greedily includes '-' so .year-old is one name + // The filter must NOT be interpreted as `.year - old > 1900`. + var yearOld = MatchedIds(db.Execute("VSIM", [VectorSet, "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", ".year-old > 1900", "COUNT", "20"])); + AssertSameIds(ExpectIds([0, 0, 0, 8]), yearOld, ".year-old must be treated as a single selector and match only id 8"); + + // ── Case 10: top-level string array still works with `in` ───────── + var classicInTags = MatchedIds(db.Execute("VSIM", [VectorSet, "VALUES", "3", "0.0", "0.0", "0.0", "FILTER", "\"classic\" in .tags", "COUNT", "20"])); + AssertSameIds(ExpectIds([0, 0, 0, 10]), classicInTags, "\"classic\" in .tags should match only id 10"); + + // ── Case 6 + 7 (implicit): the above filters together demonstrate + // that ids 6 (non-object top-level JSON) and 7 (empty object) never + // appear in any field-based result and never cause an error. + ClassicAssert.IsFalse(yearOld.Contains([0, 0, 0, 6], ByteArrayComparer.Instance), "Non-object top-level JSON (id 6) must be silently skipped, not error"); + ClassicAssert.IsFalse(yearOld.Contains([0, 0, 0, 7], ByteArrayComparer.Instance), "Empty-object JSON (id 7) must be silently skipped, not error"); + + static HashSet MatchedIds(RedisResult res) + => new((byte[][])res, ByteArrayComparer.Instance); + + static HashSet ExpectIds(params byte[][] ids) + => new(ids, ByteArrayComparer.Instance); + + static void AssertSameIds(HashSet expected, HashSet actual, string message) + => ClassicAssert.IsTrue(expected.SetEquals(actual), $"{message} (expected {Format(expected)}, got {Format(actual)})"); + + static string Format(HashSet set) + => "{" + string.Join(", ", set.Select(static b => "[" + string.Join(",", b) + "]")) + "}"; + } + [Test] public void VSIMErrors() { diff --git a/website/docs/commands/vector-sets.md b/website/docs/commands/vector-sets.md index 02dcf84ad62..bd4000ff299 100644 --- a/website/docs/commands/vector-sets.md +++ b/website/docs/commands/vector-sets.md @@ -10,7 +10,7 @@ slug: vector-sets Vector Sets are a Garnet data type backed by the [DiskANN](https://github.com/microsoft/DiskANN) algorithm — Microsoft's graph-based approximate nearest-neighbor (ANN) index — coupled with the scalable storage performance of Tsavorite, Garnet's storage engine for holding the state. They let you insert high-dimensional vector embeddings under a Garnet -key and perform fast similarity search over them, with optional JSON attributes for post-filtering. +key and perform fast similarity search over them, with optional JSON attributes for filtering. The command surface is inspired by Redis' `V*` Vector Set commands but is implemented natively on top of Garnet's storage stack and DiskANN. Some commands are Garnet-specific extensions (prefixed `X*`). @@ -381,7 +381,7 @@ The query's effective dimension must match the index's `input-vector-dimensions` | `COUNT n` | `10` | Maximum number of results to return. Must be in `[0, 100000000]`. | | `EPSILON delta` | `2.0` | DiskANN `L_search` epsilon — controls how aggressively the graph is explored beyond the current best. | | `EF n` | `100` | Search-time exploration factor (`L_search` candidate-list size). Must be in `[1, 1000000]`. | -| `FILTER expr` | _none_ | Post-filter results by an attribute expression (see [Filter Expressions](#filter-expressions)). | +| `FILTER expr` | _none_ | Filter results by an attribute expression (see [Filter Expressions](#filter-expressions)). | | `FILTER-EF n` | `16` | Scale factor for adaptive inline filter search. Must be in `[4, 256]`. This controls how high the EF will scale based on selectivity. | | `TRUTH` | _off_ | Accepted for compatibility; exact / brute-force search is not yet wired up. | | `NOTHREAD` | _off_ | Accepted for compatibility; currently ignored (search always runs on the calling thread). | @@ -423,7 +423,7 @@ VSIM movies VALUES 3 0.0 0.0 0.0 \ ### Filter Expressions -`VSIM ... FILTER ` post-filters candidates by their JSON attribute. The expression is compiled once and +`VSIM ... FILTER ` filters candidates by their JSON attribute. The expression is compiled once and evaluated against each candidate's attribute. #### Syntax diff --git a/website/docs/dev/filtered-search-design.md b/website/docs/dev/filtered-search-design.md index dd87ff65075..ebea1d6db43 100644 --- a/website/docs/dev/filtered-search-design.md +++ b/website/docs/dev/filtered-search-design.md @@ -17,7 +17,7 @@ Evaluate the filter predicate *during* graph traversal so that non-matching cand --- -## 2. Garnet-Side: Attribute Storage Design for Inline Filtering (Current Change) +## 2. Garnet-Side: Attribute Storage Design for Inline Filtering ### Existing Attribute Store @@ -31,20 +31,13 @@ However, this store creates a mismatch with how DiskANN's graph traversal operat With inline filtering, this callback runs on **every candidate the graph traversal considers** (potentially thousands per query). The two store reads and JSON parsing per candidate become the dominant cost on the hot path. -### Solution: Add a second attribute store optimized for query-time filter evaluation +### Solution: Store binary attributes on the ends of quant vectors (or full precision for unqunatized indexes) -The current change **adds a new attribute store** alongside the existing one. The two stores serve different purposes: +Instead of storing raw JSON blobs, the attributes will be serialized as an optimized binary blob which is stored immediately after the most used vector data (in an unquantized index, this is the full vector; in an quantized index, this is the quant vector). -| Store | Keyed by | Format | Purpose | -|-------|----------|--------|---------| -| Existing | External ID (user key) | Raw JSON | RESP command operations (`VGETATTR`, `VSETATTR`, etc.) | -| **New** | Internal ID (DiskANN ID) | Binary | Inline filter evaluation at query time | +### Why store alongside vectors? -The existing external ID keyed JSON store is untouched — it continues to serve all RESP command operations. The new internal ID keyed binary store is a **write-time derived projection** of the same data, optimized purely for the inline filter callback's access pattern. - -### Why key by internal ID - -DiskANN hands the callback an internal ID; the existing attribute store expects an external key. Bridging this gap requires reading the `ExternalIdMap` — a store read that exists purely because of the keying mismatch. By adding a store keyed by internal ID, the filter callback can look up attributes directly without any ID translation. This eliminates the `ExternalIdMap` read entirely — one fewer store read per candidate. +By storing after vector data, we can access the attributes at the same time as the vector data saving any extra key accesses. A single read will allow us to compute distance and evaluate the filter. Because vector data is a fixed length, the filter attributes are easy to pick out. ### Why store in binary format @@ -102,7 +95,7 @@ Co-located (0 extra store reads per candidate): This would reduce the per-candidate cost to **zero extra store reads** — the only remaining overhead is the binary field scan and expression evaluation. -### Further with attibute index: Pre-built attribute index to replace per-candidate filter evaluation +### Further with attribute index: Pre-built attribute index to replace per-candidate filter evaluation If an attribute index is available (e.g., inverted indexes or roaring bitmaps built over attribute values), the filter predicate can be evaluated **at query planning time** rather than per-candidate during graph traversal. The index would produce a pre-computed set of matching internal IDs (e.g., a bitmap), which can be fed directly into DiskANN as a `GarnetFilter::Bitmap`. This replaces the per-candidate FFI callback entirely — DiskANN checks the bitmap with a single bit lookup instead of reading attributes and running the expression evaluator.