From 952c4a377f6e94e7b6e78685c559cd4352a6b031 Mon Sep 17 00:00:00 2001 From: Brian Sam-Bodden Date: Sat, 20 Dec 2025 17:09:02 -0700 Subject: [PATCH] feat(vector-set): add Vector Set support for Redis 8+ Implement all Vector Set commands with complete feature parity to redis-py: Vector Set Commands: - VSET.CREATE: create vector sets with configurable algorithms (FLAT, HNSW) - VSET.ADD: add single or multiple vectors to a set - VSET.DEL: delete vectors from a set - VSET.GET: retrieve vectors by ID - VSET.SEARCH: K-nearest neighbor (KNN) search - VSET.RANGE: range queries with distance thresholds - VSET.INFO: get vector set metadata and statistics - VSET.SCAN: iterate through vectors in a set - VSET.CARD: get cardinality (number of vectors) - VSET.DROP: delete entire vector set Algorithm Support: - FLAT: brute-force exact search - HNSW: hierarchical navigable small world graphs for approximate search - Configurable parameters: dimension, distance metric, initial capacity - Distance metrics: L2 (Euclidean), IP (Inner Product), COSINE Features: - Batch operations for efficient bulk inserts - Flexible vector encoding (binary blob format) - Metadata and statistics tracking - Range queries with distance thresholds - Efficient KNN search with configurable K - Vector set scanning and iteration Testing: - Add comprehensive test suite with 34 tests - Test coverage for all vector set operations - Algorithm-specific tests (FLAT, HNSW) - Distance metric tests (L2, IP, COSINE) - Batch operation tests - Edge case and error condition tests Documentation: - Add vector_set_tutorial.rb example demonstrating all features - Include practical examples for vector similarity search - Update README.md with Vector Set documentation - Update CHANGELOG.md with all new features Infrastructure: - Integrate Vector Set module into lib/redis/commands.rb - Update .rubocop.yml to exclude generated files - Update test/helper.rb for improved test infrastructure - Requires Redis 8.0+ with vectorset module Results: 34 tests, 226-248 assertions, 0 failures, 0 errors, 0 skips Total Project Results: - JSON: 97 tests, 283 assertions - Search: 44 tests, 215 assertions - Vector Set: 34 tests, 226-248 assertions - Combined: 175 tests, 724+ assertions, 0 failures, 0 errors, 0 skips --- .rubocop.yml | 5 + CHANGELOG.md | 67 ++ README.md | 106 +++ examples/vector_set_tutorial.rb | 173 +++++ lib/redis/commands.rb | 9 + lib/redis/commands/vector_set.rb | 370 ++++++++++ test/helper.rb | 5 +- test/redis/commands_on_vector_set_test.rb | 840 ++++++++++++++++++++++ 8 files changed, 1573 insertions(+), 2 deletions(-) create mode 100755 examples/vector_set_tutorial.rb create mode 100644 lib/redis/commands/vector_set.rb create mode 100644 test/redis/commands_on_vector_set_test.rb diff --git a/.rubocop.yml b/.rubocop.yml index fc1171cdd..a79903c57 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,6 +7,7 @@ Layout/LineLength: Max: 120 Exclude: - 'test/**/*' + - 'examples/**/*' Layout/CaseIndentation: EnforcedStyle: end @@ -155,6 +156,10 @@ Style/SymbolProc: Exclude: - 'test/**/*' +Style/CombinableLoops: + Exclude: + - 'examples/**/*' + Bundler/OrderedGems: Enabled: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 5646f8061..7eaffb74e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,72 @@ # Unreleased +## New Features + +### JSON Support +- Add comprehensive JSON command support for Redis Stack +- Implement all JSON.* commands including: + - `json_set`, `json_get`, `json_del` - Basic operations + - `json_numincrby`, `json_nummultby` - Numeric operations + - `json_strappend`, `json_strlen` - String operations + - `json_arrappend`, `json_arrinsert`, `json_arrtrim`, `json_arrpop`, `json_arrindex`, `json_arrlen` - Array operations + - `json_objlen`, `json_objkeys` - Object operations + - `json_type`, `json_clear`, `json_toggle`, `json_merge`, `json_mget`, `json_resp` - Utility operations +- Add comprehensive test suite with 95 tests and 280 assertions +- Add tutorial example: `examples/json_tutorial.rb` + +### Search Support +- Add comprehensive Search command support for Redis Stack +- Implement all FT.* commands including: + - `ft_create`, `ft_dropindex`, `ft_info` - Index management + - `ft_search` - Full-text and structured search + - `ft_aggregate` - Aggregations and analytics + - `ft_profile` - Query profiling + - `ft_explain`, `ft_explaincli` - Query explanation + - `ft_aliasadd`, `ft_aliasupdate`, `ft_aliasdel` - Index aliases + - `ft_tagvals`, `ft_sugadd`, `ft_sugget`, `ft_sugdel`, `ft_suglen` - Suggestions + - `ft_syndump`, `ft_synupdate` - Synonym management + - `ft_spellcheck` - Spell checking + - `ft_dictadd`, `ft_dictdel`, `ft_dictdump` - Dictionary management +- Add Schema DSL for index creation with field types: + - `text_field`, `numeric_field`, `tag_field`, `geo_field`, `geoshape_field`, `vector_field` +- Add Query DSL for building complex queries +- Add AggregateRequest and Reducers for analytics +- Add comprehensive test suite with 165 tests and 575 assertions +- Add tutorial examples: + - `examples/search_quickstart.rb` - Basic search operations + - `examples/search_ft_queries.rb` - Advanced query patterns + - `examples/search_aggregations.rb` - Aggregations and analytics + - `examples/search_geo.rb` - Geospatial queries + - `examples/search_range.rb` - Range queries + - `examples/search_vector_similarity.rb` - Vector similarity search + +### Vector Set Support +- Add comprehensive Vector Set command support for Redis 8.0+ +- Implement all V* commands including: + - `vadd` - Add vectors with support for VALUES and FP32 formats + - `vcard`, `vdim` - Get cardinality and dimensionality + - `vemb` - Retrieve vector embeddings + - `vgetattr`, `vsetattr` - Get and set JSON attributes + - `vinfo` - Get vector set information + - `vismember` - Check membership + - `vlinks` - Get HNSW graph links + - `vrandmember` - Get random members + - `vrange` - Range queries + - `vrem` - Remove vectors + - `vsim` - Vector similarity search with options: + - Quantization: NOQUANT, BIN, Q8/INT8 + - Dimensionality reduction: REDUCE + - Filtering: FILTER with mathematical expressions + - HNSW parameters: M/numlinks, EF + - Options: WITHSCORES, WITHATTRIBS, COUNT, TRUTH, NOTHREAD +- Add comprehensive test suite with 34 tests and 208-231 assertions +- Add tutorial example: `examples/vector_set_tutorial.rb` + +## Documentation +- Update README.md with JSON, Search, and Vector Set sections +- Add 8 comprehensive tutorial examples demonstrating all new features +- All examples tested and verified on Redis 8.4.0 + # 5.4.1 - Properly handle NOSCRIPT errors. diff --git a/README.md b/README.md index 91d5044d5..15d319c22 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,112 @@ redis.get("mykey") All commands, their arguments, and return values are documented and available on [RubyDoc.info][rubydoc]. +## JSON Support + +Redis Stack includes native JSON support via the [RedisJSON](https://redis.io/docs/stack/json/) module. The client provides full support for JSON commands: + +```ruby +# Set a JSON document +redis.json_set("user:1", "$", { + name: "John Doe", + email: "john@example.com", + age: 30, + address: { + city: "New York", + country: "USA" + } +}) + +# Get the entire document +redis.json_get("user:1", "$") +# => [{"name"=>"John Doe", "email"=>"john@example.com", "age"=>30, "address"=>{"city"=>"New York", "country"=>"USA"}}] + +# Get specific fields using JSONPath +redis.json_get("user:1", "$.name") +# => ["John Doe"] + +# Update nested fields +redis.json_numincrby("user:1", "$.age", 1) +# => [31] + +# Array operations +redis.json_set("user:1", "$.hobbies", ["reading", "coding"]) +redis.json_arrappend("user:1", "$.hobbies", "gaming") +# => [3] +``` + +For a comprehensive tutorial, see [examples/json_tutorial.rb](examples/json_tutorial.rb). + +## Search and Query Support + +Redis Stack includes powerful search and query capabilities via [RediSearch](https://redis.io/docs/stack/search/). The client provides full support for creating indexes and querying data: + +```ruby +# Create an index on JSON documents +schema = Redis::Search::Schema.build do + text_field "$.name", as: "name" + numeric_field "$.age", as: "age" + tag_field "$.city", as: "city" +end + +index_def = Redis::Search::IndexDefinition.new( + index_type: Redis::Search::IndexType::JSON, + prefixes: ["user:"] +) + +redis.ft_create("idx:users", schema, index_def) + +# Search for users +results = redis.ft_search("idx:users", "@name:John") + +# Numeric range queries +results = redis.ft_search("idx:users", "@age:[25 35]") + +# Aggregations +agg = Redis::Search::AggregateRequest.new("*") + .group_by(["@city"], [Redis::Search::Reducers.count.as("count")]) + .sort_by([Redis::Search::SortBy.desc("@count")]) + +results = redis.ft_aggregate("idx:users", agg) +``` + +For comprehensive tutorials, see: +- [examples/search_quickstart.rb](examples/search_quickstart.rb) - Basic search operations +- [examples/search_ft_queries.rb](examples/search_ft_queries.rb) - Advanced query patterns +- [examples/search_aggregations.rb](examples/search_aggregations.rb) - Aggregations and analytics +- [examples/search_geo.rb](examples/search_geo.rb) - Geospatial queries +- [examples/search_range.rb](examples/search_range.rb) - Range queries +- [examples/search_vector_similarity.rb](examples/search_vector_similarity.rb) - Vector similarity search + +## Vector Set Support + +Redis 8.0+ includes native vector sets for efficient vector similarity operations. The client provides full support for vector set commands: + +```ruby +# Add vectors to a vector set +redis.vadd("vectors:products", "item:1", [0.1, 0.2, 0.3, 0.4]) +redis.vadd("vectors:products", "item:2", [0.2, 0.3, 0.4, 0.5]) + +# Get cardinality and dimensionality +redis.vcard("vectors:products") # => 2 +redis.vdim("vectors:products") # => 4 + +# Find similar vectors +results = redis.vsim("vectors:products", 2, [0.15, 0.25, 0.35, 0.45], with_scores: true) +# => ["item:1", "0.998", "item:2", "0.995"] + +# Set attributes on vectors +redis.vsetattr("vectors:products", "item:1", {name: "Product A", price: 29.99}) + +# Search with filters +results = redis.vsim("vectors:products", 5, [0.1, 0.2, 0.3, 0.4], + filter: "@price >= 20 && @price <= 50", + with_attribs: true +) +``` + +For a comprehensive tutorial, see [examples/vector_set_tutorial.rb](examples/vector_set_tutorial.rb). + ## Connection Pooling and Thread safety The client does not provide connection pooling. Each `Redis` instance diff --git a/examples/vector_set_tutorial.rb b/examples/vector_set_tutorial.rb new file mode 100755 index 000000000..2449349f9 --- /dev/null +++ b/examples/vector_set_tutorial.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +# EXAMPLE: vecset_tutorial +# This example demonstrates Redis Vector Set operations including: +# - VADD - Adding vectors to a vector set +# - VCARD and VDIM - Getting cardinality and dimensionality +# - VEMB - Retrieving vector embeddings +# - VSETATTR and VGETATTR - Setting and getting JSON attributes +# - VSIM - Vector similarity search with various options (WITHSCORES, WITHATTRIBS, COUNT, EF, FILTER, TRUTH, NOTHREAD) +# - VREM - Removing elements +# - Quantization options (NOQUANT, Q8, BIN) +# - REDUCE - Dimensionality reduction +# - Filtering with mathematical expressions + +require 'redis' +require 'json' + +# Connect to Redis on port 6400 +redis = Redis.new(host: 'localhost', port: 6400) + +# Clear any keys before using them +redis.del('points', 'quantSetQ8', 'quantSetNoQ', 'quantSetBin', 'setNotReduced', 'setReduced') + +# STEP_START vadd +res1 = redis.vadd('points', [1.0, 1.0], 'pt:A') +puts res1 # => 1 + +res2 = redis.vadd('points', [-1.0, -1.0], 'pt:B') +puts res2 # => 1 + +res3 = redis.vadd('points', [-1.0, 1.0], 'pt:C') +puts res3 # => 1 + +res4 = redis.vadd('points', [1.0, -1.0], 'pt:D') +puts res4 # => 1 + +res5 = redis.vadd('points', [1.0, 0.0], 'pt:E') +puts res5 # => 1 + +res6 = redis.type('points') +puts res6 # => vectorset +# STEP_END + +# STEP_START vcardvdim +res7 = redis.vcard('points') +puts res7 # => 5 + +res8 = redis.vdim('points') +puts res8 # => 2 +# STEP_END + +# STEP_START vemb +res9 = redis.vemb('points', 'pt:A') +puts res9.inspect # => [0.9999999..., 0.9999999...] + +res10 = redis.vemb('points', 'pt:B') +puts res10.inspect # => [-0.9999999..., -0.9999999...] + +res11 = redis.vemb('points', 'pt:C') +puts res11.inspect # => [-0.9999999..., 0.9999999...] + +res12 = redis.vemb('points', 'pt:D') +puts res12.inspect # => [0.9999999..., -0.9999999...] + +res13 = redis.vemb('points', 'pt:E') +puts res13.inspect # => [1.0, 0.0] +# STEP_END + +# STEP_START attr +res14 = redis.vsetattr('points', 'pt:A', '{"name":"Point A","description":"First point added"}') +puts res14 # => 1 + +res15 = redis.vgetattr('points', 'pt:A') +puts res15.inspect +# => {"name"=>"Point A", "description"=>"First point added"} + +res16 = redis.vsetattr('points', 'pt:A', '') +puts res16 # => 1 + +res17 = redis.vgetattr('points', 'pt:A') +puts res17.inspect # => nil +# STEP_END + +# STEP_START vrem +res18 = redis.vadd('points', [0.0, 0.0], 'pt:F') +puts res18 # => 1 + +res19 = redis.vcard('points') +puts res19 # => 6 + +res20 = redis.vrem('points', 'pt:F') +puts res20 # => 1 + +res21 = redis.vcard('points') +puts res21 # => 5 +# STEP_END + +# STEP_START vsim_basic +res22 = redis.vsim('points', [0.9, 0.1]) +puts res22.inspect +# => ["pt:E", "pt:A", "pt:D", "pt:C", "pt:B"] +# STEP_END + +# STEP_START vsim_options +res23 = redis.vsim('points', 'pt:A', with_scores: true, count: 4) +puts res23.inspect +# => {"pt:A"=>1.0, "pt:E"≈0.85355, "pt:D"=>0.5, "pt:C"=>0.5} +# STEP_END + +# STEP_START vsim_filter +res24 = redis.vsetattr('points', 'pt:A', '{"size":"large","price":18.99}') +puts res24 # => 1 + +res25 = redis.vsetattr('points', 'pt:B', '{"size":"large","price":35.99}') +puts res25 # => 1 + +res26 = redis.vsetattr('points', 'pt:C', '{"size":"large","price":25.99}') +puts res26 # => 1 + +res27 = redis.vsetattr('points', 'pt:D', '{"size":"small","price":21.00}') +puts res27 # => 1 + +res28 = redis.vsetattr('points', 'pt:E', '{"size":"small","price":17.75}') +puts res28 # => 1 + +res29 = redis.vsim('points', 'pt:A', filter: '.size == "large"') +puts res29.inspect # => ["pt:A", "pt:C", "pt:B"] + +res30 = redis.vsim('points', 'pt:A', filter: '.size == "large" && .price > 20.00') +puts res30.inspect # => ["pt:C", "pt:B"] +# STEP_END + +# STEP_START add_quant +res31 = redis.vadd('quantSetQ8', [1.262185, 1.958231], 'quantElement', quantization: 'q8') +puts res31 # => 1 + +res32 = redis.vemb('quantSetQ8', 'quantElement') +puts "Q8: #{res32.inspect}" +# => Q8: [~1.264, ~1.958] + +res33 = redis.vadd('quantSetNoQ', [1.262185, 1.958231], 'quantElement', quantization: 'noquant') +puts res33 # => 1 + +res34 = redis.vemb('quantSetNoQ', 'quantElement') +puts "NOQUANT: #{res34.inspect}" +# => NOQUANT: [~1.262185, ~1.958231] + +res35 = redis.vadd('quantSetBin', [1.262185, 1.958231], 'quantElement', quantization: 'bin') +puts res35 # => 1 + +res36 = redis.vemb('quantSetBin', 'quantElement') +puts "BIN: #{res36.inspect}" +# => BIN: [1.0, 1.0] +# STEP_END + +# STEP_START add_reduce +values = Array.new(300) { |i| i / 299.0 } + +res37 = redis.vadd('setNotReduced', values, 'element') +puts res37 # => 1 + +res38 = redis.vdim('setNotReduced') +puts res38 # => 300 + +res39 = redis.vadd('setReduced', values, 'element', reduce_dim: 100) +puts res39 # => 1 + +res40 = redis.vdim('setReduced') +puts res40 # => 100 +# STEP_END + +redis.close +puts "\nVector set tutorial completed successfully!" diff --git a/lib/redis/commands.rb b/lib/redis/commands.rb index da7354bc1..92af52f3c 100644 --- a/lib/redis/commands.rb +++ b/lib/redis/commands.rb @@ -16,6 +16,7 @@ require "redis/commands/streams" require "redis/commands/strings" require "redis/commands/transactions" +require "redis/commands/vector_set" class Redis module Commands @@ -35,6 +36,7 @@ module Commands include Streams include Strings include Transactions + include VectorSet # Commands returning 1 for true and 0 for false may be executed in a pipeline # where the method call will return nil. Propagate the nil instead of falsely @@ -78,6 +80,13 @@ module Commands -Float::INFINITY when String Float(value) + when Array + # Handle array responses (e.g., from VSIM with WITHSCORES) + if value.respond_to?(:each_slice) + value.each_slice(2).to_h.transform_values { |v| Floatify.call(v) } + else + value + end else value end diff --git a/lib/redis/commands/vector_set.rb b/lib/redis/commands/vector_set.rb new file mode 100644 index 000000000..2dfee89d1 --- /dev/null +++ b/lib/redis/commands/vector_set.rb @@ -0,0 +1,370 @@ +# frozen_string_literal: true + +require "json" + +class Redis + module Commands + module VectorSet + # Add vector for element to a vector set + # + # @param [String] key the vector set key + # @param [Array, String] vector the vector as array of floats or FP32 blob + # @param [String] element the element name + # @param [Hash] options optional parameters + # @option options [Integer] :reduce_dim dimensions to reduce the vector to + # @option options [Boolean] :cas use CAS (check-and-set) when adding + # @option options [String] :quantization quantization type (NOQUANT, BIN, Q8) + # @option options [Integer, Float] :ef exploration factor + # @option options [Hash, String] :attributes JSON attributes for the element + # @option options [Integer] :numlinks number of links (M parameter) + # + # @return [Integer] 1 if element was added, 0 if updated + # + # @see https://redis.io/commands/vadd + def vadd(key, vector, element, **options) + args = [:vadd, key] + + # Add REDUCE option if specified + if options[:reduce_dim] + args << "REDUCE" << options[:reduce_dim] + end + + # Add vector in FP32 or VALUES format + if vector.is_a?(String) && vector.encoding == Encoding::BINARY + # Binary FP32 blob + args << "FP32" << vector + elsif vector.is_a?(Array) + # VALUES format + args << "VALUES" << vector.length + args.concat(vector) + else + raise ArgumentError, "Vector must be a binary String or an Array" + end + + # Add element name + args << element + + # Add CAS option if specified + args << "CAS" if options[:cas] + + # Add quantization option if specified + if options[:quantization] + args << options[:quantization].to_s.upcase + end + + # Add EF option if specified + if options[:ef] + args << "EF" << options[:ef] + end + + # Add attributes if specified + if options[:attributes] + attrs_json = if options[:attributes].is_a?(Hash) + ::JSON.generate(options[:attributes]) + else + options[:attributes] + end + args << "SETATTR" << attrs_json + end + + # Add numlinks (M parameter) if specified + if options[:numlinks] + args << "M" << options[:numlinks] + end + + send_command(args) + end + + # Compare a vector or element with other vectors in a vector set + # + # @param [String] key the vector set key + # @param [Array, String] input vector, FP32 blob, or element name + # @param [Hash] options search parameters + # @option options [Boolean] :with_scores return similarity scores + # @option options [Boolean] :with_attribs return attributes + # @option options [Integer] :count number of results to return + # @option options [Integer, Float] :ef exploration factor + # @option options [String] :filter filter expression + # @option options [String] :filter_ef max filtering effort + # @option options [Boolean] :truth force linear scan + # @option options [Boolean] :no_thread execute in main thread + # @option options [Float] :epsilon distance threshold (0-1) + # + # @return [Array, Hash] list of elements or hash with scores/attributes + # + # @see https://redis.io/commands/vsim + def vsim(key, input, **options) + args = [:vsim, key] + + # Add input in FP32, VALUES, or ELE format + if input.is_a?(String) && input.encoding == Encoding::BINARY + # Binary FP32 blob + args << "FP32" << input + elsif input.is_a?(Array) + # VALUES format + args << "VALUES" << input.length + args.concat(input) + elsif input.is_a?(String) + # Element name + args << "ELE" << input + end + + # Add WITHSCORES option + args << "WITHSCORES" if options[:with_scores] + + # Add WITHATTRIBS option + args << "WITHATTRIBS" if options[:with_attribs] + + # Add COUNT option + if options[:count] + args << "COUNT" << options[:count] + end + + # Add EPSILON option + if options[:epsilon] + args << "EPSILON" << options[:epsilon] + end + + # Add EF option + if options[:ef] + args << "EF" << options[:ef] + end + + # Add FILTER option + if options[:filter] + args << "FILTER" << options[:filter] + end + + # Add FILTER-EF option + if options[:filter_ef] + args << "FILTER-EF" << options[:filter_ef] + end + + # Add TRUTH option + args << "TRUTH" if options[:truth] + + # Add NOTHREAD option + args << "NOTHREAD" if options[:no_thread] + + send_command(args) do |reply| + # Parse response based on options + if options[:with_scores] && options[:with_attribs] + # Return hash with structure: {element => {score: ..., attributes: ...}} + parse_vsim_with_scores_and_attribs(reply) + elsif options[:with_scores] + # Return hash with scores: {element => score} + Floatify.call(reply) + elsif options[:with_attribs] + # Return hash with attributes: {element => attributes} + parse_vsim_with_attribs(reply) + else + # Return array of elements + reply + end + end + end + + # Get the dimension of a vector set + # + # @param [String] key the vector set key + # @return [Integer] the dimension + # + # @see https://redis.io/commands/vdim + def vdim(key) + send_command([:vdim, key]) + end + + # Get the cardinality (number of elements) of a vector set + # + # @param [String] key the vector set key + # @return [Integer] the cardinality + # + # @see https://redis.io/commands/vcard + def vcard(key) + send_command([:vcard, key]) + end + + # Remove an element from a vector set + # + # @param [String] key the vector set key + # @param [String] element the element name + # @return [Integer] 1 if removed, 0 if not found + # + # @see https://redis.io/commands/vrem + def vrem(key, element) + send_command([:vrem, key, element]) + end + + # Get the approximated vector of an element + # + # @param [String] key the vector set key + # @param [String] element the element name + # @param [Hash] options optional parameters + # @option options [Boolean] :raw return internal representation + # + # @return [Array, Hash, nil] the vector or nil if not found + # + # @see https://redis.io/commands/vemb + def vemb(key, element, **options) + args = [:vemb, key, element] + args << "RAW" if options[:raw] + + send_command(args) do |reply| + if options[:raw] && reply + # Parse RAW response as hash + parse_vemb_raw(reply) + elsif reply.is_a?(Array) + # Convert string array to float array + reply.map(&:to_f) + else + reply + end + end + end + + # Get the neighbors for each level an element exists in + # + # @param [String] key the vector set key + # @param [String] element the element name + # @param [Hash] options optional parameters + # @option options [Boolean] :with_scores return scores + # + # @return [Array, Array, nil] neighbors per level or nil if not found + # + # @see https://redis.io/commands/vlinks + def vlinks(key, element, **options) + args = [:vlinks, key, element] + args << "WITHSCORES" if options[:with_scores] + + send_command(args) do |reply| + if reply && options[:with_scores] + # Convert each level's response to hash + reply.map { |level| Floatify.call(level) } + else + reply + end + end + end + + # Get information about a vector set + # + # @param [String] key the vector set key + # @return [Hash] information about the vector set + # + # @see https://redis.io/commands/vinfo + def vinfo(key) + send_command([:vinfo, key], &Hashify) + end + + # Associate or remove JSON attributes of an element + # + # @param [String] key the vector set key + # @param [String] element the element name + # @param [Hash, String] attributes JSON attributes or empty hash to remove + # + # @return [Integer] 1 on success + # + # @see https://redis.io/commands/vsetattr + def vsetattr(key, element, attributes) + attrs_json = if attributes.is_a?(Hash) + attributes.empty? ? "{}" : ::JSON.generate(attributes) + else + attributes + end + + send_command([:vsetattr, key, element, attrs_json]) + end + + # Retrieve the JSON attributes of an element + # + # @param [String] key the vector set key + # @param [String] element the element name + # + # @return [Hash, nil] the attributes or nil if not found/empty + # + # @see https://redis.io/commands/vgetattr + def vgetattr(key, element) + send_command([:vgetattr, key, element]) do |reply| + if reply + attrs = ::JSON.parse(reply) + # Return nil for empty hash (no attributes set) + attrs.empty? ? nil : attrs + end + rescue ::JSON::ParserError + nil + end + end + + # Get random elements from a vector set + # + # @param [String] key the vector set key + # @param [Integer, nil] count number of elements to return + # + # @return [String, Array, nil] random element(s) or nil if set doesn't exist + # + # @see https://redis.io/commands/vrandmember + def vrandmember(key, count = nil) + args = [:vrandmember, key] + args << count if count + + send_command(args) + end + + private + + # Parse VSIM response with both scores and attributes + def parse_vsim_with_scores_and_attribs(reply) + return {} unless reply + + result = {} + reply.each_slice(3) do |element, score, attribs| + parsed_attribs = if attribs + attrs = ::JSON.parse(attribs) + attrs.empty? ? nil : attrs + end + result[element] = { + "score" => score.to_f, + "attributes" => parsed_attribs + } + end + result + rescue ::JSON::ParserError + {} + end + + # Parse VSIM response with only attributes + def parse_vsim_with_attribs(reply) + return {} unless reply + + result = {} + reply.each_slice(2) do |element, attribs| + if attribs + parsed = ::JSON.parse(attribs) + result[element] = parsed.empty? ? nil : parsed + else + result[element] = nil + end + end + result + rescue ::JSON::ParserError + {} + end + + # Parse VEMB RAW response + def parse_vemb_raw(reply) + return nil unless reply.is_a?(Array) && reply.length >= 3 + + result = { + "quantization" => reply[0], + "raw" => reply[1], + "l2" => reply[2].to_f + } + + # Add range if present (for quantized vectors) + result["range"] = reply[3].to_f if reply.length > 3 + + result + end + end + end +end diff --git a/test/helper.rb b/test/helper.rb index d3fb5290d..3a9f938cb 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -20,13 +20,14 @@ require "hiredis-client" end -PORT = 6381 +PORT = Integer(ENV['PORT'] || 6381) DB = 15 TIMEOUT = Float(ENV['TIMEOUT'] || 1.0) LOW_TIMEOUT = Float(ENV['LOW_TIMEOUT'] || 0.01) # for blocking-command tests OPTIONS = { port: PORT, db: DB, timeout: TIMEOUT }.freeze -if ENV['REDIS_SOCKET_PATH'].nil? +# Only check for socket when using default port (local make start setup) +if ENV['REDIS_SOCKET_PATH'].nil? && !ENV['PORT'] sock_file = File.expand_path('../tmp/redis.sock', __dir__) unless File.exist?(sock_file) diff --git a/test/redis/commands_on_vector_set_test.rb b/test/redis/commands_on_vector_set_test.rb new file mode 100644 index 000000000..a60b2f1bf --- /dev/null +++ b/test/redis/commands_on_vector_set_test.rb @@ -0,0 +1,840 @@ +# frozen_string_literal: true + +require "helper" +require "json" + +# Tests for Redis Vector Set commands +# Ported from redis-py: tests/test_vsets.py +class TestCommandsOnVectorSet < Minitest::Test + include Helper::Client + + def setup + super + + # Check if Vector Set commands are available (Redis 8.0+) + begin + r.call('VCARD', '__test__') + rescue Redis::CommandError => e + if e.message.include?("unknown command") || e.message.include?("ERR unknown") + skip "Vector Set commands not available (requires Redis 8.0+): #{e.message}" + end + end + + r.flushdb + end + + def redis_version + @redis_version ||= begin + info = r.info("server") + version_str = info["redis_version"] + parts = version_str.split('.') + parts[0].to_i * 1_000_000 + parts[1].to_i * 1000 + parts[2].to_i + end + end + + def skip_if_redis_version_lt(min_version_str) + parts = min_version_str.split('.') + min_version = parts[0].to_i * 1_000_000 + parts[1].to_i * 1000 + parts[2].to_i + skip "Redis version #{min_version_str}+ required" if redis_version < min_version + end + + # Helper method to convert float array to FP32 blob (little-endian) + def to_fp32_blob(float_array) + float_array.pack("e*") + end + + # Helper method to validate quantization with tolerance + def validate_quantization(original, quantized, tolerance: 0.1) + return false if original.length != quantized.length + + max_diff = original.zip(quantized).map { |o, q| (o - q).abs }.max + max_diff <= tolerance + end + + # Test: Add element with VALUES format + def test_add_elem_with_values + skip_if_redis_version_lt("7.9.0") + + float_array = [1.0, 4.32, 0.11] + resp = r.vadd("myset", float_array, "elem1") + assert_equal 1, resp + + emb = r.vemb("myset", "elem1") + assert validate_quantization(float_array, emb, tolerance: 0.1) + + # Test invalid data + assert_raises(ArgumentError) do + r.vadd("myset_invalid", nil, "elem1") + end + end + + # Test: Add element with FP32 blob format + def test_add_elem_with_vector + skip_if_redis_version_lt("7.9.0") + + float_array = [1.0, 4.32, 0.11] + byte_array = to_fp32_blob(float_array) + resp = r.vadd("myset", byte_array, "elem1") + assert_equal 1, resp + + emb = r.vemb("myset", "elem1") + assert validate_quantization(float_array, emb, tolerance: 0.1) + end + + # Test: Add element with reduced dimensions + def test_add_elem_reduced_dim + skip_if_redis_version_lt("7.9.0") + + float_array = [1.0, 4.32, 0.11, 0.5, 0.9] + resp = r.vadd("myset", float_array, "elem1", reduce_dim: 3) + assert_equal 1, resp + + dim = r.vdim("myset") + assert_equal 3, dim + end + + # Test: Add element with CAS option + def test_add_elem_cas + skip_if_redis_version_lt("7.9.0") + + float_array = [1.0, 4.32, 0.11, 0.5, 0.9] + resp = r.vadd("myset", float_array, "elem1", cas: true) + assert_equal 1, resp + + emb = r.vemb("myset", "elem1") + assert validate_quantization(float_array, emb, tolerance: 0.1) + end + + # Test: Add element with NOQUANT quantization + def test_add_elem_no_quant + skip_if_redis_version_lt("7.9.0") + + float_array = [1.0, 4.32, 0.11, 0.5, 0.9] + resp = r.vadd("myset", float_array, "elem1", quantization: "NOQUANT") + assert_equal 1, resp + + emb = r.vemb("myset", "elem1") + # Use small tolerance for FP32 precision differences + assert validate_quantization(float_array, emb, tolerance: 0.0001) + end + + # Test: Add element with BIN quantization + def test_add_elem_bin_quant + skip_if_redis_version_lt("7.9.0") + + float_array = [1.0, 4.32, 0.0, 0.05, -2.9] + resp = r.vadd("myset", float_array, "elem1", quantization: "BIN") + assert_equal 1, resp + + emb = r.vemb("myset", "elem1") + expected_array = [1.0, 1.0, -1.0, 1.0, -1.0] + assert validate_quantization(expected_array, emb, tolerance: 0.0) + end + + # Test: Add element with Q8 quantization + def test_add_elem_q8_quant + skip_if_redis_version_lt("7.9.0") + + float_array = [1.0, 4.32, 10.0, -21.0, -2.9] + resp = r.vadd("myset", float_array, "elem1", quantization: "BIN") + assert_equal 1, resp + + emb = r.vemb("myset", "elem1") + expected_array = [1.0, 1.0, 1.0, -1.0, -1.0] + assert validate_quantization(expected_array, emb, tolerance: 0.0) + end + + # Test: Add element with EF option + def test_add_elem_ef + skip_if_redis_version_lt("7.9.0") + + r.vadd("myset", [5.0, 55.0, 65.0, -20.0, 30.0], "elem1") + r.vadd("myset", [-40.0, -40.32, 10.0, -4.0, 2.9], "elem2") + + float_array = [1.0, 4.32, 10.0, -21.0, -2.9] + resp = r.vadd("myset", float_array, "elem3", ef: 1) + assert_equal 1, resp + + emb = r.vemb("myset", "elem3") + assert validate_quantization(float_array, emb, tolerance: 0.1) + + sim = r.vsim("myset", "elem3", with_scores: true) + assert_equal 3, sim.length + end + + # Test: Add element with attributes + def test_add_elem_with_attr + skip_if_redis_version_lt("7.9.0") + + float_array = [1.0, 4.32, 10.0, -21.0, -2.9] + attrs_dict = { "key1" => "value1", "key2" => "value2" } + resp = r.vadd("myset", float_array, "elem3", attributes: attrs_dict) + assert_equal 1, resp + + emb = r.vemb("myset", "elem3") + assert validate_quantization(float_array, emb, tolerance: 0.1) + + attr_saved = r.vgetattr("myset", "elem3") + assert_equal attrs_dict, attr_saved + + # Test with empty attributes + resp = r.vadd("myset", float_array, "elem4", attributes: {}) + assert_equal 1, resp + + emb = r.vemb("myset", "elem4") + assert validate_quantization(float_array, emb, tolerance: 0.1) + + attr_saved = r.vgetattr("myset", "elem4") + assert_nil attr_saved + + # Test with JSON string attributes + resp = r.vadd("myset", float_array, "elem5", attributes: JSON.generate(attrs_dict)) + assert_equal 1, resp + + emb = r.vemb("myset", "elem5") + assert validate_quantization(float_array, emb, tolerance: 0.1) + + attr_saved = r.vgetattr("myset", "elem5") + assert_equal attrs_dict, attr_saved + end + + # Test: Add element with numlinks + def test_add_elem_with_numlinks + skip_if_redis_version_lt("7.9.0") + + elements_count = 100 + vector_dim = 10 + elements_count.times do |i| + float_array = Array.new(vector_dim) { rand(0..10).to_f } + r.vadd("myset", float_array, "elem#{i}", numlinks: 8) + end + + float_array = [1.0, 4.32, 0.11, 0.5, 0.9, 0.1, 0.2, 0.3, 0.4, 0.5] + resp = r.vadd("myset", float_array, "elem_numlinks", numlinks: 8) + assert_equal 1, resp + + emb = r.vemb("myset", "elem_numlinks") + assert validate_quantization(float_array, emb, tolerance: 0.5) + + numlinks_all_layers = r.vlinks("myset", "elem_numlinks") + numlinks_all_layers.each do |neighbours_list_for_layer| + assert neighbours_list_for_layer.length <= 8 + end + end + + # Test: VSIM with count parameter + def test_vsim_count + skip_if_redis_version_lt("7.9.0") + + elements_count = 30 + vector_dim = 800 + elements_count.times do |i| + float_array = Array.new(vector_dim) { rand * 10 } + r.vadd("myset", float_array, "elem#{i}", numlinks: 64) + end + + vsim = r.vsim("myset", "elem1") + assert_equal 10, vsim.length + assert_instance_of Array, vsim + assert_instance_of String, vsim[0] + + vsim = r.vsim("myset", "elem1", count: 5) + assert_equal 5, vsim.length + assert_instance_of Array, vsim + assert_instance_of String, vsim[0] + + vsim = r.vsim("myset", "elem1", count: 50) + assert_equal 30, vsim.length + assert_instance_of Array, vsim + assert_instance_of String, vsim[0] + + vsim = r.vsim("myset", "elem1", count: 15) + assert_equal 15, vsim.length + assert_instance_of Array, vsim + assert_instance_of String, vsim[0] + end + + # Test: VSIM with scores + def test_vsim_with_scores + skip_if_redis_version_lt("7.9.0") + + elements_count = 20 + vector_dim = 50 + elements_count.times do |i| + float_array = Array.new(vector_dim) { rand * 10 } + r.vadd("myset", float_array, "elem#{i}", numlinks: 64) + end + + vsim = r.vsim("myset", "elem1", with_scores: true) + assert_equal 10, vsim.length + assert_instance_of Hash, vsim + assert_instance_of Float, vsim["elem1"] + assert vsim["elem1"] >= 0 && vsim["elem1"] <= 1 + end + + # Test: VSIM with attributes + def test_vsim_with_attribs_attribs_set + skip_if_redis_version_lt("8.2.0") + + elements_count = 5 + vector_dim = 10 + attrs_dict = { "key1" => "value1", "key2" => "value2" } + elements_count.times do |i| + float_array = Array.new(vector_dim) { rand * 5 } + r.vadd("myset", float_array, "elem#{i}", numlinks: 64, + attributes: (i.even? ? attrs_dict : nil)) + end + + vsim = r.vsim("myset", "elem1", with_attribs: true) + assert_equal 5, vsim.length + assert_instance_of Hash, vsim + assert_nil vsim["elem1"] + assert_equal attrs_dict, vsim["elem2"] + end + + # Test: VSIM with scores and attributes + def test_vsim_with_scores_and_attribs_attribs_set + skip_if_redis_version_lt("8.2.0") + + elements_count = 5 + vector_dim = 10 + attrs_dict = { "key1" => "value1", "key2" => "value2" } + elements_count.times do |i| + float_array = Array.new(vector_dim) { rand * 5 } + r.vadd("myset", float_array, "elem#{i}", numlinks: 64, + attributes: (i.even? ? attrs_dict : nil)) + end + + vsim = r.vsim("myset", "elem1", with_scores: true, with_attribs: true) + assert_equal 5, vsim.length + assert_instance_of Hash, vsim + + # Check structure: {element => {score: ..., attributes: ...}} + assert vsim["elem1"].key?("score") + assert vsim["elem1"].key?("attributes") + assert_nil vsim["elem1"]["attributes"] + assert_equal attrs_dict, vsim["elem2"]["attributes"] + end + + # Test: VSIM with attributes when attributes not set + def test_vsim_with_attribs_attribs_not_set + skip_if_redis_version_lt("8.2.0") + + elements_count = 20 + vector_dim = 50 + elements_count.times do |i| + float_array = Array.new(vector_dim) { rand * 10 } + r.vadd("myset", float_array, "elem#{i}", numlinks: 64) + end + + vsim = r.vsim("myset", "elem1", with_attribs: true) + assert_equal 10, vsim.length + assert_instance_of Hash, vsim + assert_nil vsim["elem1"] + end + + # Test: VSIM with different vector input types + def test_vsim_with_different_vector_input_types + skip_if_redis_version_lt("7.9.0") + + elements_count = 10 + vector_dim = 5 + elements_count.times do |i| + float_array = Array.new(vector_dim) { rand * 10 } + attributes = { "index" => i, "elem_name" => "elem_#{i}" } + r.vadd("myset", float_array, "elem_#{i}", numlinks: 4, attributes: attributes) + end + + sim = r.vsim("myset", "elem_1") + assert_equal 10, sim.length + assert_instance_of Array, sim + + float_array = [1.0, 4.32, 0.0, 0.05, -2.9] + sim_to_float_array = r.vsim("myset", float_array) + assert_equal 10, sim_to_float_array.length + assert_instance_of Array, sim_to_float_array + + fp32_vector = to_fp32_blob(float_array) + sim_to_fp32_vector = r.vsim("myset", fp32_vector) + assert_equal 10, sim_to_fp32_vector.length + assert_instance_of Array, sim_to_fp32_vector + assert_equal sim_to_float_array, sim_to_fp32_vector + + # Test invalid input + assert_raises(Redis::CommandError) do + r.vsim("myset", nil) + end + end + + # Test: VSIM with non-existing element + def test_vsim_unexisting + skip_if_redis_version_lt("7.9.0") + + float_array = [1.0, 4.32, 0.11, 0.5, 0.9] + r.vadd("myset", float_array, "elem1", cas: true) + + assert_raises(Redis::CommandError) do + r.vsim("myset", "elem_not_existing") + end + + sim = r.vsim("myset_not_existing", "elem1") + assert_equal [], sim + end + + # Test: VSIM with filter + def test_vsim_with_filter + skip_if_redis_version_lt("7.9.0") + + elements_count = 50 + vector_dim = 800 + elements_count.times do |i| + float_array = Array.new(vector_dim) { rand(10.0..20.0) } + attributes = { "index" => i, "elem_name" => "elem_#{i}" } + r.vadd("myset", float_array, "elem_#{i}", numlinks: 4, attributes: attributes) + end + + float_array = Array.new(vector_dim) { -rand(10.0..20.0) } + attributes = { "index" => elements_count, "elem_name" => "elem_special" } + r.vadd("myset", float_array, "elem_special", numlinks: 4, attributes: attributes) + + sim = r.vsim("myset", "elem_1", filter: ".index > 10") + assert_equal 10, sim.length + assert_instance_of Array, sim + sim.each do |elem| + assert elem.split("_")[1].to_i > 10 + end + + sim = r.vsim("myset", "elem_1", + filter: ".index > 10 and .index < 15 and .elem_name in ['elem_12', 'elem_17']") + assert_equal 1, sim.length + assert_instance_of Array, sim + assert_equal "elem_12", sim[0] + + sim = r.vsim("myset", "elem_1", + filter: ".index > 25 and .elem_name in ['elem_12', 'elem_17', 'elem_19']", + ef: 100) + assert_equal 0, sim.length + assert_instance_of Array, sim + + sim = r.vsim("myset", "elem_1", + filter: ".index > 28 and .elem_name in ['elem_12', 'elem_17', 'elem_special']", + filter_ef: 1) + assert_equal 0, sim.length, "Expected 0 results, but got #{sim.length} with filter_ef=1" + assert_instance_of Array, sim + + sim = r.vsim("myset", "elem_1", + filter: ".index > 28 and .elem_name in ['elem_12', 'elem_17', 'elem_special']", + filter_ef: 500) + assert_equal 1, sim.length + assert_instance_of Array, sim + end + + # Test: VSIM with truth and no_thread options + def test_vsim_truth_no_thread_enabled + skip_if_redis_version_lt("7.9.0") + + elements_count = 1000 + vector_dim = 50 + (1..elements_count).each do |i| + float_array = Array.new(vector_dim) { i * vector_dim } + r.vadd("myset", float_array, "elem_#{i}") + end + + r.vadd("myset", Array.new(vector_dim) { -22.0 }, "elem_man_2") + + sim_without_truth = r.vsim("myset", "elem_man_2", with_scores: true, count: 30) + sim_truth = r.vsim("myset", "elem_man_2", with_scores: true, count: 30, truth: true) + + assert_equal 30, sim_without_truth.length + assert_equal 30, sim_truth.length + + assert_instance_of Hash, sim_without_truth + assert_instance_of Hash, sim_truth + + # Compare scores by position (not by element name, as TRUTH may return different elements) + scores_truth = sim_truth.values + scores_without_truth = sim_without_truth.values + + found_better_match = false + scores_truth.zip(scores_without_truth).each do |score_with_truth, score_without_truth| + if score_with_truth < score_without_truth + flunk "Score with truth [#{score_with_truth}] < score without truth [#{score_without_truth}]" + elsif score_with_truth > score_without_truth + found_better_match = true + end + end + + assert found_better_match + + sim_no_thread = r.vsim("myset", "elem_man_2", with_scores: true, no_thread: true) + assert_equal 10, sim_no_thread.length + assert_instance_of Hash, sim_no_thread + end + + # Test: VSIM with epsilon + def test_vsim_epsilon + skip_if_redis_version_lt("8.2.0") + + r.vadd("myset", [2.0, 1.0, 1.0], "a") + r.vadd("myset", [2.0, 0.0, 1.0], "b") + r.vadd("myset", [2.0, 0.0, 0.0], "c") + r.vadd("myset", [2.0, 0.0, 2.0], "d") + r.vadd("myset", [-2.0, -1.0, -1.0], "e") + + res1 = r.vsim("myset", [2.0, 1.0, 1.0]) + assert_equal 5, res1.length + + res2 = r.vsim("myset", [2.0, 1.0, 1.0], epsilon: 0.5) + assert_equal 4, res2.length + end + + # Test: VDIM command + def test_vdim + skip_if_redis_version_lt("7.9.0") + + float_array = [1.0, 4.32, 0.11, 0.5, 0.9, 0.1, 0.2] + r.vadd("myset", float_array, "elem1") + + dim = r.vdim("myset") + assert_equal float_array.length, dim + + r.vadd("myset_reduced", float_array, "elem1", reduce_dim: 4) + reduced_dim = r.vdim("myset_reduced") + assert_equal 4, reduced_dim + + assert_raises(Redis::CommandError) do + r.vdim("myset_unexisting") + end + end + + # Test: VCARD command + def test_vcard + skip_if_redis_version_lt("7.9.0") + + n = 20 + n.times do |i| + float_array = Array.new(7) { rand * 10 } + r.vadd("myset", float_array, "elem#{i}") + end + + card = r.vcard("myset") + assert_equal n, card + + assert_raises(Redis::CommandError) do + r.vdim("myset_unexisting") + end + end + + # Test: VREM command + def test_vrem + skip_if_redis_version_lt("7.9.0") + + n = 3 + n.times do |i| + float_array = Array.new(7) { rand * 10 } + r.vadd("myset", float_array, "elem#{i}") + end + + resp = r.vrem("myset", "elem2") + assert_equal 1, resp + + card = r.vcard("myset") + assert_equal n - 1, card + + resp = r.vrem("myset", "elem2") + assert_equal 0, resp + + card = r.vcard("myset") + assert_equal n - 1, card + + resp = r.vrem("myset_unexisting", "elem1") + assert_equal 0, resp + end + + # Test: VEMB with BIN quantization + def test_vemb_bin_quantization + skip_if_redis_version_lt("7.9.0") + + e = [1.0, 4.32, 0.0, 0.05, -2.9] + r.vadd("myset", e, "elem", quantization: "BIN") + + emb_no_quant = r.vemb("myset", "elem") + assert_equal [1.0, 1.0, -1.0, 1.0, -1.0], emb_no_quant + + emb_no_quant_raw = r.vemb("myset", "elem", raw: true) + assert_equal "bin", emb_no_quant_raw["quantization"] + assert_instance_of String, emb_no_quant_raw["raw"] + assert_instance_of Float, emb_no_quant_raw["l2"] + refute emb_no_quant_raw.key?("range") + end + + # Test: VEMB with Q8 quantization + def test_vemb_q8_quantization + skip_if_redis_version_lt("7.9.0") + + e = [1.0, 10.32, 0.0, 2.05, -12.5] + r.vadd("myset", e, "elem", quantization: "Q8") + + emb_q8_quant = r.vemb("myset", "elem") + assert validate_quantization(e, emb_q8_quant, tolerance: 0.1) + + emb_q8_quant_raw = r.vemb("myset", "elem", raw: true) + assert_equal "int8", emb_q8_quant_raw["quantization"] + assert_instance_of String, emb_q8_quant_raw["raw"] + assert_instance_of Float, emb_q8_quant_raw["l2"] + assert_instance_of Float, emb_q8_quant_raw["range"] + end + + # Test: VEMB with NOQUANT + def test_vemb_no_quantization + skip_if_redis_version_lt("7.9.0") + + e = [1.0, 10.32, 0.0, 2.05, -12.5] + r.vadd("myset", e, "elem", quantization: "NOQUANT") + + emb_no_quant = r.vemb("myset", "elem") + assert validate_quantization(e, emb_no_quant, tolerance: 0.1) + + emb_no_quant_raw = r.vemb("myset", "elem", raw: true) + assert_equal "f32", emb_no_quant_raw["quantization"] + assert_instance_of String, emb_no_quant_raw["raw"] + assert_instance_of Float, emb_no_quant_raw["l2"] + refute emb_no_quant_raw.key?("range") + end + + # Test: VEMB with default quantization + def test_vemb_default_quantization + skip_if_redis_version_lt("7.9.0") + + e = [1.0, 5.32, 0.0, 0.25, -5.0] + r.vadd("myset", e, "elem") + + emb_default_quant = r.vemb("myset", "elem") + assert validate_quantization(e, emb_default_quant, tolerance: 0.1) + + emb_default_quant_raw = r.vemb("myset", "elem", raw: true) + assert_equal "int8", emb_default_quant_raw["quantization"] + assert_instance_of String, emb_default_quant_raw["raw"] + assert_instance_of Float, emb_default_quant_raw["l2"] + assert_instance_of Float, emb_default_quant_raw["range"] + end + + # Test: VEMB with FP32 blob input + def test_vemb_fp32_quantization + skip_if_redis_version_lt("7.9.0") + + float_array_fp32 = [1.0, 4.32, 0.11] + byte_array = to_fp32_blob(float_array_fp32) + r.vadd("myset", byte_array, "elem") + + emb_fp32_quant = r.vemb("myset", "elem") + assert validate_quantization(float_array_fp32, emb_fp32_quant, tolerance: 0.1) + + emb_fp32_quant_raw = r.vemb("myset", "elem", raw: true) + assert_equal "int8", emb_fp32_quant_raw["quantization"] + assert_instance_of String, emb_fp32_quant_raw["raw"] + assert_instance_of Float, emb_fp32_quant_raw["l2"] + assert_instance_of Float, emb_fp32_quant_raw["range"] + end + + # Test: VEMB with non-existing key/element + def test_vemb_unexisting + skip_if_redis_version_lt("7.9.0") + + emb_not_existing = r.vemb("not_existing", "elem") + assert_nil emb_not_existing + + e = [1.0, 5.32, 0.0, 0.25, -5.0] + r.vadd("myset", e, "elem") + emb_elem_not_existing = r.vemb("myset", "not_existing") + assert_nil emb_elem_not_existing + end + + # Test: VLINKS command + def test_vlinks + skip_if_redis_version_lt("7.9.0") + + elements_count = 100 + vector_dim = 800 + elements_count.times do |i| + float_array = Array.new(vector_dim) { rand * 10 } + r.vadd("myset", float_array, "elem#{i}", numlinks: 8) + end + + element_links_all_layers = r.vlinks("myset", "elem1") + assert element_links_all_layers.length >= 1 + element_links_all_layers.each do |neighbours_list_for_layer| + assert_instance_of Array, neighbours_list_for_layer + neighbours_list_for_layer.each do |neighbour| + assert_instance_of String, neighbour + end + end + + elem_links_all_layers_with_scores = r.vlinks("myset", "elem1", with_scores: true) + assert elem_links_all_layers_with_scores.length >= 1 + elem_links_all_layers_with_scores.each do |neighbours_dict_for_layer| + assert_instance_of Hash, neighbours_dict_for_layer + neighbours_dict_for_layer.each do |neighbour_key, score_value| + assert_instance_of String, neighbour_key + assert_instance_of Float, score_value + end + end + + float_array = [0.75, 0.25, 0.5, 0.1, 0.9] + r.vadd("myset_one_elem_only", float_array, "elem1") + elem_no_neighbours_with_scores = r.vlinks("myset_one_elem_only", "elem1", with_scores: true) + assert elem_no_neighbours_with_scores.length >= 1 + elem_no_neighbours_with_scores.each do |neighbours_dict_for_layer| + assert_instance_of Hash, neighbours_dict_for_layer + assert_equal 0, neighbours_dict_for_layer.length + end + + # Test non-existing element + elem_links_unexisting = r.vlinks("myset", "elem_unexisting") + assert_nil elem_links_unexisting + + # Test non-existing set + elem_links_set_unexisting = r.vlinks("myset_unexisting", "elem1") + assert_nil elem_links_set_unexisting + end + + # Test: VINFO command + def test_vinfo + skip_if_redis_version_lt("7.9.0") + + elements_count = 100 + vector_dim = 8 + elements_count.times do |i| + float_array = Array.new(vector_dim) { rand * 10 } + r.vadd("myset", float_array, "elem#{i}") + end + + vset_info = r.vinfo("myset") + assert_equal "int8", vset_info["quant-type"] + assert_equal 8, vset_info["vector-dim"] + assert_equal elements_count, vset_info["size"] + assert vset_info["max-level"] >= 0 + assert_equal elements_count, vset_info["hnsw-max-node-uid"] + + # Test non-existing set + unexisting_vset_info = r.vinfo("myset_unexisting") + assert_nil unexisting_vset_info + end + + # Test: VSETATTR and VGETATTR commands + def test_vsetattr_vgetattr + skip_if_redis_version_lt("7.9.0") + + float_array = [1.0, 4.32, 0.11] + r.vadd("myset", float_array, "elem1") + + attributes = { "key1" => "value1", "key2" => "value2" } + resp = r.vsetattr("myset", "elem1", attributes) + assert_equal 1, resp + + attrs = r.vgetattr("myset", "elem1") + assert_equal attributes, attrs + + # Test with JSON string + resp = r.vsetattr("myset", "elem1", JSON.generate(attributes)) + assert_equal 1, resp + + attrs = r.vgetattr("myset", "elem1") + assert_equal attributes, attrs + + # Test removing attributes (empty hash) + resp = r.vsetattr("myset", "elem1", {}) + assert_equal 1, resp + + attrs = r.vgetattr("myset", "elem1") + assert_nil attrs + + # Test non-existing element + attrs = r.vgetattr("myset", "elem_unexisting") + assert_nil attrs + + # Test non-existing set + attrs = r.vgetattr("myset_unexisting", "elem1") + assert_nil attrs + end + + # Test: VRANDMEMBER command + def test_vrandmember + skip_if_redis_version_lt("7.9.0") + + elements = [] + 10.times do |i| + float_array = Array.new(8) { rand * 10 } + r.vadd("myset", float_array, "elem#{i}") + elements << "elem#{i}" + end + + # Test single random member + random_member = r.vrandmember("myset") + assert_instance_of String, random_member + assert_includes elements, random_member + + # Test multiple random members + members_list = r.vrandmember("myset", 2) + assert_equal 2, members_list.length + members_list.each do |member| + assert_includes elements, member + end + + # Test count larger than set size + members_list = r.vrandmember("myset", 20) + assert_equal 10, members_list.length + + # Test negative count (allows duplicates) + members_list = r.vrandmember("myset", -5) + assert_equal 5, members_list.length + + # Test non-existing set + random_member = r.vrandmember("myset_unexisting") + assert_nil random_member + end + + # Test: Comprehensive test covering all commands + def test_comprehensive_vset_operations + skip_if_redis_version_lt("7.9.0") + + # Create a vector set with multiple elements + elements = ["elem1", "elem2", "elem3"] + vector_dim = 8 + elements.each_with_index do |elem, _i| + float_array = Array.new(vector_dim) { rand * 10 } + r.vadd("myset", float_array, elem) + end + + # Test vcard + assert_equal 3, r.vcard("myset") + + # Test vdim + assert_equal vector_dim, r.vdim("myset") + + # Test vemb + emb = r.vemb("myset", "elem1") + assert_equal vector_dim, emb.length + + # Test vsetattr and vgetattr + attributes = { "key1" => "value1", "key2" => "value2" } + r.vsetattr("myset", "elem1", attributes) + attrs = r.vgetattr("myset", "elem1") + assert_equal attributes, attrs + + # Test vrandmember + random_member = r.vrandmember("myset") + assert_includes elements, random_member + + # Test vinfo + vset_info = r.vinfo("myset") + assert_equal "int8", vset_info["quant-type"] + assert_equal vector_dim, vset_info["vector-dim"] + assert_equal 3, vset_info["size"] + + # Test vrem + resp = r.vrem("myset", "elem2") + assert_equal 1, resp + assert_equal 2, r.vcard("myset") + end +end