FTHybridCommandsTestBase.java
package redis.clients.jedis.commands.unified.search;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.closeTo;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static redis.clients.jedis.util.AssertUtil.assertOK;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.HashMap;
import java.util.Map;
import io.redis.test.annotations.SinceRedisVersion;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
import redis.clients.jedis.Endpoints;
import redis.clients.jedis.RedisProtocol;
import redis.clients.jedis.commands.unified.UnifiedJedisCommandsTestBase;
import redis.clients.jedis.search.Document;
import redis.clients.jedis.search.FTCreateParams;
import redis.clients.jedis.search.IndexDataType;
import redis.clients.jedis.search.Scorer;
import redis.clients.jedis.search.Scorers;
import redis.clients.jedis.search.aggr.Group;
import redis.clients.jedis.search.aggr.Reducers;
import redis.clients.jedis.search.aggr.SortedField;
import redis.clients.jedis.search.Apply;
import redis.clients.jedis.search.Filter;
import redis.clients.jedis.search.Combiners;
import redis.clients.jedis.search.hybrid.FTHybridParams;
import redis.clients.jedis.search.hybrid.FTHybridPostProcessingParams;
import redis.clients.jedis.search.hybrid.FTHybridSearchParams;
import redis.clients.jedis.search.hybrid.FTHybridVectorParams;
import redis.clients.jedis.search.hybrid.HybridResult;
import redis.clients.jedis.search.Limit;
import redis.clients.jedis.search.schemafields.NumericField;
import redis.clients.jedis.search.schemafields.TagField;
import redis.clients.jedis.search.schemafields.TextField;
import redis.clients.jedis.search.schemafields.VectorField;
/**
* Base test class for FT.HYBRID command using the UnifiedJedis pattern. Tests hybrid search
* functionality combining text search and vector similarity.
*/
@Tag("integration")
@Tag("search")
@SinceRedisVersion("8.4.0")
public abstract class FTHybridCommandsTestBase extends UnifiedJedisCommandsTestBase {
@BeforeAll
public static void prepareEndpoint() {
endpoint = Endpoints.getRedisEndpoint("modules-docker");
}
private static final String INDEX_NAME = "hybrid-test-idx";
private static final String PREFIX = "product:hybrid:";
private static final int VECTOR_DIM = 10;
protected byte[] queryVector;
public FTHybridCommandsTestBase(RedisProtocol protocol) {
super(protocol);
}
@BeforeEach
public void setUpTestData() {
if (!jedis.ftList().contains(INDEX_NAME)) {
createHybridIndex();
createSampleProducts();
}
// Always initialize query vector (cheap operation)
queryVector = floatArrayToByteArray(
new float[] { 0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f, 0.8f, 0.9f, 1.0f });
}
// ========== Setup Helper Methods ==========
private void createHybridIndex() {
Map<String, Object> vectorAttrs = new HashMap<>();
vectorAttrs.put("TYPE", "FLOAT32");
vectorAttrs.put("DIM", VECTOR_DIM);
vectorAttrs.put("DISTANCE_METRIC", "COSINE");
assertOK(jedis.ftCreate(INDEX_NAME,
FTCreateParams.createParams().on(IndexDataType.HASH).prefix(PREFIX), TextField.of("id"),
TextField.of("title"), TagField.of("category"), TagField.of("brand"),
NumericField.of("price"), NumericField.of("rating"),
VectorField.builder().fieldName("image_embedding").algorithm(VectorField.VectorAlgorithm.HNSW)
.attributes(vectorAttrs).build()));
}
private void createSampleProducts() {
// Electronics - Apple products
createProduct("1", "Apple iPhone 15 Pro smartphone with advanced camera", "electronics",
"apple", "999", "4.8",
new float[] { 0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f, 0.8f, 0.9f, 1.0f });
createProduct("4", "Apple iPhone 15 Pro smartphone camera", "electronics", "apple", "999",
"4.8", new float[] { 0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f, 0.8f, 0.9f, 1.0f });
createProduct("10", "Apple iPhone 15 Pro smartphone camera", "electronics", "apple", "999",
"4.8", new float[] { 0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f, 0.8f, 0.9f, 1.0f });
// Electronics - Samsung products
createProduct("2", "Samsung Galaxy S24 smartphone camera", "electronics", "samsung", "799",
"4.6", new float[] { 0.15f, 0.25f, 0.35f, 0.45f, 0.55f, 0.65f, 0.75f, 0.85f, 0.95f, 0.9f });
createProduct("5", "Samsung Galaxy S24", "electronics", "samsung", "799", "4.6",
new float[] { 0.15f, 0.25f, 0.35f, 0.45f, 0.55f, 0.65f, 0.75f, 0.85f, 0.95f, 0.9f });
// Electronics - Google products
createProduct("3", "Google Pixel 8 Pro camera smartphone", "electronics", "google", "699",
"4.5", new float[] { 0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f, 0.8f, 0.9f, 1.0f, 0.8f });
createProduct("6", "Google Pixel 8 Pro", "electronics", "google", "699", "4.5",
new float[] { 0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f, 0.8f, 0.9f, 1.0f, 0.8f });
// Other categories
createProduct("7", "Best T-shirt", "apparel", "denim", "255", "4.2",
new float[] { 0.12f, 0.22f, 0.32f, 0.42f, 0.52f, 0.62f, 0.72f, 0.82f, 0.92f, 0.85f });
createProduct("8", "Best makeup", "beauty", "loreal", "155", "4.4",
new float[] { 0.18f, 0.28f, 0.38f, 0.48f, 0.58f, 0.68f, 0.78f, 0.88f, 0.98f, 0.75f });
createProduct("9", "Best punching bag", "sports", "lonsdale", "733", "4.6",
new float[] { 0.11f, 0.21f, 0.31f, 0.41f, 0.51f, 0.61f, 0.71f, 0.81f, 0.91f, 0.95f });
}
private void createProduct(String id, String title, String category, String brand, String price,
String rating, float[] embedding) {
Map<byte[], byte[]> fields = new HashMap<>();
fields.put("id".getBytes(), id.getBytes());
fields.put("title".getBytes(), title.getBytes());
fields.put("category".getBytes(), category.getBytes());
fields.put("brand".getBytes(), brand.getBytes());
fields.put("price".getBytes(), price.getBytes());
fields.put("rating".getBytes(), rating.getBytes());
fields.put("image_embedding".getBytes(), floatArrayToByteArray(embedding));
jedis.hset((PREFIX + id).getBytes(), fields);
}
private byte[] floatArrayToByteArray(float[] floats) {
ByteBuffer buffer = ByteBuffer.allocate(floats.length * 4).order(ByteOrder.LITTLE_ENDIAN);
for (float f : floats) {
buffer.putFloat(f);
}
return buffer.array();
}
// ========== Test Methods ==========
@Test
public void testComprehensiveFtHybridWithAllFeatures() {
FTHybridPostProcessingParams postProcessing = FTHybridPostProcessingParams.builder()
.load("price", "brand", "@category")
.groupBy(new Group("@brand").reduce(Reducers.sum("@price").as("sum"))
.reduce(Reducers.count().as("count")))
.apply(Apply.of("@sum * 0.9", "discounted_price"))
.sortBy(SortedField.asc("@sum"), SortedField.desc("@count")).filter(Filter.of("@sum > 700"))
.limit(Limit.of(0, 20)).build();
FTHybridParams hybridArgs = FTHybridParams.builder()
.search(FTHybridSearchParams.builder().query("@category:{electronics} smartphone camera")
.scorer(Scorers.bm25std()).scoreAlias("text_score").build())
.vectorSearch(FTHybridVectorParams.builder().field("@image_embedding").vector("vector")
.method(FTHybridVectorParams.Knn.of(20).efRuntime(150))
.filter("(@brand:{apple|samsung|google}) (@price:[500 1500]) (@category:{electronics})")
.scoreAlias("vector_score").build())
.combine(Combiners.linear().alpha(0.7).beta(0.3).window(25)).postProcessing(postProcessing)
.param("discount_rate", "0.9").param("vector", queryVector).build();
HybridResult reply = jedis.ftHybrid(INDEX_NAME, hybridArgs);
assertThat(reply, notNullValue());
assertThat(reply.getDocuments(), not(empty()));
assertThat(reply.getTotalResults(), equalTo(3L));
assertThat(reply.getDocuments().size(), equalTo(3));
assertThat(reply.getWarnings().size(), greaterThanOrEqualTo(0));
assertThat(reply.getExecutionTime(), greaterThan(0.0));
// Verify first result (google) - exact field values
Document r1 = reply.getDocuments().get(0);
assertThat(r1.get("brand"), equalTo("google"));
assertThat(r1.get("count"), equalTo("2"));
assertThat(r1.get("sum"), equalTo("1398"));
assertThat(r1.get("discounted_price"), equalTo("1258.2"));
// Verify second result (samsung) - exact field values
Document r2 = reply.getDocuments().get(1);
assertThat(r2.get("brand"), equalTo("samsung"));
assertThat(r2.get("count"), equalTo("2"));
assertThat(r2.get("sum"), equalTo("1598"));
assertThat(r2.get("discounted_price"), equalTo("1438.2"));
// Verify third result (apple) - exact field values
Document r3 = reply.getDocuments().get(2);
assertThat(r3.get("brand"), equalTo("apple"));
assertThat(r3.get("count"), equalTo("3"));
assertThat(r3.get("sum"), equalTo("2997"));
assertThat(r3.get("discounted_price"), equalTo("2697.3"));
}
@Test
public void testLoadSpecificFields() {
// Test LOAD with specific fields
FTHybridPostProcessingParams postProcessing = FTHybridPostProcessingParams.builder()
.load("title", "@price", "brand").build();
FTHybridParams hybridArgs = FTHybridParams.builder()
.search(FTHybridSearchParams.builder().query("@category:{electronics} smartphone")
.scoreAlias("text_score").build())
.vectorSearch(FTHybridVectorParams.builder().field("@image_embedding").vector("vector")
.method(FTHybridVectorParams.Knn.of(5)).scoreAlias("vector_score").build())
.combine(Combiners.linear().alpha(0.5).beta(0.5)).postProcessing(postProcessing)
.param("vector", queryVector).build();
HybridResult reply = jedis.ftHybrid(INDEX_NAME, hybridArgs);
assertThat(reply, notNullValue());
assertThat(reply.getDocuments(), not(empty()));
// Result count assertions
assertThat(reply.getTotalResults(), greaterThan(0L));
assertThat(reply.getDocuments().size(), greaterThan(0));
// Field count and content assertions
Document firstResult = reply.getDocuments().get(0);
// Loaded fields should be present
assertThat(firstResult.hasProperty("title"), equalTo(true));
assertThat(firstResult.hasProperty("price"), equalTo(true));
assertThat(firstResult.hasProperty("brand"), equalTo(true));
// Non-loaded document fields should NOT be present
assertThat(firstResult.hasProperty("category"), equalTo(false));
assertThat(firstResult.hasProperty("rating"), equalTo(false));
assertThat(firstResult.hasProperty("image_embedding"), equalTo(false));
}
@Test
@SinceRedisVersion("8.6.0")
public void testLoadAllFields() {
// Test LOAD * to load all fields
FTHybridPostProcessingParams postProcessing = FTHybridPostProcessingParams.builder().loadAll()
.build();
FTHybridParams hybridArgs = FTHybridParams.builder()
.search(FTHybridSearchParams.builder().query("@category:{electronics}")
.scoreAlias("text_score").build())
.vectorSearch(FTHybridVectorParams.builder().field("@image_embedding").vector("vector")
.method(FTHybridVectorParams.Knn.of(3)).scoreAlias("vector_score").build())
.combine(Combiners.linear().alpha(0.5).beta(0.5)).postProcessing(postProcessing)
.param("vector", queryVector).build();
HybridResult reply = jedis.ftHybrid(INDEX_NAME, hybridArgs);
assertThat(reply, notNullValue());
assertThat(reply.getDocuments(), not(empty()));
// Result count assertions
assertThat(reply.getTotalResults(), greaterThan(0L));
assertThat(reply.getDocuments().size(), greaterThan(0));
// Field count and content assertions
Document firstResult = reply.getDocuments().get(0);
// All document fields should be present
assertThat(firstResult.hasProperty("title"), equalTo(true));
assertThat(firstResult.hasProperty("category"), equalTo(true));
assertThat(firstResult.hasProperty("brand"), equalTo(true));
assertThat(firstResult.hasProperty("price"), equalTo(true));
assertThat(firstResult.hasProperty("rating"), equalTo(true));
// Vector field should also be present with LOAD *
assertThat(firstResult.hasProperty("image_embedding"), equalTo(true));
// Score fields should be present
assertThat(firstResult.hasProperty("text_score"), equalTo(true));
}
@Test
public void testLoadWithGroupBy() {
// Test LOAD behavior with GROUPBY - loaded fields should be available for grouping
FTHybridPostProcessingParams postProcessing = FTHybridPostProcessingParams.builder()
.load("brand", "price", "category").groupBy(new Group("@brand")
.reduce(Reducers.count().as("count")).reduce(Reducers.avg("@price").as("avg_price")))
.build();
FTHybridParams hybridArgs = FTHybridParams.builder()
.search(FTHybridSearchParams.builder().query("@category:{electronics}")
.scoreAlias("text_score").build())
.vectorSearch(FTHybridVectorParams.builder().field("@image_embedding").vector("vector")
.method(FTHybridVectorParams.Knn.of(10)).scoreAlias("vector_score").build())
.combine(Combiners.linear().alpha(0.5).beta(0.5)).postProcessing(postProcessing)
.param("vector", queryVector).build();
HybridResult reply = jedis.ftHybrid(INDEX_NAME, hybridArgs);
assertThat(reply, notNullValue());
assertThat(reply.getDocuments(), not(empty()));
// Result count assertions
assertThat(reply.getTotalResults(), greaterThan(0L));
assertThat(reply.getDocuments().size(), greaterThan(0));
// Field count and content assertions
Document firstResult = reply.getDocuments().get(0);
// Grouping field should be present
assertThat(firstResult.hasProperty("brand"), equalTo(true));
// Reducer results should be present
assertThat(firstResult.hasProperty("count"), equalTo(true));
assertThat(firstResult.hasProperty("avg_price"), equalTo(true));
// Original loaded fields (price, category) should NOT be present after GROUPBY
// GROUPBY transforms the results, only group keys and reducers remain
assertThat(firstResult.hasProperty("price"), equalTo(false));
assertThat(firstResult.hasProperty("category"), equalTo(false));
assertThat(firstResult.hasProperty("title"), equalTo(false));
}
@Test
public void testLoadWithApply() {
// Test LOAD with APPLY - loaded fields should be available for expressions
FTHybridPostProcessingParams postProcessing = FTHybridPostProcessingParams.builder()
.load("price", "rating")
// with alias
.apply(Apply.of("@price * @rating", "value_score"))
// without alias returns field name as the expression itself
.apply(Apply.of("@price * 0.9")).build();
FTHybridParams hybridArgs = FTHybridParams.builder()
.search(FTHybridSearchParams.builder().query("smartphone").build())
.vectorSearch(FTHybridVectorParams.builder().field("@image_embedding").vector("vector")
.method(FTHybridVectorParams.Knn.of(10)).scoreAlias("vector_score").build())
.combine(Combiners.linear().alpha(0.5).beta(0.5).window(25)).postProcessing(postProcessing)
.param("vector", queryVector).build();
HybridResult reply = jedis.ftHybrid(INDEX_NAME, hybridArgs);
assertThat(reply, notNullValue());
assertThat(reply.getDocuments(), not(empty()));
// Result count assertions
assertThat(reply.getTotalResults(), greaterThan(0L));
assertThat(reply.getDocuments().size(), greaterThan(0));
// Field count and content assertions
Document firstResult = reply.getDocuments().get(0);
// Loaded fields should be present
assertThat(firstResult.hasProperty("price"), equalTo(true));
assertThat(firstResult.hasProperty("rating"), equalTo(true));
// Computed fields (from APPLY) should be present
assertThat(firstResult.hasProperty("value_score"), equalTo(true));
assertThat(firstResult.hasProperty("@price * 0.9"), equalTo(true));
// Non-loaded document fields should NOT be present
assertThat(firstResult.hasProperty("title"), equalTo(false));
}
@Test
public void testLoadWithFilter() {
// Test LOAD with FILTER - loaded fields should be available for filtering
FTHybridPostProcessingParams postProcessing = FTHybridPostProcessingParams.builder()
.load("price", "brand", "title").filter(Filter.of("@price > 700"))
.sortBy(SortedField.asc("@price")).build();
FTHybridParams hybridArgs = FTHybridParams.builder()
.search(FTHybridSearchParams.builder().query("@category:{electronics}")
.scoreAlias("text_score").build())
.vectorSearch(FTHybridVectorParams.builder().field("@image_embedding").vector("vector")
.method(FTHybridVectorParams.Knn.of(10)).scoreAlias("vector_score").build())
.combine(Combiners.linear().alpha(0.5).beta(0.5)).postProcessing(postProcessing)
.param("vector", queryVector).build();
HybridResult reply = jedis.ftHybrid(INDEX_NAME, hybridArgs);
assertThat(reply, notNullValue());
assertThat(reply.getDocuments(), not(empty()));
// Result count assertions
assertThat(reply.getTotalResults(), greaterThan(0L));
assertThat(reply.getDocuments().size(), greaterThan(0));
// Verify loaded fields are present and FILTER condition is applied
for (Document result : reply.getDocuments()) {
// Loaded fields should be present
assertThat(result.hasProperty("price"), equalTo(true));
assertThat(result.hasProperty("brand"), equalTo(true));
assertThat(result.hasProperty("title"), equalTo(true));
// FILTER condition: all results should have price > 700
double price = Double.parseDouble(result.getString("price"));
assertThat(price, greaterThan(700.0));
// Non-loaded document fields should NOT be present
assertThat(result.hasProperty("category"), equalTo(false));
assertThat(result.hasProperty("rating"), equalTo(false));
assertThat(result.hasProperty("image_embedding"), equalTo(false));
}
}
@Test
public void testLoadNoFields() {
// Test without LOAD - should return only document IDs and scores
FTHybridPostProcessingParams postProcessing = FTHybridPostProcessingParams.builder()
.limit(Limit.of(0, 5)).build();
FTHybridParams hybridArgs = FTHybridParams.builder()
.search(FTHybridSearchParams.builder().query("smartphone").scoreAlias("text_score").build())
.vectorSearch(FTHybridVectorParams.builder().field("@image_embedding").vector("vector")
.method(FTHybridVectorParams.Knn.of(5)).scoreAlias("vector_score").build())
.combine(Combiners.linear().alpha(0.5).beta(0.5)).postProcessing(postProcessing)
.param("vector", queryVector).build();
HybridResult reply = jedis.ftHybrid(INDEX_NAME, hybridArgs);
assertThat(reply, notNullValue());
assertThat(reply.getDocuments(), not(empty()));
// Result count assertions
assertThat(reply.getTotalResults(), greaterThan(0L));
assertThat(reply.getDocuments().size(), greaterThan(0));
// Without LOAD, results should contain only keys and scores, NO document fields
Document firstResult = reply.getDocuments().get(0);
// Document id and score should be present (extracted from __key and __score)
assertThat(firstResult.getId(), notNullValue());
assertThat(firstResult.getScore(), notNullValue());
// Document fields should NOT be present when no LOAD is specified
assertThat(firstResult.hasProperty("title"), equalTo(false));
assertThat(firstResult.hasProperty("category"), equalTo(false));
assertThat(firstResult.hasProperty("brand"), equalTo(false));
assertThat(firstResult.hasProperty("price"), equalTo(false));
assertThat(firstResult.hasProperty("rating"), equalTo(false));
assertThat(firstResult.hasProperty("image_embedding"), equalTo(false));
}
@Test
public void testNoSort() {
// Test NOSORT - disables default sorting by score
FTHybridPostProcessingParams postProcessing = FTHybridPostProcessingParams.builder()
.load("title", "price").noSort().limit(Limit.of(0, 10)).build();
FTHybridParams hybridArgs = FTHybridParams.builder()
.search(FTHybridSearchParams.builder().query("@category:{electronics}")
.scoreAlias("text_score").build())
.vectorSearch(FTHybridVectorParams.builder().field("@image_embedding").vector("vector")
.method(FTHybridVectorParams.Knn.of(10)).scoreAlias("vector_score").build())
.combine(Combiners.linear().alpha(0.5).beta(0.5)).postProcessing(postProcessing)
.param("vector", queryVector).build();
HybridResult reply = jedis.ftHybrid(INDEX_NAME, hybridArgs);
assertThat(reply, notNullValue());
assertThat(reply.getDocuments(), not(empty()));
// Result count assertions
assertThat(reply.getTotalResults(), greaterThan(0L));
assertThat(reply.getDocuments().size(), greaterThan(0));
// Loaded fields should be present
Document firstResult = reply.getDocuments().get(0);
assertThat(firstResult.hasProperty("title"), equalTo(true));
assertThat(firstResult.hasProperty("price"), equalTo(true));
}
// ========== Scorer Tests ==========
/**
* Nested test class to verify all supported scorers work correctly with FT.HYBRID command. Tests
* each scorer from {@link Scorers} to ensure proper integration.
*/
@Nested
@Tag("integration")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@SinceRedisVersion("8.4.0")
class SupportedScorersTest {
/**
* Provides scorer instances and their expected scores for parameterized testing. Sccore values
* might differ between cluster and standalone modes. To perform basic verification use same
* values for both cluster/standalone with tolerance.
* @return Stream of Arguments containing (Scorer, expectedScore, tolerance)
*/
Stream<Arguments> scorerProvider() {
return Stream.of(Arguments.of(Scorers.tfidf(), 2.5, 0.5),
Arguments.of(Scorers.tfidfDocnorm(), 0.2, 0.5), Arguments.of(Scorers.bm25stdNorm(), 1, 0.5),
Arguments.of(Scorers.bm25std(), 1.3, 0.5), Arguments.of(Scorers.dismax(), 1.0, 0.5),
Arguments.of(Scorers.docscore(), 1.0, 0.5), Arguments.of(Scorers.hamming(), 0.0, 0.5));
}
@ParameterizedTest(name = "{index}: {0}")
@MethodSource("scorerProvider")
public void testScorer(Scorer scorer, double expectedScore, double tolerance) {
// Create hybrid search with the provided scorer
FTHybridParams hybridArgs = FTHybridParams.builder()
.search(FTHybridSearchParams.builder().query("@id:1").scorer(scorer)
.scoreAlias("text_score").build())
.vectorSearch(FTHybridVectorParams.builder().field("@image_embedding").vector("vector")
.filter("@id:1").method(FTHybridVectorParams.Knn.of(5)).scoreAlias("vector_score")
.build())
.param("vector", queryVector).build();
// Execute hybrid search
HybridResult result = jedis.ftHybrid(INDEX_NAME, hybridArgs);
// Verify results are returned
assertThat(result, notNullValue());
assertThat(result.getTotalResults(), equalTo(1L));
// Verify scorer is working - text_score should be present
Document firstDoc = result.getDocuments().get(0);
assertThat(firstDoc.hasProperty("text_score"), equalTo(true));
// Verify score is valid and within expected range
double scoreValue = Double.parseDouble(firstDoc.getString("text_score"));
assertThat(scoreValue, closeTo(expectedScore, tolerance));
}
}
// ========== HybridResult Population Tests ==========
/**
* Verify that HybridResult is properly populated from FT.HYBRID command responses. Tests all
* HybridResult fields and Document structure in various scenarios.
*/
@Nested
@Tag("integration")
@SinceRedisVersion("8.4.0")
class HybridResultPopulationTest {
/**
* Verify : This command will only return document IDs (keyid) and scores to which the user has
* read access. To retrieve entire documents, use projections with LOAD * or LOAD <count> field
*/
@Test
public void verifyHybridResultBasicFieldsNoLoad() {
FTHybridParams hybridArgs = FTHybridParams.builder()
.search(FTHybridSearchParams.builder().query("@id:1").build())
.vectorSearch(FTHybridVectorParams.builder().filter("@id:1").field("@image_embedding")
.vector("vector").method(FTHybridVectorParams.Knn.of(5)).build())
.param("vector", queryVector).build();
HybridResult reply = jedis.ftHybrid(INDEX_NAME, hybridArgs);
// Verify HybridResult is not null
assertThat(reply, notNullValue());
// Verify totalResults is populated and > 0
assertThat(reply.getTotalResults(), equalTo(1L));
// Verify executionTime is populated and reasonable
assertThat(reply.getExecutionTime(), greaterThan(0.0));
// Verify documents list is populated
assertThat(reply.getDocuments(), notNullValue());
assertThat(reply.getDocuments(), not(empty()));
Document doc = reply.getDocuments().get(0);
assertThat(doc.getId(), equalTo("product:hybrid:1"));
assertThat(doc.hasProperty("title"), equalTo(false));
assertThat(doc.getScore(), closeTo(0.03, 0.01));
// Verify warnings list is not null (may be empty)
assertThat(reply.getWarnings(), notNullValue());
assertThat(reply.getWarnings(), empty());
}
/**
* Verify : This command will only return document IDs (keyid) and scores to which the user has
* read access. To retrieve entire documents, use projections with LOAD * or LOAD <count> field
*/
@Test
@SinceRedisVersion("8.6.0")
public void verifyHybridResultBasicFieldsWithLoadAll() {
// Execute a simple hybrid search with known results
FTHybridPostProcessingParams postProcessing = FTHybridPostProcessingParams.builder().loadAll()
.build();
FTHybridParams hybridArgs = FTHybridParams.builder()
.search(FTHybridSearchParams.builder().query("@id:1").build())
.vectorSearch(FTHybridVectorParams.builder().filter("@id:1").field("@image_embedding")
.vector("vector").method(FTHybridVectorParams.Knn.of(5)).build())
.combine(Combiners.linear().alpha(1).beta(0)).postProcessing(postProcessing)
.param("vector", queryVector).build();
HybridResult reply = jedis.ftHybrid(INDEX_NAME, hybridArgs);
// Verify HybridResult is not null
assertThat(reply, notNullValue());
// Verify totalResults is populated and > 0
assertThat(reply.getTotalResults(), equalTo(1L));
// Verify executionTime is populated and reasonable
assertThat(reply.getExecutionTime(), greaterThan(0.0));
// Verify documents list is populated
assertThat(reply.getDocuments(), notNullValue());
assertThat(reply.getDocuments(), not(empty()));
Document doc = reply.getDocuments().get(0);
assertThat(doc.getId(), nullValue());
assertThat(doc.hasProperty("title"), equalTo(true));
assertThat(doc.get("title"), equalTo("Apple iPhone 15 Pro smartphone with advanced camera"));
assertThat(doc.getScore(), closeTo(0.5, 0.5));
// Verify warnings list is not null (may be empty)
assertThat(reply.getWarnings(), notNullValue());
assertThat(reply.getWarnings(), empty());
}
@Test
public void verifyHybridResultWithEmptyResults() {
// Execute hybrid search with filter that matches no documents
FTHybridParams hybridArgs = FTHybridParams.builder()
.search(FTHybridSearchParams.builder().query("@category:{nonexistent}").build())
.vectorSearch(
FTHybridVectorParams.builder().filter("@id:nonexistent").field("@image_embedding")
.vector("vector").method(FTHybridVectorParams.Knn.of(5)).build())
.param("vector", queryVector).build();
HybridResult reply = jedis.ftHybrid(INDEX_NAME, hybridArgs);
// Verify HybridResult is not null even with empty results
assertThat(reply, notNullValue());
// Verify totalResults is 0
assertThat(reply.getTotalResults(), equalTo(0L));
// Verify documents list is empty
assertThat(reply.getDocuments(), notNullValue());
assertThat(reply.getDocuments(), empty());
// Verify executionTime is still populated (>= 0.0)
assertThat(reply.getExecutionTime(), greaterThanOrEqualTo(0.0));
}
}
}