From f11e85504768feb02a16a7bf137d7e39a05890dd Mon Sep 17 00:00:00 2001 From: Vladyslav Vildanov <117659936+vladvildanov@users.noreply.github.com> Date: Tue, 11 Nov 2025 15:31:10 +0200 Subject: [PATCH] Added experimental support for FT.HYBRID (#1607) * Added basic structures and test coverage * Added most of the test coverage * Added more test coverage * Codestyle fixes * Fixed static analysis * ANother static analysis fix * Codestyle fixes * Fixed version constraints * Added missing annotations * Use class context * Added more test coverage * Codestyle fixes * Added experimental mentioning --- CHANGELOG.md | 1 + src/ClientContextInterface.php | 2 + src/ClientInterface.php | 2 + .../HybridSearch/Combine/BaseCombine.php | 44 + .../Combine/LinearCombineConfig.php | 79 ++ .../HybridSearch/Combine/RRFCombineConfig.php | 79 ++ .../Search/HybridSearch/HybridSearchQuery.php | 352 ++++++ .../Argument/Search/HybridSearch/Reducer.php | 51 + .../Search/HybridSearch/ScorerConfig.php | 60 + .../Search/HybridSearch/SearchConfig.php | 80 ++ .../VectorSearch/BaseVectorSearchConfig.php | 85 ++ .../VectorSearch/KNNVectorSearchConfig.php | 91 ++ .../VectorSearch/RangeVectorSearchConfig.php | 89 ++ src/Command/Redis/Search/FTHYBRID.php | 53 + src/Command/Redis/Utils/CommandUtility.php | 17 + .../Combine/LinearCombineConfigTest.php | 57 + .../Combine/RRFCombineConfigTest.php | 54 + .../HybridSearch/HybridSearchQueryTest.php | 288 +++++ .../Search/HybridSearch/ReducerTest.php | 27 + .../Search/HybridSearch/ScorerConfigTest.php | 52 + .../Search/HybridSearch/SearchConfigTest.php | 59 + .../KNNVectorSearchConfigTest.php | 76 ++ .../RangeVectorSearchConfigTest.php | 86 ++ .../Command/Redis/Search/FTHYBRID_Test.php | 1087 +++++++++++++++++ .../Command/Utils/CommandUtilityTest.php | 17 + 25 files changed, 2888 insertions(+) create mode 100644 src/Command/Argument/Search/HybridSearch/Combine/BaseCombine.php create mode 100644 src/Command/Argument/Search/HybridSearch/Combine/LinearCombineConfig.php create mode 100644 src/Command/Argument/Search/HybridSearch/Combine/RRFCombineConfig.php create mode 100644 src/Command/Argument/Search/HybridSearch/HybridSearchQuery.php create mode 100644 src/Command/Argument/Search/HybridSearch/Reducer.php create mode 100644 src/Command/Argument/Search/HybridSearch/ScorerConfig.php create mode 100644 src/Command/Argument/Search/HybridSearch/SearchConfig.php create mode 100644 src/Command/Argument/Search/HybridSearch/VectorSearch/BaseVectorSearchConfig.php create mode 100644 src/Command/Argument/Search/HybridSearch/VectorSearch/KNNVectorSearchConfig.php create mode 100644 src/Command/Argument/Search/HybridSearch/VectorSearch/RangeVectorSearchConfig.php create mode 100644 src/Command/Redis/Search/FTHYBRID.php create mode 100644 tests/Predis/Command/Argument/Search/HybridSearch/Combine/LinearCombineConfigTest.php create mode 100644 tests/Predis/Command/Argument/Search/HybridSearch/Combine/RRFCombineConfigTest.php create mode 100644 tests/Predis/Command/Argument/Search/HybridSearch/HybridSearchQueryTest.php create mode 100644 tests/Predis/Command/Argument/Search/HybridSearch/ReducerTest.php create mode 100644 tests/Predis/Command/Argument/Search/HybridSearch/ScorerConfigTest.php create mode 100644 tests/Predis/Command/Argument/Search/HybridSearch/SearchConfigTest.php create mode 100644 tests/Predis/Command/Argument/Search/HybridSearch/VectorSearch/KNNVectorSearchConfigTest.php create mode 100644 tests/Predis/Command/Argument/Search/HybridSearch/VectorSearch/RangeVectorSearchConfigTest.php create mode 100644 tests/Predis/Command/Redis/Search/FTHYBRID_Test.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c3a74ae..00b5bd4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ ### Added - Added support for `XDELEX` and `XACKDEL` (#1580) - Added missing VSIM argument (#1582) +- Added experimental support for FT.HYBRID (#1607) ### Changed - Extended `XTRIM` and `XADD` commands with new parameters (#1580) diff --git a/src/ClientContextInterface.php b/src/ClientContextInterface.php index f69854e1..a215e83a 100644 --- a/src/ClientContextInterface.php +++ b/src/ClientContextInterface.php @@ -19,6 +19,7 @@ use Predis\Command\Argument\Search\AlterArguments; use Predis\Command\Argument\Search\CreateArguments; use Predis\Command\Argument\Search\DropArguments; use Predis\Command\Argument\Search\ExplainArguments; +use Predis\Command\Argument\Search\HybridSearch\HybridSearchQuery; use Predis\Command\Argument\Search\ProfileArguments; use Predis\Command\Argument\Search\SchemaFields\FieldInterface; use Predis\Command\Argument\Search\SearchArguments; @@ -126,6 +127,7 @@ use Predis\Command\Redis\VADD; * @method $this ftdictdump(string $dict) * @method $this ftdropindex(string $index, ?DropArguments $arguments = null) * @method $this ftexplain(string $index, string $query, ?ExplainArguments $arguments = null) + * @method $this fthybrid(string $index, HybridSearchQuery $query) * @method $this ftinfo(string $index) * @method $this ftprofile(string $index, ProfileArguments $arguments) * @method $this ftsearch(string $index, string $query, ?SearchArguments $arguments = null) diff --git a/src/ClientInterface.php b/src/ClientInterface.php index 148e2747..08c24d51 100644 --- a/src/ClientInterface.php +++ b/src/ClientInterface.php @@ -19,6 +19,7 @@ use Predis\Command\Argument\Search\AlterArguments; use Predis\Command\Argument\Search\CreateArguments; use Predis\Command\Argument\Search\DropArguments; use Predis\Command\Argument\Search\ExplainArguments; +use Predis\Command\Argument\Search\HybridSearch\HybridSearchQuery; use Predis\Command\Argument\Search\ProfileArguments; use Predis\Command\Argument\Search\SchemaFields\FieldInterface; use Predis\Command\Argument\Search\SearchArguments; @@ -136,6 +137,7 @@ use Predis\Response\Status; * @method array ftdictdump(string $dict) * @method Status ftdropindex(string $index, ?DropArguments $arguments = null) * @method string ftexplain(string $index, string $query, ?ExplainArguments $arguments = null) + * @method array fthybrid(string $index, HybridSearchQuery $query) * @method array ftinfo(string $index) * @method array ftprofile(string $index, ProfileArguments $arguments) * @method array ftsearch(string $index, string $query, ?SearchArguments $arguments = null) diff --git a/src/Command/Argument/Search/HybridSearch/Combine/BaseCombine.php b/src/Command/Argument/Search/HybridSearch/Combine/BaseCombine.php new file mode 100644 index 00000000..0e987275 --- /dev/null +++ b/src/Command/Argument/Search/HybridSearch/Combine/BaseCombine.php @@ -0,0 +1,44 @@ +as, 'YIELD_SCORE_AS', $alias); + + return $this; + } + + /** + * {@inheritDoc} + */ + abstract public function toArray(): array; +} diff --git a/src/Command/Argument/Search/HybridSearch/Combine/LinearCombineConfig.php b/src/Command/Argument/Search/HybridSearch/Combine/LinearCombineConfig.php new file mode 100644 index 00000000..9e8c1528 --- /dev/null +++ b/src/Command/Argument/Search/HybridSearch/Combine/LinearCombineConfig.php @@ -0,0 +1,79 @@ +alpha = $alpha; + + return $this; + } + + /** + * The weight for the vector score (a value between 0 and 1). + * + * @param float $beta + * @return $this + */ + public function beta(float $beta): self + { + $this->beta = $beta; + + return $this; + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + $this->arguments[] = 'LINEAR'; + $tokens = []; + + if ($this->alpha !== null) { + array_push($tokens, 'ALPHA', $this->alpha); + } + + if ($this->beta !== null) { + array_push($tokens, 'BETA', $this->beta); + } + + if ($this->as) { + array_push($tokens, ...$this->as); + } + + if (!empty($tokens)) { + array_push($this->arguments, count($tokens), ...$tokens); + } + + return $this->arguments; + } +} diff --git a/src/Command/Argument/Search/HybridSearch/Combine/RRFCombineConfig.php b/src/Command/Argument/Search/HybridSearch/Combine/RRFCombineConfig.php new file mode 100644 index 00000000..0e28f7d4 --- /dev/null +++ b/src/Command/Argument/Search/HybridSearch/Combine/RRFCombineConfig.php @@ -0,0 +1,79 @@ +window = $window; + + return $this; + } + + /** + * The RRF ranking constant. A smaller value gives more weight to top-ranked items. Defaults to 60. + * + * @param int $constant + * @return $this + */ + public function rrfConstant(int $constant): self + { + $this->rrfConstant = $constant; + + return $this; + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + $this->arguments[] = 'RRF'; + $tokens = []; + + if ($this->window !== null) { + array_push($tokens, 'WINDOW', $this->window); + } + + if ($this->rrfConstant !== null) { + array_push($tokens, 'CONSTANT', $this->rrfConstant); + } + + if ($this->as) { + array_push($tokens, ...$this->as); + } + + if (!empty($tokens)) { + array_push($this->arguments, count($tokens), ...$tokens); + } + + return $this->arguments; + } +} diff --git a/src/Command/Argument/Search/HybridSearch/HybridSearchQuery.php b/src/Command/Argument/Search/HybridSearch/HybridSearchQuery.php new file mode 100644 index 00000000..8afcdda9 --- /dev/null +++ b/src/Command/Argument/Search/HybridSearch/HybridSearchQuery.php @@ -0,0 +1,352 @@ +searchConfig = new SearchConfig(); + $this->vectorSearchConfig = new $vectorSearchMethod(); + $this->combineConfig = new $combineMethod(); + } + + /** + * @param callable(SearchConfig): void $callable + * @return $this + */ + public function buildSearchConfig(callable $callable): self + { + $callable($this->searchConfig); + + return $this; + } + + /** + * @param callable(KNNVectorSearchConfig|RangeVectorSearchConfig): void $callable + * @return $this + */ + public function buildVectorSearchConfig(callable $callable): self + { + $callable($this->vectorSearchConfig); + + return $this; + } + + /** + * @param callable(RRFCombineConfig|LinearCombineConfig): void $callable + * @return $this + */ + public function buildCombineConfig(callable $callable): self + { + $callable($this->combineConfig); + + return $this; + } + + /** + * The list of fields to return in the results. + * + * @param array $fields + * @return $this + */ + public function load(array $fields): self + { + array_push($this->load, 'LOAD', count($fields), ...$fields); + + return $this; + } + + /** + * @param array $fields + * @param Reducer[] $reducers + * @return $this + */ + public function groupBy(array $fields, array $reducers): self + { + array_push($this->groupBy, 'GROUPBY', count($fields), ...$fields); + + foreach ($reducers as $reducer) { + array_push($this->groupBy, 'REDUCE', ...$reducer->toArray()); + } + + return $this; + } + + /** + * @param array $expressionFieldDict field => function dictionary + * @return $this + */ + public function apply(array $expressionFieldDict): self + { + foreach ($expressionFieldDict as $field => $function) { + array_push($this->apply, 'APPLY', $function, 'AS', $field); + } + + return $this; + } + + /** + * Sorts the final results by a specific field. + * + * @param array $fields Dictionary with fields and sort direction. Check class constants. + * @return $this + */ + public function sortBy(array $fields): self + { + $fieldsArray = []; + foreach ($fields as $field => $direction) { + if (!in_array(strtoupper($direction), [self::SORT_ASC, self::SORT_DESC])) { + throw new ValueError('Sort direction must be one of "ASC" or "DESC".'); + } + + array_push($fieldsArray, $field, $direction); + } + + array_push($this->sortBy, 'SORTBY', count($fieldsArray), ...$fieldsArray); + + return $this; + } + + /** + * Final result filtering. + * + * @param string $expression + * @return $this + */ + public function filter(string $expression): self + { + $this->filter = $expression; + + return $this; + } + + /** + * @param int $offset + * @param int $num + * @return $this + */ + public function limit(int $offset, int $num): self + { + array_push($this->limit, 'LIMIT', $offset, $num); + + return $this; + } + + /** + * Binds values to named parameters in the query string. + * + * @param array $params + * @return $this + */ + public function params(array $params): self + { + $arrayParams = CommandUtility::dictionaryToArray($params); + array_push($this->params, 'PARAMS', count($arrayParams), ...$arrayParams); + + return $this; + } + + /** + * @return $this + */ + public function explainScore(): self + { + $this->explainScore = true; + + return $this; + } + + /** + * @return $this + */ + public function timeout(): self + { + $this->timeout = true; + + return $this; + } + + /** + * @param int|null $readSize + * @param int|null $idleTime + * @return $this + */ + public function withCursor(?int $readSize = null, ?int $idleTime = null): self + { + $this->withCursor[] = 'WITHCURSOR'; + + if ($readSize) { + array_push($this->withCursor, 'COUNT', $readSize); + } + + if ($idleTime) { + array_push($this->withCursor, 'MAXIDLE', $idleTime); + } + + return $this; + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + $this->arguments = array_merge( + $this->arguments, + $this->searchConfig->toArray(), + $this->vectorSearchConfig->toArray() + ); + + $combineConfig = $this->combineConfig->toArray(); + + // Only add if any configuration was applied + if (count($combineConfig) > 2) { + $this->arguments = array_merge($this->arguments, $combineConfig); + } + + if ($this->load) { + $this->arguments = array_merge($this->arguments, $this->load); + } + + if ($this->groupBy) { + $this->arguments = array_merge($this->arguments, $this->groupBy); + } + + if ($this->apply) { + $this->arguments = array_merge($this->arguments, $this->apply); + } + + if ($this->sortBy) { + $this->arguments = array_merge($this->arguments, $this->sortBy); + } + + if ($this->filter) { + array_push($this->arguments, 'FILTER', $this->filter); + } + + if ($this->limit) { + $this->arguments = array_merge($this->arguments, $this->limit); + } + + if ($this->params) { + $this->arguments = array_merge($this->arguments, $this->params); + } + + if ($this->explainScore) { + $this->arguments[] = 'EXPLAINSCORE'; + } + + if ($this->timeout) { + $this->arguments[] = 'TIMEOUT'; + } + + if ($this->withCursor) { + $this->arguments = array_merge($this->arguments, $this->withCursor); + } + + return $this->arguments; + } +} diff --git a/src/Command/Argument/Search/HybridSearch/Reducer.php b/src/Command/Argument/Search/HybridSearch/Reducer.php new file mode 100644 index 00000000..ad0c5886 --- /dev/null +++ b/src/Command/Argument/Search/HybridSearch/Reducer.php @@ -0,0 +1,51 @@ +arguments, $function, count($arguments), ...$arguments); + + if ($alias) { + array_push($this->arguments, 'AS', $alias); + } + } + + public function toArray(): array + { + return $this->arguments; + } +} diff --git a/src/Command/Argument/Search/HybridSearch/ScorerConfig.php b/src/Command/Argument/Search/HybridSearch/ScorerConfig.php new file mode 100644 index 00000000..c508eccd --- /dev/null +++ b/src/Command/Argument/Search/HybridSearch/ScorerConfig.php @@ -0,0 +1,60 @@ +arguments[] = $type; + + return $this; + } + + /** + * An alias for the text score field in the results. + * The aliased field will be included in the `value` object of each returned document. + * + * @param string $alias + * @return $this + */ + public function as(string $alias): self + { + array_push($this->arguments, 'YIELD_SCORE_AS', $alias); + + return $this; + } + + public function toArray(): array + { + return $this->arguments; + } +} diff --git a/src/Command/Argument/Search/HybridSearch/SearchConfig.php b/src/Command/Argument/Search/HybridSearch/SearchConfig.php new file mode 100644 index 00000000..cf5e8586 --- /dev/null +++ b/src/Command/Argument/Search/HybridSearch/SearchConfig.php @@ -0,0 +1,80 @@ +scorerConfig = new ScorerConfig(); + } + + /** + * Search query. + * + * @param string $query + * @return $this + */ + public function query(string $query): self + { + $this->arguments[] = $query; + + return $this; + } + + /** + * @param string $alias + * @return $this + */ + public function as(string $alias): self + { + array_push($this->arguments, 'YIELD_SCORE_AS', $alias); + + return $this; + } + + /** + * @param callable(ScorerConfig): void $callable + * @return $this + */ + public function buildScorerConfig(callable $callable): self + { + $callable($this->scorerConfig); + + return $this; + } + + public function toArray(): array + { + $scorerConfig = $this->scorerConfig->toArray(); + + if (!empty($scorerConfig)) { + $this->arguments[] = 'SCORER'; + $this->arguments = array_merge($this->arguments, $scorerConfig); + } + + return $this->arguments; + } +} diff --git a/src/Command/Argument/Search/HybridSearch/VectorSearch/BaseVectorSearchConfig.php b/src/Command/Argument/Search/HybridSearch/VectorSearch/BaseVectorSearchConfig.php new file mode 100644 index 00000000..ea262cdc --- /dev/null +++ b/src/Command/Argument/Search/HybridSearch/VectorSearch/BaseVectorSearchConfig.php @@ -0,0 +1,85 @@ +vector, $field, VectorUtility::toBlob($value)); + } else { + array_push($this->vector, $field, $value); + } + + return $this; + } + + /** + * @param string $expression + * @return $this + */ + public function filter(string $expression): self + { + array_push($this->filter, 'FILTER', $expression); + + return $this; + } + + /** + * @param string $alias + * @return $this + */ + public function as(string $alias): self + { + array_push($this->as, 'YIELD_SCORE_AS', $alias); + + return $this; + } + + abstract public function toArray(): array; +} diff --git a/src/Command/Argument/Search/HybridSearch/VectorSearch/KNNVectorSearchConfig.php b/src/Command/Argument/Search/HybridSearch/VectorSearch/KNNVectorSearchConfig.php new file mode 100644 index 00000000..6a627268 --- /dev/null +++ b/src/Command/Argument/Search/HybridSearch/VectorSearch/KNNVectorSearchConfig.php @@ -0,0 +1,91 @@ +k = $k; + + return $this; + } + + /** + * The HNSW `ef_runtime` parameter for tuning the accuracy/speed trade-off. + * + * @param int $ef + * @return $this + */ + public function ef(int $ef): self + { + $this->ef = $ef; + + return $this; + } + + public function toArray(): array + { + if (!$this->vector) { + throw new ValueError('Vector configuration not specified.'); + } + + $this->arguments = array_merge($this->arguments, $this->vector); + + if ($this->k || $this->ef) { + $this->arguments[] = 'KNN'; + } + + $tokens = []; + + if ($this->k !== null) { + array_push($tokens, 'K', $this->k); + } + + if ($this->ef !== null) { + array_push($tokens, 'EF_RUNTIME', $this->ef); + } + + if ($this->as) { + array_push($tokens, ...$this->as); + } + + if (!empty($tokens)) { + array_push($this->arguments, count($tokens), ...$tokens); + } + + if ($this->filter) { + $this->arguments = array_merge($this->arguments, $this->filter); + } + + return $this->arguments; + } +} diff --git a/src/Command/Argument/Search/HybridSearch/VectorSearch/RangeVectorSearchConfig.php b/src/Command/Argument/Search/HybridSearch/VectorSearch/RangeVectorSearchConfig.php new file mode 100644 index 00000000..a4457df6 --- /dev/null +++ b/src/Command/Argument/Search/HybridSearch/VectorSearch/RangeVectorSearchConfig.php @@ -0,0 +1,89 @@ +radius = $radius; + + return $this; + } + + /** + * @param float $epsilon + * @return $this + */ + public function epsilon(float $epsilon): self + { + $this->epsilon = $epsilon; + + return $this; + } + + public function toArray(): array + { + if (!$this->vector) { + throw new ValueError('Vector configuration not specified.'); + } + + $this->arguments = array_merge($this->arguments, $this->vector); + + if ($this->radius || $this->epsilon) { + $this->arguments[] = 'RANGE'; + } + + $tokens = []; + + if ($this->radius !== null) { + array_push($tokens, 'RADIUS', $this->radius); + } + + if ($this->epsilon !== null) { + array_push($tokens, 'EPSILON', $this->epsilon); + } + + if ($this->as) { + array_push($tokens, ...$this->as); + } + + if (!empty($tokens)) { + array_push($this->arguments, count($tokens), ...$tokens); + } + + if ($this->filter) { + $this->arguments = array_merge($this->arguments, $this->filter); + } + + return $this->arguments; + } +} diff --git a/src/Command/Redis/Search/FTHYBRID.php b/src/Command/Redis/Search/FTHYBRID.php new file mode 100644 index 00000000..c9368bd3 --- /dev/null +++ b/src/Command/Redis/Search/FTHYBRID.php @@ -0,0 +1,53 @@ +toArray() + )); + } + + public function parseResponse($data) + { + $response = CommandUtility::arrayToDictionary($data, null, false); + + foreach ($response['results'] as $key => $result) { + $response['results'][$key] = CommandUtility::arrayToDictionary($result); + } + + return $response; + } + + public function prefixKeys($prefix) + { + $this->applyPrefixForFirstArgument($prefix); + } +} diff --git a/src/Command/Redis/Utils/CommandUtility.php b/src/Command/Redis/Utils/CommandUtility.php index 4425f8e1..540afea9 100644 --- a/src/Command/Redis/Utils/CommandUtility.php +++ b/src/Command/Redis/Utils/CommandUtility.php @@ -53,4 +53,21 @@ class CommandUtility return $dict; } + + /** + * Converts associative array into flatten array (key1, value1...keyN, valueN). + * + * @param array $dict + * @return array + */ + public static function dictionaryToArray(array $dict): array + { + $array = []; + + array_walk($dict, function ($value, $key) use (&$array) { + array_push($array, $key, $value); + }); + + return $array; + } } diff --git a/tests/Predis/Command/Argument/Search/HybridSearch/Combine/LinearCombineConfigTest.php b/tests/Predis/Command/Argument/Search/HybridSearch/Combine/LinearCombineConfigTest.php new file mode 100644 index 00000000..d85e288b --- /dev/null +++ b/tests/Predis/Command/Argument/Search/HybridSearch/Combine/LinearCombineConfigTest.php @@ -0,0 +1,57 @@ +assertEquals($config, $config->alpha($alpha)); + } + + if ($beta) { + $this->assertEquals($config, $config->beta($beta)); + } + + if ($as) { + $this->assertEquals($config, $config->as($as)); + } + + $this->assertSame($expectedReturn, $config->toArray()); + } + + public function argumentsProvider(): array + { + return [ + 'with ALPHA' => [['COMBINE', 'LINEAR', 2, 'ALPHA', 0.2], 0.2, null, null], + 'with BETA' => [['COMBINE', 'LINEAR', 2, 'BETA', 0.2], null, 0.2, null], + 'with all arguments' => [ + ['COMBINE', 'LINEAR', 6, 'ALPHA', 0.3, 'BETA', 0.2, 'YIELD_SCORE_AS', 'alias'], + 0.3, 0.2, 'alias', + ], + ]; + } +} diff --git a/tests/Predis/Command/Argument/Search/HybridSearch/Combine/RRFCombineConfigTest.php b/tests/Predis/Command/Argument/Search/HybridSearch/Combine/RRFCombineConfigTest.php new file mode 100644 index 00000000..e2e43cfa --- /dev/null +++ b/tests/Predis/Command/Argument/Search/HybridSearch/Combine/RRFCombineConfigTest.php @@ -0,0 +1,54 @@ +assertEquals($config, $config->window($window)); + } + + if ($rrfConstant) { + $this->assertEquals($config, $config->rrfConstant($rrfConstant)); + } + + if ($as) { + $this->assertEquals($config, $config->as($as)); + } + + $this->assertSame($expectedReturn, $config->toArray()); + } + + public function argumentsProvider(): array + { + return [ + 'with WINDOW' => [['COMBINE', 'RRF', 2, 'WINDOW', 5], 5, null, null], + 'with CONSTANT' => [['COMBINE', 'RRF', 2, 'CONSTANT', 5], null, 5, null], + 'with all' => [['COMBINE', 'RRF', 6, 'WINDOW', 10, 'CONSTANT', 5, 'YIELD_SCORE_AS', 'alias'], 10, 5, 'alias'], + ]; + } +} diff --git a/tests/Predis/Command/Argument/Search/HybridSearch/HybridSearchQueryTest.php b/tests/Predis/Command/Argument/Search/HybridSearch/HybridSearchQueryTest.php new file mode 100644 index 00000000..e9c85fa3 --- /dev/null +++ b/tests/Predis/Command/Argument/Search/HybridSearch/HybridSearchQueryTest.php @@ -0,0 +1,288 @@ +assertSame($expectedReturn, $config->toArray()); + } + + public function argumentsProvider(): array + { + return [ + 'with default configs' => [ + (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config->buildScorerConfig(function (ScorerConfig $scorerConfig) { + $scorerConfig->type(ScorerConfig::TYPE_DISMAX); + }) + ->query('*'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('vector', [0.1, 0.2, 0.3]) + ->k(5) + ->ef(10); + }), + ['SEARCH', '*', 'SCORER', ScorerConfig::TYPE_DISMAX, 'VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'KNN', 4, 'K', 5, 'EF_RUNTIME', 10], + ], + 'with RANGE vector search' => [ + (new HybridSearchQuery(RangeVectorSearchConfig::class)) + ->buildSearchConfig(function (SearchConfig $config) { + $config->buildScorerConfig(function (ScorerConfig $scorerConfig) { + $scorerConfig->type(ScorerConfig::TYPE_DISMAX); + }) + ->query('*'); + }) + ->buildVectorSearchConfig(function (RangeVectorSearchConfig $config) { + $config + ->vector('vector', [0.1, 0.2, 0.3]) + ->radius(5) + ->epsilon(0.2); + }), + ['SEARCH', '*', 'SCORER', ScorerConfig::TYPE_DISMAX, 'VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'RANGE', 4, 'RADIUS', 5, 'EPSILON', 0.2], + ], + 'with COMBINE config - RRF' => [ + (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config->buildScorerConfig(function (ScorerConfig $scorerConfig) { + $scorerConfig->type(ScorerConfig::TYPE_DISMAX); + }) + ->query('*'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('vector', [0.1, 0.2, 0.3]) + ->k(5) + ->ef(10); + }) + ->buildCombineConfig(function (RRFCombineConfig $config) { + $config + ->window(5) + ->rrfConstant(10); + }), + ['SEARCH', '*', 'SCORER', ScorerConfig::TYPE_DISMAX, 'VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'KNN', 4, 'K', 5, 'EF_RUNTIME', 10, 'COMBINE', 'RRF', 4, 'WINDOW', 5, 'CONSTANT', 10], + ], + 'with COMBINE config - LINEAR' => [ + (new HybridSearchQuery(KNNVectorSearchConfig::class, LinearCombineConfig::class)) + ->buildSearchConfig(function (SearchConfig $config) { + $config->buildScorerConfig(function (ScorerConfig $scorerConfig) { + $scorerConfig->type(ScorerConfig::TYPE_DISMAX); + }) + ->query('*'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('vector', [0.1, 0.2, 0.3]) + ->k(5) + ->ef(10); + }) + ->buildCombineConfig(function (LinearCombineConfig $config) { + $config + ->alpha(0.2) + ->beta(0.3); + }), + ['SEARCH', '*', 'SCORER', ScorerConfig::TYPE_DISMAX, 'VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'KNN', 4, 'K', 5, 'EF_RUNTIME', 10, 'COMBINE', 'LINEAR', 4, 'ALPHA', 0.2, 'BETA', 0.3], + ], + 'with LOAD' => [ + (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config->buildScorerConfig(function (ScorerConfig $scorerConfig) { + $scorerConfig->type(ScorerConfig::TYPE_DISMAX); + }) + ->query('*'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('vector', [0.1, 0.2, 0.3]) + ->k(5) + ->ef(10); + }) + ->load(['field1', 'field2']), + ['SEARCH', '*', 'SCORER', ScorerConfig::TYPE_DISMAX, 'VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'KNN', 4, 'K', 5, 'EF_RUNTIME', 10, 'LOAD', 2, 'field1', 'field2'], + ], + 'with GROUPBY' => [ + (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config->buildScorerConfig(function (ScorerConfig $scorerConfig) { + $scorerConfig->type(ScorerConfig::TYPE_DISMAX); + }) + ->query('*'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('vector', [0.1, 0.2, 0.3]) + ->k(5) + ->ef(10); + }) + ->groupBy( + ['field1', 'field2'], + [ + new Reducer(Reducer::REDUCE_COUNT, ['prop1', 'prop2'], 'alias'), + new Reducer(Reducer::REDUCE_MAX, ['prop1', 'prop2']), + ] + ), + ['SEARCH', '*', 'SCORER', ScorerConfig::TYPE_DISMAX, 'VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'KNN', 4, 'K', 5, 'EF_RUNTIME', 10, 'GROUPBY', 2, 'field1', 'field2', 'REDUCE', 'COUNT', 2, 'prop1', 'prop2', 'AS', 'alias', 'REDUCE', 'MAX', 2, 'prop1', 'prop2'], + ], + 'with APPLY' => [ + (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config->buildScorerConfig(function (ScorerConfig $scorerConfig) { + $scorerConfig->type(ScorerConfig::TYPE_DISMAX); + }) + ->query('*'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('vector', [0.1, 0.2, 0.3]) + ->k(5) + ->ef(10); + }) + ->apply(['field' => 'expr']), + ['SEARCH', '*', 'SCORER', ScorerConfig::TYPE_DISMAX, 'VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'KNN', 4, 'K', 5, 'EF_RUNTIME', 10, 'APPLY', 'expr', 'AS', 'field'], + ], + 'with SORTBY' => [ + (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config->buildScorerConfig(function (ScorerConfig $scorerConfig) { + $scorerConfig->type(ScorerConfig::TYPE_DISMAX); + }) + ->query('*'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('vector', [0.1, 0.2, 0.3]) + ->k(5) + ->ef(10); + }) + ->sortBy(['field' => 'DESC']), + ['SEARCH', '*', 'SCORER', ScorerConfig::TYPE_DISMAX, 'VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'KNN', 4, 'K', 5, 'EF_RUNTIME', 10, 'SORTBY', 2, 'field', 'DESC'], + ], + 'with FILTER' => [ + (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config->buildScorerConfig(function (ScorerConfig $scorerConfig) { + $scorerConfig->type(ScorerConfig::TYPE_DISMAX); + }) + ->query('*'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('vector', [0.1, 0.2, 0.3]) + ->k(5) + ->ef(10); + }) + ->filter('expr'), + ['SEARCH', '*', 'SCORER', ScorerConfig::TYPE_DISMAX, 'VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'KNN', 4, 'K', 5, 'EF_RUNTIME', 10, 'FILTER', 'expr'], + ], + 'with LIMIT' => [ + (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config->buildScorerConfig(function (ScorerConfig $scorerConfig) { + $scorerConfig->type(ScorerConfig::TYPE_DISMAX); + }) + ->query('*'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('vector', [0.1, 0.2, 0.3]) + ->k(5) + ->ef(10); + }) + ->limit(0, 10), + ['SEARCH', '*', 'SCORER', ScorerConfig::TYPE_DISMAX, 'VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'KNN', 4, 'K', 5, 'EF_RUNTIME', 10, 'LIMIT', 0, 10], + ], + 'with PARAMS' => [ + (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config->buildScorerConfig(function (ScorerConfig $scorerConfig) { + $scorerConfig->type(ScorerConfig::TYPE_DISMAX); + }) + ->query('*'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('vector', [0.1, 0.2, 0.3]) + ->k(5) + ->ef(10); + }) + ->params(['param1' => 'value1', 'param2' => 'value2']), + ['SEARCH', '*', 'SCORER', ScorerConfig::TYPE_DISMAX, 'VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'KNN', 4, 'K', 5, 'EF_RUNTIME', 10, 'PARAMS', 4, 'param1', 'value1', 'param2', 'value2'], + ], + 'with EXPLAINSCORE' => [ + (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config->buildScorerConfig(function (ScorerConfig $scorerConfig) { + $scorerConfig->type(ScorerConfig::TYPE_DISMAX); + }) + ->query('*'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('vector', [0.1, 0.2, 0.3]) + ->k(5) + ->ef(10); + }) + ->explainScore(), + ['SEARCH', '*', 'SCORER', ScorerConfig::TYPE_DISMAX, 'VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'KNN', 4, 'K', 5, 'EF_RUNTIME', 10, 'EXPLAINSCORE'], + ], + 'with TIMEOUT' => [ + (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config->buildScorerConfig(function (ScorerConfig $scorerConfig) { + $scorerConfig->type(ScorerConfig::TYPE_DISMAX); + }) + ->query('*'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('vector', [0.1, 0.2, 0.3]) + ->k(5) + ->ef(10); + }) + ->timeout(), + ['SEARCH', '*', 'SCORER', ScorerConfig::TYPE_DISMAX, 'VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'KNN', 4, 'K', 5, 'EF_RUNTIME', 10, 'TIMEOUT'], + ], + 'with WITHCURSOR' => [ + (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config->buildScorerConfig(function (ScorerConfig $scorerConfig) { + $scorerConfig->type(ScorerConfig::TYPE_DISMAX); + }) + ->query('*'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('vector', [0.1, 0.2, 0.3]) + ->k(5) + ->ef(10); + }) + ->withCursor(10, 10), + ['SEARCH', '*', 'SCORER', ScorerConfig::TYPE_DISMAX, 'VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'KNN', 4, 'K', 5, 'EF_RUNTIME', 10, 'WITHCURSOR', 'COUNT', 10, 'MAXIDLE', 10], + ], + ]; + } +} diff --git a/tests/Predis/Command/Argument/Search/HybridSearch/ReducerTest.php b/tests/Predis/Command/Argument/Search/HybridSearch/ReducerTest.php new file mode 100644 index 00000000..88d9b1cd --- /dev/null +++ b/tests/Predis/Command/Argument/Search/HybridSearch/ReducerTest.php @@ -0,0 +1,27 @@ +assertSame([Reducer::REDUCE_MAX, 2, 'prop1', 'prop2', 'AS', 'alias'], $reducer->toArray()); + } +} diff --git a/tests/Predis/Command/Argument/Search/HybridSearch/ScorerConfigTest.php b/tests/Predis/Command/Argument/Search/HybridSearch/ScorerConfigTest.php new file mode 100644 index 00000000..570b6ea9 --- /dev/null +++ b/tests/Predis/Command/Argument/Search/HybridSearch/ScorerConfigTest.php @@ -0,0 +1,52 @@ +assertEquals($config, $config->type($type)); + } + + if ($as) { + $this->assertEquals($config, $config->as($as)); + } + + $this->assertSame($expectedReturn, $config->toArray()); + } + + public function argumentsProvider(): array + { + return [ + 'with TYPE' => [[ScorerConfig::TYPE_BM25], ScorerConfig::TYPE_BM25], + 'with YIELD_SCORE_AS' => [['YIELD_SCORE_AS', 'alias'], null, 'alias'], + 'with all' => [ + [ScorerConfig::TYPE_DISMAX, 'YIELD_SCORE_AS', 'alias'], + ScorerConfig::TYPE_DISMAX, 'alias', + ], + ]; + } +} diff --git a/tests/Predis/Command/Argument/Search/HybridSearch/SearchConfigTest.php b/tests/Predis/Command/Argument/Search/HybridSearch/SearchConfigTest.php new file mode 100644 index 00000000..f0fb1a23 --- /dev/null +++ b/tests/Predis/Command/Argument/Search/HybridSearch/SearchConfigTest.php @@ -0,0 +1,59 @@ +assertEquals($config, $config->query($query)); + } + + if ($type) { + $this->assertEquals($config, $config->buildScorerConfig(function (ScorerConfig $scorerConfig) use ($type) { + $scorerConfig->type($type); + })); + } + + if ($as) { + $this->assertEquals($config, $config->as($as)); + } + + $this->assertSame($expectedReturn, $config->toArray()); + } + + public function argumentsProvider(): array + { + return [ + 'with query' => [['SEARCH', '*'], '*', null, null], + 'with AS' => [['SEARCH', '*', 'YIELD_SCORE_AS', 'alias'], '*', null, 'alias'], + 'with SCORER' => [ + ['SEARCH', '*', 'SCORER', ScorerConfig::TYPE_DOCSCORE], + '*', ScorerConfig::TYPE_DOCSCORE, null, + ], + ]; + } +} diff --git a/tests/Predis/Command/Argument/Search/HybridSearch/VectorSearch/KNNVectorSearchConfigTest.php b/tests/Predis/Command/Argument/Search/HybridSearch/VectorSearch/KNNVectorSearchConfigTest.php new file mode 100644 index 00000000..bf9c92b3 --- /dev/null +++ b/tests/Predis/Command/Argument/Search/HybridSearch/VectorSearch/KNNVectorSearchConfigTest.php @@ -0,0 +1,76 @@ +assertSame($expectedReturn, $config->toArray()); + } + + /** + * @return void + */ + public function testToArrayThrowsExceptionOnMissingProperty(): void + { + $config = new KNNVectorSearchConfig(); + + $this->expectException(ValueError::class); + $this->expectExceptionMessage('Vector configuration not specified.'); + + $config + ->filter('filter') + ->as('alias') + ->toArray(); + } + + public function argumentsProvider(): array + { + return [ + 'with vector and K' => [ + (new KNNVectorSearchConfig()) + ->vector('vector', [0.1, 0.2, 0.3]) + ->k(5), + ['VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'KNN', 2, 'K', 5]], + 'with vector, K and EF_RUNTIME' => [ + (new KNNVectorSearchConfig()) + ->vector('vector', [0.1, 0.2, 0.3]) + ->k(5) + ->ef(5), + ['VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'KNN', 4, 'K', 5, 'EF_RUNTIME', 5]], + 'with vector, K and FILTER' => [ + (new KNNVectorSearchConfig()) + ->vector('vector', [0.1, 0.2, 0.3]) + ->k(5) + ->filter('*'), + ['VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'KNN', 2, 'K', 5, 'FILTER', '*']], + 'with all' => [ + (new KNNVectorSearchConfig()) + ->vector('vector', VectorUtility::toBlob([0.1, 0.2, 0.3])) + ->k(5) + ->ef(5) + ->filter('*') + ->as('alias'), + ['VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'KNN', 6, 'K', 5, 'EF_RUNTIME', 5, 'YIELD_SCORE_AS', 'alias', 'FILTER', '*']], + ]; + } +} diff --git a/tests/Predis/Command/Argument/Search/HybridSearch/VectorSearch/RangeVectorSearchConfigTest.php b/tests/Predis/Command/Argument/Search/HybridSearch/VectorSearch/RangeVectorSearchConfigTest.php new file mode 100644 index 00000000..1cbac528 --- /dev/null +++ b/tests/Predis/Command/Argument/Search/HybridSearch/VectorSearch/RangeVectorSearchConfigTest.php @@ -0,0 +1,86 @@ +assertSame($expectedReturn, $config->toArray()); + } + + /** + * @return void + */ + public function testAs() + { + $config = new RangeVectorSearchConfig(); + + $this->assertEquals($config, $config->as('alias')); + } + + /** + * @return void + */ + public function testToArrayThrowsExceptionOnMissingProperty(): void + { + $config = new RangeVectorSearchConfig(); + + $this->expectException(ValueError::class); + $this->expectExceptionMessage('Vector configuration not specified.'); + + $config + ->filter('filter') + ->as('alias') + ->toArray(); + } + + public function argumentsProvider(): array + { + return [ + 'with vector and RADIUS' => [ + (new RangeVectorSearchConfig()) + ->vector('vector', [0.1, 0.2, 0.3]) + ->radius(5), + ['VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'RANGE', 2, 'RADIUS', 5]], + 'with vector, RADIUS and EPSILON' => [ + (new RangeVectorSearchConfig()) + ->vector('vector', [0.1, 0.2, 0.3]) + ->radius(5) + ->epsilon(0.2), + ['VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'RANGE', 4, 'RADIUS', 5, 'EPSILON', 0.2]], + 'with vector, RADIUS and FILTER' => [ + (new RangeVectorSearchConfig()) + ->vector('vector', [0.1, 0.2, 0.3]) + ->radius(5) + ->filter('*'), + ['VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'RANGE', 2, 'RADIUS', 5, 'FILTER', '*']], + 'with all arguments' => [ + (new RangeVectorSearchConfig()) + ->vector('vector', VectorUtility::toBlob([0.1, 0.2, 0.3])) + ->radius(5) + ->epsilon(0.2) + ->as('alias') + ->filter('*'), + ['VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'RANGE', 6, 'RADIUS', 5, 'EPSILON', 0.2, 'YIELD_SCORE_AS', 'alias', 'FILTER', '*']], + ]; + } +} diff --git a/tests/Predis/Command/Redis/Search/FTHYBRID_Test.php b/tests/Predis/Command/Redis/Search/FTHYBRID_Test.php new file mode 100644 index 00000000..e593dcbd --- /dev/null +++ b/tests/Predis/Command/Redis/Search/FTHYBRID_Test.php @@ -0,0 +1,1087 @@ +getCommand(); + $query = (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config->buildScorerConfig(function (ScorerConfig $scorerConfig) { + $scorerConfig->type(ScorerConfig::TYPE_DISMAX); + }) + ->query('*'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('vector', [0.1, 0.2, 0.3]) + ->k(5) + ->ef(10); + }); + $index = 'idx'; + + $command->setArguments([$index, $query]); + + $this->assertSameValues( + ['idx', 'SEARCH', '*', 'SCORER', ScorerConfig::TYPE_DISMAX, 'VSIM', 'vector', VectorUtility::toBlob([0.1, 0.2, 0.3]), 'KNN', 4, 'K', 5, 'EF_RUNTIME', 10], + $command->getArguments() + ); + } + + /** + * @return void + */ + public function testParseResponse(): void + { + $response = ['total_results', 5, 'results', [['key', 'value'], ['key', 'value']]]; + $command = new FTHYBRID(); + + $this->assertEquals([ + 'total_results' => 5, + 'results' => [['key' => 'value'], ['key' => 'value']], + ], $command->parseResponse($response)); + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testReviewFeedbackHybridSearch() + { + $redis = $this->getClient(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis, 5); + + $query = (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('@color:{red} @color:{green}') + ->buildScorerConfig(function (ScorerConfig $scorerConfig) { + $scorerConfig->type(ScorerConfig::TYPE_TFIDF); + }); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config->vector('@embedding', [-100, -200, -200, -300]); + }); + + $this->assertGreaterThan(0, $redis->fthybrid('idx', $query)['total_results']); + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testReviewFeedbackHybridSearchResp3() + { + $redis = $this->getResp3Client(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis, 5); + + $query = (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('@color:{red} @color:{green}') + ->buildScorerConfig(function (ScorerConfig $scorerConfig) { + $scorerConfig->type(ScorerConfig::TYPE_TFIDF); + }); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config->vector('@embedding', [-100, -200, -200, -300]); + }); + + $this->assertGreaterThan(0, $redis->fthybrid('idx', $query)['total_results']); + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testDefaultHybridSearch() + { + $redis = $this->getClient(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis, 5); + + $query = (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('@color:{red} @color:{green}'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config->vector('@embedding', [-100, -200, -200, -300]); + }); + + $response = $redis->fthybrid('idx', $query); + + $this->assertEquals(10, $response['total_results']); + $this->assertCount(10, $response['results']); + $this->assertEmpty($response['warnings']); + $this->assertGreaterThan(0, $response['execution_time']); + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testDefaultHybridSearchResp3() + { + $redis = $this->getResp3Client(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis, 5); + + $query = (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('@color:{red} @color:{green}'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config->vector('@embedding', [-100, -200, -200, -300]); + }); + + $response = $redis->fthybrid('idx', $query); + + $this->assertEquals(10, $response['total_results']); + $this->assertCount(10, $response['results']); + $this->assertEmpty($response['warnings']); + $this->assertGreaterThan(0, $response['execution_time']); + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testHybridSearchQueryWithScorer() + { + $redis = $this->getClient(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis, 10); + + $query = (new HybridSearchQuery(KNNVectorSearchConfig::class, LinearCombineConfig::class)) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('shoes') + ->buildScorerConfig(function (ScorerConfig $scorerConfig) { + $scorerConfig->type(ScorerConfig::TYPE_TFIDF); + }); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config->vector('@embedding', [1, 2, 2, 3]); + }) + ->buildCombineConfig(function (LinearCombineConfig $config) { + $config + ->alpha(1) + ->beta(0); + }) + ->load(['@description', '@color', '@price', '@size', '@__score', '@__item']) + ->limit(0, 2); + + $expectedResultsTFIDF = [ + [ + 'description' => 'red shoes', + 'color' => 'red', + 'price' => '15', + 'size' => '10', + '__score' => '2', + ], + [ + 'description' => 'green shoes with red laces', + 'color' => 'green', + 'price' => '16', + 'size' => '11', + '__score' => '2', + ], + ]; + + $response = $redis->fthybrid('idx', $query); + $this->assertEquals($expectedResultsTFIDF, $response['results']); + + $query = (new HybridSearchQuery(KNNVectorSearchConfig::class, LinearCombineConfig::class)) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('shoes') + ->buildScorerConfig(function (ScorerConfig $scorerConfig) { + $scorerConfig->type(ScorerConfig::TYPE_BM25); + }); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config->vector('@embedding', [1, 2, 2, 3]); + }) + ->buildCombineConfig(function (LinearCombineConfig $config) { + $config + ->alpha(1) + ->beta(0); + }) + ->load(['@description', '@color', '@price', '@size', '@__score', '@__item']) + ->limit(0, 2); + + $expectedResultsBM25 = [ + [ + 'description' => 'red shoes', + 'color' => 'red', + 'price' => '15', + 'size' => '10', + '__score' => '0.657894719299', + ], + [ + 'description' => 'green shoes with red laces', + 'color' => 'green', + 'price' => '16', + 'size' => '11', + '__score' => '0.657894719299', + ], + ]; + + $response = $redis->fthybrid('idx', $query); + $this->assertEquals($expectedResultsBM25, $response['results']); + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testHybridSearchQueryWithVsimMethodDefinedQueryInit() + { + $redis = $this->getClient(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis, 5); + + $query = (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('shoes'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('@embedding-hnsw', 'abcd1234efgh5678') + ->k(3) + ->ef(1); + }); + $response = $redis->fthybrid('idx', $query); + $this->assertGreaterThan(0, count($response['results'])); + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testHybridSearchQueryWithVsimFilter() + { + $redis = $this->getClient(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis, 5); + + $query = (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('@color:{missing}'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('@embedding', 'abcd1234efgh5678') + ->filter('@price:[15 16] @size:[10 11]'); + }) + ->load(['@price', '@size']); + + $response = $redis->fthybrid('idx', $query); + $this->assertGreaterThan(0, count($response['results'])); + + foreach ($response['results'] as $result) { + $this->assertTrue(in_array($result['price'], ['15', '16'])); + $this->assertTrue(in_array($result['size'], ['10', '11'])); + } + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testHybridSearchQueryWithSearchScoreAliases() + { + $redis = $this->getClient(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis); + + $query = (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('shoes') + ->as('search_score'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('@embedding', 'abcd1234efgh5678'); + }); + + $response = $redis->fthybrid('idx', $query); + $this->assertGreaterThan(0, count($response['results'])); + + foreach ($response['results'] as $result) { + if (in_array($result['__key'], ['item:0', 'item:1', 'item:4'])) { + $this->assertArrayHasKey('search_score', $result); + $this->assertArrayHasKey('__score', $result); + } + } + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testHybridSearchQueryWithVsimScoreAliases() + { + $redis = $this->getClient(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis); + + $query = (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('shoes'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('@embedding-hnsw', 'abcd1234efgh5678') + ->k(3) + ->ef(1) + ->as('vsim_score'); + }); + + $response = $redis->fthybrid('idx', $query); + $this->assertGreaterThan(0, count($response['results'])); + + foreach ($response['results'] as $result) { + if (in_array($result['__key'], ['item:0', 'item:1', 'item:2'])) { + $this->assertArrayHasKey('vsim_score', $result); + $this->assertArrayHasKey('__score', $result); + } + } + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testHybridSearchQueryWithCombineScoreAliases() + { + $redis = $this->getClient(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis); + + $query = (new HybridSearchQuery(KNNVectorSearchConfig::class, LinearCombineConfig::class)) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('shoes') + ->as('search_score'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('@embedding-hnsw', 'abcd1234efgh5678') + ->k(3) + ->ef(1) + ->as('vsim_score'); + }) + ->buildCombineConfig(function (LinearCombineConfig $config) { + $config + ->alpha(0.5) + ->beta(0.5) + ->as('combine_score'); + }); + + $response = $redis->fthybrid('idx', $query); + $this->assertGreaterThan(0, count($response['results'])); + + foreach ($response['results'] as $result) { + if (in_array($result['__key'], ['item:0', 'item:1', 'item:2'])) { + $this->assertArrayHasKey('vsim_score', $result); + } else { + $this->assertArrayNotHasKey('vsim_score', $result); + } + + if (in_array($result['__key'], ['item:0', 'item:1', 'item:4'])) { + $this->assertArrayHasKey('search_score', $result); + } else { + $this->assertArrayNotHasKey('search_score', $result); + } + + $this->assertArrayHasKey('combine_score', $result); + $this->assertArrayNotHasKey('__score', $result); + } + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testHybridSearchQueryWithCombineAllScoreAliases() + { + $redis = $this->getClient(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis); + + $query = (new HybridSearchQuery(KNNVectorSearchConfig::class, LinearCombineConfig::class)) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('shoes') + ->as('search_score'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('@embedding-hnsw', 'abcd1234efgh5678') + ->k(3) + ->ef(1) + ->as('vsim_score'); + }) + ->buildCombineConfig(function (LinearCombineConfig $config) { + $config + ->alpha(0.5) + ->beta(0.5) + ->as('combine_score'); + }); + + $response = $redis->fthybrid('idx', $query); + $this->assertGreaterThan(0, count($response['results'])); + + foreach ($response['results'] as $result) { + $this->assertArrayHasKey('combine_score', $result); + $this->assertArrayNotHasKey('__score', $result); + } + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testHybridSearchQueryWithVsimKNN() + { + $redis = $this->getClient(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis, 10); + + $query = (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('@color:{none}'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('@embedding', [1, 2, 2, 3]) + ->k(3); + }); + + $expected_results = [ + ['__key' => 'item:2', '__score' => '0.016393442623'], + ['__key' => 'item:7', '__score' => '0.0161290322581'], + ['__key' => 'item:12', '__score' => '0.015873015873'], + ]; + + $response = $redis->fthybrid('idx', $query); + $this->assertEquals($expected_results, $response['results']); + + $query = (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('@color:{none}'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('@embedding-hnsw', [1, 2, 2, 3]) + ->k(3) + ->ef(1); + }); + + $expected_results = [ + ['__key' => 'item:12', '__score' => '0.016393442623'], + ['__key' => 'item:22', '__score' => '0.0161290322581'], + ['__key' => 'item:27', '__score' => '0.015873015873'], + ]; + + $response = $redis->fthybrid('idx', $query); + $this->assertEquals($expected_results, $response['results']); + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testHybridSearchQueryWithVsimRange() + { + $redis = $this->getClient(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis, 10); + + $query = (new HybridSearchQuery(RangeVectorSearchConfig::class)) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('@color:{none}'); + }) + ->buildVectorSearchConfig(function (RangeVectorSearchConfig $config) { + $config + ->vector('@embedding', [1, 2, 7, 6]); + }) + ->limit(0, 3); + + $expected_results = [ + ['__key' => 'item:2', '__score' => '0.016393442623'], + ['__key' => 'item:7', '__score' => '0.0161290322581'], + ['__key' => 'item:12', '__score' => '0.015873015873'], + ]; + + $response = $redis->fthybrid('idx', $query); + $this->assertEquals($expected_results, $response['results']); + + $query = (new HybridSearchQuery(RangeVectorSearchConfig::class)) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('@color:{none}'); + }) + ->buildVectorSearchConfig(function (RangeVectorSearchConfig $config) { + $config + ->vector('@embedding-hnsw', [1, 2, 7, 6]) + ->radius(2) + ->epsilon(0.5); + }) + ->limit(0, 3); + + $expected_results = [ + ['__key' => 'item:27', '__score' => '0.016393442623'], + ['__key' => 'item:12', '__score' => '0.0161290322581'], + ['__key' => 'item:22', '__score' => '0.015873015873'], + ]; + + $response = $redis->fthybrid('idx', $query); + $this->assertEquals($expected_results, $response['results']); + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testHybridSearchQueryWithCombine() + { + $redis = $this->getClient(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis, 10); + + $query = (new HybridSearchQuery(KNNVectorSearchConfig::class, LinearCombineConfig::class)) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('@color:{red}'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('@embedding', [1, 2, 7, 6]); + }) + ->buildCombineConfig(function (LinearCombineConfig $config) { + $config + ->alpha(0.5) + ->beta(0.5); + }) + ->limit(0, 3); + + $expected_results = [ + ['__key' => 'item:2', '__score' => '0.166666666667'], + ['__key' => 'item:7', '__score' => '0.166666666667'], + ['__key' => 'item:12', '__score' => '0.166666666667'], + ]; + + $response = $redis->fthybrid('idx', $query); + $this->assertEquals($expected_results, $response['results']); + + $query = (new HybridSearchQuery(KNNVectorSearchConfig::class, RRFCombineConfig::class)) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('@color:{red}'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('@embedding', [1, 2, 7, 6]); + }) + ->buildCombineConfig(function (RRFCombineConfig $config) { + $config + ->window(3) + ->rrfConstant(0.5); + }) + ->limit(0, 3); + + $expected_results = [ + ['__key' => 'item:2', '__score' => '1.5'], + ['__key' => 'item:0', '__score' => '1'], + ['__key' => 'item:7', '__score' => '0.5'], + ]; + + $response = $redis->fthybrid('idx', $query); + $this->assertEquals($expected_results, $response['results']); + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testHybridSearchQueryWithLoad() + { + $redis = $this->getClient(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis, 10); + + $query = (new HybridSearchQuery(KNNVectorSearchConfig::class, LinearCombineConfig::class)) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('@color:{red|green|black}'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('@embedding', [1, 2, 7, 6]); + }) + ->buildCombineConfig(function (LinearCombineConfig $config) { + $config + ->alpha(0.5) + ->beta(0.5); + }) + ->load(['@description', '@color', '@price', '@size', '@__key']) + ->limit(0, 1); + + $expected_results = [ + 'description' => 'red dress', + 'color' => 'red', + 'price' => '17', + 'size' => '12', + '__key' => 'item:2', + ]; + + $response = $redis->fthybrid('idx', $query); + $this->assertEquals($expected_results, $response['results'][0]); + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testHybridSearchQueryWithLoadAndApply() + { + $redis = $this->getClient(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis, 10); + + $query = (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('@color:{red}'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('@embedding', [1, 2, 7, 6]); + }) + ->load(['@color', '@price', '@size']) + ->apply([ + 'price_discount' => '@price - (@price * 0.1)', + 'tax_discount' => '@price_discount * 0.2', + ]) + ->limit(0, 3); + + $expected_results = [ + [ + 'color' => 'red', + 'price' => '17', + 'size' => '12', + 'price_discount' => '15.3', + 'tax_discount' => '3.06', + ], + [ + 'color' => 'red', + 'price' => '18', + 'size' => '11', + 'price_discount' => '16.2', + 'tax_discount' => '3.24', + ], + [ + 'color' => 'red', + 'price' => '15', + 'size' => '10', + 'price_discount' => '13.5', + 'tax_discount' => '2.7', + ], + ]; + + $response = $redis->fthybrid('idx', $query); + $this->assertEquals($expected_results, $response['results']); + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testHybridSearchQueryWithLoadAndFilter() + { + $redis = $this->getClient(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis, 10); + + $query = (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('@color:{red|green|black}'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('@embedding', [1, 2, 7, 6]); + }) + ->load(['@description', '@color', '@price', '@size']) + ->filter('@price=="15"') + ->limit(0, 3); + + $response = $redis->fthybrid('idx', $query); + $this->assertCount(3, $response['results']); + + foreach ($response['results'] as $result) { + $this->assertEquals(15, $result['price']); + } + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testHybridSearchQueryWithLoadApplyAndParams() + { + $redis = $this->getClient(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis, 5); + + $query = (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('@color:{$color_criteria}'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('@embedding', '$vector'); + }) + ->load(['@description', '@color', '@price']) + ->apply([ + 'price_discount' => '@price - (@price * 0.1)', + ]) + ->params([ + 'vector' => 'abcd1234abcd5678', + 'color_criteria' => 'red', + ]) + ->limit(0, 3); + + $expected_results = [ + [ + 'description' => 'red shoes', + 'color' => 'red', + 'price' => '15', + 'price_discount' => '13.5', + ], + [ + 'description' => 'red dress', + 'color' => 'red', + 'price' => '17', + 'price_discount' => '15.3', + ], + [ + 'description' => 'red shoes', + 'color' => 'red', + 'price' => '16', + 'price_discount' => '14.4', + ], + ]; + + $response = $redis->fthybrid('idx', $query); + $this->assertEquals($expected_results, $response['results']); + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testHybridSearchQueryWithApplyAndSortBy() + { + $redis = $this->getClient(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis); + + $query = (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('@color:{red|green}'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('@embedding', [1, 2, 7, 6]); + }) + ->load(['@color', '@price']) + ->apply([ + 'price_discount' => '@price - (@price * 0.1)', + ]) + ->sortBy([ + '@price_discount' => 'DESC', + '@color' => 'ASC', + ]) + ->limit(0, 5); + + $expected_results = [ + [ + 'color' => 'orange', + 'price' => '18', + 'price_discount' => '16.2', + ], + [ + 'color' => 'red', + 'price' => '17', + 'price_discount' => '15.3', + ], + [ + 'color' => 'green', + 'price' => '16', + 'price_discount' => '14.4', + ], + [ + 'color' => 'black', + 'price' => '15', + 'price_discount' => '13.5', + ], + [ + 'color' => 'red', + 'price' => '15', + 'price_discount' => '13.5', + ], + ]; + + $response = $redis->fthybrid('idx', $query); + $this->assertEquals($expected_results, $response['results']); + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testHybridSearchQueryWithLoadAndGroupBy() + { + $redis = $this->getClient(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis, 10); + + $query = (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('@color:{red|green}'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('@embedding', [1, 2, 7, 6]); + }) + ->load(['@color', '@price', '@size', '@item_type']) + ->groupBy( + ['@item_type', '@price'], + [ + new Reducer(Reducer::REDUCE_COUNT_DISTINCT, ['@color'], 'colors_count'), + new Reducer(Reducer::REDUCE_MIN, ['@size']), + ] + ) + ->sortBy([ + '@price' => 'ASC', + ]) + ->limit(0, 4); + + $expected_results = [ + [ + 'item_type' => 'dress', + 'price' => '15', + 'colors_count' => '1', + '__generated_aliasminsize' => '10', + ], + [ + 'item_type' => 'shoes', + 'price' => '15', + 'colors_count' => '2', + '__generated_aliasminsize' => '10', + ], + [ + 'item_type' => 'shoes', + 'price' => '16', + 'colors_count' => '2', + '__generated_aliasminsize' => '10', + ], + [ + 'item_type' => 'dress', + 'price' => '16', + 'colors_count' => '1', + '__generated_aliasminsize' => '11', + ], + ]; + + $response = $redis->fthybrid('idx', $query); + $this->assertEquals($expected_results, $response['results']); + } + + /** + * @group connected + * @group relay-resp3 + * @requiresRedisVersion >= 8.3.224 + * @return void + */ + public function testHybridSearchQueryWithCursor() + { + $redis = $this->getClient(); + $this->createHybridSearchIndex($redis); + $this->generateData($redis, 10); + + $query = (new HybridSearchQuery()) + ->buildSearchConfig(function (SearchConfig $config) { + $config + ->query('@color:{red|green}'); + }) + ->buildVectorSearchConfig(function (KNNVectorSearchConfig $config) { + $config + ->vector('@embedding', [1, 2, 7, 6]); + }) + ->withCursor(5, 100); + + $response = $redis->fthybrid('idx', $query); + $this->assertGreaterThan(0, $response['SEARCH']); + $this->assertGreaterThan(0, $response['VSIM']); + } + + protected function createHybridSearchIndex(ClientInterface $client) + { + $fields = [ + new TextField('description'), + new NumericField('price'), + new TagField('color'), + new TagField('item_type'), + new NumericField('size'), + new VectorField('embedding', 'FLAT', + ['TYPE', 'FLOAT32', 'DIM', 4, 'DISTANCE_METRIC', 'L2'] + ), + new VectorField('embedding-hnsw', 'HNSW', + ['TYPE', 'FLOAT32', 'DIM', 4, 'DISTANCE_METRIC', 'L2'] + ), + ]; + + $arguments = new CreateArguments(); + $arguments->prefix(['item:']); + + $this->assertEquals('OK', $client->ftcreate('idx', $fields, $arguments)); + sleep(0.1); + } + + protected function generateData(ClientInterface $client, int $itemSets = 1) + { + $items = [ + [[1.0, 2.0, 7.0, 8.0], 'red shoes'], + [[1.0, 4.0, 7.0, 8.0], 'green shoes with red laces'], + [[1.0, 2.0, 6.0, 5.0], 'red dress'], + [[2.0, 3.0, 6.0, 5.0], 'orange dress'], + [[5.0, 6.0, 7.0, 8.0], 'black shoes'], + ]; + $mergedItems = []; + + for ($i = 0; $i < $itemSets; ++$i) { + $mergedItems = array_merge($mergedItems, $items); + } + + $client->pipeline(function (ClientContextInterface $pipe) use ($mergedItems) { + for ($i = 0; $i < count($mergedItems); ++$i) { + [$vec, $description] = $mergedItems[$i]; + + $pipe->hmset("item:{$i}", [ + 'description' => $description, + 'embedding' => VectorUtility::toBlob($vec), + 'embedding-hnsw' => VectorUtility::toBlob($vec), + 'price' => 15 + $i % 4, + 'color' => explode(' ', $description)[0], + 'item_type' => explode(' ', $description)[1], + 'size' => 10 + $i % 3, + ]); + } + }); + } +} diff --git a/tests/Predis/Command/Utils/CommandUtilityTest.php b/tests/Predis/Command/Utils/CommandUtilityTest.php index 858b7653..11fa6688 100644 --- a/tests/Predis/Command/Utils/CommandUtilityTest.php +++ b/tests/Predis/Command/Utils/CommandUtilityTest.php @@ -42,6 +42,23 @@ class CommandUtilityTest extends PredisTestCase CommandUtility::arrayToDictionary(['key1', 'value1', 'key1']); } + /** + * @return void + */ + public function testDictionaryToArray(): void + { + $dict = [ + 'key1' => 'value1', + 'key2' => 'value2', + 'key3' => 'value3', + ]; + + $this->assertSame( + ['key1', 'value1', 'key2', 'value2', 'key3', 'value3'], + CommandUtility::dictionaryToArray($dict) + ); + } + public function arrayProvider(): array { return [