Skip to content

Commit fe5a0bc

Browse files
committed
Implement vector search command with enhanced limit and offset handling, including error responses for invalid syntax
1 parent 7a620de commit fe5a0bc

3 files changed

Lines changed: 226 additions & 16 deletions

File tree

Dredis.Tests/DredisCommandHandlerTests.cs

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1690,6 +1690,132 @@ public async Task Vector_Search_InvalidOffset_ReturnsError()
16901690
}
16911691
}
16921692

1693+
[Fact]
1694+
public async Task Vector_Search_LimitSyntax_WorksWithoutPositionalTopK()
1695+
{
1696+
var store = new InMemoryKeyValueStore();
1697+
var channel = new EmbeddedChannel(new DredisCommandHandler(store));
1698+
1699+
try
1700+
{
1701+
channel.WriteInbound(Command("VSET", "emb:a", "1", "0"));
1702+
channel.RunPendingTasks();
1703+
_ = ReadOutbound(channel);
1704+
1705+
channel.WriteInbound(Command("VSET", "emb:b", "0.8", "0.2"));
1706+
channel.RunPendingTasks();
1707+
_ = ReadOutbound(channel);
1708+
1709+
channel.WriteInbound(Command("VSEARCH", "emb:", "COSINE", "LIMIT", "1", "1", "0"));
1710+
channel.RunPendingTasks();
1711+
1712+
var response = ReadOutbound(channel);
1713+
var array = Assert.IsType<ArrayRedisMessage>(response);
1714+
Assert.Equal(2, array.Children.Count);
1715+
Assert.Equal("emb:a", GetBulkString(Assert.IsType<FullBulkStringRedisMessage>(array.Children[0])));
1716+
}
1717+
finally
1718+
{
1719+
await channel.CloseAsync();
1720+
}
1721+
}
1722+
1723+
[Fact]
1724+
public async Task Vector_Search_LimitMissing_ReturnsError()
1725+
{
1726+
var store = new InMemoryKeyValueStore();
1727+
var channel = new EmbeddedChannel(new DredisCommandHandler(store));
1728+
1729+
try
1730+
{
1731+
channel.WriteInbound(Command("VSEARCH", "emb:", "COSINE", "1", "0"));
1732+
channel.RunPendingTasks();
1733+
1734+
var response = ReadOutbound(channel);
1735+
var error = Assert.IsType<ErrorRedisMessage>(response);
1736+
Assert.Equal("ERR LIMIT is required", error.Content);
1737+
}
1738+
finally
1739+
{
1740+
await channel.CloseAsync();
1741+
}
1742+
}
1743+
1744+
[Fact]
1745+
public async Task Vector_Search_OffsetBeforeLimit_ReturnsSyntaxError()
1746+
{
1747+
var store = new InMemoryKeyValueStore();
1748+
var channel = new EmbeddedChannel(new DredisCommandHandler(store));
1749+
1750+
try
1751+
{
1752+
channel.WriteInbound(Command("VSET", "emb:a", "1", "0"));
1753+
channel.RunPendingTasks();
1754+
_ = ReadOutbound(channel);
1755+
1756+
channel.WriteInbound(Command("VSEARCH", "emb:", "COSINE", "OFFSET", "1", "LIMIT", "1", "1", "0"));
1757+
channel.RunPendingTasks();
1758+
1759+
var response = ReadOutbound(channel);
1760+
var error = Assert.IsType<ErrorRedisMessage>(response);
1761+
Assert.Equal("ERR LIMIT is required", error.Content);
1762+
}
1763+
finally
1764+
{
1765+
await channel.CloseAsync();
1766+
}
1767+
}
1768+
1769+
[Fact]
1770+
public async Task Vector_Search_LimitInPositionalForm_ReturnsSyntaxError()
1771+
{
1772+
var store = new InMemoryKeyValueStore();
1773+
var channel = new EmbeddedChannel(new DredisCommandHandler(store));
1774+
1775+
try
1776+
{
1777+
channel.WriteInbound(Command("VSET", "emb:a", "1", "0"));
1778+
channel.RunPendingTasks();
1779+
_ = ReadOutbound(channel);
1780+
1781+
channel.WriteInbound(Command("VSEARCH", "emb:", "2", "COSINE", "LIMIT", "1", "1", "0"));
1782+
channel.RunPendingTasks();
1783+
1784+
var response = ReadOutbound(channel);
1785+
var error = Assert.IsType<ErrorRedisMessage>(response);
1786+
Assert.Equal("ERR syntax error", error.Content);
1787+
}
1788+
finally
1789+
{
1790+
await channel.CloseAsync();
1791+
}
1792+
}
1793+
1794+
[Fact]
1795+
public async Task Vector_Search_DuplicateOffset_ReturnsSyntaxError()
1796+
{
1797+
var store = new InMemoryKeyValueStore();
1798+
var channel = new EmbeddedChannel(new DredisCommandHandler(store));
1799+
1800+
try
1801+
{
1802+
channel.WriteInbound(Command("VSET", "emb:a", "1", "0"));
1803+
channel.RunPendingTasks();
1804+
_ = ReadOutbound(channel);
1805+
1806+
channel.WriteInbound(Command("VSEARCH", "emb:", "2", "COSINE", "OFFSET", "0", "OFFSET", "1", "1", "0"));
1807+
channel.RunPendingTasks();
1808+
1809+
var response = ReadOutbound(channel);
1810+
var error = Assert.IsType<ErrorRedisMessage>(response);
1811+
Assert.Equal("ERR syntax error", error.Content);
1812+
}
1813+
finally
1814+
{
1815+
await channel.CloseAsync();
1816+
}
1817+
}
1818+
16931819
[Fact]
16941820
public async Task Publish_NoSubscribers_ReturnsZero()
16951821
{

DredisCommandHandler.cs

Lines changed: 97 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2343,48 +2343,130 @@ private async Task HandleVectorSearchAsync(
23432343
IChannelHandlerContext ctx,
23442344
IList<IRedisMessage> args)
23452345
{
2346-
if (args.Count < 6)
2346+
if (args.Count < 5)
23472347
{
23482348
WriteError(ctx, "ERR wrong number of arguments for 'vsearch' command");
23492349
return;
23502350
}
23512351

2352-
if (!TryGetString(args[1], out var keyPrefix) ||
2353-
!TryGetString(args[2], out var topKText) ||
2354-
!TryGetString(args[3], out var metric))
2352+
if (!TryGetString(args[1], out var keyPrefix))
23552353
{
23562354
WriteError(ctx, "ERR null bulk string");
23572355
return;
23582356
}
23592357

2360-
if (!int.TryParse(topKText, out var topK) || topK <= 0)
2358+
if (!TryGetString(args[2], out var token2))
23612359
{
2362-
WriteError(ctx, "ERR value is not an integer or out of range");
2360+
WriteError(ctx, "ERR null bulk string");
23632361
return;
23642362
}
23652363

2364+
int topK;
23662365
int offset = 0;
2367-
int vectorStartIndex = 4;
2368-
if (args.Count >= 7 && TryGetString(args[4], out var offsetToken) &&
2369-
string.Equals(offsetToken, "OFFSET", StringComparison.OrdinalIgnoreCase))
2366+
string metric;
2367+
int index;
2368+
bool positionalForm;
2369+
2370+
if (int.TryParse(token2, out var positionalTopK))
2371+
{
2372+
if (positionalTopK <= 0)
2373+
{
2374+
WriteError(ctx, "ERR value is not an integer or out of range");
2375+
return;
2376+
}
2377+
2378+
topK = positionalTopK;
2379+
if (!TryGetString(args[3], out metric))
2380+
{
2381+
WriteError(ctx, "ERR null bulk string");
2382+
return;
2383+
}
2384+
2385+
index = 4;
2386+
positionalForm = true;
2387+
}
2388+
else
2389+
{
2390+
metric = token2;
2391+
topK = -1;
2392+
index = 3;
2393+
positionalForm = false;
2394+
}
2395+
2396+
if (positionalForm)
2397+
{
2398+
if (index + 1 < args.Count && TryGetString(args[index], out var option))
2399+
{
2400+
if (option.Equals("LIMIT", StringComparison.OrdinalIgnoreCase))
2401+
{
2402+
WriteError(ctx, "ERR syntax error");
2403+
return;
2404+
}
2405+
2406+
if (option.Equals("OFFSET", StringComparison.OrdinalIgnoreCase))
2407+
{
2408+
if (!TryGetString(args[index + 1], out var offsetText) || !int.TryParse(offsetText, out offset) || offset < 0)
2409+
{
2410+
WriteError(ctx, "ERR value is not an integer or out of range");
2411+
return;
2412+
}
2413+
2414+
index += 2;
2415+
}
2416+
}
2417+
}
2418+
else
23702419
{
2371-
if (!TryGetString(args[5], out var offsetText) || !int.TryParse(offsetText, out offset) || offset < 0)
2420+
if (args.Count < 4)
2421+
{
2422+
WriteError(ctx, "ERR wrong number of arguments for 'vsearch' command");
2423+
return;
2424+
}
2425+
2426+
if (!TryGetString(args[index], out var limitToken) ||
2427+
!limitToken.Equals("LIMIT", StringComparison.OrdinalIgnoreCase))
2428+
{
2429+
WriteError(ctx, "ERR LIMIT is required");
2430+
return;
2431+
}
2432+
2433+
if (!TryGetString(args[index + 1], out var limitText) || !int.TryParse(limitText, out topK) || topK <= 0)
23722434
{
23732435
WriteError(ctx, "ERR value is not an integer or out of range");
23742436
return;
23752437
}
23762438

2377-
vectorStartIndex = 6;
2439+
index += 2;
2440+
2441+
if (index + 1 < args.Count && TryGetString(args[index], out var offsetToken) &&
2442+
offsetToken.Equals("OFFSET", StringComparison.OrdinalIgnoreCase))
2443+
{
2444+
if (!TryGetString(args[index + 1], out var offsetText) || !int.TryParse(offsetText, out offset) || offset < 0)
2445+
{
2446+
WriteError(ctx, "ERR value is not an integer or out of range");
2447+
return;
2448+
}
2449+
2450+
index += 2;
2451+
}
2452+
}
2453+
2454+
if (index < args.Count && TryGetString(args[index], out var unexpectedToken) &&
2455+
(unexpectedToken.Equals("LIMIT", StringComparison.OrdinalIgnoreCase) ||
2456+
unexpectedToken.Equals("OFFSET", StringComparison.OrdinalIgnoreCase)))
2457+
{
2458+
WriteError(ctx, "ERR syntax error");
2459+
return;
23782460
}
23792461

2380-
if (args.Count <= vectorStartIndex)
2462+
if (args.Count <= index)
23812463
{
23822464
WriteError(ctx, "ERR wrong number of arguments for 'vsearch' command");
23832465
return;
23842466
}
23852467

2386-
var queryVector = new double[args.Count - vectorStartIndex];
2387-
for (int i = vectorStartIndex; i < args.Count; i++)
2468+
var queryVector = new double[args.Count - index];
2469+
for (int i = index; i < args.Count; i++)
23882470
{
23892471
if (!TryGetString(args[i], out var valueText) ||
23902472
!double.TryParse(valueText, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ||
@@ -2395,7 +2477,7 @@ private async Task HandleVectorSearchAsync(
23952477
return;
23962478
}
23972479

2398-
queryVector[i - vectorStartIndex] = value;
2480+
queryVector[i - index] = value;
23992481
}
24002482

24012483
var result = await _store.VectorSearchAsync(keyPrefix, topK, offset, metric, queryVector).ConfigureAwait(false);

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ Notes:
5656
- `JSON.ARRREM` removes an element at the specified index from the array (or last element if no index provided).
5757
- `JSON.ARRTRIM` trims the array at the specified path to the specified range.
5858
- `JSON.MGET` retrieves JSON values from multiple keys at the specified path.
59-
- `VSEARCH` supports optional paging via `OFFSET <n>` before query vector components.
59+
- `VSEARCH` supports paging/options via `LIMIT <n>` and optional `OFFSET <n>` before query vector components.
60+
- `VSEARCH` is backward-compatible with positional top-k form: `VSEARCH prefix topK metric ...`.
61+
- `VSEARCH` strict option order: metric form must be `VSEARCH prefix metric LIMIT n [OFFSET n] ...`; positional form supports only optional `OFFSET`.
6062

6163
## Feature matrix
6264

0 commit comments

Comments
 (0)