├── .github └── workflows │ └── tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml.dist ├── src ├── Enum │ ├── Condition.php │ ├── Logical.php │ ├── SearchField.php │ ├── StorageType.php │ ├── Traits │ │ └── EnumNames.php │ └── Unit.php ├── Factory.php ├── FactoryInterface.php ├── Index │ ├── IndexInterface.php │ └── SearchIndex.php ├── Query │ ├── AbstractQuery.php │ ├── Filter │ │ ├── AbstractFilter.php │ │ ├── AggregateFilter.php │ │ ├── AggregateFilterInterface.php │ │ ├── FilterInterface.php │ │ ├── GeoFilter.php │ │ ├── NumericFilter.php │ │ ├── TagFilter.php │ │ └── TextFilter.php │ ├── QueryInterface.php │ └── VectorQuery.php ├── VectorHelper.php └── Vectorizer │ ├── Factory.php │ ├── FactoryInterface.php │ ├── OpenAIVectorizer.php │ └── VectorizerInterface.php └── tests ├── Feature ├── FeatureTestCase.php └── Index │ └── SearchIndexTest.php └── Unit ├── Enum └── SearchFieldTest.php ├── FactoryTest.php ├── Index └── SearchIndexTest.php ├── Query ├── Filter │ ├── AbstractFilter.php │ ├── AggregateFilterTest.php │ ├── GeoFilterTest.php │ ├── NumericFilterTest.php │ ├── TagFilterTest.php │ └── TextFilterTest.php └── VectorQueryTest.php ├── VectorHelperTest.php └── Vectorizer ├── FactoryTest.php └── OpenAIVectorizerTest.php /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | unit_tests: 15 | name: Unit tests 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v3 22 | 23 | - name: Setup PHP with Composer and extensions 24 | uses: shivammathur/setup-php@v2 25 | with: 26 | php-version: '8.1' 27 | coverage: xdebug 28 | 29 | - name: Install Composer dependencies 30 | uses: ramsey/composer-install@v2 31 | with: 32 | dependency-versions: highest 33 | 34 | - name: Run unit tests 35 | run: vendor/bin/phpunit -c phpunit.xml.dist --testsuite Unit --coverage-clover build/logs/clover.xml --coverage-filter ./src 36 | 37 | - name: Upload codecov coverage 38 | uses: codecov/codecov-action@v3 39 | with: 40 | fail_ci_if_error: false 41 | files: build/logs/clover.xml 42 | verbose: true 43 | env: 44 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 45 | 46 | feature_tests: 47 | name: Feature tests 48 | runs-on: ubuntu-latest 49 | 50 | services: 51 | redis: 52 | image: redis/redis-stack-server:${{ matrix.redis_stack }} 53 | options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3 54 | ports: 55 | - 6379:6379 56 | 57 | strategy: 58 | fail-fast: false 59 | matrix: 60 | redis_stack: 61 | - latest 62 | - edge 63 | 64 | steps: 65 | - name: Checkout repository 66 | uses: actions/checkout@v3 67 | 68 | - name: Setup PHP with Composer and extensions 69 | uses: shivammathur/setup-php@v2 70 | with: 71 | php-version: '8.1' 72 | coverage: xdebug 73 | 74 | - name: Install Composer dependencies 75 | uses: ramsey/composer-install@v2 76 | with: 77 | dependency-versions: highest 78 | 79 | - name: Run feature tests 80 | run: vendor/bin/phpunit -c phpunit.xml.dist --testsuite Feature 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | .idea/ 3 | vendor/ 4 | .phpunit.result.cache -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Vladyslav Vildanov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Introduction ## 2 | 3 | The Redis Vector Library (RedisVL) is a PHP client for AI applications leveraging Redis. 4 | 5 | Designed for: 6 | - Vector similarity search 7 | - Recommendation engine 8 | 9 | A perfect tool for Redis-based applications, incorporating capabilities like vector-based semantic search, 10 | full-text search, and geo-spatial search. 11 | 12 | ## Getting started ## 13 | 14 | ### Installation ### 15 | ```shell 16 | composer install redis-ventures/redisvl 17 | ``` 18 | 19 | ### Setting up Redis #### 20 | 21 | Choose from multiple Redis deployment options: 22 | 1. [Redis Cloud](https://redis.com/try-free/): Managed cloud database (free tier available) 23 | 2. [Redis Stack](https://redis.io/docs/install/install-stack/docker/): Docker image for development 24 | ```shell 25 | docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest 26 | ``` 27 | 3. [Redis Enterprise](https://redis.com/redis-enterprise/advantages/): Commercial, self-hosted database 28 | 29 | ## What's included? ## 30 | 31 | ### Redis index management ### 32 | 33 | 1. Design your schema that models your dataset with one of the available Redis data structures (HASH, JSON) 34 | and indexable fields (e.g. text, tags, numerics, geo, and vectors). 35 | 36 | Load schema as a dictionary: 37 | ```php 38 | $schema = [ 39 | 'index' => [ 40 | 'name' => 'products', 41 | 'prefix' => 'product:', 42 | 'storage_type' => 'hash', 43 | ], 44 | 'fields' => [ 45 | 'id' => [ 46 | 'type' => 'numeric', 47 | ], 48 | 'categories' => [ 49 | 'type' => 'tag', 50 | ], 51 | 'description' => [ 52 | 'type' => 'text', 53 | ], 54 | 'description_embedding' => [ 55 | 'type' => 'vector', 56 | 'dims' => 3, 57 | 'datatype' => 'float32', 58 | 'algorithm' => 'flat', 59 | 'distance_metric' => 'cosine' 60 | ], 61 | ], 62 | ]; 63 | ``` 64 | 2. Create a SearchIndex object with an input schema and client connection to be able to interact with your Redis index 65 | ```php 66 | use Predis\Client; 67 | use RedisVentures\RedisVl\Index\SearchIndex; 68 | 69 | $client = new Client(); 70 | $index = new SearchIndex($client, $schema); 71 | 72 | // Creates index in the Redis 73 | $index->create(); 74 | ``` 75 | 3. Load/fetch your data from index. If you have a hash index data should be loaded as key-value pairs 76 | , for json type data loads as json string. 77 | ```php 78 | $data = ['id' => '1', 'count' => 10, 'id_embeddings' => VectorHelper::toBytes([0.000001, 0.000002, 0.000003])]; 79 | 80 | // Loads given dataset associated with given key. 81 | $index->load('key', $data); 82 | 83 | // Fetch dataset corresponding to given key 84 | $index->fetch('key'); 85 | ``` 86 | 87 | ### Realtime search ### 88 | 89 | Define queries and perform advanced search over your indices, including combination of vectors and variety of filters. 90 | 91 | **VectorQuery** - flexible vector-similarity semantic search with customizable filters 92 | ```php 93 | use RedisVentures\RedisVl\Query\VectorQuery; 94 | 95 | $query = new VectorQuery( 96 | [0.001, 0.002, 0.03], 97 | 'description_embedding', 98 | null, 99 | 3 100 | ); 101 | 102 | // Run vector search against vector field specified in schema. 103 | $results = $index->query($query); 104 | ``` 105 | 106 | Incorporate complex metadata filters on your queries: 107 | ```php 108 | use RedisVentures\RedisVl\Query\Filter\TagFilter; 109 | use RedisVentures\RedisVl\Enum\Condition; 110 | 111 | $filter = new TagFilter( 112 | 'categories', 113 | Condition::equal, 114 | 'foo' 115 | ); 116 | 117 | $query = new VectorQuery( 118 | [0.001, 0.002, 0.03], 119 | 'description_embedding', 120 | null, 121 | 10, 122 | true, 123 | 2, 124 | $filter 125 | ); 126 | 127 | // Results will be filtered by tag field values. 128 | $results = $index->query($query); 129 | ``` 130 | 131 | ### Filter types ### 132 | 133 | #### Numeric #### 134 | 135 | Numeric filters could be applied to numeric fields. 136 | Supports variety of conditions applicable for scalar types (==, !=, <, >, <=, >=). 137 | More information [here](https://redis.io/docs/interact/search-and-query/query/range/). 138 | ```php 139 | use RedisVentures\RedisVl\Query\Filter\NumericFilter; 140 | use RedisVentures\RedisVl\Enum\Condition; 141 | 142 | $equal = new NumericFilter('numeric', Condition::equal, 10); 143 | $notEqual = new NumericFilter('numeric', Condition::notEqual, 10); 144 | $greaterThan = new NumericFilter('numeric', Condition::greaterThan, 10); 145 | $greaterThanOrEqual = new NumericFilter('numeric', Condition::greaterThanOrEqual, 10); 146 | $lowerThan = new NumericFilter('numeric', Condition::lowerThan, 10); 147 | $lowerThanOrEqual = new NumericFilter('numeric', Condition::lowerThanOrEqual, 10); 148 | ``` 149 | 150 | #### Tag #### 151 | 152 | Tag filters could be applied to tag fields. Single or multiple values can be provided, single values supports only 153 | equality conditions (==, !==), for multiple tags additional conjunction (AND, OR) could be specified. 154 | More information [here](https://redis.io/docs/interact/search-and-query/advanced-concepts/tags/) 155 | ```php 156 | use RedisVentures\RedisVl\Query\Filter\TagFilter; 157 | use RedisVentures\RedisVl\Enum\Condition; 158 | use RedisVentures\RedisVl\Enum\Logical; 159 | 160 | $singleTag = new TagFilter('tag', Condition::equal, 'value') 161 | $multipleTags = new TagFilter('tag', Condition::notEqual, [ 162 | 'conjunction' => Logical::or, 163 | 'tags' => ['value1', 'value2'] 164 | ]) 165 | ``` 166 | 167 | #### Text #### 168 | 169 | Text filters could be applied to text fields. Values can be provided as a single word or multiple words with 170 | specified condition. Empty value corresponds to all values (*). 171 | More information [here](https://redis.io/docs/interact/search-and-query/query/full-text/) 172 | ```php 173 | use RedisVentures\RedisVl\Query\Filter\TextFilter; 174 | use RedisVentures\RedisVl\Enum\Condition; 175 | 176 | $single = new TextFilter('text', Condition::equal, 'foo'); 177 | 178 | // Matching foo AND bar 179 | $multipleAnd = new TextFilter('text', Condition::equal, 'foo bar'); 180 | 181 | // Matching foo OR bar 182 | $multipleOr = new TextFilter('text', Condition::equal, 'foo|bar'); 183 | 184 | // Perform fuzzy search 185 | $fuzzy = new TextFilter('text', Condition::equal, '%foobaz%'); 186 | ``` 187 | 188 | #### Geo #### 189 | 190 | Geo filters could be applied to geo fields. Supports only equality conditions, 191 | value should be specified as specific-shape array. 192 | More information [here](https://redis.io/docs/interact/search-and-query/query/geo-spatial/) 193 | ```php 194 | use RedisVentures\RedisVl\Query\Filter\GeoFilter; 195 | use RedisVentures\RedisVl\Enum\Condition; 196 | use RedisVentures\RedisVl\Enum\Unit; 197 | 198 | $geo = new GeoFilter('geo', Condition::equal, [ 199 | 'lon' => 10.111, 200 | 'lat' => 11.111, 201 | 'radius' => 100, 202 | 'unit' => Unit::kilometers 203 | ]); 204 | ``` 205 | 206 | #### Aggregate #### 207 | 208 | To apply multiple filters to a single query use AggregateFilter. 209 | If there's the same logical operator that should be applied for each filter you can pass values in constructor, 210 | if you need a specific combination use `and()` and `or()` methods to create combined filter. 211 | ```php 212 | use RedisVentures\RedisVl\Query\Filter\AggregateFilter; 213 | use RedisVentures\RedisVl\Query\Filter\TextFilter; 214 | use RedisVentures\RedisVl\Query\Filter\NumericFilter; 215 | use RedisVentures\RedisVl\Enum\Condition; 216 | use RedisVentures\RedisVl\Enum\Logical; 217 | 218 | $aggregate = new AggregateFilter([ 219 | new TextFilter('text', Condition::equal, 'value'), 220 | new NumericFilter('numeric', Condition::greaterThan, 10) 221 | ], Logical::or); 222 | 223 | $combinedAggregate = new AggregateFilter(); 224 | $combinedAggregate 225 | ->and( 226 | new TextFilter('text', Condition::equal, 'value'), 227 | new NumericFilter('numeric', Condition::greaterThan, 10) 228 | )->or( 229 | new NumericFilter('numeric', Condition::lowerThan, 100) 230 | ); 231 | ``` 232 | 233 | ## Vectorizers ## 234 | 235 | To be able to effectively create vector representations for your indexed data or queries, you have to use 236 | [LLM's](https://en.wikipedia.org/wiki/Large_language_model). There's a variety of vectorizers that provide integration 237 | with popular embedding models. 238 | 239 | The only required option is your API key specified as environment variable or configuration option. 240 | 241 | ### OpenAI ### 242 | ```php 243 | use RedisVentures\RedisVl\Vectorizer\Factory; 244 | 245 | putenv('OPENAI_API_TOKEN=your_token'); 246 | 247 | $factory = new Factory(); 248 | $vectorizer = $factory->createVectorizer('openai'); 249 | 250 | // Creates vector representation of given text. 251 | $embedding = $vectorizer->embed('your_text') 252 | 253 | // Creates a single vector representation from multiple chunks. 254 | $mergedEmbedding = $vectorizer->batchEmbed(['first_chunk', 'second_chunk']); 255 | ``` 256 | 257 | ### VectorHelper ### 258 | 259 | When you perform vector queries against Redis or load hash data into index that contains vector field data, 260 | your vector should be represented as a blob string. VectorHelper allows you to create 261 | blob representation from your vector represented as array of floats. 262 | ```php 263 | use RedisVentures\RedisVl\VectorHelper; 264 | 265 | $blobVector = VectorHelper::toBytes([0.001, 0.002, 0.003]); 266 | ``` -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redis-ventures/redisvl", 3 | "description": "Redis Vector Library (RedisVL) enables Redis as a real-time database for LLM applications, based on Predis PHP client", 4 | "type": "library", 5 | "license": "MIT", 6 | "require": { 7 | "php" : "^8.1", 8 | "predis/predis": "^2.2.0", 9 | "guzzlehttp/guzzle": "*" 10 | }, 11 | "require-dev": { 12 | "phpunit/phpunit": "*", 13 | "mockery/mockery": "^1.6" 14 | }, 15 | "autoload": { 16 | "psr-4": { 17 | "RedisVentures\\RedisVl\\": "src/" 18 | } 19 | }, 20 | "autoload-dev": { 21 | "psr-4": { 22 | "RedisVentures\\RedisVl\\": "tests/" 23 | } 24 | }, 25 | "authors": [ 26 | { 27 | "name": "vladvildanov", 28 | "email": "divinez122@outlook.com" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | tests/Unit/ 12 | 13 | 14 | tests/Feature/ 15 | 16 | 17 | 18 | 19 | 20 | ./src 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/Enum/Condition.php: -------------------------------------------------------------------------------- 1 | '; 14 | case greaterThanOrEqual = '>='; 15 | case lowerThan = '<'; 16 | case lowerThanOrEqual = '<='; 17 | case pattern = '%'; 18 | case between = 'between'; 19 | } 20 | -------------------------------------------------------------------------------- /src/Enum/Logical.php: -------------------------------------------------------------------------------- 1 | TagField::class, 31 | self::text => TextField::class, 32 | self::numeric => NumericField::class, 33 | self::vector => VectorField::class, 34 | self::geo => GeoField::class, 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Enum/StorageType.php: -------------------------------------------------------------------------------- 1 | $enum->name, static::cases()); 13 | } 14 | 15 | /** 16 | * @param string $name 17 | * @return self 18 | */ 19 | public static function fromName(string $name): self 20 | { 21 | return constant("self::$name"); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Enum/Unit.php: -------------------------------------------------------------------------------- 1 | validateSchema($schema); 30 | $this->factory = $factory ?? new Factory(); 31 | } 32 | 33 | /** 34 | * @inheritDoc 35 | */ 36 | public function getSchema(): array 37 | { 38 | return $this->schema; 39 | } 40 | 41 | /** 42 | * @inheritDoc 43 | */ 44 | public function create(bool $isOverwrite = false): bool 45 | { 46 | if ($isOverwrite) { 47 | try { 48 | $this->client->ftdropindex($this->schema['index']['name']); 49 | } catch (ServerException $exception) { 50 | // Do nothing on exception, there's no way to check if index already exists. 51 | } 52 | } 53 | 54 | $createArguments = $this->factory->createIndexBuilder(); 55 | 56 | if (array_key_exists('storage_type', $this->schema['index'])) { 57 | $createArguments = $createArguments->on( 58 | StorageType::from(strtoupper($this->schema['index']['storage_type']))->value 59 | ); 60 | } else { 61 | $createArguments = $createArguments->on(); 62 | } 63 | 64 | if (array_key_exists('prefix', $this->schema['index'])) { 65 | $createArguments = $createArguments->prefix([$this->schema['index']['prefix']]); 66 | } 67 | 68 | $schema = []; 69 | 70 | foreach ($this->schema['fields'] as $fieldName => $fieldData) { 71 | $fieldEnum = SearchField::fromName($fieldData['type']); 72 | 73 | if (array_key_exists('alias', $fieldData)) { 74 | $alias = $fieldData['alias']; 75 | } else { 76 | $alias = ''; 77 | } 78 | 79 | if ($fieldEnum === SearchField::vector) { 80 | $schema[] = $this->createVectorField($fieldName, $alias, $fieldData); 81 | } else { 82 | $fieldClass = $fieldEnum->fieldMapping(); 83 | $schema[] = new $fieldClass($fieldName, $alias); 84 | } 85 | } 86 | 87 | $response = $this->client->ftcreate($this->schema['index']['name'], $schema, $createArguments); 88 | 89 | return $response == 'OK'; 90 | } 91 | 92 | /** 93 | * Loads data into current index. 94 | * Accepts array for hashes and string for JSON type. 95 | */ 96 | public function load(string $key, mixed $values): bool 97 | { 98 | $key = (array_key_exists('prefix', $this->schema['index'])) 99 | ? $this->schema['index']['prefix'] . $key 100 | : $key; 101 | 102 | if (is_string($values)) { 103 | $response = $this->client->jsonset($key, '$', $values); 104 | } elseif (is_array($values)) { 105 | $response = $this->client->hmset($key, $values); 106 | } 107 | 108 | return $response == 'OK'; 109 | } 110 | 111 | /** 112 | * @inheritDoc 113 | */ 114 | public function fetch(string $id): mixed 115 | { 116 | $key = (array_key_exists('prefix', $this->schema['index'])) 117 | ? $this->schema['index']['prefix'] . $id 118 | : $id; 119 | 120 | if ( 121 | array_key_exists('storage_type', $this->schema['index']) 122 | && StorageType::from(strtoupper($this->schema['index']['storage_type'])) === StorageType::json 123 | ) { 124 | return $this->client->jsonget($key); 125 | } 126 | 127 | return $this->client->hgetall($key); 128 | } 129 | 130 | /** 131 | * @inheritDoc 132 | */ 133 | public function query(QueryInterface $query) 134 | { 135 | $response = $this->client->ftsearch( 136 | $this->schema['index']['name'], 137 | $query->getQueryString(), 138 | $query->getSearchArguments() 139 | ); 140 | 141 | $processedResponse = ['count' => $response[0]]; 142 | $withScores = in_array('WITHSCORES', $query->getSearchArguments()->toArray(), true); 143 | 144 | if (count($response) > 1) { 145 | for ($i = 1, $iMax = count($response); $i < $iMax; $i++) { 146 | $processedResponse['results'][$response[$i]] = []; 147 | 148 | // Different return type depends on WITHSCORE condition 149 | if ($withScores) { 150 | $processedResponse['results'][$response[$i]]['score'] = $response[$i + 1]; 151 | $step = 2; 152 | } else { 153 | $step = 1; 154 | } 155 | 156 | for ($j = 0, $jMax = count($response[$i + $step]); $j < $jMax; $j++) { 157 | $processedResponse['results'][$response[$i]][$response[$i + $step][$j]] = $response[$i + $step][$j + 1]; 158 | ++$j; 159 | } 160 | 161 | $i += $step; 162 | } 163 | } 164 | 165 | return $processedResponse; 166 | } 167 | 168 | /** 169 | * Validates schema array. 170 | * 171 | * @param array $schema 172 | * @return void 173 | * @throws Exception 174 | */ 175 | protected function validateSchema(array $schema): void 176 | { 177 | if (!array_key_exists('index', $schema)) { 178 | throw new Exception("Schema should contains 'index' entry."); 179 | } 180 | 181 | if (!array_key_exists('name', $schema['index'])) { 182 | throw new Exception("Index name is required."); 183 | } 184 | 185 | if ( 186 | array_key_exists('storage_type', $schema['index']) && 187 | null === StorageType::tryFrom(strtoupper($schema['index']['storage_type'])) 188 | ) { 189 | throw new Exception('Invalid storage type value.'); 190 | } 191 | 192 | if (!array_key_exists('fields', $schema)) { 193 | throw new Exception('Schema should contains at least one field.'); 194 | } 195 | 196 | foreach ($schema['fields'] as $fieldData) { 197 | if (!array_key_exists('type', $fieldData)) { 198 | throw new Exception('Field type should be specified for each field.'); 199 | } 200 | 201 | if (!in_array($fieldData['type'], SearchField::names(), true)) { 202 | throw new Exception('Invalid field type.'); 203 | } 204 | } 205 | 206 | $this->schema = $schema; 207 | } 208 | 209 | /** 210 | * Creates a Vector field from given configuration. 211 | * 212 | * @param string $fieldName 213 | * @param string $alias 214 | * @param array $fieldData 215 | * @return VectorField 216 | * @throws Exception 217 | */ 218 | protected function createVectorField(string $fieldName, string $alias, array $fieldData): VectorField 219 | { 220 | $mandatoryKeys = ['datatype', 'dims', 'distance_metric', 'algorithm']; 221 | $intersections = array_intersect($mandatoryKeys, array_keys($fieldData)); 222 | 223 | if (count($intersections) !== count($mandatoryKeys)) { 224 | throw new Exception("datatype, dims, distance_metric and algorithm are mandatory parameters for vector field."); 225 | } 226 | 227 | return new VectorField( 228 | $fieldName, 229 | strtoupper($fieldData['algorithm']), 230 | [ 231 | 'TYPE', strtoupper($fieldData['datatype']), 232 | 'DIM', strtoupper($fieldData['dims']), 233 | 'DISTANCE_METRIC', strtoupper($fieldData['distance_metric']) 234 | ], 235 | $alias 236 | ); 237 | } 238 | } -------------------------------------------------------------------------------- /src/Query/AbstractQuery.php: -------------------------------------------------------------------------------- 1 | factory = $factory ?? new Factory(); 25 | } 26 | 27 | /** 28 | * @inheritDoc 29 | */ 30 | public function getFilter(): FilterInterface 31 | { 32 | return $this->filter; 33 | } 34 | 35 | /** 36 | * @inheritDoc 37 | */ 38 | abstract public function getSearchArguments(): SearchArguments; 39 | 40 | /** 41 | * @inheritDoc 42 | */ 43 | abstract public function getQueryString(): string; 44 | 45 | /** 46 | * @inheritDoc 47 | */ 48 | public function setPagination(int $first, int $limit): void 49 | { 50 | $this->pagination['first'] = $first; 51 | $this->pagination['limit'] = $limit; 52 | } 53 | } -------------------------------------------------------------------------------- /src/Query/Filter/AbstractFilter.php: -------------------------------------------------------------------------------- 1 | '', 16 | '!=' => '-' 17 | ]; 18 | 19 | /** 20 | * @inheritDoc 21 | */ 22 | abstract public function toExpression(): string; 23 | } -------------------------------------------------------------------------------- /src/Query/Filter/AggregateFilter.php: -------------------------------------------------------------------------------- 1 | ' ', 22 | '||' => ' | ', 23 | ]; 24 | 25 | /** 26 | * Creates an aggregated filter based on the set of given filters. 27 | * 28 | * If the same logical operator should be applied to all filters, you could pass them here in constructor. 29 | * If you need a combination of logical operators use and() or() methods to build suitable aggregate filter. 30 | * 31 | * @param FilterInterface $filters 32 | * @param Logical $conjunction 33 | */ 34 | public function __construct($filters = [], Logical $conjunction = Logical::and) 35 | { 36 | if (!empty($filters)) { 37 | $this->addFilters($filters, $conjunction); 38 | } 39 | } 40 | 41 | /** 42 | * @inheritDoc 43 | */ 44 | public function and(FilterInterface ...$filter): AggregateFilterInterface 45 | { 46 | $this->addFilters(func_get_args(), Logical::and); 47 | 48 | return $this; 49 | } 50 | 51 | /** 52 | * @inheritDoc 53 | */ 54 | public function or(FilterInterface ...$filter): AggregateFilterInterface 55 | { 56 | $this->addFilters(func_get_args(), Logical::or); 57 | 58 | return $this; 59 | } 60 | 61 | /** 62 | * @inheritDoc 63 | */ 64 | public function toExpression(): string 65 | { 66 | return trim(implode('', $this->filters), ' |'); 67 | } 68 | 69 | /** 70 | * Add filters with given conjunction into array. 71 | * 72 | * @param FilterInterface[] $filters 73 | * @param Logical $conjunction 74 | * @return void 75 | */ 76 | private function addFilters(array $filters, Logical $conjunction): void 77 | { 78 | if (count($filters) === 1) { 79 | $this->filters[] = $this->conjunctionMapping[$conjunction->value]; 80 | $this->filters[] = $filters[0]->toExpression(); 81 | } else { 82 | if (!empty($this->filters)) { 83 | $this->filters[] = ' '; 84 | } 85 | 86 | foreach ($filters as $i => $filter) { 87 | $this->filters[] = $filter->toExpression(); 88 | 89 | if ($i !== count($filters) - 1) { 90 | $this->filters[] = $this->conjunctionMapping[$conjunction->value]; 91 | } 92 | } 93 | } 94 | } 95 | } -------------------------------------------------------------------------------- /src/Query/Filter/AggregateFilterInterface.php: -------------------------------------------------------------------------------- 1 | 'float', 26 | 'lat' => 'float', 27 | 'radius' => 'int', 28 | 'unit' => Unit::class 29 | ])] protected readonly array $value 30 | ) { 31 | } 32 | 33 | /** 34 | * @inheritDoc 35 | */ 36 | public function toExpression(): string 37 | { 38 | $condition = $this->conditionMappings[$this->condition->value]; 39 | 40 | $lon = (float) $this->value['lon']; 41 | $lat = (float) $this->value['lat']; 42 | 43 | return "$condition@$this->fieldName:[{$lon} {$lat} {$this->value['radius']} {$this->value['unit']->value}]"; 44 | } 45 | } -------------------------------------------------------------------------------- /src/Query/Filter/NumericFilter.php: -------------------------------------------------------------------------------- 1 | condition === Condition::equal || $this->condition === Condition::notEqual) { 28 | $condition = $this->conditionMappings[$this->condition->value]; 29 | return "$condition@$this->fieldName:[$this->value $this->value]"; 30 | } 31 | 32 | if ($this->condition === Condition::greaterThan) { 33 | return "@$this->fieldName:[($this->value +inf]"; 34 | } 35 | 36 | if ($this->condition === Condition::greaterThanOrEqual) { 37 | return "@$this->fieldName:[$this->value +inf]"; 38 | } 39 | 40 | if ($this->condition === Condition::lowerThan) { 41 | return "@$this->fieldName:[-inf ($this->value]"; 42 | } 43 | 44 | if ($this->condition === Condition::lowerThanOrEqual) { 45 | return "@$this->fieldName:[-inf $this->value]"; 46 | } 47 | 48 | if ($this->condition === Condition::between && !is_array($this->value)) { 49 | throw new Exception('Between condition requires int[] as $ value'); 50 | } 51 | 52 | return "@$this->fieldName:[{$this->value[0]} {$this->value[1]}]"; 53 | } 54 | } -------------------------------------------------------------------------------- /src/Query/Filter/TagFilter.php: -------------------------------------------------------------------------------- 1 | Logical::class, 24 | 'tags' => 'array', 25 | ])] protected mixed $value 26 | ) { 27 | } 28 | 29 | /** 30 | * @inheritDoc 31 | */ 32 | public function toExpression(): string 33 | { 34 | $condition = $this->conditionMappings[$this->condition->value]; 35 | 36 | if (is_string($this->value)) { 37 | return "{$condition}@{$this->fieldName}:{{$this->value}}"; 38 | } 39 | 40 | if ($this->value['conjunction'] === Logical::or) { 41 | $query = "{$condition}@{$this->fieldName}:{"; 42 | 43 | foreach ($this->value['tags'] as $tag) { 44 | $query .= $tag . " | "; 45 | } 46 | 47 | return trim($query, '| ') . '}'; 48 | } 49 | 50 | $query = ''; 51 | 52 | foreach ($this->value['tags'] as $tag) { 53 | $query .= "{$condition}@{$this->fieldName}:{{$tag}} "; 54 | } 55 | 56 | return trim($query); 57 | } 58 | } -------------------------------------------------------------------------------- /src/Query/Filter/TextFilter.php: -------------------------------------------------------------------------------- 1 | value)) { 34 | return '*'; 35 | } 36 | 37 | if ($this->condition === Condition::equal || $this->condition === Condition::notEqual) { 38 | $condition = $this->conditionMappings[$this->condition->value]; 39 | 40 | return "$condition@$this->fieldName:($this->value)"; 41 | } 42 | 43 | return "@$this->fieldName:(w'$this->value')"; 44 | } 45 | } -------------------------------------------------------------------------------- /src/Query/QueryInterface.php: -------------------------------------------------------------------------------- 1 | filter instanceof FilterInterface) { 33 | $filter = $this->filter->toExpression(); 34 | $query .= "($filter)=>"; 35 | } else { 36 | $query .= '(*)=>'; 37 | } 38 | 39 | $query .= "[KNN $this->resultsCount @$this->vectorFieldName" . ' $vector' . ' AS vector_score]'; 40 | 41 | return $query; 42 | } 43 | 44 | public function getSearchArguments(): SearchArguments 45 | { 46 | $searchArguments = $this->factory->createSearchBuilder(); 47 | 48 | $searchArguments = $searchArguments 49 | ->params(['vector', VectorHelper::toBytes($this->vector)]) 50 | ->sortBy('vector_score') 51 | ->dialect((string) $this->dialect); 52 | 53 | if (!empty($this->returnFields)) { 54 | $searchArguments = $searchArguments->addReturn(count($this->returnFields), ...$this->returnFields); 55 | } 56 | 57 | if ($this->returnScore) { 58 | $searchArguments = $searchArguments->withScores(); 59 | } 60 | 61 | if (!empty($this->pagination)) { 62 | $searchArguments = $searchArguments->limit($this->pagination['first'], $this->pagination['limit']); 63 | } 64 | 65 | return $searchArguments; 66 | } 67 | } -------------------------------------------------------------------------------- /src/VectorHelper.php: -------------------------------------------------------------------------------- 1 | OpenAIVectorizer::class, 14 | ]; 15 | 16 | /** 17 | * Allows to provide additional class mappings for custom vectorizers. 18 | * 19 | * Example: ['foo' => Foo::class, 'bar' => Bar::class] 20 | * 21 | * @param array $additionalMappings 22 | */ 23 | public function __construct(array $additionalMappings = []) 24 | { 25 | if (!empty($additionalMappings)) { 26 | $this->classMappings = array_merge($this->classMappings, $additionalMappings); 27 | } 28 | } 29 | 30 | /** 31 | * @inheritDoc 32 | */ 33 | public function createVectorizer(string $vectorizer, string $model = null, array $configuration = []): VectorizerInterface 34 | { 35 | if (!array_key_exists(strtolower($vectorizer), $this->classMappings)) { 36 | throw new Exception('Given vectorizer does not exists.'); 37 | } 38 | 39 | $class = $this->classMappings[strtolower($vectorizer)]; 40 | 41 | return new $class($model, $configuration); 42 | } 43 | } -------------------------------------------------------------------------------- /src/Vectorizer/FactoryInterface.php: -------------------------------------------------------------------------------- 1 | $token, 'requestParams' => [...]] 30 | * 31 | * @link https://platform.openai.com/docs/api-reference/embeddings/create 32 | * @param string|null $model 33 | * @param array $apiConfiguration 34 | * @param Client|null $client 35 | */ 36 | public function __construct( 37 | string $model = null, 38 | private array $apiConfiguration = [], 39 | Client $client = null 40 | ) { 41 | $this->client = $client ?? new Client(); 42 | $this->model = $model ?? 'text-embedding-ada-002'; 43 | $this->apiUrl = getenv('OPENAI_API_URL') ?: 'https://api.openai.com/v1/embeddings'; 44 | } 45 | 46 | /** 47 | * @inheritDoc 48 | */ 49 | public function embed(string $text): array 50 | { 51 | $jsonResponse = $this->sendRequest( 52 | [ 53 | 'model' => $this->model, 54 | 'input' => $text, 55 | ] 56 | )->getBody()->getContents(); 57 | 58 | return json_decode($jsonResponse, true, 512, JSON_THROW_ON_ERROR); 59 | } 60 | 61 | /** 62 | * @inheritDoc 63 | */ 64 | public function batchEmbed(array $texts): array 65 | { 66 | $jsonResponse = $this->sendRequest( 67 | [ 68 | 'model' => $this->model, 69 | 'input' => $texts, 70 | ] 71 | )->getBody()->getContents(); 72 | 73 | return json_decode($jsonResponse, true, 512, JSON_THROW_ON_ERROR); 74 | } 75 | 76 | /** 77 | * @inheritDoc 78 | */ 79 | public function getModel(): string 80 | { 81 | return $this->model; 82 | } 83 | 84 | /** 85 | * @inheritDoc 86 | */ 87 | public function getConfiguration(): array 88 | { 89 | return $this->apiConfiguration; 90 | } 91 | 92 | /** 93 | * Returns API token associated with current vectorizer. 94 | * 95 | * @return string 96 | * @throws Exception 97 | */ 98 | private function getApiToken(): string 99 | { 100 | if (array_key_exists('token', $this->apiConfiguration)) { 101 | return $this->apiConfiguration['token']; 102 | } 103 | 104 | if (false !== $token = getenv('OPENAI_API_TOKEN')) { 105 | return $token; 106 | } 107 | 108 | throw new Exception( 109 | 'API token should be provided in API configuration or as an environment variable.' 110 | ); 111 | } 112 | 113 | /** 114 | * Sends an actual request to API endpoint. 115 | * 116 | * @param array $requestBody 117 | * @return ResponseInterface 118 | * @throws Exception|\GuzzleHttp\Exception\GuzzleException 119 | */ 120 | private function sendRequest(array $requestBody): ResponseInterface 121 | { 122 | $requestParams = (array_key_exists('requestParams', $this->apiConfiguration)) 123 | ? $this->apiConfiguration['requestParams'] 124 | : []; 125 | $requestBody = array_merge($requestBody, $requestParams); 126 | 127 | return $this->client->post($this->apiUrl, [ 128 | 'headers' => [ 129 | 'Authorization' => 'Bearer ' . $this->getApiToken(), 130 | 'Content-Type' => 'application/json', 131 | 'Accept' => 'application/json', 132 | ], 133 | 'json' => $requestBody 134 | ]); 135 | } 136 | } -------------------------------------------------------------------------------- /src/Vectorizer/VectorizerInterface.php: -------------------------------------------------------------------------------- 1 | 'tcp', 19 | 'host' => constant('REDIS_SERVER_HOST'), 20 | 'port' => constant('REDIS_SERVER_PORT'), 21 | 'database' => constant('REDIS_SERVER_DBNUM'), 22 | ]; 23 | } 24 | 25 | /** 26 | * Creates Redis client with default or given configuration. 27 | * 28 | * @param array|null $parameters 29 | * @param array|null $options 30 | * @param bool|null $flushDB 31 | * @return Client 32 | */ 33 | protected function getClient(?array $parameters = null, ?array $options = null, ?bool $flushDB = true): Client 34 | { 35 | $parameters = array_merge( 36 | $this->getDefaultParametersArray(), 37 | $parameters ?: [] 38 | ); 39 | 40 | $client = new Client($parameters, $options); 41 | 42 | if ($flushDB) { 43 | $client->flushdb(); 44 | } 45 | 46 | return $client; 47 | } 48 | } -------------------------------------------------------------------------------- /tests/Feature/Index/SearchIndexTest.php: -------------------------------------------------------------------------------- 1 | client = $this->getClient(); 40 | $this->hashSchema = [ 41 | 'index' => [ 42 | 'name' => 'foobar', 43 | 'prefix' => 'foo:', 44 | ], 45 | 'fields' => [ 46 | 'id' => [ 47 | 'type' => 'text', 48 | 'alias' => 'foo', 49 | ], 50 | 'count' => [ 51 | 'type' => 'numeric', 52 | ], 53 | 'id_embeddings' => [ 54 | 'type' => 'vector', 55 | 'algorithm' => 'flat', 56 | 'dims' => 3, 57 | 'datatype' => 'float32', 58 | 'distance_metric' => 'cosine', 59 | ] 60 | ] 61 | ]; 62 | $this->jsonSchema = [ 63 | 'index' => [ 64 | 'name' => 'foobar', 65 | 'prefix' => 'foo:', 66 | 'storage_type' => 'json' 67 | ], 68 | 'fields' => [ 69 | '$.id' => [ 70 | 'type' => 'text', 71 | 'alias' => 'foo', 72 | ], 73 | '$.count' => [ 74 | 'type' => 'numeric', 75 | ], 76 | '$.id_embeddings' => [ 77 | 'type' => 'vector', 78 | 'algorithm' => 'flat', 79 | 'dims' => 3, 80 | 'datatype' => 'float32', 81 | 'distance_metric' => 'cosine', 82 | ] 83 | ] 84 | ]; 85 | } 86 | 87 | /** 88 | * @return void 89 | */ 90 | public function testCreatesHashIndexWithMultipleFields(): void 91 | { 92 | $index = new SearchIndex($this->client, $this->hashSchema); 93 | $this->assertEquals('OK', $index->create()); 94 | 95 | $indexInfo = $this->client->ftinfo($this->hashSchema['index']['name']); 96 | 97 | $this->assertEquals('foobar', $indexInfo[1]); 98 | $this->assertEquals('HASH', $indexInfo[5][1]); 99 | $this->assertEquals('foo:', $indexInfo[5][3][0]); 100 | $this->assertEquals('foo', $indexInfo[7][0][3]); 101 | $this->assertEquals('TEXT', $indexInfo[7][0][5]); 102 | $this->assertEquals('NUMERIC', $indexInfo[7][1][5]); 103 | $this->assertEquals('VECTOR', $indexInfo[7][2][5]); 104 | 105 | $this->assertEquals( 106 | 'OK', 107 | $index->load('1', ['id' => '1', 'count' => 10, 'id_embeddings' => VectorHelper::toBytes([0.000001, 0.000002, 0.000003])]) 108 | ); 109 | 110 | $searchResult = $this->client->ftsearch($this->hashSchema['index']['name'], '*'); 111 | 112 | $this->assertSame('foo:1', $searchResult[1]); 113 | $this->assertSame('1', $searchResult[2][1]); 114 | $this->assertSame('10', $searchResult[2][3]); 115 | $this->assertSame(VectorHelper::toBytes([0.000001, 0.000002, 0.000003]), $searchResult[2][5]); 116 | } 117 | 118 | /** 119 | * @return void 120 | */ 121 | public function testCreatesJsonIndexWithMultipleFields(): void 122 | { 123 | $index = new SearchIndex($this->client, $this->jsonSchema); 124 | $this->assertEquals('OK', $index->create()); 125 | 126 | $indexInfo = $this->client->ftinfo($this->jsonSchema['index']['name']); 127 | 128 | $this->assertEquals('foobar', $indexInfo[1]); 129 | $this->assertEquals('JSON', $indexInfo[5][1]); 130 | $this->assertEquals('foo:', $indexInfo[5][3][0]); 131 | $this->assertEquals('foo', $indexInfo[7][0][3]); 132 | $this->assertEquals('TEXT', $indexInfo[7][0][5]); 133 | $this->assertEquals('NUMERIC', $indexInfo[7][1][5]); 134 | $this->assertEquals('VECTOR', $indexInfo[7][2][5]); 135 | 136 | $this->assertEquals( 137 | 'OK', 138 | $index->load('1', '{"id":"1","count":10,"id_embeddings":[0.000001, 0.000002, 0.000003]}') 139 | ); 140 | 141 | $searchResult = $this->client->ftsearch($this->jsonSchema['index']['name'], '*'); 142 | 143 | $this->assertSame('foo:1', $searchResult[1]); 144 | $this->assertSame('{"id":"1","count":10,"id_embeddings":[1e-6,2e-6,3e-6]}', $searchResult[2][1]); 145 | } 146 | 147 | /** 148 | * @return void 149 | */ 150 | public function testCreatesHashIndexWithOverride(): void 151 | { 152 | $index = new SearchIndex($this->client, $this->hashSchema); 153 | $this->assertEquals('OK', $index->create()); 154 | 155 | $indexInfo = $this->client->ftinfo($this->hashSchema['index']['name']); 156 | 157 | $this->assertEquals('foobar', $indexInfo[1]); 158 | $this->assertEquals('HASH', $indexInfo[5][1]); 159 | $this->assertEquals('foo:', $indexInfo[5][3][0]); 160 | $this->assertEquals('foo', $indexInfo[7][0][3]); 161 | $this->assertEquals('TEXT', $indexInfo[7][0][5]); 162 | $this->assertEquals('NUMERIC', $indexInfo[7][1][5]); 163 | $this->assertEquals('VECTOR', $indexInfo[7][2][5]); 164 | 165 | $this->assertEquals('OK', $index->create(true)); 166 | 167 | $this->assertEquals( 168 | 'OK', 169 | $index->load('1', ['id' => '1', 'count' => 10, 'id_embeddings' => VectorHelper::toBytes([0.000001, 0.000002, 0.000003])]) 170 | ); 171 | 172 | $searchResult = $this->client->ftsearch($this->hashSchema['index']['name'], '*'); 173 | 174 | $this->assertSame('foo:1', $searchResult[1]); 175 | $this->assertSame('1', $searchResult[2][1]); 176 | $this->assertSame('10', $searchResult[2][3]); 177 | $this->assertSame(VectorHelper::toBytes([0.000001, 0.000002, 0.000003]), $searchResult[2][5]); 178 | } 179 | 180 | /** 181 | * @return void 182 | */ 183 | public function testFetchHashData(): void 184 | { 185 | $index = new SearchIndex($this->client, $this->hashSchema); 186 | $this->assertEquals('OK', $index->create()); 187 | 188 | $this->assertEquals( 189 | 'OK', 190 | $index->load('1', ['id' => '1', 'count' => 10, 'id_embeddings' => VectorHelper::toBytes([0.000001, 0.000002, 0.000003])]) 191 | ); 192 | 193 | $this->assertEquals( 194 | ['id' => '1', 'count' => 10, 'id_embeddings' => VectorHelper::toBytes([0.000001, 0.000002, 0.000003])], 195 | $index->fetch('1')); 196 | } 197 | 198 | /** 199 | * @return void 200 | */ 201 | public function testFetchJsonData(): void 202 | { 203 | $index = new SearchIndex($this->client, $this->jsonSchema); 204 | $this->assertEquals('OK', $index->create()); 205 | 206 | $this->assertEquals( 207 | 'OK', 208 | $index->load('1', '{"id":"1","count":10,"id_embeddings":[0.000001, 0.000002, 0.000003]}') 209 | ); 210 | 211 | $this->assertEquals( 212 | '{"id":"1","count":10,"id_embeddings":[1e-6,2e-6,3e-6]}', 213 | $index->fetch('1')); 214 | } 215 | 216 | /** 217 | * @dataProvider vectorQueryScoreProvider 218 | * @param array $vector 219 | * @param int $resultsCount 220 | * @param array $expectedResponse 221 | * @return void 222 | */ 223 | public function testVectorQueryHashIndexReturnsCorrectVectorScore( 224 | array $vector, 225 | int $resultsCount, 226 | array $expectedResponse 227 | ): void { 228 | $schema = [ 229 | 'index' => [ 230 | 'name' => 'products', 231 | 'prefix' => 'product:', 232 | ], 233 | 'fields' => [ 234 | 'id' => [ 235 | 'type' => 'text', 236 | ], 237 | 'category' => [ 238 | 'type' => 'tag', 239 | ], 240 | 'description' => [ 241 | 'type' => 'text', 242 | ], 243 | 'description_embedding' => [ 244 | 'type' => 'vector', 245 | 'dims' => 3, 246 | 'datatype' => 'float32', 247 | 'algorithm' => 'flat', 248 | 'distance_metric' => 'cosine' 249 | ], 250 | ], 251 | ]; 252 | 253 | $index = new SearchIndex($this->client, $schema); 254 | $this->assertEquals('OK', $index->create()); 255 | 256 | for ($i = 1; $i < 5; $i++) { 257 | $this->assertTrue($index->load( 258 | $i, 259 | [ 260 | 'id' => $i, 'category' => ($i % 2 === 0) ? 'foo' : 'bar', 'description' => 'Foobar foobar', 261 | 'description_embedding' => VectorHelper::toBytes([$i / 1000, ($i + 1) / 1000, ($i + 2) / 1000]) 262 | ]) 263 | ); 264 | } 265 | 266 | $query = new VectorQuery( 267 | $vector, 268 | 'description_embedding', 269 | null, 270 | $resultsCount, 271 | true, 272 | 2, 273 | null 274 | ); 275 | 276 | $response = $index->query($query); 277 | $this->assertSame($expectedResponse['count'], $response['count']); 278 | 279 | foreach ($expectedResponse['results'] as $key => $value) { 280 | $this->assertSame( 281 | $expectedResponse['results'][$key]['vector_score'], 282 | round((float) $response['results'][$key]['vector_score'], 2) 283 | ); 284 | } 285 | } 286 | 287 | /** 288 | * @dataProvider vectorTagFilterProvider 289 | * @param FilterInterface|null $filter 290 | * @param array $expectedResponse 291 | * @return void 292 | */ 293 | public function testVectorQueryHashWithTagsFilter( 294 | ?FilterInterface $filter, 295 | array $expectedResponse 296 | ): void { 297 | $schema = [ 298 | 'index' => [ 299 | 'name' => 'products', 300 | 'prefix' => 'product:', 301 | ], 302 | 'fields' => [ 303 | 'id' => [ 304 | 'type' => 'text', 305 | ], 306 | 'category' => [ 307 | 'type' => 'tag', 308 | ], 309 | 'description' => [ 310 | 'type' => 'text', 311 | ], 312 | 'description_embedding' => [ 313 | 'type' => 'vector', 314 | 'dims' => 3, 315 | 'datatype' => 'float32', 316 | 'algorithm' => 'flat', 317 | 'distance_metric' => 'cosine' 318 | ], 319 | ], 320 | ]; 321 | 322 | $index = new SearchIndex($this->client, $schema); 323 | $this->assertEquals('OK', $index->create()); 324 | 325 | for ($i = 1; $i < 5; $i++) { 326 | $this->assertTrue($index->load( 327 | $i, 328 | [ 329 | 'id' => $i, 'category' => ($i % 2 === 0) ? 'foo' : 'bar', 'description' => 'Foobar foobar', 330 | 'description_embedding' => VectorHelper::toBytes([$i / 1000, ($i + 1) / 1000, ($i + 2) / 1000]) 331 | ]) 332 | ); 333 | } 334 | 335 | $this->assertTrue($index->load( 336 | 5, 337 | [ 338 | 'id' => 5, 'category' => 'foo,bar', 'description' => 'Foobar foobar', 339 | 'description_embedding' => VectorHelper::toBytes([0.005, 0.006, 0.007]) 340 | ]) 341 | ); 342 | 343 | $query = new VectorQuery( 344 | [0.001, 0.002, 0.03], 345 | 'description_embedding', 346 | null, 347 | 10, 348 | true, 349 | 2, 350 | $filter 351 | ); 352 | 353 | $response = $index->query($query); 354 | $this->assertSame($expectedResponse['count'], $response['count']); 355 | 356 | foreach ($expectedResponse['results'] as $key => $value) { 357 | $this->assertSame( 358 | $expectedResponse['results'][$key]['category'], 359 | $response['results'][$key]['category'] 360 | ); 361 | } 362 | } 363 | 364 | /** 365 | * @dataProvider vectorNumericFilterProvider 366 | * @param FilterInterface|null $filter 367 | * @param array $expectedResponse 368 | * @return void 369 | */ 370 | public function testVectorQueryHashWithNumericFilter( 371 | ?FilterInterface $filter, 372 | array $expectedResponse 373 | ): void { 374 | $schema = [ 375 | 'index' => [ 376 | 'name' => 'products', 377 | 'prefix' => 'product:', 378 | ], 379 | 'fields' => [ 380 | 'id' => [ 381 | 'type' => 'text', 382 | ], 383 | 'price' => [ 384 | 'type' => 'numeric', 385 | ], 386 | 'description' => [ 387 | 'type' => 'text', 388 | ], 389 | 'description_embedding' => [ 390 | 'type' => 'vector', 391 | 'dims' => 3, 392 | 'datatype' => 'float32', 393 | 'algorithm' => 'flat', 394 | 'distance_metric' => 'cosine' 395 | ], 396 | ], 397 | ]; 398 | 399 | $index = new SearchIndex($this->client, $schema); 400 | $this->assertEquals('OK', $index->create()); 401 | 402 | for ($i = 1; $i < 5; $i++) { 403 | $this->assertTrue($index->load( 404 | $i, 405 | [ 406 | 'id' => $i, 'price' => $i * 10, 'description' => 'Foobar foobar', 407 | 'description_embedding' => VectorHelper::toBytes([$i / 1000, ($i + 1) / 1000, ($i + 2) / 1000]) 408 | ]) 409 | ); 410 | } 411 | 412 | $query = new VectorQuery( 413 | [0.001, 0.002, 0.03], 414 | 'description_embedding', 415 | null, 416 | 10, 417 | true, 418 | 2, 419 | $filter 420 | ); 421 | 422 | $response = $index->query($query); 423 | $this->assertSame($expectedResponse['count'], $response['count']); 424 | 425 | foreach ($expectedResponse['results'] as $key => $value) { 426 | $this->assertSame( 427 | $expectedResponse['results'][$key]['price'], 428 | (int) $response['results'][$key]['price'] 429 | ); 430 | } 431 | } 432 | 433 | /** 434 | * @dataProvider vectorTextFilterProvider 435 | * @param FilterInterface|null $filter 436 | * @param array $expectedResponse 437 | * @return void 438 | */ 439 | public function testVectorQueryHashWithTextFilter( 440 | ?FilterInterface $filter, 441 | array $expectedResponse 442 | ): void { 443 | $schema = [ 444 | 'index' => [ 445 | 'name' => 'products', 446 | 'prefix' => 'product:', 447 | ], 448 | 'fields' => [ 449 | 'id' => [ 450 | 'type' => 'text', 451 | ], 452 | 'price' => [ 453 | 'type' => 'numeric', 454 | ], 455 | 'description' => [ 456 | 'type' => 'text', 457 | ], 458 | 'description_embedding' => [ 459 | 'type' => 'vector', 460 | 'dims' => 3, 461 | 'datatype' => 'float32', 462 | 'algorithm' => 'flat', 463 | 'distance_metric' => 'cosine' 464 | ], 465 | ], 466 | ]; 467 | 468 | $index = new SearchIndex($this->client, $schema); 469 | $this->assertEquals('OK', $index->create()); 470 | 471 | $this->assertTrue($index->load( 472 | '1', 473 | [ 474 | 'id' => '1', 'price' => 10, 'description' => 'foobar', 475 | 'description_embedding' => VectorHelper::toBytes([0.001, 0.002, 0.003]) 476 | ]) 477 | ); 478 | $this->assertTrue($index->load( 479 | '2', 480 | [ 481 | 'id' => '2', 'price' => 20, 'description' => 'barfoo', 482 | 'description_embedding' => VectorHelper::toBytes([0.001, 0.002, 0.003]) 483 | ]) 484 | ); 485 | $this->assertTrue($index->load( 486 | '3', 487 | [ 488 | 'id' => '3', 'price' => 30, 'description' => 'foobar barfoo', 489 | 'description_embedding' => VectorHelper::toBytes([0.001, 0.002, 0.003]) 490 | ]) 491 | ); 492 | $this->assertTrue($index->load( 493 | '4', 494 | [ 495 | 'id' => '4', 'price' => 40, 'description' => 'barfoo bazfoo', 496 | 'description_embedding' => VectorHelper::toBytes([0.001, 0.002, 0.003]) 497 | ]) 498 | ); 499 | 500 | $query = new VectorQuery( 501 | [0.001, 0.002, 0.03], 502 | 'description_embedding', 503 | null, 504 | 10, 505 | true, 506 | 2, 507 | $filter 508 | ); 509 | 510 | $response = $index->query($query); 511 | $this->assertSame($expectedResponse['count'], $response['count']); 512 | 513 | foreach ($expectedResponse['results'] as $key => $value) { 514 | $this->assertSame( 515 | $expectedResponse['results'][$key]['description'], 516 | $response['results'][$key]['description'] 517 | ); 518 | } 519 | } 520 | 521 | /** 522 | * @dataProvider vectorGeoFilterProvider 523 | * @param FilterInterface|null $filter 524 | * @param array $expectedResponse 525 | * @return void 526 | */ 527 | public function testVectorQueryHashWithGeoFilter( 528 | ?FilterInterface $filter, 529 | array $expectedResponse 530 | ): void { 531 | $schema = [ 532 | 'index' => [ 533 | 'name' => 'products', 534 | 'prefix' => 'product:', 535 | ], 536 | 'fields' => [ 537 | 'id' => [ 538 | 'type' => 'text', 539 | ], 540 | 'price' => [ 541 | 'type' => 'numeric', 542 | ], 543 | 'location' => [ 544 | 'type' => 'geo', 545 | ], 546 | 'description_embedding' => [ 547 | 'type' => 'vector', 548 | 'dims' => 3, 549 | 'datatype' => 'float32', 550 | 'algorithm' => 'flat', 551 | 'distance_metric' => 'cosine' 552 | ], 553 | ], 554 | ]; 555 | 556 | $index = new SearchIndex($this->client, $schema); 557 | $this->assertEquals('OK', $index->create()); 558 | 559 | $this->assertTrue($index->load( 560 | '1', 561 | [ 562 | 'id' => '1', 'price' => 10, 'location' => '10.111,11.111', 563 | 'description_embedding' => VectorHelper::toBytes([0.001, 0.002, 0.003]) 564 | ]) 565 | ); 566 | $this->assertTrue($index->load( 567 | '2', 568 | [ 569 | 'id' => '2', 'price' => 20, 'location' => '10.222,11.222', 570 | 'description_embedding' => VectorHelper::toBytes([0.001, 0.002, 0.003]) 571 | ]) 572 | ); 573 | $this->assertTrue($index->load( 574 | '3', 575 | [ 576 | 'id' => '3', 'price' => 30, 'location' => '10.333,11.333', 577 | 'description_embedding' => VectorHelper::toBytes([0.001, 0.002, 0.003]) 578 | ]) 579 | ); 580 | $this->assertTrue($index->load( 581 | '4', 582 | [ 583 | 'id' => '4', 'price' => 40, 'location' => '10.444,11.444', 584 | 'description_embedding' => VectorHelper::toBytes([0.001, 0.002, 0.003]) 585 | ]) 586 | ); 587 | 588 | $query = new VectorQuery( 589 | [0.001, 0.002, 0.03], 590 | 'description_embedding', 591 | null, 592 | 10, 593 | true, 594 | 2, 595 | $filter 596 | ); 597 | 598 | $response = $index->query($query); 599 | $this->assertSame($expectedResponse['count'], $response['count']); 600 | 601 | foreach ($expectedResponse['results'] as $key => $value) { 602 | $this->assertSame( 603 | $expectedResponse['results'][$key]['location'], 604 | $response['results'][$key]['location'] 605 | ); 606 | } 607 | } 608 | 609 | /** 610 | * @dataProvider vectorAggregateFilterProvider 611 | * @param FilterInterface|null $filter 612 | * @param array $expectedResponse 613 | * @return void 614 | */ 615 | public function testVectorQueryHashWithAggregateFilter( 616 | ?FilterInterface $filter, 617 | array $expectedResponse 618 | ): void { 619 | $schema = [ 620 | 'index' => [ 621 | 'name' => 'products', 622 | 'prefix' => 'product:', 623 | ], 624 | 'fields' => [ 625 | 'id' => [ 626 | 'type' => 'numeric', 627 | ], 628 | 'categories' => [ 629 | 'type' => 'tag', 630 | ], 631 | 'description' => [ 632 | 'type' => 'text', 633 | ], 634 | 'description_embedding' => [ 635 | 'type' => 'vector', 636 | 'dims' => 3, 637 | 'datatype' => 'float32', 638 | 'algorithm' => 'flat', 639 | 'distance_metric' => 'cosine' 640 | ], 641 | ], 642 | ]; 643 | 644 | $index = new SearchIndex($this->client, $schema); 645 | $this->assertEquals('OK', $index->create()); 646 | 647 | $this->assertTrue($index->load( 648 | '1', 649 | [ 650 | 'id' => 1, 'categories' => 'foo', 'description' => 'foobar', 651 | 'description_embedding' => VectorHelper::toBytes([0.001, 0.002, 0.003]) 652 | ]) 653 | ); 654 | $this->assertTrue($index->load( 655 | '2', 656 | [ 657 | 'id' => 2, 'categories' => 'bar', 'description' => 'barfoo', 658 | 'description_embedding' => VectorHelper::toBytes([0.001, 0.002, 0.003]) 659 | ]) 660 | ); 661 | $this->assertTrue($index->load( 662 | '3', 663 | [ 664 | 'id' => 3, 'categories' => 'foo', 'description' => 'foobar barfoo', 665 | 'description_embedding' => VectorHelper::toBytes([0.001, 0.002, 0.003]) 666 | ]) 667 | ); 668 | $this->assertTrue($index->load( 669 | '4', 670 | [ 671 | 'id' => 4, 'categories' => 'foo,bar', 'description' => 'barfoo bazfoo', 672 | 'description_embedding' => VectorHelper::toBytes([0.001, 0.002, 0.003]) 673 | ]) 674 | ); 675 | 676 | $query = new VectorQuery( 677 | [0.001, 0.002, 0.03], 678 | 'description_embedding', 679 | null, 680 | 10, 681 | false, 682 | 2, 683 | $filter 684 | ); 685 | 686 | $response = $index->query($query); 687 | $this->assertSame($expectedResponse['count'], $response['count']); 688 | 689 | foreach ($expectedResponse['results'] as $key => $value) { 690 | $this->assertSame( 691 | $expectedResponse['results'][$key]['id'], 692 | (int) $response['results'][$key]['id'] 693 | ); 694 | $this->assertSame( 695 | $expectedResponse['results'][$key]['categories'], 696 | $response['results'][$key]['categories'] 697 | ); 698 | $this->assertSame( 699 | $expectedResponse['results'][$key]['description'], 700 | $response['results'][$key]['description'] 701 | ); 702 | } 703 | } 704 | 705 | /** 706 | * @return void 707 | */ 708 | public function testVectorQueryHashIndexReturnsCorrectFields(): void 709 | { 710 | $schema = [ 711 | 'index' => [ 712 | 'name' => 'products', 713 | 'prefix' => 'product:', 714 | ], 715 | 'fields' => [ 716 | 'id' => [ 717 | 'type' => 'text', 718 | ], 719 | 'category' => [ 720 | 'type' => 'tag', 721 | ], 722 | 'description' => [ 723 | 'type' => 'text', 724 | ], 725 | 'description_embedding' => [ 726 | 'type' => 'vector', 727 | 'dims' => 3, 728 | 'datatype' => 'float32', 729 | 'algorithm' => 'flat', 730 | 'distance_metric' => 'cosine' 731 | ], 732 | ], 733 | ]; 734 | 735 | $index = new SearchIndex($this->client, $schema); 736 | $this->assertEquals('OK', $index->create()); 737 | 738 | for ($i = 1; $i < 5; $i++) { 739 | $this->assertTrue($index->load( 740 | $i, 741 | [ 742 | 'id' => $i, 'category' => ($i % 2 === 0) ? 'foo' : 'bar', 'description' => 'Foobar foobar', 743 | 'description_embedding' => VectorHelper::toBytes([$i / 1000, ($i + 1) / 1000, ($i + 2) / 1000]) 744 | ]) 745 | ); 746 | } 747 | 748 | $query = new VectorQuery( 749 | [0.001, 0.002, 0.03], 750 | 'description_embedding', 751 | ['id', 'category'], 752 | 10, 753 | false, 754 | 2, 755 | null 756 | ); 757 | 758 | $response = $index->query($query); 759 | $this->assertSame(4, $response['count']); 760 | 761 | foreach ($response['results'] as $value) { 762 | $this->assertNotTrue(array_key_exists('description', $value)); 763 | $this->assertNotTrue(array_key_exists('description_embedding', $value)); 764 | $this->assertNotTrue(array_key_exists('vector_score', $value)); 765 | } 766 | } 767 | 768 | /** 769 | * @return void 770 | * @throws \JsonException 771 | */ 772 | public function testVectorQueryJsonIndexReturnsCorrectVectorScore(): void 773 | { 774 | $schema = [ 775 | 'index' => [ 776 | 'name' => 'products', 777 | 'prefix' => 'product:', 778 | 'storage_type' => 'json' 779 | ], 780 | 'fields' => [ 781 | '$.id' => [ 782 | 'type' => 'text', 783 | ], 784 | '$.category' => [ 785 | 'type' => 'tag', 786 | ], 787 | '$.description' => [ 788 | 'type' => 'text', 789 | ], 790 | '$.description_embedding' => [ 791 | 'type' => 'vector', 792 | 'dims' => 3, 793 | 'datatype' => 'float32', 794 | 'algorithm' => 'flat', 795 | 'distance_metric' => 'cosine', 796 | 'alias' => 'vector_embedding', 797 | ], 798 | ], 799 | ]; 800 | 801 | $expectedResponse = [ 802 | 'count' => 4, 803 | 'results' => [ 804 | 'product:1' => [ 805 | 'vector_score' => 0.16, 806 | ], 807 | 'product:2' => [ 808 | 'vector_score' => 0.21, 809 | ], 810 | 'product:3' => [ 811 | 'vector_score' => 0.24, 812 | ], 813 | 'product:4' => [ 814 | 'vector_score' => 0.27, 815 | ], 816 | ], 817 | ]; 818 | 819 | $index = new SearchIndex($this->client, $schema); 820 | $this->assertEquals('OK', $index->create()); 821 | 822 | for ($i = 1; $i < 5; $i++) { 823 | $json = json_encode([ 824 | 'id' => (string)$i, 'category' => ($i % 2 === 0) ? 'foo' : 'bar', 'description' => 'Foobar foobar', 825 | 'description_embedding' => [$i / 1000, ($i + 1) / 1000, ($i + 2) / 1000], 826 | ], JSON_THROW_ON_ERROR); 827 | 828 | $this->assertTrue($index->load( 829 | $i, 830 | $json 831 | )); 832 | } 833 | 834 | $query = new VectorQuery( 835 | [0.001, 0.002, 0.03], 836 | 'vector_embedding', 837 | null, 838 | 10, 839 | true, 840 | 2, 841 | null 842 | ); 843 | 844 | $response = $index->query($query); 845 | $this->assertSame($expectedResponse['count'], $response['count']); 846 | 847 | foreach ($expectedResponse['results'] as $key => $value) { 848 | $this->assertSame( 849 | $expectedResponse['results'][$key]['vector_score'], 850 | round((float) $response['results'][$key]['vector_score'], 2) 851 | ); 852 | } 853 | } 854 | 855 | /** 856 | * @dataProvider vectorTagFilterProvider 857 | * @param FilterInterface|null $filter 858 | * @param array $expectedResponse 859 | * @return void 860 | * @throws \JsonException 861 | */ 862 | public function testVectorQueryJsonIndexWithTagFilter( 863 | ?FilterInterface $filter, 864 | array $expectedResponse 865 | ): void { 866 | $schema = [ 867 | 'index' => [ 868 | 'name' => 'products', 869 | 'prefix' => 'product:', 870 | 'storage_type' => 'json' 871 | ], 872 | 'fields' => [ 873 | '$.id' => [ 874 | 'type' => 'text', 875 | ], 876 | '$.category' => [ 877 | 'type' => 'tag', 878 | 'alias' => 'category', 879 | ], 880 | '$.description' => [ 881 | 'type' => 'text', 882 | ], 883 | '$.description_embedding' => [ 884 | 'type' => 'vector', 885 | 'dims' => 3, 886 | 'datatype' => 'float32', 887 | 'algorithm' => 'flat', 888 | 'distance_metric' => 'cosine', 889 | 'alias' => 'vector_embedding', 890 | ], 891 | ], 892 | ]; 893 | 894 | $index = new SearchIndex($this->client, $schema); 895 | $this->assertEquals('OK', $index->create()); 896 | 897 | for ($i = 1; $i < 5; $i++) { 898 | $json = json_encode([ 899 | 'id' => (string)$i, 'category' => ($i % 2 === 0) ? 'foo' : 'bar', 'description' => 'Foobar foobar', 900 | 'description_embedding' => [$i / 1000, ($i + 1) / 1000, ($i + 2) / 1000], 901 | ], JSON_THROW_ON_ERROR); 902 | 903 | $this->assertTrue($index->load( 904 | $i, 905 | $json 906 | )); 907 | } 908 | 909 | $this->assertTrue($index->load( 910 | "5", 911 | json_encode([ 912 | 'id' => "5", 'category' => ['foo', 'bar'], 'description' => 'Foobar foobar', 913 | 'description_embedding' => [0.005, 0.006, 0.007], 914 | ], JSON_THROW_ON_ERROR)) 915 | ); 916 | 917 | $query = new VectorQuery( 918 | [0.001, 0.002, 0.03], 919 | 'vector_embedding', 920 | null, 921 | 10, 922 | false, 923 | 2, 924 | $filter 925 | ); 926 | 927 | $response = $index->query($query); 928 | $this->assertSame($expectedResponse['count'], $response['count']); 929 | 930 | foreach ($response['results'] as $key => $value) { 931 | $expectedCategories = ($key === 'product:5') 932 | ? $expectedResponse['results'][$key]['category_array'] 933 | : $expectedResponse['results'][$key]['category']; 934 | 935 | $decodedValue = json_decode($value['$'], true, 512, JSON_THROW_ON_ERROR); 936 | 937 | $this->assertSame( 938 | $expectedCategories, 939 | $decodedValue['category'] 940 | ); 941 | } 942 | } 943 | 944 | /** 945 | * @dataProvider vectorNumericFilterProvider 946 | * @param FilterInterface|null $filter 947 | * @param array $expectedResponse 948 | * @return void 949 | * @throws \JsonException 950 | */ 951 | public function testVectorQueryJsonIndexWithNumericFilter( 952 | ?FilterInterface $filter, 953 | array $expectedResponse 954 | ): void { 955 | $schema = [ 956 | 'index' => [ 957 | 'name' => 'products', 958 | 'prefix' => 'product:', 959 | 'storage_type' => 'json' 960 | ], 961 | 'fields' => [ 962 | '$.id' => [ 963 | 'type' => 'text', 964 | ], 965 | '$.price' => [ 966 | 'type' => 'numeric', 967 | 'alias' => 'price', 968 | ], 969 | '$.description' => [ 970 | 'type' => 'text', 971 | ], 972 | '$.description_embedding' => [ 973 | 'type' => 'vector', 974 | 'dims' => 3, 975 | 'datatype' => 'float32', 976 | 'algorithm' => 'flat', 977 | 'distance_metric' => 'cosine', 978 | 'alias' => 'vector_embedding', 979 | ], 980 | ], 981 | ]; 982 | 983 | $index = new SearchIndex($this->client, $schema); 984 | $this->assertEquals('OK', $index->create()); 985 | 986 | for ($i = 1; $i < 5; $i++) { 987 | $json = json_encode([ 988 | 'id' => (string)$i, 'price' => $i * 10, 'description' => 'Foobar foobar', 989 | 'description_embedding' => [$i / 1000, ($i + 1) / 1000, ($i + 2) / 1000], 990 | ], JSON_THROW_ON_ERROR); 991 | 992 | $this->assertTrue($index->load( 993 | $i, 994 | $json 995 | )); 996 | } 997 | 998 | $query = new VectorQuery( 999 | [0.001, 0.002, 0.03], 1000 | 'vector_embedding', 1001 | null, 1002 | 10, 1003 | true, 1004 | 2, 1005 | $filter 1006 | ); 1007 | 1008 | $response = $index->query($query); 1009 | $this->assertSame($expectedResponse['count'], $response['count']); 1010 | 1011 | foreach ($response['results'] as $key => $value) { 1012 | $decodedResponse = json_decode($value['$'], true, 512, JSON_THROW_ON_ERROR); 1013 | 1014 | $this->assertSame( 1015 | $expectedResponse['results'][$key]['price'], 1016 | (int) $decodedResponse['price'] 1017 | ); 1018 | } 1019 | } 1020 | 1021 | /** 1022 | * @dataProvider vectorTextFilterProvider 1023 | * @param FilterInterface|null $filter 1024 | * @param array $expectedResponse 1025 | * @return void 1026 | * @throws \JsonException 1027 | */ 1028 | public function testVectorQueryJsonIndexWithTextFilter( 1029 | ?FilterInterface $filter, 1030 | array $expectedResponse 1031 | ): void { 1032 | $schema = [ 1033 | 'index' => [ 1034 | 'name' => 'products', 1035 | 'prefix' => 'product:', 1036 | 'storage_type' => 'json' 1037 | ], 1038 | 'fields' => [ 1039 | '$.id' => [ 1040 | 'type' => 'text', 1041 | ], 1042 | '$.price' => [ 1043 | 'type' => 'numeric', 1044 | ], 1045 | '$.description' => [ 1046 | 'type' => 'text', 1047 | 'alias' => 'description', 1048 | ], 1049 | '$.description_embedding' => [ 1050 | 'type' => 'vector', 1051 | 'dims' => 3, 1052 | 'datatype' => 'float32', 1053 | 'algorithm' => 'flat', 1054 | 'distance_metric' => 'cosine', 1055 | 'alias' => 'vector_embedding', 1056 | ], 1057 | ], 1058 | ]; 1059 | 1060 | $index = new SearchIndex($this->client, $schema); 1061 | $this->assertEquals('OK', $index->create()); 1062 | 1063 | $this->assertTrue($index->load( 1064 | '1', 1065 | json_encode([ 1066 | 'id' => '1', 'price' => 10, 'description' => 'foobar', 1067 | 'description_embedding' => [0.001, 0.002, 0.003], 1068 | ], JSON_THROW_ON_ERROR) 1069 | )); 1070 | $this->assertTrue($index->load( 1071 | '2', 1072 | json_encode([ 1073 | 'id' => '2', 'price' => 20, 'description' => 'barfoo', 1074 | 'description_embedding' => [0.001, 0.002, 0.003], 1075 | ], JSON_THROW_ON_ERROR) 1076 | )); 1077 | $this->assertTrue($index->load( 1078 | '3', 1079 | json_encode([ 1080 | 'id' => '3', 'price' => 30, 'description' => 'foobar barfoo', 1081 | 'description_embedding' => [0.001, 0.002, 0.003], 1082 | ], JSON_THROW_ON_ERROR) 1083 | )); 1084 | $this->assertTrue($index->load( 1085 | '4', 1086 | json_encode([ 1087 | 'id' => '4', 'price' => 40, 'description' => 'barfoo bazfoo', 1088 | 'description_embedding' => [0.001, 0.002, 0.003], 1089 | ], JSON_THROW_ON_ERROR) 1090 | )); 1091 | 1092 | $query = new VectorQuery( 1093 | [0.001, 0.002, 0.03], 1094 | 'vector_embedding', 1095 | null, 1096 | 10, 1097 | true, 1098 | 2, 1099 | $filter 1100 | ); 1101 | 1102 | $response = $index->query($query); 1103 | $this->assertSame($expectedResponse['count'], $response['count']); 1104 | 1105 | foreach ($response['results'] as $key => $value) { 1106 | $decodedResponse = json_decode($value['$'], true, 512, JSON_THROW_ON_ERROR); 1107 | 1108 | $this->assertSame( 1109 | $expectedResponse['results'][$key]['description'], 1110 | $decodedResponse['description'] 1111 | ); 1112 | } 1113 | } 1114 | 1115 | /** 1116 | * @dataProvider vectorGeoFilterProvider 1117 | * @param FilterInterface|null $filter 1118 | * @param array $expectedResponse 1119 | * @return void 1120 | * @throws \JsonException 1121 | */ 1122 | public function testVectorQueryJsonIndexWithGeoFilter( 1123 | ?FilterInterface $filter, 1124 | array $expectedResponse 1125 | ): void { 1126 | $schema = [ 1127 | 'index' => [ 1128 | 'name' => 'products', 1129 | 'prefix' => 'product:', 1130 | 'storage_type' => 'json' 1131 | ], 1132 | 'fields' => [ 1133 | '$.id' => [ 1134 | 'type' => 'text', 1135 | ], 1136 | '$.price' => [ 1137 | 'type' => 'numeric', 1138 | ], 1139 | '$.location' => [ 1140 | 'type' => 'geo', 1141 | 'alias' => 'location', 1142 | ], 1143 | '$.description_embedding' => [ 1144 | 'type' => 'vector', 1145 | 'dims' => 3, 1146 | 'datatype' => 'float32', 1147 | 'algorithm' => 'flat', 1148 | 'distance_metric' => 'cosine', 1149 | 'alias' => 'vector_embedding', 1150 | ], 1151 | ], 1152 | ]; 1153 | 1154 | $index = new SearchIndex($this->client, $schema); 1155 | $this->assertEquals('OK', $index->create()); 1156 | 1157 | $this->assertTrue($index->load( 1158 | '1', 1159 | json_encode([ 1160 | 'id' => '1', 'price' => 10, 'location' => ['10.111,11.111'], 1161 | 'description_embedding' => [0.001, 0.002, 0.003], 1162 | ], JSON_THROW_ON_ERROR) 1163 | )); 1164 | $this->assertTrue($index->load( 1165 | '2', 1166 | json_encode([ 1167 | 'id' => '2', 'price' => 20, 'location' => ['10.222,11.222'], 1168 | 'description_embedding' => [0.001, 0.002, 0.003], 1169 | ], JSON_THROW_ON_ERROR) 1170 | )); 1171 | $this->assertTrue($index->load( 1172 | '3', 1173 | json_encode([ 1174 | 'id' => '3', 'price' => 30, 'location' => ['10.333,11.333'], 1175 | 'description_embedding' => [0.001, 0.002, 0.003], 1176 | ], JSON_THROW_ON_ERROR) 1177 | )); 1178 | $this->assertTrue($index->load( 1179 | '4', 1180 | json_encode([ 1181 | 'id' => '4', 'price' => 40, 'location' => ['10.444,11.444'], 1182 | 'description_embedding' => [0.001, 0.002, 0.003], 1183 | ], JSON_THROW_ON_ERROR) 1184 | )); 1185 | 1186 | $query = new VectorQuery( 1187 | [0.001, 0.002, 0.03], 1188 | 'vector_embedding', 1189 | null, 1190 | 10, 1191 | true, 1192 | 2, 1193 | $filter 1194 | ); 1195 | 1196 | $response = $index->query($query); 1197 | $this->assertSame($expectedResponse['count'], $response['count']); 1198 | 1199 | foreach ($response['results'] as $key => $value) { 1200 | $decodedResponse = json_decode($value['$'], true, 512, JSON_THROW_ON_ERROR); 1201 | 1202 | $this->assertSame( 1203 | $expectedResponse['results'][$key]['location'], 1204 | $decodedResponse['location'][0] 1205 | ); 1206 | } 1207 | } 1208 | 1209 | /** 1210 | * @dataProvider vectorAggregateFilterProvider 1211 | * @param FilterInterface|null $filter 1212 | * @param array $expectedResponse 1213 | * @return void 1214 | * @throws \JsonException 1215 | */ 1216 | public function testVectorQueryJsonIndexWithAggregateFilter( 1217 | ?FilterInterface $filter, 1218 | array $expectedResponse 1219 | ): void { 1220 | $schema = [ 1221 | 'index' => [ 1222 | 'name' => 'products', 1223 | 'prefix' => 'product:', 1224 | 'storage_type' => 'json' 1225 | ], 1226 | 'fields' => [ 1227 | '$.id' => [ 1228 | 'type' => 'numeric', 1229 | 'alias' => 'id', 1230 | ], 1231 | '$.categories' => [ 1232 | 'type' => 'tag', 1233 | 'alias' => 'categories', 1234 | ], 1235 | '$.description' => [ 1236 | 'type' => 'text', 1237 | 'alias' => 'description', 1238 | ], 1239 | '$.description_embedding' => [ 1240 | 'type' => 'vector', 1241 | 'dims' => 3, 1242 | 'datatype' => 'float32', 1243 | 'algorithm' => 'flat', 1244 | 'distance_metric' => 'cosine', 1245 | 'alias' => 'vector_embedding', 1246 | ], 1247 | ], 1248 | ]; 1249 | 1250 | $index = new SearchIndex($this->client, $schema); 1251 | $this->assertEquals('OK', $index->create()); 1252 | 1253 | $this->assertTrue($index->load( 1254 | '1', 1255 | json_encode([ 1256 | 'id' => 1, 'categories' => 'foo', 'description' => 'foobar', 1257 | 'description_embedding' => [0.001, 0.002, 0.003], 1258 | ], JSON_THROW_ON_ERROR) 1259 | )); 1260 | $this->assertTrue($index->load( 1261 | '2', 1262 | json_encode([ 1263 | 'id' => 2, 'categories' => 'bar', 'description' => 'barfoo', 1264 | 'description_embedding' => [0.001, 0.002, 0.003], 1265 | ], JSON_THROW_ON_ERROR) 1266 | )); 1267 | $this->assertTrue($index->load( 1268 | '3', 1269 | json_encode([ 1270 | 'id' => 3, 'categories' => 'foo', 'description' => 'foobar barfoo', 1271 | 'description_embedding' => [0.001, 0.002, 0.003], 1272 | ], JSON_THROW_ON_ERROR) 1273 | )); 1274 | $this->assertTrue($index->load( 1275 | '4', 1276 | json_encode([ 1277 | 'id' => 4, 'categories' => ['foo', 'bar'], 'description' => 'barfoo bazfoo', 1278 | 'description_embedding' => [0.001, 0.002, 0.003], 1279 | ], JSON_THROW_ON_ERROR) 1280 | )); 1281 | 1282 | $query = new VectorQuery( 1283 | [0.001, 0.002, 0.03], 1284 | 'vector_embedding', 1285 | null, 1286 | 10, 1287 | true, 1288 | 2, 1289 | $filter 1290 | ); 1291 | 1292 | $response = $index->query($query); 1293 | $this->assertSame($expectedResponse['count'], $response['count']); 1294 | 1295 | foreach ($response['results'] as $key => $value) { 1296 | $decodedResponse = json_decode($value['$'], true, 512, JSON_THROW_ON_ERROR); 1297 | $expectedCategories = (array_key_exists('categories_array', $expectedResponse['results'][$key])) 1298 | ? $expectedResponse['results'][$key]['categories_array'] 1299 | : $expectedResponse['results'][$key]['categories']; 1300 | 1301 | $this->assertSame( 1302 | $expectedResponse['results'][$key]['id'], 1303 | (int) $decodedResponse['id'] 1304 | ); 1305 | $this->assertSame( 1306 | $expectedCategories, 1307 | $decodedResponse['categories'] 1308 | ); 1309 | $this->assertSame( 1310 | $expectedResponse['results'][$key]['description'], 1311 | $decodedResponse['description'] 1312 | ); 1313 | } 1314 | } 1315 | 1316 | /** 1317 | * @return void 1318 | * @throws \JsonException 1319 | */ 1320 | public function testVectorQueryJsonIndexReturnsCorrectFields(): void 1321 | { 1322 | $schema = [ 1323 | 'index' => [ 1324 | 'name' => 'products', 1325 | 'prefix' => 'product:', 1326 | 'storage_type' => 'json' 1327 | ], 1328 | 'fields' => [ 1329 | '$.id' => [ 1330 | 'type' => 'text', 1331 | ], 1332 | '$.category' => [ 1333 | 'type' => 'tag', 1334 | ], 1335 | '$.description' => [ 1336 | 'type' => 'text', 1337 | ], 1338 | '$.description_embedding' => [ 1339 | 'type' => 'vector', 1340 | 'dims' => 3, 1341 | 'datatype' => 'float32', 1342 | 'algorithm' => 'flat', 1343 | 'distance_metric' => 'cosine', 1344 | 'alias' => 'vector_embedding', 1345 | ], 1346 | ], 1347 | ]; 1348 | 1349 | $index = new SearchIndex($this->client, $schema); 1350 | $this->assertEquals('OK', $index->create()); 1351 | 1352 | for ($i = 1; $i < 5; $i++) { 1353 | $json = json_encode([ 1354 | 'id' => (string)$i, 'category' => ($i % 2 === 0) ? 'foo' : 'bar', 'description' => 'Foobar foobar', 1355 | 'description_embedding' => [$i / 1000, ($i + 1) / 1000, ($i + 2) / 1000], 1356 | ], JSON_THROW_ON_ERROR); 1357 | 1358 | $this->assertTrue($index->load( 1359 | $i, 1360 | $json 1361 | )); 1362 | } 1363 | 1364 | $query = new VectorQuery( 1365 | [0.001, 0.002, 0.03], 1366 | 'vector_embedding', 1367 | ['$.id', '$.category'], 1368 | 10, 1369 | true, 1370 | 2, 1371 | null 1372 | ); 1373 | 1374 | $response = $index->query($query); 1375 | $this->assertSame(4, $response['count']); 1376 | 1377 | foreach ($response['results'] as $value) { 1378 | $this->assertNotTrue(array_key_exists('$.description', $value)); 1379 | $this->assertNotTrue(array_key_exists('$.description_embedding', $value)); 1380 | $this->assertNotTrue(array_key_exists('$.vector_score', $value)); 1381 | } 1382 | } 1383 | 1384 | public static function vectorQueryScoreProvider(): array 1385 | { 1386 | return [ 1387 | 'default' => [ 1388 | [0.001, 0.002, 0.03], 1389 | 10, 1390 | [ 1391 | 'count' => 4, 1392 | 'results' => [ 1393 | 'product:1' => [ 1394 | 'vector_score' => 0.16, 1395 | ], 1396 | 'product:2' => [ 1397 | 'vector_score' => 0.21, 1398 | ], 1399 | 'product:3' => [ 1400 | 'vector_score' => 0.24, 1401 | ], 1402 | 'product:4' => [ 1403 | 'vector_score' => 0.27, 1404 | ], 1405 | ], 1406 | ] 1407 | ], 1408 | 'with_results_count' => [ 1409 | [0.001, 0.002, 0.03], 1410 | 2, 1411 | [ 1412 | 'count' => 2, 1413 | 'results' => [ 1414 | 'product:1' => [ 1415 | 'vector_score' => 0.16, 1416 | ], 1417 | 'product:2' => [ 1418 | 'vector_score' => 0.21, 1419 | ], 1420 | ], 1421 | ] 1422 | ], 1423 | ]; 1424 | } 1425 | 1426 | public static function vectorTagFilterProvider(): array 1427 | { 1428 | return [ 1429 | 'default' => [ 1430 | null, 1431 | [ 1432 | 'count' => 5, 1433 | 'results' => [ 1434 | 'product:1' => [ 1435 | 'category' => 'bar', 1436 | ], 1437 | 'product:2' => [ 1438 | 'category' => 'foo', 1439 | ], 1440 | 'product:3' => [ 1441 | 'category' => 'bar', 1442 | ], 1443 | 'product:4' => [ 1444 | 'category' => 'foo', 1445 | ], 1446 | 'product:5' => [ 1447 | 'category' => 'foo,bar', 1448 | 'category_array' => ['foo', 'bar'], 1449 | ], 1450 | ], 1451 | ] 1452 | ], 1453 | 'with_tag_equal_bar' => [ 1454 | new TagFilter('category', Condition::equal, 'bar'), 1455 | [ 1456 | 'count' => 3, 1457 | 'results' => [ 1458 | 'product:1' => [ 1459 | 'category' => 'bar', 1460 | ], 1461 | 'product:3' => [ 1462 | 'category' => 'bar', 1463 | ], 1464 | 'product:5' => [ 1465 | 'category' => 'foo,bar', 1466 | 'category_array' => ['foo', 'bar'], 1467 | ], 1468 | ], 1469 | ] 1470 | ], 1471 | 'with_tag_equal_foo' => [ 1472 | new TagFilter('category', Condition::equal, 'foo'), 1473 | [ 1474 | 'count' => 3, 1475 | 'results' => [ 1476 | 'product:2' => [ 1477 | 'category' => 'foo', 1478 | ], 1479 | 'product:4' => [ 1480 | 'category' => 'foo', 1481 | ], 1482 | 'product:5' => [ 1483 | 'category' => 'foo,bar', 1484 | 'category_array' => ['foo', 'bar'], 1485 | ], 1486 | ], 1487 | ] 1488 | ], 1489 | 'with_tag_not_equal_bar' => [ 1490 | new TagFilter('category', Condition::notEqual, 'bar'), 1491 | [ 1492 | 'count' => 2, 1493 | 'results' => [ 1494 | 'product:2' => [ 1495 | 'category' => 'foo', 1496 | ], 1497 | 'product:4' => [ 1498 | 'category' => 'foo', 1499 | ], 1500 | ], 1501 | ] 1502 | ], 1503 | 'with_tag_not_equal_foo' => [ 1504 | new TagFilter('category', Condition::notEqual, 'foo'), 1505 | [ 1506 | 'count' => 2, 1507 | 'results' => [ 1508 | 'product:1' => [ 1509 | 'category' => 'bar', 1510 | ], 1511 | 'product:3' => [ 1512 | 'category' => 'bar', 1513 | ], 1514 | ], 1515 | ] 1516 | ], 1517 | 'with_equal_foo_or_bar' => [ 1518 | new TagFilter('category', Condition::equal, [ 1519 | 'conjunction' => Logical::or, 1520 | 'tags' => ['foo', 'bar'], 1521 | ]), 1522 | [ 1523 | 'count' => 5, 1524 | 'results' => [ 1525 | 'product:1' => [ 1526 | 'category' => 'bar', 1527 | ], 1528 | 'product:2' => [ 1529 | 'category' => 'foo', 1530 | ], 1531 | 'product:3' => [ 1532 | 'category' => 'bar', 1533 | ], 1534 | 'product:4' => [ 1535 | 'category' => 'foo', 1536 | ], 1537 | 'product:5' => [ 1538 | 'category' => 'foo,bar', 1539 | 'category_array' => ['foo', 'bar'], 1540 | ], 1541 | ], 1542 | ] 1543 | ], 1544 | 'with_not_equal_foo_or_bar' => [ 1545 | new TagFilter('category', Condition::notEqual, [ 1546 | 'conjunction' => Logical::or, 1547 | 'tags' => ['foo', 'bar'], 1548 | ]), 1549 | [ 1550 | 'count' => 0, 1551 | ] 1552 | ], 1553 | 'with_equal_foo_and_bar' => [ 1554 | new TagFilter('category', Condition::equal, [ 1555 | 'conjunction' => Logical::and, 1556 | 'tags' => ['foo', 'bar'], 1557 | ]), 1558 | [ 1559 | 'count' => 1, 1560 | 'results' => [ 1561 | 'product:5' => [ 1562 | 'category' => 'foo,bar', 1563 | 'category_array' => ['foo', 'bar'], 1564 | ], 1565 | ] 1566 | ] 1567 | ], 1568 | 'with_not_equal_foo_and_bar' => [ 1569 | new TagFilter('category', Condition::notEqual, [ 1570 | 'conjunction' => Logical::and, 1571 | 'tags' => ['foo', 'bar'], 1572 | ]), 1573 | [ 1574 | 'count' => 0, 1575 | ] 1576 | ], 1577 | ]; 1578 | } 1579 | 1580 | public static function vectorNumericFilterProvider(): array 1581 | { 1582 | return [ 1583 | 'default' => [ 1584 | null, 1585 | [ 1586 | 'count' => 4, 1587 | 'results' => [ 1588 | 'product:1' => [ 1589 | 'price' => 10, 1590 | ], 1591 | 'product:2' => [ 1592 | 'price' => 20, 1593 | ], 1594 | 'product:3' => [ 1595 | 'price' => 30, 1596 | ], 1597 | 'product:4' => [ 1598 | 'price' => 40, 1599 | ], 1600 | ], 1601 | ] 1602 | ], 1603 | 'with_equal' => [ 1604 | new NumericFilter('price', Condition::equal, 30), 1605 | [ 1606 | 'count' => 1, 1607 | 'results' => [ 1608 | 'product:3' => [ 1609 | 'price' => 30, 1610 | ], 1611 | ], 1612 | ] 1613 | ], 1614 | 'with_not_equal' => [ 1615 | new NumericFilter('price', Condition::notEqual, 30), 1616 | [ 1617 | 'count' => 3, 1618 | 'results' => [ 1619 | 'product:1' => [ 1620 | 'price' => 10, 1621 | ], 1622 | 'product:2' => [ 1623 | 'price' => 20, 1624 | ], 1625 | 'product:4' => [ 1626 | 'price' => 40, 1627 | ], 1628 | ], 1629 | ] 1630 | ], 1631 | 'with_greater_than' => [ 1632 | new NumericFilter('price', Condition::greaterThan, 20), 1633 | [ 1634 | 'count' => 2, 1635 | 'results' => [ 1636 | 'product:3' => [ 1637 | 'price' => 30, 1638 | ], 1639 | 'product:4' => [ 1640 | 'price' => 40, 1641 | ], 1642 | ], 1643 | ] 1644 | ], 1645 | 'with_greater_than_or_equal' => [ 1646 | new NumericFilter('price', Condition::greaterThanOrEqual, 20), 1647 | [ 1648 | 'count' => 3, 1649 | 'results' => [ 1650 | 'product:2' => [ 1651 | 'price' => 20, 1652 | ], 1653 | 'product:3' => [ 1654 | 'price' => 30, 1655 | ], 1656 | 'product:4' => [ 1657 | 'price' => 40, 1658 | ], 1659 | ], 1660 | ] 1661 | ], 1662 | 'with_lower_than' => [ 1663 | new NumericFilter('price', Condition::lowerThan, 20), 1664 | [ 1665 | 'count' => 1, 1666 | 'results' => [ 1667 | 'product:1' => [ 1668 | 'price' => 10, 1669 | ], 1670 | ], 1671 | ] 1672 | ], 1673 | 'with_lower_than_or_equal' => [ 1674 | new NumericFilter('price', Condition::lowerThanOrEqual, 20), 1675 | [ 1676 | 'count' => 2, 1677 | 'results' => [ 1678 | 'product:1' => [ 1679 | 'price' => 10, 1680 | ], 1681 | 'product:2' => [ 1682 | 'price' => 20, 1683 | ], 1684 | ], 1685 | ] 1686 | ], 1687 | 'with_between' => [ 1688 | new NumericFilter('price', Condition::between, [20, 30]), 1689 | [ 1690 | 'count' => 2, 1691 | 'results' => [ 1692 | 'product:2' => [ 1693 | 'price' => 20, 1694 | ], 1695 | 'product:3' => [ 1696 | 'price' => 30, 1697 | ], 1698 | ], 1699 | ] 1700 | ], 1701 | ]; 1702 | } 1703 | 1704 | public static function vectorTextFilterProvider(): array 1705 | { 1706 | return [ 1707 | 'default' => [ 1708 | null, 1709 | [ 1710 | 'count' => 4, 1711 | 'results' => [ 1712 | 'product:1' => [ 1713 | 'description' => 'foobar', 1714 | ], 1715 | 'product:2' => [ 1716 | 'description' => 'barfoo', 1717 | ], 1718 | 'product:3' => [ 1719 | 'description' => 'foobar barfoo', 1720 | ], 1721 | 'product:4' => [ 1722 | 'description' => 'barfoo bazfoo', 1723 | ], 1724 | ] 1725 | ] 1726 | ], 1727 | 'empty_value' => [ 1728 | new TextFilter('description', Condition::equal, ''), 1729 | [ 1730 | 'count' => 4, 1731 | 'results' => [ 1732 | 'product:1' => [ 1733 | 'description' => 'foobar', 1734 | ], 1735 | 'product:2' => [ 1736 | 'description' => 'barfoo', 1737 | ], 1738 | 'product:3' => [ 1739 | 'description' => 'foobar barfoo', 1740 | ], 1741 | 'product:4' => [ 1742 | 'description' => 'barfoo bazfoo', 1743 | ], 1744 | ] 1745 | ] 1746 | ], 1747 | 'matching_single' => [ 1748 | new TextFilter('description', Condition::equal, 'foobar'), 1749 | [ 1750 | 'count' => 2, 1751 | 'results' => [ 1752 | 'product:1' => [ 1753 | 'description' => 'foobar', 1754 | ], 1755 | 'product:3' => [ 1756 | 'description' => 'foobar barfoo', 1757 | ], 1758 | ] 1759 | ] 1760 | ], 1761 | 'matching_multiple_and' => [ 1762 | new TextFilter('description', Condition::equal, 'foobar barfoo'), 1763 | [ 1764 | 'count' => 1, 1765 | 'results' => [ 1766 | 'product:3' => [ 1767 | 'description' => 'foobar barfoo', 1768 | ], 1769 | ] 1770 | ] 1771 | ], 1772 | 'matching_multiple_or' => [ 1773 | new TextFilter('description', Condition::equal, 'foobar | bazfoo'), 1774 | [ 1775 | 'count' => 3, 1776 | 'results' => [ 1777 | 'product:1' => [ 1778 | 'description' => 'foobar', 1779 | ], 1780 | 'product:3' => [ 1781 | 'description' => 'foobar barfoo', 1782 | ], 1783 | 'product:4' => [ 1784 | 'description' => 'barfoo bazfoo', 1785 | ], 1786 | ] 1787 | ] 1788 | ], 1789 | 'matching_fuzzy' => [ 1790 | new TextFilter('description', Condition::equal, '%foobaz%'), 1791 | [ 1792 | 'count' => 2, 1793 | 'results' => [ 1794 | 'product:1' => [ 1795 | 'description' => 'foobar', 1796 | ], 1797 | 'product:3' => [ 1798 | 'description' => 'foobar barfoo', 1799 | ], 1800 | ] 1801 | ] 1802 | ], 1803 | 'not_matching_single' => [ 1804 | new TextFilter('description', Condition::notEqual, 'foobar'), 1805 | [ 1806 | 'count' => 2, 1807 | 'results' => [ 1808 | 'product:2' => [ 1809 | 'description' => 'barfoo', 1810 | ], 1811 | 'product:4' => [ 1812 | 'description' => 'barfoo bazfoo', 1813 | ], 1814 | ] 1815 | ] 1816 | ], 1817 | 'not_matching_multiple_and' => [ 1818 | new TextFilter('description', Condition::notEqual, 'foobar barfoo'), 1819 | [ 1820 | 'count' => 3, 1821 | 'results' => [ 1822 | 'product:1' => [ 1823 | 'description' => 'foobar', 1824 | ], 1825 | 'product:2' => [ 1826 | 'description' => 'barfoo', 1827 | ], 1828 | 'product:4' => [ 1829 | 'description' => 'barfoo bazfoo', 1830 | ], 1831 | ] 1832 | ] 1833 | ], 1834 | 'not_matching_multiple_or' => [ 1835 | new TextFilter('description', Condition::notEqual, 'foobar | bazfoo'), 1836 | [ 1837 | 'count' => 1, 1838 | 'results' => [ 1839 | 'product:2' => [ 1840 | 'description' => 'barfoo', 1841 | ], 1842 | ] 1843 | ] 1844 | ], 1845 | 'pattern_matching_prefix' => [ 1846 | new TextFilter('description', Condition::pattern, 'baz*'), 1847 | [ 1848 | 'count' => 1, 1849 | 'results' => [ 1850 | 'product:4' => [ 1851 | 'description' => 'barfoo bazfoo', 1852 | ], 1853 | ] 1854 | ] 1855 | ], 1856 | 'pattern_matching_prefix_and_infix' => [ 1857 | new TextFilter('description', Condition::pattern, 'bar**foo'), 1858 | [ 1859 | 'count' => 3, 1860 | 'results' => [ 1861 | 'product:2' => [ 1862 | 'description' => 'barfoo', 1863 | ], 1864 | 'product:3' => [ 1865 | 'description' => 'foobar barfoo', 1866 | ], 1867 | 'product:4' => [ 1868 | 'description' => 'barfoo bazfoo', 1869 | ], 1870 | ] 1871 | ] 1872 | ], 1873 | ]; 1874 | } 1875 | 1876 | public static function vectorGeoFilterProvider(): array 1877 | { 1878 | return [ 1879 | 'default' => [ 1880 | null, 1881 | [ 1882 | 'count' => 4, 1883 | 'results' => [ 1884 | 'product:1' => [ 1885 | 'location' => '10.111,11.111', 1886 | ], 1887 | 'product:2' => [ 1888 | 'location' => '10.222,11.222', 1889 | ], 1890 | 'product:3' => [ 1891 | 'location' => '10.333,11.333', 1892 | ], 1893 | 'product:4' => [ 1894 | 'location' => '10.444,11.444', 1895 | ], 1896 | ] 1897 | ] 1898 | ], 1899 | 'equal_radius' => [ 1900 | new GeoFilter( 1901 | 'location', 1902 | Condition::equal, 1903 | ['lon' => 10.000, 'lat' => 12.000, 'radius' => 85, 'unit' => Unit::kilometers] 1904 | ), 1905 | [ 1906 | 'count' => 2, 1907 | 'results' => [ 1908 | 'product:3' => [ 1909 | 'location' => '10.333,11.333', 1910 | ], 1911 | 'product:4' => [ 1912 | 'location' => '10.444,11.444', 1913 | ], 1914 | ] 1915 | ] 1916 | ], 1917 | 'not_equal_radius' => [ 1918 | new GeoFilter( 1919 | 'location', 1920 | Condition::notEqual, 1921 | ['lon' => 10.000, 'lat' => 12.000, 'radius' => 85, 'unit' => Unit::kilometers] 1922 | ), 1923 | [ 1924 | 'count' => 2, 1925 | 'results' => [ 1926 | 'product:1' => [ 1927 | 'location' => '10.111,11.111', 1928 | ], 1929 | 'product:2' => [ 1930 | 'location' => '10.222,11.222', 1931 | ], 1932 | ] 1933 | ] 1934 | ], 1935 | ]; 1936 | } 1937 | 1938 | public static function vectorAggregateFilterProvider(): array 1939 | { 1940 | return [ 1941 | 'default' => [ 1942 | null, 1943 | [ 1944 | 'count' => 4, 1945 | 'results' => [ 1946 | 'product:1' => [ 1947 | 'id' => 1, 1948 | 'categories' => 'foo', 1949 | 'description' => 'foobar', 1950 | ], 1951 | 'product:2' => [ 1952 | 'id' => 2, 1953 | 'categories' => 'bar', 1954 | 'description' => 'barfoo', 1955 | ], 1956 | 'product:3' => [ 1957 | 'id' => 3, 1958 | 'categories' => 'foo', 1959 | 'description' => 'foobar barfoo', 1960 | ], 1961 | 'product:4' => [ 1962 | 'id' => 4, 1963 | 'categories' => 'foo,bar', 1964 | 'categories_array' => ['foo', 'bar'], 1965 | 'description' => 'barfoo bazfoo', 1966 | ], 1967 | ] 1968 | ] 1969 | ], 1970 | 'with_numeric_and_tag' => [ 1971 | new AggregateFilter([ 1972 | new NumericFilter('id', Condition::greaterThan, 1), 1973 | new TagFilter('categories', Condition::equal, 'foo') 1974 | ]), 1975 | [ 1976 | 'count' => 2, 1977 | 'results' => [ 1978 | 'product:3' => [ 1979 | 'id' => 3, 1980 | 'categories' => 'foo', 1981 | 'description' => 'foobar barfoo', 1982 | ], 1983 | 'product:4' => [ 1984 | 'id' => 4, 1985 | 'categories' => 'foo,bar', 1986 | 'categories_array' => ['foo', 'bar'], 1987 | 'description' => 'barfoo bazfoo', 1988 | ], 1989 | ] 1990 | ] 1991 | ], 1992 | 'with_numeric_or_tag' => [ 1993 | new AggregateFilter([ 1994 | new NumericFilter('id', Condition::greaterThan, 2), 1995 | new TagFilter('categories', Condition::equal, 'foo') 1996 | ], Logical::or), 1997 | [ 1998 | 'count' => 3, 1999 | 'results' => [ 2000 | 'product:1' => [ 2001 | 'id' => 1, 2002 | 'categories' => 'foo', 2003 | 'description' => 'foobar', 2004 | ], 2005 | 'product:3' => [ 2006 | 'id' => 3, 2007 | 'categories' => 'foo', 2008 | 'description' => 'foobar barfoo', 2009 | ], 2010 | 'product:4' => [ 2011 | 'id' => 4, 2012 | 'categories' => 'foo,bar', 2013 | 'categories_array' => ['foo', 'bar'], 2014 | 'description' => 'barfoo bazfoo', 2015 | ], 2016 | ] 2017 | ] 2018 | ], 2019 | 'with_tag_and_text' => [ 2020 | new AggregateFilter([ 2021 | new TagFilter('categories', Condition::equal, 'foo'), 2022 | new TextFilter('description', Condition::pattern, 'foo*') 2023 | ], Logical::and), 2024 | [ 2025 | 'count' => 2, 2026 | 'results' => [ 2027 | 'product:1' => [ 2028 | 'id' => 1, 2029 | 'categories' => 'foo', 2030 | 'description' => 'foobar', 2031 | ], 2032 | 'product:3' => [ 2033 | 'id' => 3, 2034 | 'categories' => 'foo', 2035 | 'description' => 'foobar barfoo', 2036 | ], 2037 | ] 2038 | ] 2039 | ], 2040 | 'with_tag_and_tag_or_text' => [ 2041 | (new AggregateFilter([ 2042 | new TagFilter('categories', Condition::equal, 'foo'), 2043 | new TagFilter('categories', Condition::equal, 'bar'), 2044 | ]))->or( 2045 | new TextFilter('description', Condition::pattern, '*bar') 2046 | ), 2047 | [ 2048 | 'count' => 3, 2049 | 'results' => [ 2050 | 'product:1' => [ 2051 | 'id' => 1, 2052 | 'categories' => 'foo', 2053 | 'description' => 'foobar', 2054 | ], 2055 | 'product:3' => [ 2056 | 'id' => 3, 2057 | 'categories' => 'foo', 2058 | 'description' => 'foobar barfoo', 2059 | ], 2060 | 'product:4' => [ 2061 | 'id' => 4, 2062 | 'categories' => 'foo,bar', 2063 | 'categories_array' => ['foo', 'bar'], 2064 | 'description' => 'barfoo bazfoo', 2065 | ], 2066 | ] 2067 | ] 2068 | ], 2069 | 'with_numeric_and_numeric_and_tag' => [ 2070 | (new AggregateFilter([ 2071 | new NumericFilter('id', Condition::greaterThanOrEqual, 1), 2072 | new NumericFilter('id', Condition::lowerThanOrEqual, 3), 2073 | ]))->and( 2074 | new TagFilter('categories', Condition::equal, 'bar') 2075 | ), 2076 | [ 2077 | 'count' => 1, 2078 | 'results' => [ 2079 | 'product:2' => [ 2080 | 'id' => 2, 2081 | 'categories' => 'bar', 2082 | 'description' => 'barfoo', 2083 | ], 2084 | ] 2085 | ] 2086 | ], 2087 | ]; 2088 | } 2089 | } -------------------------------------------------------------------------------- /tests/Unit/Enum/SearchFieldTest.php: -------------------------------------------------------------------------------- 1 | assertSame($expectedClass, $enum->fieldMapping()); 24 | } 25 | 26 | /** 27 | * @return void 28 | */ 29 | public function testNames(): void 30 | { 31 | $this->assertSame(['tag', 'text', 'numeric', 'vector', 'geo'], SearchField::names()); 32 | } 33 | 34 | /** 35 | * @dataProvider fromNameProvider 36 | * @param string $name 37 | * @param SearchField $expectedEnum 38 | * @return void 39 | */ 40 | public function testFromName(string $name, SearchField $expectedEnum): void 41 | { 42 | $this->assertSame(SearchField::fromName($name), $expectedEnum); 43 | } 44 | 45 | public static function mappingProvider(): array 46 | { 47 | return [ 48 | 'tag' => [SearchField::tag, TagField::class], 49 | 'text' => [SearchField::text, TextField::class], 50 | 'numeric' => [SearchField::numeric, NumericField::class], 51 | 'vector' => [SearchField::vector, VectorField::class], 52 | 'geo' => [SearchField::geo, GeoField::class], 53 | ]; 54 | } 55 | 56 | public static function fromNameProvider(): array 57 | { 58 | return [ 59 | ['tag', SearchField::tag], 60 | ['text', SearchField::text], 61 | ['numeric', SearchField::numeric], 62 | ['vector', SearchField::vector], 63 | ['geo', SearchField::geo], 64 | ]; 65 | } 66 | } -------------------------------------------------------------------------------- /tests/Unit/FactoryTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(CreateArguments::class, $factory->createIndexBuilder()); 19 | } 20 | 21 | /** 22 | * @return void 23 | */ 24 | public function testCreateSearchBuilder(): void 25 | { 26 | $factory = new Factory(); 27 | $this->assertInstanceOf(SearchArguments::class, $factory->createSearchBuilder()); 28 | } 29 | } -------------------------------------------------------------------------------- /tests/Unit/Index/SearchIndexTest.php: -------------------------------------------------------------------------------- 1 | mockClient = Mockery::mock(Client::class); 44 | $this->mockFactory = Mockery::mock(FactoryInterface::class); 45 | $this->mockIndexBuilder = Mockery::mock(CreateArguments::class); 46 | 47 | $this->schema = [ 48 | 'index' => [ 49 | 'name' => 'foobar', 50 | 'storage_type' => 'hash', 51 | ], 52 | 'fields' => [ 53 | 'foo' => [ 54 | 'type' => 'text', 55 | ] 56 | ] 57 | ]; 58 | } 59 | 60 | /** 61 | * @dataProvider validateSchemaProvider 62 | * @param array $schema 63 | * @param string $exceptionMessage 64 | * @return void 65 | */ 66 | public function testValidateSchemaThrowsException(array $schema, string $exceptionMessage): void 67 | { 68 | $this->expectException(Exception::class); 69 | $this->expectExceptionMessage($exceptionMessage); 70 | 71 | new SearchIndex($this->mockClient, $schema); 72 | } 73 | 74 | /** 75 | * @return void 76 | */ 77 | public function testCreateWithNoPrefixWithNoOverride(): void 78 | { 79 | $expectedSchema = [ 80 | new TextField('foo'), 81 | ]; 82 | 83 | $this->mockFactory 84 | ->shouldReceive('createIndexBuilder') 85 | ->once() 86 | ->withNoArgs() 87 | ->andReturn($this->mockIndexBuilder); 88 | 89 | $this->mockIndexBuilder 90 | ->shouldReceive('on') 91 | ->once() 92 | ->with('HASH') 93 | ->andReturn($this->mockIndexBuilder); 94 | 95 | $this->mockClient 96 | ->shouldReceive('ftcreate') 97 | ->once() 98 | ->with('foobar', $expectedSchema, $this->mockIndexBuilder) 99 | ->andReturn(new Status('OK')); 100 | 101 | $index = new SearchIndex($this->mockClient, $this->schema, $this->mockFactory); 102 | 103 | $this->assertTrue($index->create()); 104 | } 105 | 106 | /** 107 | * @return void 108 | */ 109 | public function testCreateWithOverride(): void 110 | { 111 | $expectedSchema = [ 112 | new TextField('foo'), 113 | ]; 114 | 115 | $this->mockClient 116 | ->shouldReceive('ftdropindex') 117 | ->once() 118 | ->with('foobar'); 119 | 120 | $this->mockFactory 121 | ->shouldReceive('createIndexBuilder') 122 | ->once() 123 | ->withNoArgs() 124 | ->andReturn($this->mockIndexBuilder); 125 | 126 | $this->mockIndexBuilder 127 | ->shouldReceive('on') 128 | ->once() 129 | ->with('HASH') 130 | ->andReturn($this->mockIndexBuilder); 131 | 132 | $this->mockClient 133 | ->shouldReceive('ftcreate') 134 | ->once() 135 | ->with('foobar', $expectedSchema, $this->mockIndexBuilder) 136 | ->andReturn(new Status('OK')); 137 | 138 | $index = new SearchIndex($this->mockClient, $this->schema, $this->mockFactory); 139 | 140 | $this->assertTrue($index->create(true)); 141 | } 142 | 143 | /** 144 | * @return void 145 | */ 146 | public function testCreateWithOverrideDoesNotFailsOnCommandError(): void 147 | { 148 | $expectedSchema = [ 149 | new TextField('foo'), 150 | ]; 151 | 152 | $this->mockClient 153 | ->shouldReceive('ftdropindex') 154 | ->once() 155 | ->with('foobar') 156 | ->andThrow(ServerException::class); 157 | 158 | $this->mockFactory 159 | ->shouldReceive('createIndexBuilder') 160 | ->once() 161 | ->withNoArgs() 162 | ->andReturn($this->mockIndexBuilder); 163 | 164 | $this->mockIndexBuilder 165 | ->shouldReceive('on') 166 | ->once() 167 | ->with('HASH') 168 | ->andReturn($this->mockIndexBuilder); 169 | 170 | $this->mockClient 171 | ->shouldReceive('ftcreate') 172 | ->once() 173 | ->with('foobar', $expectedSchema, $this->mockIndexBuilder) 174 | ->andReturn(new Status('OK')); 175 | 176 | $index = new SearchIndex($this->mockClient, $this->schema, $this->mockFactory); 177 | 178 | $this->assertTrue($index->create(true)); 179 | } 180 | 181 | /** 182 | * @return void 183 | */ 184 | public function testCreateReturnsFalseOnCommandError(): void 185 | { 186 | $expectedSchema = [ 187 | new TextField('foo'), 188 | ]; 189 | 190 | $this->mockFactory 191 | ->shouldReceive('createIndexBuilder') 192 | ->once() 193 | ->withNoArgs() 194 | ->andReturn($this->mockIndexBuilder); 195 | 196 | $this->mockIndexBuilder 197 | ->shouldReceive('on') 198 | ->once() 199 | ->with('HASH') 200 | ->andReturn($this->mockIndexBuilder); 201 | 202 | $this->mockClient 203 | ->shouldReceive('ftcreate') 204 | ->once() 205 | ->with('foobar', $expectedSchema, $this->mockIndexBuilder) 206 | ->andReturn(new ServerException('Error')); 207 | 208 | $index = new SearchIndex($this->mockClient, $this->schema, $this->mockFactory); 209 | 210 | $this->assertFalse($index->create()); 211 | } 212 | 213 | /** 214 | * @return void 215 | */ 216 | public function testCreateWithNoPrefixWithNoOverrideWithNoStorageType(): void 217 | { 218 | $expectedSchema = [ 219 | new TextField('foo'), 220 | ]; 221 | 222 | $schema = $this->schema; 223 | unset($schema['index']['storage_type']); 224 | 225 | $this->mockFactory 226 | ->shouldReceive('createIndexBuilder') 227 | ->once() 228 | ->withNoArgs() 229 | ->andReturn($this->mockIndexBuilder); 230 | 231 | $this->mockIndexBuilder 232 | ->shouldReceive('on') 233 | ->once() 234 | ->withNoArgs() 235 | ->andReturn($this->mockIndexBuilder); 236 | 237 | $this->mockClient 238 | ->shouldReceive('ftcreate') 239 | ->once() 240 | ->with('foobar', $expectedSchema, $this->mockIndexBuilder) 241 | ->andReturn(new Status('OK')); 242 | 243 | $index = new SearchIndex($this->mockClient, $schema, $this->mockFactory); 244 | 245 | $this->assertTrue($index->create()); 246 | } 247 | 248 | /** 249 | * @dataProvider prefixProvider 250 | * @param $prefix 251 | * @return void 252 | */ 253 | public function testCreateWithPrefixWithNoOverride($prefix): void 254 | { 255 | $expectedSchema = [ 256 | new TextField('foo'), 257 | ]; 258 | $schema = $this->schema; 259 | $schema['index']['prefix'] = $prefix; 260 | 261 | $this->mockFactory 262 | ->shouldReceive('createIndexBuilder') 263 | ->once() 264 | ->withNoArgs() 265 | ->andReturn($this->mockIndexBuilder); 266 | 267 | $this->mockIndexBuilder 268 | ->shouldReceive('on') 269 | ->once() 270 | ->with('HASH') 271 | ->andReturn($this->mockIndexBuilder); 272 | 273 | $this->mockIndexBuilder 274 | ->shouldReceive('prefix') 275 | ->once() 276 | ->with([$prefix]) 277 | ->andReturn($this->mockIndexBuilder); 278 | 279 | $this->mockClient 280 | ->shouldReceive('ftcreate') 281 | ->once() 282 | ->with('foobar', $expectedSchema, $this->mockIndexBuilder) 283 | ->andReturn(new Status('OK')); 284 | 285 | $index = new SearchIndex($this->mockClient, $schema, $this->mockFactory); 286 | 287 | $this->assertTrue($index->create()); 288 | } 289 | 290 | /** 291 | * @return void 292 | */ 293 | public function testCreateWithAlias(): void 294 | { 295 | $expectedSchema = [ 296 | new TextField('foo', 'bar'), 297 | ]; 298 | 299 | $schema = $this->schema; 300 | $schema['fields']['foo']['alias'] = 'bar'; 301 | 302 | $this->mockFactory 303 | ->shouldReceive('createIndexBuilder') 304 | ->once() 305 | ->withNoArgs() 306 | ->andReturn($this->mockIndexBuilder); 307 | 308 | $this->mockIndexBuilder 309 | ->shouldReceive('on') 310 | ->once() 311 | ->with('HASH') 312 | ->andReturn($this->mockIndexBuilder); 313 | 314 | $this->mockClient 315 | ->shouldReceive('ftcreate') 316 | ->once() 317 | ->with('foobar', $expectedSchema, $this->mockIndexBuilder) 318 | ->andReturn(new Status('OK')); 319 | 320 | $index = new SearchIndex($this->mockClient, $schema, $this->mockFactory); 321 | 322 | $this->assertTrue($index->create()); 323 | } 324 | 325 | /** 326 | * @return void 327 | */ 328 | public function testCreateWithMultipleFields(): void 329 | { 330 | $expectedSchema = [ 331 | new TextField('foo'), 332 | new VectorField('bar', 'FLAT', 333 | [ 334 | 'TYPE', 'FLOAT32', 335 | 'DIM', 3, 336 | 'DISTANCE_METRIC', 'COSINE' 337 | ] 338 | ), 339 | ]; 340 | 341 | $schema = $this->schema; 342 | $schema['fields']['bar'] = [ 343 | 'type' => 'vector', 344 | 'dims' => 3, 345 | 'distance_metric' => 'cosine', 346 | 'algorithm' => 'flat', 347 | 'datatype' => 'float32' 348 | ]; 349 | 350 | $this->mockFactory 351 | ->shouldReceive('createIndexBuilder') 352 | ->once() 353 | ->withNoArgs() 354 | ->andReturn($this->mockIndexBuilder); 355 | 356 | $this->mockIndexBuilder 357 | ->shouldReceive('on') 358 | ->once() 359 | ->with('HASH') 360 | ->andReturn($this->mockIndexBuilder); 361 | 362 | $this->mockClient 363 | ->shouldReceive('ftcreate') 364 | ->once() 365 | ->with('foobar', $expectedSchema, $this->mockIndexBuilder) 366 | ->andReturn(new Status('OK')); 367 | 368 | $index = new SearchIndex($this->mockClient, $schema, $this->mockFactory); 369 | 370 | $this->assertTrue($index->create()); 371 | } 372 | 373 | /** 374 | * @return void 375 | */ 376 | public function testCreateThrowsExceptionOnMissingMandatoryVectorFields(): void 377 | { 378 | $schema = $this->schema; 379 | $schema['fields']['bar'] = [ 380 | 'type' => 'vector', 381 | 'distance_metric' => 'cosine', 382 | 'algorithm' => 'flat', 383 | 'datatype' => 'float32' 384 | ]; 385 | 386 | $this->mockFactory 387 | ->shouldReceive('createIndexBuilder') 388 | ->once() 389 | ->withNoArgs() 390 | ->andReturn($this->mockIndexBuilder); 391 | 392 | $this->mockIndexBuilder 393 | ->shouldReceive('on') 394 | ->once() 395 | ->with('HASH') 396 | ->andReturn($this->mockIndexBuilder); 397 | 398 | $this->mockClient 399 | ->shouldReceive('ftcreate') 400 | ->once() 401 | ->with('foobar', $schema, $this->mockIndexBuilder) 402 | ->andReturn(new Status('OK')); 403 | 404 | $index = new SearchIndex($this->mockClient, $schema, $this->mockFactory); 405 | 406 | $this->expectException(Exception::class); 407 | $this->expectExceptionMessage("datatype, dims, distance_metric and algorithm are mandatory parameters for vector field."); 408 | 409 | $this->assertTrue($index->create()); 410 | } 411 | 412 | /** 413 | * @return void 414 | */ 415 | public function testLoadHash(): void 416 | { 417 | $this->mockClient 418 | ->shouldReceive('hmset') 419 | ->once() 420 | ->with('key', ['key' => 'value']) 421 | ->andReturn(new Status('OK')); 422 | 423 | $index = new SearchIndex($this->mockClient, $this->schema); 424 | 425 | $this->assertTrue($index->load('key',['key' => 'value'])); 426 | } 427 | 428 | /** 429 | * @return void 430 | */ 431 | public function testLoadJson(): void 432 | { 433 | $this->mockClient 434 | ->shouldReceive('jsonset') 435 | ->once() 436 | ->with('key', '$', '{"key":"value"}') 437 | ->andReturn(new Status('OK')); 438 | 439 | $index = new SearchIndex($this->mockClient, $this->schema); 440 | 441 | $this->assertTrue($index->load('key','{"key":"value"}')); 442 | } 443 | 444 | /** 445 | * @return void 446 | */ 447 | public function testLoadReturnsFalseOnCommandError(): void 448 | { 449 | $this->mockClient 450 | ->shouldReceive('jsonset') 451 | ->once() 452 | ->with('key', '$', '{"key":"value"}') 453 | ->andReturn(new ServerException('Error')); 454 | 455 | $index = new SearchIndex($this->mockClient, $this->schema); 456 | 457 | $this->assertFalse($index->load('key','{"key":"value"}')); 458 | } 459 | 460 | /** 461 | * @return void 462 | */ 463 | public function testLoadWithPrefix(): void 464 | { 465 | $schema = $this->schema; 466 | $schema['index']['prefix'] = 'foo:'; 467 | 468 | $this->mockClient 469 | ->shouldReceive('hmset') 470 | ->once() 471 | ->with('foo:key', ['key' => 'value']) 472 | ->andReturn(new Status('OK')); 473 | 474 | $index = new SearchIndex($this->mockClient, $schema); 475 | 476 | $this->assertTrue($index->load('key',['key' => 'value'])); 477 | } 478 | 479 | /** 480 | * @return void 481 | */ 482 | public function testFetchWithPrefix(): void 483 | { 484 | $schema = $this->schema; 485 | $schema['index']['prefix'] = 'prefix:'; 486 | 487 | $this->mockClient 488 | ->shouldReceive('hgetall') 489 | ->once() 490 | ->with('prefix:key') 491 | ->andReturn(['key' => 'value']); 492 | 493 | $index = new SearchIndex($this->mockClient, $schema); 494 | 495 | $this->assertSame(['key' => 'value'], $index->fetch('key')); 496 | } 497 | 498 | /** 499 | * @return void 500 | */ 501 | public function testFetchHash(): void 502 | { 503 | $schema = $this->schema; 504 | unset($schema['index']['storage_type']); 505 | 506 | $this->mockClient 507 | ->shouldReceive('hgetall') 508 | ->once() 509 | ->with('key') 510 | ->andReturn(['key' => 'value']); 511 | 512 | $index = new SearchIndex($this->mockClient, $schema); 513 | 514 | $this->assertSame(['key' => 'value'], $index->fetch('key')); 515 | } 516 | 517 | /** 518 | * @return void 519 | */ 520 | public function testFetchJson(): void 521 | { 522 | $schema = $this->schema; 523 | $schema['index']['storage_type'] = 'json'; 524 | 525 | $this->mockClient 526 | ->shouldReceive('jsonget') 527 | ->once() 528 | ->with('key') 529 | ->andReturn('{"key":"value"}'); 530 | 531 | $index = new SearchIndex($this->mockClient, $schema); 532 | 533 | $this->assertSame('{"key":"value"}', $index->fetch('key')); 534 | } 535 | 536 | /** 537 | * @return void 538 | */ 539 | public function testGetSchema(): void 540 | { 541 | $index = new SearchIndex($this->mockClient, [ 542 | 'index' => ['name' => 'bar'], 543 | 'fields' => [ 544 | 'foo' => ['type' => 'tag'] 545 | ] 546 | ]); 547 | 548 | $this->assertSame([ 549 | 'index' => ['name' => 'bar'], 550 | 'fields' => [ 551 | 'foo' => ['type' => 'tag'] 552 | ] 553 | ], $index->getSchema()); 554 | } 555 | 556 | /** 557 | * @dataProvider queryProvider 558 | * @param bool $returnScore 559 | * @param array $expectedResponse 560 | * @param array $expectedProcessedResponse 561 | * @return void 562 | */ 563 | public function testQuery(bool $returnScore, array $expectedResponse, array $expectedProcessedResponse): void 564 | { 565 | $searchArguments = new SearchArguments(); 566 | 567 | $mockFactory = Mockery::mock(FactoryInterface::class); 568 | $mockFactory 569 | ->shouldReceive('createSearchBuilder') 570 | ->once() 571 | ->withNoArgs() 572 | ->andReturn($searchArguments); 573 | 574 | $query = new VectorQuery( 575 | [0.01, 0.02, 0.03], 576 | 'vector', 577 | null, 578 | 10, 579 | $returnScore, 580 | 2, 581 | null, 582 | $mockFactory 583 | ); 584 | 585 | $this->mockClient 586 | ->shouldReceive('ftsearch') 587 | ->once() 588 | ->with($this->schema['index']['name'], $query->getQueryString(), $searchArguments) 589 | ->andReturn($expectedResponse); 590 | 591 | $index = new SearchIndex($this->mockClient, $this->schema); 592 | 593 | $this->assertSame($expectedProcessedResponse, $index->query($query)); 594 | } 595 | 596 | public static function queryProvider(): array 597 | { 598 | return [ 599 | 'return_score' => [ 600 | true, 601 | [1, 'foo:bar', 0, ['foo', 'bar']], 602 | [ 603 | 'count' => 1, 604 | 'results' => [ 605 | 'foo:bar' => ['score' => 0, 'foo' => 'bar'] 606 | ], 607 | ] 608 | ], 609 | 'no_return_score' => [ 610 | false, 611 | [1, 'foo:bar', ['foo', 'bar']], 612 | [ 613 | 'count' => 1, 614 | 'results' => [ 615 | 'foo:bar' => ['foo' => 'bar'] 616 | ], 617 | ] 618 | ], 619 | ]; 620 | } 621 | 622 | public static function validateSchemaProvider(): array 623 | { 624 | return [ 625 | 'no_index' => [ 626 | ['fields' => ['foo' => 'bar']], 627 | "Schema should contains 'index' entry.", 628 | ], 629 | 'no_index_name' => [ 630 | ['index' => ['foo' => 'bar']], 631 | "Index name is required.", 632 | ], 633 | 'incorrect_storage_type' => [ 634 | ['index' => ['name' => 'bar', 'storage_type' => 'foo']], 635 | "Invalid storage type value.", 636 | ], 637 | 'no_fields' => [ 638 | ['index' => ['name' => 'bar']], 639 | "Schema should contains at least one field.", 640 | ], 641 | 'no_field_type' => [ 642 | ['index' => ['name' => 'bar'], 'fields' => ['foo' => []]], 643 | "Field type should be specified for each field.", 644 | ], 645 | 'incorrect_field_type' => [ 646 | ['index' => ['name' => 'bar'], 'fields' => ['foo' => ['type' => 'bar']]], 647 | "Invalid field type.", 648 | ], 649 | ]; 650 | } 651 | 652 | public static function prefixProvider(): array 653 | { 654 | return [[ 655 | 'prefix:', 656 | ['prefix1:', 'prefix2:'] 657 | ]]; 658 | } 659 | } -------------------------------------------------------------------------------- /tests/Unit/Query/Filter/AbstractFilter.php: -------------------------------------------------------------------------------- 1 | testClass = new class() extends \RedisVentures\RedisVl\Query\Filter\AbstractFilter 18 | { 19 | public function toExpression(): string 20 | { 21 | return 'foobar'; 22 | } 23 | }; 24 | } 25 | 26 | /** 27 | * @return void 28 | */ 29 | public function testToExpression(): void 30 | { 31 | $this->assertSame('foobar', $this->testClass->toExpression()); 32 | } 33 | } -------------------------------------------------------------------------------- /tests/Unit/Query/Filter/AggregateFilterTest.php: -------------------------------------------------------------------------------- 1 | and( 23 | new TextFilter('foo', Condition::equal, 'bar'), 24 | new NumericFilter('bar', Condition::greaterThan, 100) 25 | ); 26 | 27 | $this->assertSame('@foo:(bar) @bar:[(100 +inf]', $filter->toExpression()); 28 | } 29 | 30 | /** 31 | * @return void 32 | */ 33 | public function testOr(): void 34 | { 35 | $filter = (new AggregateFilter())->or( 36 | new TextFilter('foo', Condition::equal, 'bar'), 37 | new NumericFilter('bar', Condition::greaterThan, 100), 38 | new TagFilter('baz', Condition::equal, 'bar') 39 | ); 40 | 41 | $this->assertSame('@foo:(bar) | @bar:[(100 +inf] | @baz:{bar}', $filter->toExpression()); 42 | } 43 | 44 | /** 45 | * @dataProvider filterProvider 46 | * @param array $filters 47 | * @param Logical|null $conjunction 48 | * @param string $expectedExpression 49 | * @return void 50 | */ 51 | public function testToExpressionWithSameLogicalOperator( 52 | array $filters, 53 | ?Logical $conjunction, 54 | string $expectedExpression 55 | ): void { 56 | $filter = new AggregateFilter($filters, $conjunction); 57 | 58 | $this->assertSame($expectedExpression, $filter->toExpression()); 59 | } 60 | 61 | /** 62 | * @return void 63 | */ 64 | public function testToExpressionCreatesCombinedLogicalOperatorExpression(): void 65 | { 66 | $filter = new AggregateFilter( 67 | [ 68 | new TextFilter('foo', Condition::equal, 'bar'), 69 | new NumericFilter('bar', Condition::greaterThan, 100) 70 | ], 71 | Logical::and 72 | ); 73 | 74 | $filter = $filter 75 | ->or( 76 | new TagFilter('baz', Condition::notEqual, 'bar'), 77 | new TagFilter('bal', Condition::notEqual, 'bak') 78 | ) 79 | ->and(new GeoFilter( 80 | 'bad', 81 | Condition::equal, 82 | ['lon' => 10.111, 'lat' => 11.111, 'radius' => 100, 'unit' => Unit::kilometers] 83 | )); 84 | 85 | $this->assertSame( 86 | '@foo:(bar) @bar:[(100 +inf] -@baz:{bar} | -@bal:{bak} @bad:[10.111 11.111 100 km]', 87 | $filter->toExpression() 88 | ); 89 | } 90 | 91 | public static function filterProvider(): array 92 | { 93 | return [ 94 | 'empty' => [ 95 | [], 96 | Logical::and, 97 | '' 98 | ], 99 | 'AND' => [ 100 | [ 101 | new TextFilter('foo', Condition::equal, 'bar'), 102 | new NumericFilter('bar', Condition::greaterThan, 100) 103 | ], 104 | Logical::and, 105 | '@foo:(bar) @bar:[(100 +inf]' 106 | ], 107 | 'OR' => [ 108 | [ 109 | new TextFilter('foo', Condition::equal, 'bar'), 110 | new NumericFilter('bar', Condition::greaterThan, 100), 111 | new TagFilter('baz', Condition::equal, 'bar') 112 | ], 113 | Logical::or, 114 | '@foo:(bar) | @bar:[(100 +inf] | @baz:{bar}' 115 | ], 116 | ]; 117 | } 118 | } -------------------------------------------------------------------------------- /tests/Unit/Query/Filter/GeoFilterTest.php: -------------------------------------------------------------------------------- 1 | assertSame($expectedExpression, $filter->toExpression()); 24 | } 25 | 26 | public static function filterProvider(): array 27 | { 28 | return [ 29 | 'equal' => [ 30 | Condition::equal, 31 | ['lon' => 10.111, 'lat' => 11.111, 'radius' => 100, 'unit' => Unit::kilometers], 32 | '@foo:[10.111 11.111 100 km]' 33 | ], 34 | 'not_equal' => [ 35 | Condition::notEqual, 36 | ['lon' => 10.111, 'lat' => 11.111, 'radius' => 100, 'unit' => Unit::kilometers], 37 | '-@foo:[10.111 11.111 100 km]' 38 | ], 39 | ]; 40 | } 41 | } -------------------------------------------------------------------------------- /tests/Unit/Query/Filter/NumericFilterTest.php: -------------------------------------------------------------------------------- 1 | assertSame($expectedExpression, $filter->toExpression()); 28 | } 29 | 30 | /** 31 | * @return void 32 | * @throws Exception 33 | */ 34 | public function testToExpressionThrowsExceptionOnIncorrectValue(): void 35 | { 36 | $filter = new NumericFilter('foo', Condition::between, 10); 37 | 38 | $this->expectException(Exception::class); 39 | $this->expectExceptionMessage('Between condition requires int[] as $ value'); 40 | 41 | $filter->toExpression(); 42 | } 43 | 44 | public static function filterProvider(): array 45 | { 46 | return [ 47 | 'equal' => [Condition::equal, 10, '@foo:[10 10]'], 48 | 'not_equal' => [Condition::notEqual, 10, '-@foo:[10 10]'], 49 | 'greater_than' => [Condition::greaterThan, 10, '@foo:[(10 +inf]'], 50 | 'greater_than_or_equal' => [Condition::greaterThanOrEqual, 10, '@foo:[10 +inf]'], 51 | 'lower_than' => [Condition::lowerThan, 10, '@foo:[-inf (10]'], 52 | 'lower_than_or_equal' => [Condition::lowerThanOrEqual, 10, '@foo:[-inf 10]'], 53 | 'between' => [Condition::between, [10, 20], '@foo:[10 20]'], 54 | ]; 55 | } 56 | } -------------------------------------------------------------------------------- /tests/Unit/Query/Filter/TagFilterTest.php: -------------------------------------------------------------------------------- 1 | assertSame($expectedQuery, $filter->toExpression()); 24 | } 25 | 26 | public static function filterProvider(): array 27 | { 28 | return [ 29 | 'single_tag_equal' => [Condition::equal, 'bar', '@foo:{bar}'], 30 | 'single_tag_not_equal' => [Condition::notEqual, 'bar', '-@foo:{bar}'], 31 | 'multiple_tags_equal_or' => [Condition::equal, ['conjunction' => Logical::or, 'tags' => ['bar', 'baz']], '@foo:{bar | baz}'], 32 | 'multiple_tags_equal_and' => [Condition::equal, ['conjunction' => Logical::and, 'tags' => ['bar', 'baz']], '@foo:{bar} @foo:{baz}'], 33 | 'wrong_condition' => [Condition::greaterThan, 'bar', '@foo:{bar}'], 34 | ]; 35 | } 36 | } -------------------------------------------------------------------------------- /tests/Unit/Query/Filter/TextFilterTest.php: -------------------------------------------------------------------------------- 1 | assertSame($expectedExpression, $filter->toExpression()); 23 | } 24 | 25 | public static function filterProvider(): array 26 | { 27 | return [ 28 | 'empty_value' => [Condition::equal, '', '*'], 29 | 'equal' => [Condition::equal, 'bar', '@foo:(bar)'], 30 | 'equal_multiple' => [Condition::equal, 'bar|baz', '@foo:(bar|baz)'], 31 | 'not_equal' => [Condition::notEqual, 'bar', '-@foo:(bar)'], 32 | 'pattern' => [Condition::pattern, 'bar*', "@foo:(w'bar*')"], 33 | 'pattern_multiple' => [Condition::pattern, 'bar*|baz*', "@foo:(w'bar*|baz*')"], 34 | ]; 35 | } 36 | } -------------------------------------------------------------------------------- /tests/Unit/Query/VectorQueryTest.php: -------------------------------------------------------------------------------- 1 | mockFilter = Mockery::mock(FilterInterface::class); 30 | $this->mockFactory = Mockery::mock(FactoryInterface::class); 31 | } 32 | 33 | /** 34 | * @return void 35 | */ 36 | public function testGetFilter(): void 37 | { 38 | $filter = new TagFilter('foo', Condition::equal, 'bar'); 39 | 40 | $query = new VectorQuery( 41 | [0.01, 0.02, 0.03], 42 | 'foo', 43 | null, 44 | 10, 45 | true, 46 | 2, 47 | $filter 48 | ); 49 | 50 | $this->assertSame($filter, $query->getFilter()); 51 | } 52 | 53 | /** 54 | * @dataProvider queryStringProvider 55 | * @param string $fieldName 56 | * @param int $resultsCount 57 | * @param string $expectedQuery 58 | * @return void 59 | */ 60 | public function testGetQueryStringWithoutFilter(string $fieldName, int $resultsCount, string $expectedQuery): void 61 | { 62 | $query = new VectorQuery([0.01, 0.02, 0.03], $fieldName, null, $resultsCount); 63 | 64 | $this->assertSame($expectedQuery, $query->getQueryString()); 65 | } 66 | 67 | /** 68 | * @return void 69 | */ 70 | public function testGetQueryStringWithFilter(): void 71 | { 72 | $this->mockFilter 73 | ->shouldReceive('toExpression') 74 | ->once() 75 | ->withNoArgs() 76 | ->andReturn("@foo:{bar}"); 77 | 78 | $query = new VectorQuery( 79 | [0.01, 0.02, 0.03], 80 | 'foo', 81 | null, 82 | 10, 83 | true, 84 | 2, 85 | $this->mockFilter 86 | ); 87 | 88 | $this->assertSame('(@foo:{bar})=>[KNN 10 @foo $vector AS vector_score]', $query->getQueryString()); 89 | } 90 | 91 | /** 92 | * @dataProvider searchArgumentsProvider 93 | * @param array $vector 94 | * @param string $dialect 95 | * @param array|null $returnFields 96 | * @param bool $returnScore 97 | * @param array $expectedSearchArguments 98 | * @return void 99 | */ 100 | public function testGetSearchArgumentsWithoutPagination( 101 | array $vector, 102 | string $dialect, 103 | ?array $returnFields, 104 | bool $returnScore, 105 | array $expectedSearchArguments 106 | ): void { 107 | $this->mockFactory 108 | ->shouldReceive('createSearchBuilder') 109 | ->once() 110 | ->withNoArgs() 111 | ->andReturn(new SearchArguments()); 112 | 113 | $query = new VectorQuery( 114 | $vector, 115 | 'foo', 116 | $returnFields, 117 | 10, 118 | $returnScore, 119 | $dialect, 120 | null, 121 | $this->mockFactory 122 | ); 123 | 124 | $this->assertEquals($expectedSearchArguments, $query->getSearchArguments()->toArray()); 125 | } 126 | 127 | /** 128 | * @return void 129 | */ 130 | public function testGetSearchArgumentsWithPagination(): void 131 | { 132 | $this->mockFactory 133 | ->shouldReceive('createSearchBuilder') 134 | ->once() 135 | ->withNoArgs() 136 | ->andReturn(new SearchArguments()); 137 | 138 | $query = new VectorQuery( 139 | [0.01, 0.02, 0.03], 140 | 'foo', 141 | null, 142 | 10, 143 | true, 144 | 2, 145 | null, 146 | $this->mockFactory 147 | ); 148 | 149 | $query->setPagination(10, 20); 150 | 151 | $this->assertEquals([ 152 | 'PARAMS', 2, 'vector', VectorHelper::toBytes([0.01, 0.02, 0.03]), 'SORTBY', 'vector_score', 'ASC', 153 | 'DIALECT', '2', 'WITHSCORES', 'LIMIT', 10, 20 154 | ], $query->getSearchArguments()->toArray()); 155 | } 156 | 157 | public static function queryStringProvider(): array 158 | { 159 | return [ 160 | 'default' => ['foo', 10, '(*)=>[KNN 10 @foo $vector AS vector_score]'], 161 | 'with_result_count' => ['foo', 3, '(*)=>[KNN 3 @foo $vector AS vector_score]'], 162 | ]; 163 | } 164 | 165 | public static function searchArgumentsProvider(): array 166 | { 167 | return [ 168 | 'default' => [ 169 | [0.01, 0.02, 0.03], 170 | '2', 171 | null, 172 | true, 173 | [ 174 | 'PARAMS', 2, 'vector', VectorHelper::toBytes([0.01, 0.02, 0.03]), 'SORTBY', 'vector_score', 'ASC', 175 | 'DIALECT', '2', 'WITHSCORES' 176 | ] 177 | ], 178 | 'with_dialect' => [ 179 | [0.01, 0.02, 0.03], 180 | '1', 181 | null, 182 | true, 183 | [ 184 | 'PARAMS', 2, 'vector', VectorHelper::toBytes([0.01, 0.02, 0.03]), 'SORTBY', 'vector_score', 'ASC', 185 | 'DIALECT', '1', 'WITHSCORES' 186 | ] 187 | ], 188 | 'with_return_fields' => [ 189 | [0.01, 0.02, 0.03], 190 | '2', 191 | ['bar', 'baz'], 192 | true, 193 | [ 194 | 'PARAMS', 2, 'vector', VectorHelper::toBytes([0.01, 0.02, 0.03]), 'SORTBY', 'vector_score', 'ASC', 195 | 'DIALECT', '2', 'RETURN', 2, 'bar', 'baz', 'WITHSCORES' 196 | ] 197 | ], 198 | 'without_scores' => [ 199 | [0.01, 0.02, 0.03], 200 | '2', 201 | null, 202 | false, 203 | [ 204 | 'PARAMS', 2, 'vector', VectorHelper::toBytes([0.01, 0.02, 0.03]), 'SORTBY', 'vector_score', 'ASC', 205 | 'DIALECT', '2' 206 | ] 207 | ], 208 | ]; 209 | } 210 | } -------------------------------------------------------------------------------- /tests/Unit/VectorHelperTest.php: -------------------------------------------------------------------------------- 1 | assertSame($expectedString, VectorHelper::toBytes([0.00001, 0.00002, 0.00003])); 18 | } 19 | } -------------------------------------------------------------------------------- /tests/Unit/Vectorizer/FactoryTest.php: -------------------------------------------------------------------------------- 1 | createVectorizer('openai', 'test model'); 20 | 21 | $this->assertSame('test model', $vectorizer->getModel()); 22 | } 23 | 24 | /** 25 | * @return void 26 | * @throws Exception 27 | */ 28 | public function testCreateVectorizerWithAdditionalMappings(): void 29 | { 30 | $factory = new Factory(['openai' => OpenAIVectorizer::class]); 31 | $vectorizer = $factory->createVectorizer('openai', 'test model'); 32 | 33 | $this->assertSame('test model', $vectorizer->getModel()); 34 | } 35 | 36 | /** 37 | * @return void 38 | * @throws Exception 39 | */ 40 | public function testCreateVectorizerThrowsErrorOnNonExistingVectorizer(): void 41 | { 42 | $factory = new Factory(); 43 | 44 | $this->expectException(Exception::class); 45 | $this->expectExceptionMessage('Given vectorizer does not exists.'); 46 | 47 | $factory->createVectorizer('foobar'); 48 | } 49 | } -------------------------------------------------------------------------------- /tests/Unit/Vectorizer/OpenAIVectorizerTest.php: -------------------------------------------------------------------------------- 1 | mockClient = $this->getMockBuilder(Client::class)->getMock(); 32 | $this->mockResponse = $this->getMockBuilder(ResponseInterface::class)->getMock(); 33 | $this->mockStream = $this->getMockBuilder(StreamInterface::class)->getMock(); 34 | } 35 | 36 | /** 37 | * @return void 38 | */ 39 | public function testGetModel(): void 40 | { 41 | $vectorizer = new OpenAIVectorizer('test'); 42 | 43 | $this->assertSame('test', $vectorizer->getModel()); 44 | } 45 | 46 | /** 47 | * @return void 48 | */ 49 | public function testGetConfiguration(): void 50 | { 51 | $vectorizer = new OpenAIVectorizer(null, ['foo' => 'bar']); 52 | 53 | $this->assertSame(['foo' => 'bar'], $vectorizer->getConfiguration()); 54 | } 55 | 56 | /** 57 | * @return void 58 | * @throws \JsonException 59 | */ 60 | public function testEmbedIncludeRequestParametersFromApiConfiguration(): void 61 | { 62 | $expectedBody = [ 63 | 'model' => 'test model', 64 | 'input' => 'text', 65 | 'encoding_format' => 'float', 66 | ]; 67 | 68 | $this->mockClient 69 | ->expects($this->once()) 70 | ->method('post') 71 | ->with( 72 | 'https://api.openai.com/v1/embeddings', 73 | [ 74 | 'headers' => [ 75 | 'Authorization' => 'Bearer foobar', 76 | 'Content-Type' => 'application/json', 77 | 'Accept' => 'application/json', 78 | ], 79 | 'json' => $expectedBody, 80 | ] 81 | ) 82 | ->willReturn($this->mockResponse); 83 | 84 | 85 | $this->mockResponse 86 | ->expects($this->once()) 87 | ->method('getBody') 88 | ->willReturn($this->mockStream); 89 | 90 | $this->mockStream 91 | ->expects($this->once()) 92 | ->method('getContents') 93 | ->willReturn('{"embeddings":[0.1111111,0.2222222]}'); 94 | 95 | $vectorizer = new OpenAIVectorizer( 96 | 'test model', 97 | ['token' => 'foobar', 'requestParams' => ['encoding_format' => 'float']], 98 | $this->mockClient 99 | ); 100 | 101 | $this->assertSame(['embeddings' => [0.1111111, 0.2222222]], $vectorizer->embed('text')); 102 | } 103 | 104 | /** 105 | * @return void 106 | * @throws \JsonException 107 | */ 108 | public function testEmbedWithNoRequestParams(): void 109 | { 110 | $expectedBody = [ 111 | 'model' => 'test model', 112 | 'input' => 'text', 113 | ]; 114 | 115 | $this->mockClient 116 | ->expects($this->once()) 117 | ->method('post') 118 | ->with( 119 | 'https://api.openai.com/v1/embeddings', 120 | [ 121 | 'headers' => [ 122 | 'Authorization' => 'Bearer foobar', 123 | 'Content-Type' => 'application/json', 124 | 'Accept' => 'application/json', 125 | ], 126 | 'json' => $expectedBody, 127 | ] 128 | ) 129 | ->willReturn($this->mockResponse); 130 | 131 | 132 | $this->mockResponse 133 | ->expects($this->once()) 134 | ->method('getBody') 135 | ->willReturn($this->mockStream); 136 | 137 | $this->mockStream 138 | ->expects($this->once()) 139 | ->method('getContents') 140 | ->willReturn('{"embeddings":[0.1111111,0.2222222]}'); 141 | 142 | $vectorizer = new OpenAIVectorizer( 143 | 'test model', 144 | ['token' => 'foobar'], 145 | $this->mockClient 146 | ); 147 | 148 | $this->assertSame(['embeddings' => [0.1111111, 0.2222222]], $vectorizer->embed('text')); 149 | } 150 | 151 | /** 152 | * @return void 153 | * @throws \JsonException 154 | */ 155 | public function testEmbedGetApiTokenFromEnvVariable(): void 156 | { 157 | $expectedBody = [ 158 | 'model' => 'test model', 159 | 'input' => 'text', 160 | ]; 161 | 162 | putenv('OPENAI_API_TOKEN=foobar'); 163 | 164 | $this->mockClient 165 | ->expects($this->once()) 166 | ->method('post') 167 | ->with( 168 | 'https://api.openai.com/v1/embeddings', 169 | [ 170 | 'headers' => [ 171 | 'Authorization' => 'Bearer foobar', 172 | 'Content-Type' => 'application/json', 173 | 'Accept' => 'application/json', 174 | ], 175 | 'json' => $expectedBody, 176 | ] 177 | ) 178 | ->willReturn($this->mockResponse); 179 | 180 | 181 | $this->mockResponse 182 | ->expects($this->once()) 183 | ->method('getBody') 184 | ->willReturn($this->mockStream); 185 | 186 | $this->mockStream 187 | ->expects($this->once()) 188 | ->method('getContents') 189 | ->willReturn('{"embeddings":[0.1111111,0.2222222]}'); 190 | 191 | $vectorizer = new OpenAIVectorizer( 192 | 'test model', 193 | [], 194 | $this->mockClient 195 | ); 196 | 197 | $this->assertSame(['embeddings' => [0.1111111, 0.2222222]], $vectorizer->embed('text')); 198 | putenv('OPENAI_API_TOKEN'); 199 | } 200 | 201 | /** 202 | * @return void 203 | * @throws \JsonException 204 | */ 205 | public function testEmbedThrowsExceptionOnMissingApiToken(): void 206 | { 207 | $vectorizer = new OpenAIVectorizer( 208 | 'test model', 209 | [], 210 | $this->mockClient 211 | ); 212 | 213 | $this->expectException(Exception::class); 214 | $this->expectExceptionMessage('API token should be provided in API configuration or as an environment variable.'); 215 | 216 | $vectorizer->embed('text'); 217 | } 218 | 219 | /** 220 | * @return void 221 | * @throws \JsonException 222 | */ 223 | public function testBatchEmbed(): void 224 | { 225 | $expectedBody = [ 226 | 'model' => 'test model', 227 | 'input' => ['foo', 'bar'], 228 | ]; 229 | 230 | $this->mockClient 231 | ->expects($this->once()) 232 | ->method('post') 233 | ->with( 234 | 'https://api.openai.com/v1/embeddings', 235 | [ 236 | 'headers' => [ 237 | 'Authorization' => 'Bearer foobar', 238 | 'Content-Type' => 'application/json', 239 | 'Accept' => 'application/json', 240 | ], 241 | 'json' => $expectedBody, 242 | ] 243 | ) 244 | ->willReturn($this->mockResponse); 245 | 246 | 247 | $this->mockResponse 248 | ->expects($this->once()) 249 | ->method('getBody') 250 | ->willReturn($this->mockStream); 251 | 252 | $this->mockStream 253 | ->expects($this->once()) 254 | ->method('getContents') 255 | ->willReturn('{"embeddings":[0.1111111,0.2222222]}'); 256 | 257 | $vectorizer = new OpenAIVectorizer( 258 | 'test model', 259 | ['token' => 'foobar'], 260 | $this->mockClient 261 | ); 262 | 263 | $this->assertSame(['embeddings' => [0.1111111, 0.2222222]], $vectorizer->batchEmbed(['foo', 'bar'])); 264 | } 265 | } --------------------------------------------------------------------------------