diff --git a/src/Contracts/SimilarDocumentsQuery.php b/src/Contracts/SimilarDocumentsQuery.php new file mode 100644 index 00000000..8c0b3648 --- /dev/null +++ b/src/Contracts/SimilarDocumentsQuery.php @@ -0,0 +1,87 @@ +id = $id; + + return $this; + } + + public function setOffset(?int $offset): SimilarDocumentsQuery + { + $this->offset = $offset; + + return $this; + } + + public function setLimit(?int $limit): SimilarDocumentsQuery + { + $this->limit = $limit; + + return $this; + } + + public function setFilter(array $filter): SimilarDocumentsQuery + { + $this->filter = $filter; + + return $this; + } + + public function setEmbedder(string $embedder): SimilarDocumentsQuery + { + $this->embedder = $embedder; + + return $this; + } + + public function setAttributesToRetrieve(array $attributesToRetrieve): SimilarDocumentsQuery + { + $this->attributesToRetrieve = $attributesToRetrieve; + + return $this; + } + + public function setShowRankingScore(?bool $showRankingScore): SimilarDocumentsQuery + { + $this->showRankingScore = $showRankingScore; + + return $this; + } + + public function setShowRankingScoreDetails(?bool $showRankingScoreDetails): SimilarDocumentsQuery + { + $this->showRankingScoreDetails = $showRankingScoreDetails; + + return $this; + } + + public function toArray(): array + { + return array_filter([ + 'id' => $this->id, + 'offset' => $this->offset, + 'limit' => $this->limit, + 'filter' => $this->filter, + 'embedder' => $this->embedder, + 'attributesToRetrieve' => $this->attributesToRetrieve, + 'showRankingScore' => $this->showRankingScore, + 'showRankingScoreDetails' => $this->showRankingScoreDetails, + ], function ($item) { return null !== $item; }); + } +} diff --git a/src/Endpoints/Indexes.php b/src/Endpoints/Indexes.php index 3d5f8bb9..969fcb3c 100644 --- a/src/Endpoints/Indexes.php +++ b/src/Endpoints/Indexes.php @@ -10,6 +10,7 @@ use Meilisearch\Contracts\Index\Settings; use Meilisearch\Contracts\IndexesQuery; use Meilisearch\Contracts\IndexesResults; +use Meilisearch\Contracts\SimilarDocumentsQuery; use Meilisearch\Contracts\TasksQuery; use Meilisearch\Contracts\TasksResults; use Meilisearch\Endpoints\Delegates\HandlesDocuments; @@ -18,6 +19,7 @@ use Meilisearch\Exceptions\ApiException; use Meilisearch\Search\FacetSearchResult; use Meilisearch\Search\SearchResult; +use Meilisearch\Search\SimilarDocumentsSearchResult; class Indexes extends Endpoint { @@ -213,6 +215,13 @@ public function rawSearch(?string $query, array $searchParams = []): array return $result; } + public function searchSimilarDocuments(SimilarDocumentsQuery $parameters): SimilarDocumentsSearchResult + { + $result = $this->http->post(self::PATH.'/'.$this->uid.'/similar', $parameters->toArray()); + + return new SimilarDocumentsSearchResult($result); + } + // Facet Search public function facetSearch(FacetSearchQuery $params): FacetSearchResult diff --git a/src/Search/SimilarDocumentsSearchResult.php b/src/Search/SimilarDocumentsSearchResult.php new file mode 100644 index 00000000..78e6326d --- /dev/null +++ b/src/Search/SimilarDocumentsSearchResult.php @@ -0,0 +1,135 @@ +> + */ + private array $hits; + + /** + * `estimatedTotalHits` is the attributes returned by the Meilisearch server + * and its value will not be modified by the methods in this class. + * Please, use `hitsCount` if you want to know the real size of the `hits` array at any time. + */ + private int $estimatedTotalHits; + private int $hitsCount; + private int $offset; + private int $limit; + private int $processingTimeMs; + + private string $id; + + public function __construct(array $body) + { + $this->id = $body['id']; + $this->hits = $body['hits'] ?? []; + $this->hitsCount = \count($body['hits']); + $this->processingTimeMs = $body['processingTimeMs']; + $this->offset = $body['offset']; + $this->limit = $body['limit']; + $this->estimatedTotalHits = $body['estimatedTotalHits']; + } + + /** + * Return a new {@see SearchResult} instance. + * + * The $options parameter is an array, and the following keys are accepted: + * - transformHits (callable) + * + * The method does NOT trigger a new search. + */ + public function applyOptions($options): self + { + if (\array_key_exists('transformHits', $options) && \is_callable($options['transformHits'])) { + $this->transformHits($options['transformHits']); + } + + return $this; + } + + public function transformHits(callable $callback): self + { + $this->hits = $callback($this->hits); + $this->hitsCount = \count($this->hits); + + return $this; + } + + public function getHit(int $key, $default = null) + { + return $this->hits[$key] ?? $default; + } + + /** + * @return array + */ + public function getHits(): array + { + return $this->hits; + } + + public function getOffset(): int + { + return $this->offset; + } + + public function getLimit(): int + { + return $this->limit; + } + + public function getEstimatedTotalHits(): int + { + return $this->estimatedTotalHits; + } + + public function getProcessingTimeMs(): int + { + return $this->processingTimeMs; + } + + public function getId(): string + { + return $this->id; + } + + public function getHitsCount(): int + { + return $this->hitsCount; + } + + public function toArray(): array + { + $arr = [ + 'id' => $this->id, + 'hits' => $this->hits, + 'hitsCount' => $this->hitsCount, + 'processingTimeMs' => $this->processingTimeMs, + 'offset' => $this->offset, + 'limit' => $this->limit, + 'estimatedTotalHits' => $this->estimatedTotalHits, + ]; + + return $arr; + } + + public function toJSON(): string + { + return json_encode($this->toArray(), JSON_PRETTY_PRINT); + } + + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->hits); + } + + public function count(): int + { + return $this->hitsCount; + } +} diff --git a/tests/Endpoints/SimilarDocumentsTest.php b/tests/Endpoints/SimilarDocumentsTest.php new file mode 100644 index 00000000..7648aa32 --- /dev/null +++ b/tests/Endpoints/SimilarDocumentsTest.php @@ -0,0 +1,43 @@ +index = $this->createEmptyIndex($this->safeIndexName()); + $this->index->updateDocuments(self::VECTOR_MOVIES); + } + + public function testBasicSearchWithSimilarDocuments(): void + { + $task = $this->index->updateSettings(['embedders' => ['manual' => ['source' => 'userProvided', 'dimensions' => 3]]]); + $this->client->waitForTask($task['taskUid']); + + $response = $this->index->search('room'); + + self::assertSame(1, $response->getHitsCount()); + + $documentId = $response->getHit(0)['id']; + $response = $this->index->searchSimilarDocuments( + (new SimilarDocumentsQuery()) + ->setId($documentId) + ); + + self::assertGreaterThanOrEqual(4, $response->getHitsCount()); + self::assertArrayHasKey('_vectors', $response->getHit(0)); + self::assertArrayHasKey('id', $response->getHit(0)); + self::assertSame($documentId, $response->getId()); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index be414382..4124be99 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -15,6 +15,39 @@ abstract class TestCase extends BaseTestCase { + protected const VECTOR_MOVIES = [ + [ + 'title' => 'Shazam!', + 'release_year' => 2019, + 'id' => '287947', + '_vectors' => ['manual' => [0.8, 0.4, -0.5]], + ], + [ + 'title' => 'Captain Marvel', + 'release_year' => 2019, + 'id' => '299537', + '_vectors' => ['manual' => [0.6, 0.8, -0.2]], + ], + [ + 'title' => 'Escape Room', + 'release_year' => 2019, + 'id' => '522681', + '_vectors' => ['manual' => [0.1, 0.6, 0.8]], + ], + [ + 'title' => 'How to Train Your Dragon: The Hidden World', + 'release_year' => 2019, + 'id' => '166428', + '_vectors' => ['manual' => [0.7, 0.7, -0.4]], + ], + [ + 'title' => 'All Quiet on the Western Front', + 'release_year' => 1930, + 'id' => '143', + '_vectors' => ['manual' => [-0.5, 0.3, 0.85]], + ], + ]; + protected const DOCUMENTS = [ ['id' => 123, 'title' => 'Pride and Prejudice', 'comment' => 'A great book', 'genre' => 'romance'], ['id' => 456, 'title' => 'Le Petit Prince', 'comment' => 'A french book', 'genre' => 'adventure'],