diff --git a/src/Contracts/VectorClient.php b/src/Contracts/VectorClient.php index 7a6b308..a974422 100644 --- a/src/Contracts/VectorClient.php +++ b/src/Contracts/VectorClient.php @@ -36,7 +36,7 @@ public function upsert(string $collection, array $points, bool $wait = true): Up * @param array|null $filter * @return array */ - public function search(string $collection, array $vector, int $limit = 10, ?array $filter = null): array; + public function search(string $collection, array $vector, int $limit = 10, ?array $filter = null, ?float $scoreThreshold = null): array; /** * @param array|null $filter diff --git a/src/Qdrant.php b/src/Qdrant.php index 3ced7ef..2f4d1f3 100644 --- a/src/Qdrant.php +++ b/src/Qdrant.php @@ -31,6 +31,19 @@ public function __construct( protected readonly QdrantConnector $connector, ) {} + public static function collection(string $name): QueryBuilder + { + /** @var VectorClient $client */ + $client = app(VectorClient::class); + + return new QueryBuilder($client, $name); + } + + public function query(string $collection): QueryBuilder + { + return new QueryBuilder($this, $collection); + } + /** * @param array>|null $sparseVectors */ diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php new file mode 100644 index 0000000..df16371 --- /dev/null +++ b/src/QueryBuilder.php @@ -0,0 +1,119 @@ +|null */ + private ?array $vector = null; + + public function __construct( + private readonly VectorClient $client, + private readonly string $collection, + ) { + $this->filter = new QdrantFilter; + } + + public function where(string $key, mixed $value): self + { + $this->filter->must($key, $value); + + return $this; + } + + /** + * @param array $values + */ + public function whereIn(string $key, array $values): self + { + $this->filter->mustAny($key, $values); + + return $this; + } + + public function whereNot(string $key, mixed $value): self + { + $this->filter->mustNot($key, $value); + + return $this; + } + + public function whereRange(string $key, ?float $gte = null, ?float $lte = null, ?float $gt = null, ?float $lt = null): self + { + $this->filter->mustRange($key, $gte, $lte, $gt, $lt); + + return $this; + } + + /** + * @param array $vector + */ + public function nearVector(array $vector): self + { + $this->vector = $vector; + + return $this; + } + + public function limit(int $limit): self + { + $this->queryLimit = $limit; + + return $this; + } + + public function minScore(float $score): self + { + $this->scoreThreshold = $score; + + return $this; + } + + /** + * @return array + */ + public function search(): array + { + if ($this->vector === null) { + throw new \InvalidArgumentException('A vector is required for search. Call nearVector() before search().'); + } + + $filter = $this->filter->toArray() ?: null; + + return $this->client->search( + collection: $this->collection, + vector: $this->vector, + limit: $this->queryLimit, + filter: $filter, + scoreThreshold: $this->scoreThreshold, + ); + } + + public function count(): int + { + $filter = $this->filter->toArray() ?: null; + + return $this->client->count( + collection: $this->collection, + filter: $filter, + ); + } + + public function filter(): FilterBuilder + { + return $this->filter; + } +} diff --git a/tests/Feature/QueryBuilderIntegrationTest.php b/tests/Feature/QueryBuilderIntegrationTest.php new file mode 100644 index 0000000..7dbc287 --- /dev/null +++ b/tests/Feature/QueryBuilderIntegrationTest.php @@ -0,0 +1,145 @@ +withMockClient($mock); + + return new Qdrant($connector); +} + +describe('Qdrant::query', function (): void { + it('returns a QueryBuilder instance', function (): void { + $client = makeQdrant(new MockClient([])); + + expect($client->query('memories'))->toBeInstanceOf(QueryBuilder::class); + }); + + it('executes search through the builder', function (): void { + $mock = new MockClient([ + SearchPointsRequest::class => MockResponse::make([ + 'result' => [ + ['id' => 'x', 'score' => 0.9, 'payload' => ['project' => 'lexi']], + ], + 'status' => 'ok', + ]), + ]); + + $results = makeQdrant($mock) + ->query('memories') + ->where('project', 'lexi') + ->nearVector([0.1, 0.2]) + ->limit(5) + ->search(); + + expect($results)->toHaveCount(1) + ->and($results[0]->id)->toBe('x') + ->and($results[0]->score)->toBe(0.9); + + $mock->assertSent(function (SearchPointsRequest $request): bool { + $body = invade($request)->defaultBody(); + + return $body['limit'] === 5 + && $body['filter'] === ['must' => [['key' => 'project', 'match' => ['value' => 'lexi']]]] + && ! isset($body['score_threshold']); + }); + }); + + it('passes score_threshold through minScore', function (): void { + $mock = new MockClient([ + SearchPointsRequest::class => MockResponse::make([ + 'result' => [], + 'status' => 'ok', + ]), + ]); + + makeQdrant($mock) + ->query('memories') + ->nearVector([0.1]) + ->minScore(0.75) + ->search(); + + $mock->assertSent(function (SearchPointsRequest $request): bool { + $body = invade($request)->defaultBody(); + + return $body['score_threshold'] === 0.75; + }); + }); +}); + +describe('Qdrant::collection', function (): void { + it('returns a QueryBuilder via static call', function (): void { + $builder = Qdrant::collection('memories'); + + expect($builder)->toBeInstanceOf(QueryBuilder::class); + }); + + it('resolves VectorClient from the container', function (): void { + $mock = new MockClient([ + SearchPointsRequest::class => MockResponse::make([ + 'result' => [ + ['id' => 'm-1', 'score' => 0.88, 'payload' => []], + ], + 'status' => 'ok', + ]), + ]); + + $connector = $this->app->make(QdrantConnector::class); + $connector->withMockClient($mock); + + $results = Qdrant::collection('memories') + ->nearVector([0.5]) + ->search(); + + expect($results)->toHaveCount(1) + ->and($results[0]->id)->toBe('m-1'); + }); +}); + +describe('Qdrant::search with scoreThreshold', function (): void { + it('passes score_threshold to request', function (): void { + $mock = new MockClient([ + SearchPointsRequest::class => MockResponse::make([ + 'result' => [], + 'status' => 'ok', + ]), + ]); + + makeQdrant($mock)->search('coll', [0.1], scoreThreshold: 0.5); + + $mock->assertSent(function (SearchPointsRequest $request): bool { + $body = invade($request)->defaultBody(); + + return $body['score_threshold'] === 0.5; + }); + }); + + it('omits score_threshold when null', function (): void { + $mock = new MockClient([ + SearchPointsRequest::class => MockResponse::make([ + 'result' => [], + 'status' => 'ok', + ]), + ]); + + makeQdrant($mock)->search('coll', [0.1]); + + $mock->assertSent(function (SearchPointsRequest $request): bool { + $body = invade($request)->defaultBody(); + + return ! isset($body['score_threshold']); + }); + }); +}); diff --git a/tests/Unit/QueryBuilderTest.php b/tests/Unit/QueryBuilderTest.php new file mode 100644 index 0000000..57360cc --- /dev/null +++ b/tests/Unit/QueryBuilderTest.php @@ -0,0 +1,214 @@ +shouldReceive('search') + ->once() + ->with('memories', [0.1, 0.2], 10, null, null) + ->andReturn([ + new ScoredPoint('a', 0.95, ['title' => 'Hit']), + ]); + + $results = (new QueryBuilder($client, 'memories')) + ->nearVector([0.1, 0.2]) + ->search(); + + expect($results)->toHaveCount(1) + ->and($results[0]->score)->toBe(0.95); + }); + + it('chains where, whereIn, nearVector, limit, minScore', function (): void { + $client = mockClient(); + $client->shouldReceive('search') + ->once() + ->with( + 'memories', + [0.1, 0.2, 0.3], + 10, + Mockery::on(fn (array $filter): bool => isset($filter['must']) && count($filter['must']) === 2), + 0.75, + ) + ->andReturn([]); + + $results = (new QueryBuilder($client, 'memories')) + ->where('project', 'lexi-agent') + ->whereIn('tags', ['verified']) + ->nearVector([0.1, 0.2, 0.3]) + ->limit(10) + ->minScore(0.75) + ->search(); + + expect($results)->toBe([]); + }); + + it('applies whereNot filter', function (): void { + $client = mockClient(); + $client->shouldReceive('search') + ->once() + ->with( + 'coll', + [0.5], + 10, + Mockery::on(fn (array $filter): bool => isset($filter['must_not']) + && $filter['must_not'][0]['key'] === 'status' + && $filter['must_not'][0]['match']['value'] === 'deleted'), + null, + ) + ->andReturn([]); + + (new QueryBuilder($client, 'coll')) + ->whereNot('status', 'deleted') + ->nearVector([0.5]) + ->search(); + }); + + it('applies whereRange filter', function (): void { + $client = mockClient(); + $client->shouldReceive('search') + ->once() + ->with( + 'coll', + [0.5], + 10, + Mockery::on(fn (array $filter): bool => isset($filter['must']) + && $filter['must'][0]['key'] === 'energy' + && $filter['must'][0]['range'] === ['gte' => 0.5, 'lte' => 1.0]), + null, + ) + ->andReturn([]); + + (new QueryBuilder($client, 'coll')) + ->whereRange('energy', gte: 0.5, lte: 1.0) + ->nearVector([0.5]) + ->search(); + }); + + it('passes custom limit', function (): void { + $client = mockClient(); + $client->shouldReceive('search') + ->once() + ->with('coll', [0.1], 5, null, null) + ->andReturn([]); + + (new QueryBuilder($client, 'coll')) + ->nearVector([0.1]) + ->limit(5) + ->search(); + }); + + it('passes score threshold', function (): void { + $client = mockClient(); + $client->shouldReceive('search') + ->once() + ->with('coll', [0.1], 10, null, 0.8) + ->andReturn([]); + + (new QueryBuilder($client, 'coll')) + ->nearVector([0.1]) + ->minScore(0.8) + ->search(); + }); + + it('omits filter when no conditions are set', function (): void { + $client = mockClient(); + $client->shouldReceive('search') + ->once() + ->with('coll', [0.1], 10, null, null) + ->andReturn([]); + + (new QueryBuilder($client, 'coll')) + ->nearVector([0.1]) + ->search(); + }); + + it('throws when searching without a vector', function (): void { + $client = mockClient(); + + expect(fn (): array => (new QueryBuilder($client, 'coll'))->search()) + ->toThrow(InvalidArgumentException::class, 'A vector is required for search'); + }); + + it('delegates count to client', function (): void { + $client = mockClient(); + $client->shouldReceive('count') + ->once() + ->with('memories', null) + ->andReturn(42); + + $count = (new QueryBuilder($client, 'memories')) + ->count(); + + expect($count)->toBe(42); + }); + + it('passes filter to count', function (): void { + $client = mockClient(); + $client->shouldReceive('count') + ->once() + ->with( + 'memories', + Mockery::on(fn (array $filter): bool => isset($filter['must']) + && $filter['must'][0]['key'] === 'project' + && $filter['must'][0]['match']['value'] === 'lexi'), + ) + ->andReturn(5); + + $count = (new QueryBuilder($client, 'memories')) + ->where('project', 'lexi') + ->count(); + + expect($count)->toBe(5); + }); + + it('exposes the underlying filter builder', function (): void { + $client = mockClient(); + $builder = new QueryBuilder($client, 'coll'); + + expect($builder->filter())->toBeInstanceOf(FilterBuilder::class); + }); + + it('supports the full fluent chain from the issue', function (): void { + $embedding = array_fill(0, 1536, 0.01); + $client = mockClient(); + $client->shouldReceive('search') + ->once() + ->with( + 'memories', + $embedding, + 10, + Mockery::on(fn (array $filter): bool => count($filter['must']) === 2 + && $filter['must'][0]['key'] === 'project' + && $filter['must'][0]['match']['value'] === 'lexi-agent' + && $filter['must'][1]['key'] === 'tags' + && $filter['must'][1]['match']['any'] === ['verified']), + 0.75, + ) + ->andReturn([ + new ScoredPoint('mem-1', 0.92, ['project' => 'lexi-agent']), + ]); + + $results = (new QueryBuilder($client, 'memories')) + ->where('project', 'lexi-agent') + ->whereIn('tags', ['verified']) + ->nearVector($embedding) + ->limit(10) + ->minScore(0.75) + ->search(); + + expect($results)->toHaveCount(1) + ->and($results[0]->id)->toBe('mem-1'); + }); +});