Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Choose from multiple Redis deployment options:
2. [Redis on Docker](https://hub.docker.com/_/redis): Docker image for development

```bash
docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest
docker run -d --name redis -p 6379:6379 -p 8001:8001 redis:latest
```

3. [Redis Enterprise](https://redis.io/enterprise/): Commercial, self-hosted database
Expand Down Expand Up @@ -201,25 +201,43 @@ Define queries and perform advanced searches over your indices, including the co
List<Map<String, Object>> results = index.query(filteredQuery);
```

- **HybridQuery** - Combines text and vector search with weighted scoring:
- **HybridQuery** - Native hybrid search combining text and vector similarity using Redis 8.4+ `FT.HYBRID` command with built-in score fusion:

```java
import com.redis.vl.query.HybridQuery;
import com.redis.vl.query.Filter;

// Hybrid search: text + vector with alpha weighting
// Native hybrid search with LINEAR combination (Redis 8.4+)
HybridQuery hybridQuery = HybridQuery.builder()
.text("machine learning algorithms")
.textFieldName("description")
.vector(queryVector)
.vectorFieldName("embedding")
.filterExpression(Filter.tag("category", "AI"))
.alpha(0.7f) // 70% vector, 30% text
.combinationMethod(HybridQuery.CombinationMethod.LINEAR)
.linearAlpha(0.3f) // 30% text, 70% vector
.numResults(10)
.build();

List<Map<String, Object>> results = index.query(hybridQuery);
// Results scored by: alpha * vector_similarity + (1-alpha) * text_score
// Automatically falls back to AggregateHybridQuery on older Redis versions
```

- **AggregateHybridQuery** - Backward-compatible hybrid search using `FT.AGGREGATE` for Redis versions before 8.4:

```java
import com.redis.vl.query.AggregateHybridQuery;

AggregateHybridQuery aggQuery = AggregateHybridQuery.builder()
.text("machine learning algorithms")
.textFieldName("description")
.vector(queryVector)
.vectorFieldName("embedding")
.alpha(0.7f) // 70% vector, 30% text
.numResults(10)
.build();

List<Map<String, Object>> results = index.query(aggQuery);
```

- **VectorRangeQuery** - Vector search within a defined range paired with customizable filters
Expand Down Expand Up @@ -479,7 +497,7 @@ String response = vcrChat.call("What is Redis?");

### How It Works

1. **Container Management**: VCR starts a Redis Stack container with persistence
1. **Container Management**: VCR starts a Redis container with persistence
2. **Model Wrapping**: `@VCRModel` fields are wrapped with VCR proxies
3. **Cassette Storage**: Responses stored as Redis JSON documents
4. **Persistence**: Data saved to `src/test/resources/vcr-data/` via Redis AOF/RDB
Expand Down Expand Up @@ -515,6 +533,7 @@ Check out the [notebooks](notebooks/) directory for interactive Jupyter notebook

- [Getting Started](notebooks/01_getting_started.ipynb) - Introduction to RedisVL basics
- [Hybrid Queries](notebooks/02_hybrid_queries.ipynb) - Combining vector and metadata search
- [Advanced Queries](notebooks/11_advanced_queries.ipynb) - TextQuery, HybridQuery, and MultiVectorQuery
- [LLM Cache](notebooks/03_llmcache.ipynb) - Semantic caching for LLMs
- [Hash vs JSON Storage](notebooks/05_hash_vs_json.ipynb) - Storage type comparison
- [Vectorizers](notebooks/06_vectorizers.ipynb) - Working with embedding models
Expand Down Expand Up @@ -559,7 +578,8 @@ Please help us by contributing PRs, opening GitHub issues for bugs or new featur
## Requirements

- Java 17+
- Redis Stack 7.2+ or Redis with RediSearch module
- Redis 8.0+ (includes built-in search and vector capabilities)
- For native `FT.HYBRID` support: Redis 8.4+

## License

Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ tasks.wrapper {
// Task to copy jar to notebooks directory for Jupyter
tasks.register<Copy>("copyJarToNotebooks") {
dependsOn(":core:jar")
from("core/build/libs/redisvl-0.12.2.jar")
from(project(":core").tasks.named<Jar>("jar").map { it.archiveFile })
into("notebooks")
}

Expand Down
4 changes: 2 additions & 2 deletions core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ plugins {
description = "RedisVL - Vector Library for Java"

dependencies {
// Redis client - upgraded to 7.2.0 for new RedisClient/RedisSentinelClient API
api("redis.clients:jedis:7.2.0")
// Redis client - upgraded to 7.3.0 for native FT.HYBRID support
api("redis.clients:jedis:7.3.0")

// JSON processing
implementation("com.fasterxml.jackson.core:jackson-databind:2.18.2")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ protected void setWithTtl(String key, String value, Integer ttl) {
protected void setWithTtl(byte[] key, byte[] value, Integer ttl) {
if (ttl != null || this.ttl != null) {
Integer effectiveTtl = ttl != null ? ttl : this.ttl;
redisClient.setex(key, effectiveTtl, value);
redisClient.set(key, value, new redis.clients.jedis.params.SetParams().ex(effectiveTtl));
} else {
redisClient.set(key, value);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ public void mset(Map<String, float[]> embeddings, String modelName) {
byte[] valueBytes = ArrayUtils.floatArrayToBytes(entry.getValue());

if (ttl != null && ttl > 0) {
pipeline.setex(keyBytes, ttl, valueBytes);
pipeline.set(keyBytes, valueBytes, new redis.clients.jedis.params.SetParams().ex(ttl));
} else {
pipeline.set(keyBytes, valueBytes);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.redis.vl.query.Filter;
import com.redis.vl.query.FilterQuery;
import com.redis.vl.schema.IndexSchema;
import com.redis.vl.utils.Utils;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.util.ArrayList;
import java.util.HashMap;
Expand Down Expand Up @@ -218,12 +219,18 @@ public void addMessages(List<Map<String, String>> messages, String sessionTag) {
String effectiveSessionTag = (sessionTag != null) ? sessionTag : this.sessionTag;
List<Map<String, Object>> chatMessages = new ArrayList<>();

for (Map<String, String> message : messages) {
// Use a base timestamp and increment by a small offset per message to guarantee
// insertion order is preserved when sorting by timestamp.
double baseTimestamp = Utils.currentTimestamp();

for (int i = 0; i < messages.size(); i++) {
Map<String, String> message = messages.get(i);
ChatMessage.ChatMessageBuilder builder =
ChatMessage.builder()
.role(message.get(ROLE_FIELD_NAME))
.content(message.get(CONTENT_FIELD_NAME))
.sessionTag(effectiveSessionTag);
.sessionTag(effectiveSessionTag)
.timestamp(baseTimestamp + (i * 0.000001)); // 1 microsecond offset per message

if (message.containsKey(TOOL_FIELD_NAME)) {
builder.toolCallId(message.get(TOOL_FIELD_NAME));
Expand Down
124 changes: 120 additions & 4 deletions core/src/main/java/com/redis/vl/index/SearchIndex.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@
import lombok.extern.slf4j.Slf4j;
import redis.clients.jedis.RedisClient;
import redis.clients.jedis.UnifiedJedis;
import redis.clients.jedis.search.Document;
import redis.clients.jedis.search.FTCreateParams;
import redis.clients.jedis.search.FTSearchParams;
import redis.clients.jedis.search.IndexDataType;
import redis.clients.jedis.search.SearchResult;
import redis.clients.jedis.search.hybrid.FTHybridParams;
import redis.clients.jedis.search.hybrid.HybridResult;
import redis.clients.jedis.search.schemafields.SchemaField;

/**
Expand Down Expand Up @@ -1301,6 +1304,50 @@ public SearchResult search(String query, Map<String, Object> params, int offset,
}
}

/**
* Execute a TextQuery with full support for return fields, scorer, sorting, and numResults.
*
* @param tq The TextQuery to execute
* @return Search results
*/
private SearchResult searchTextQuery(TextQuery tq) {
if (!exists()) {
throw new RedisVLException("Index " + getName() + " does not exist");
}

UnifiedJedis jedis = getUnifiedJedis();
try {
redis.clients.jedis.search.FTSearchParams searchParams =
new redis.clients.jedis.search.FTSearchParams();

searchParams.dialect(2);
searchParams.limit(0, tq.getNumResults() != null ? tq.getNumResults() : DEFAULT_NUM_RESULTS);

// Set return fields if specified
if (tq.getReturnFields() != null && !tq.getReturnFields().isEmpty()) {
searchParams.returnFields(tq.getReturnFields().toArray(new String[0]));
}

// Set scorer if specified
if (tq.getScorer() != null && !tq.getScorer().isEmpty()) {
searchParams.scorer(tq.getScorer());
}

// Set sorting if specified
if (tq.getSortBy() != null && !tq.getSortBy().isEmpty()) {
redis.clients.jedis.args.SortingOrder order =
tq.isSortDescending()
? redis.clients.jedis.args.SortingOrder.DESC
: redis.clients.jedis.args.SortingOrder.ASC;
searchParams.sortBy(tq.getSortBy(), order);
}

return jedis.ftSearch(schema.getName(), tq.toQueryString(), searchParams);
} catch (Exception e) {
throw new RuntimeException("Failed to execute text query: " + e.getMessage(), e);
}
}

/**
* Search with sorting and/or inOrder support.
*
Expand Down Expand Up @@ -1502,7 +1549,7 @@ public List<Map<String, Object>> query(Object query) {
SearchResult result = search(fq.build());
return processSearchResult(result);
} else if (query instanceof TextQuery tq) {
SearchResult result = search(tq.toString());
SearchResult result = searchTextQuery(tq);
return processSearchResult(result);
} else if (query instanceof FilterQuery fq) {
// FilterQuery: metadata-only query without vector search
Expand All @@ -1511,13 +1558,31 @@ public List<Map<String, Object>> query(Object query) {
UnifiedJedis jedis = getUnifiedJedis();
SearchResult result = jedis.ftSearch(schema.getName(), redisQuery);
return processSearchResult(result);
} else if (query instanceof HybridQuery hq) {
// HybridQuery: native FT.HYBRID command (Redis 8.4+)
// Falls back to AggregateHybridQuery (FT.AGGREGATE) if FT.HYBRID is not available
try {
FTHybridParams hybridParams = hq.buildFTHybridParams();
UnifiedJedis jedis = getUnifiedJedis();
HybridResult result = jedis.ftHybrid(schema.getName(), hybridParams);
return processHybridResult(result);
} catch (Exception e) {
// Fall back to AggregateHybridQuery (FT.AGGREGATE) when FT.HYBRID fails.
// This handles: unknown command (Redis < 8.4), unsupported parameters
// (e.g., YIELD_SCORE_AS on older versions), and other server-side errors.
log.warn(
"FT.HYBRID failed, falling back to AggregateHybridQuery (FT.AGGREGATE): {}",
e.getMessage());
AggregateHybridQuery fallback = hq.toAggregateHybridQuery();
return query(fallback);
}
} else if (query instanceof AggregationQuery aq) {
// AggregationQuery: HybridQuery and other aggregation-based queries
// Python: HybridQuery (redisvl/query/aggregate.py:23)
// AggregationQuery: AggregateHybridQuery and other aggregation-based queries
// Python: AggregateHybridQuery (redisvl/query/aggregate.py:23)
redis.clients.jedis.search.aggr.AggregationBuilder aggregation = aq.buildRedisAggregation();
UnifiedJedis jedis = getUnifiedJedis();

// Add parameters if present (e.g., vector parameter for HybridQuery)
// Add parameters if present (e.g., vector parameter for AggregateHybridQuery)
Map<String, Object> params = aq.getParams();
if (params != null && !params.isEmpty()) {
aggregation.params(params);
Expand Down Expand Up @@ -1610,6 +1675,57 @@ private List<Map<String, Object>> processAggregationResult(
return processed;
}

/**
* Process HybridResult from FT.HYBRID into List of Maps.
*
* <p>Converts Redis hybrid search results into a list of maps, where each map represents a
* document from the hybrid search result.
*
* @param result the HybridResult from Redis FT.HYBRID command
* @return list of maps containing hybrid search results
*/
private List<Map<String, Object>> processHybridResult(HybridResult result) {
List<Map<String, Object>> processed = new ArrayList<>();
if (result != null && result.getDocuments() != null) {
for (Document doc : result.getDocuments()) {
Map<String, Object> docMap = new HashMap<>();
docMap.put("id", doc.getId());
if (doc.getScore() != null) {
docMap.put("score", doc.getScore());
}

if (getStorageType() == IndexSchema.StorageType.JSON) {
Object jsonField = doc.get("$");
if (jsonField instanceof String) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> parsedDoc = jsonMapper.readValue((String) jsonField, Map.class);
for (Map.Entry<String, Object> entry : parsedDoc.entrySet()) {
docMap.put("$." + entry.getKey(), entry.getValue());
}
} catch (Exception e) {
log.warn("Failed to parse JSON document in hybrid result", e);
for (Map.Entry<String, Object> entry : doc.getProperties()) {
docMap.put(entry.getKey(), entry.getValue());
}
}
} else {
for (Map.Entry<String, Object> entry : doc.getProperties()) {
docMap.put(entry.getKey(), entry.getValue());
}
}
} else {
for (Map.Entry<String, Object> entry : doc.getProperties()) {
docMap.put(entry.getKey(), entry.getValue());
}
}

processed.add(docMap);
}
}
return processed;
}

/**
* Execute multiple search queries in batch
*
Expand Down
Loading