From ed69e024028fa1569abe9c335de60824db3f5f67 Mon Sep 17 00:00:00 2001 From: Mateusz Date: Tue, 8 Sep 2026 17:00:11 +0100 Subject: [PATCH] PCBC-1070: SDK Support for Hybrid Search Score Fusion --- Couchbase/SearchOptions.php | 36 ++++++ Couchbase/SearchScoring.php | 39 ++++++ Couchbase/SearchScoringNone.php | 64 ++++++++++ .../SearchScoringReciprocalRankFusion.php | 111 ++++++++++++++++++ .../SearchScoringRelativeScoreFusion.php | 91 ++++++++++++++ src/deps/couchbase-cxx-client | 2 +- src/wrapper/conversion_utilities.cxx | 36 ++++++ tests/Helpers/ServerVersion.php | 5 + tests/SearchTest.php | 86 ++++++++++++++ 9 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 Couchbase/SearchScoring.php create mode 100644 Couchbase/SearchScoringNone.php create mode 100644 Couchbase/SearchScoringReciprocalRankFusion.php create mode 100644 Couchbase/SearchScoringRelativeScoreFusion.php diff --git a/Couchbase/SearchOptions.php b/Couchbase/SearchOptions.php index ac0fcf99..db76bad1 100644 --- a/Couchbase/SearchOptions.php +++ b/Couchbase/SearchOptions.php @@ -20,6 +20,7 @@ namespace Couchbase; +use Couchbase\Exception\InvalidArgumentException; use JsonSerializable; class SearchOptions implements JsonSerializable @@ -29,6 +30,7 @@ class SearchOptions implements JsonSerializable private ?int $skip = null; private ?bool $explain = null; private ?bool $disableScoring = null; + private ?SearchScoring $scoring = null; private ?MutationState $consistentWith = null; private ?array $fields = null; private ?array $facets = null; @@ -113,13 +115,46 @@ public function explain(bool $explain): SearchOptions * * @return SearchOptions * @since 4.0.0 + * + * @deprecated Use scoring(new SearchScoringNone()) instead. + * + * @throws InvalidArgumentException if $disabled is true and scoring() has already been set: + * both would write the same field, so they cannot be used together. */ public function disableScoring(bool $disabled): SearchOptions { + if ($disabled && $this->scoring !== null) { + throw new InvalidArgumentException("disableScoring(true) cannot be used together with scoring()"); + } $this->disableScoring = $disabled; return $this; } + /** + * Selects how the server scores the hits, and how it merges the FTS and vector result sets + * of a hybrid request into a single ranked list. + * + * @param SearchScoring $scoring the scoring mode + * + * @return SearchOptions + * @since 4.6.0 + * + * @see \SearchScoringNone + * @see \SearchScoringReciprocalRankFusion + * @see \SearchScoringRelativeScoreFusion + * + * @throws InvalidArgumentException if disableScoring(true) has already been set: both would + * write the same field, so they cannot be used together. + */ + public function scoring(SearchScoring $scoring): SearchOptions + { + if ($this->disableScoring === true) { + throw new InvalidArgumentException("scoring() cannot be used together with disableScoring(true)"); + } + $this->scoring = $scoring; + return $this; + } + /** * Sets the consistency to consider for this FTS query to AT_PLUS and * uses the MutationState to parameterize the consistency. @@ -334,6 +369,7 @@ public static function export(?SearchOptions $options): array 'skip' => $options->skip, 'explain' => $options->explain, 'disableScoring' => $options->disableScoring, + 'scoring' => $options->scoring?->export(), 'fields' => $options->fields, 'sortSpecs' => $sort, 'consistentWith' => $options->consistentWith == null ? null : $options->consistentWith->export(), diff --git a/Couchbase/SearchScoring.php b/Couchbase/SearchScoring.php new file mode 100644 index 00000000..962473fa --- /dev/null +++ b/Couchbase/SearchScoring.php @@ -0,0 +1,39 @@ +export(); + } + + /** + * @internal + */ + public function export(): array + { + return [ + 'strategy' => 'none', + ]; + } +} diff --git a/Couchbase/SearchScoringReciprocalRankFusion.php b/Couchbase/SearchScoringReciprocalRankFusion.php new file mode 100644 index 00000000..56fb5d5f --- /dev/null +++ b/Couchbase/SearchScoringReciprocalRankFusion.php @@ -0,0 +1,111 @@ +rankConstant = $rankConstant; + return $this; + } + + /** + * Sets how many results per list are considered for fusion. + * + * @param int $windowSize the window size + * + * @return SearchScoringReciprocalRankFusion + * @since 4.6.0 + * + * @UNCOMMITTED: This API may change in the future. + */ + public function windowSize(int $windowSize): SearchScoringReciprocalRankFusion + { + $this->windowSize = $windowSize; + return $this; + } + + /** + * @internal + * @return mixed + */ + public function jsonSerialize(): mixed + { + return $this->export(); + } + + /** + * @internal + */ + public function export(): array + { + $json = [ + 'strategy' => 'rrf', + ]; + if ($this->rankConstant !== null) { + $json['rankConstant'] = $this->rankConstant; + } + if ($this->windowSize !== null) { + $json['windowSize'] = $this->windowSize; + } + return $json; + } +} diff --git a/Couchbase/SearchScoringRelativeScoreFusion.php b/Couchbase/SearchScoringRelativeScoreFusion.php new file mode 100644 index 00000000..83e09c3c --- /dev/null +++ b/Couchbase/SearchScoringRelativeScoreFusion.php @@ -0,0 +1,91 @@ +windowSize = $windowSize; + return $this; + } + + /** + * @internal + * @return mixed + */ + public function jsonSerialize(): mixed + { + return $this->export(); + } + + /** + * @internal + */ + public function export(): array + { + $json = [ + 'strategy' => 'rsf', + ]; + if ($this->windowSize !== null) { + $json['windowSize'] = $this->windowSize; + } + return $json; + } +} diff --git a/src/deps/couchbase-cxx-client b/src/deps/couchbase-cxx-client index 488c7502..c5b00a67 160000 --- a/src/deps/couchbase-cxx-client +++ b/src/deps/couchbase-cxx-client @@ -1 +1 @@ -Subproject commit 488c75021059c4bbad9b8d9a0d47790dd1c41477 +Subproject commit c5b00a6728e167dc4c52ee7285a683f8f0882722 diff --git a/src/wrapper/conversion_utilities.cxx b/src/wrapper/conversion_utilities.cxx index 66c3ef5e..04fada2a 100644 --- a/src/wrapper/conversion_utilities.cxx +++ b/src/wrapper/conversion_utilities.cxx @@ -717,6 +717,42 @@ zval_to_common_search_request(const zend_string* index_name, if (auto e = cb_assign_boolean(request.disable_scoring, options, "disableScoring"); e.ec) { return { {}, e }; } + if (const zval* scoring = zend_symtable_str_find(Z_ARRVAL_P(options), ZEND_STRL("scoring")); + scoring != nullptr && Z_TYPE_P(scoring) == IS_ARRAY) { + if (auto [e, strategy] = cb_get_string(scoring, "strategy"); strategy) { + if (strategy == "none") { + request.scoring = core::search_scoring_none{}; + } else if (strategy == "rrf") { + core::search_scoring_reciprocal_rank_fusion rrf{}; + if (auto [e, val] = cb_get_integer(scoring, "rankConstant"); val) { + rrf.rank_constant = val.value(); + } else if (e.ec) { + return { {}, e }; + } + if (auto [e, val] = cb_get_integer(scoring, "windowSize"); val) { + rrf.window_size = val.value(); + } else if (e.ec) { + return { {}, e }; + } + request.scoring = rrf; + } else if (strategy == "rsf") { + core::search_scoring_relative_score_fusion rsf{}; + if (auto [e, val] = cb_get_integer(scoring, "windowSize"); val) { + rsf.window_size = val.value(); + } else if (e.ec) { + return { {}, e }; + } + request.scoring = rsf; + } else { + return { {}, + { errc::common::invalid_argument, + ERROR_LOCATION, + fmt::format("invalid value used for scoring strategy: {}", *strategy) } }; + } + } else if (e.ec) { + return { {}, e }; + } + } if (auto e = cb_assign_boolean(request.include_locations, options, "includeLocations"); e.ec) { return { {}, e }; } diff --git a/tests/Helpers/ServerVersion.php b/tests/Helpers/ServerVersion.php index cf915962..45af4473 100644 --- a/tests/Helpers/ServerVersion.php +++ b/tests/Helpers/ServerVersion.php @@ -302,6 +302,11 @@ public function supportsMemcachedBuckets(): bool return $this->major < 8; } + public function supportsScoreFusion(): bool + { + return $this->major > 8 || ($this->major == 8 && $this->minor >= 1); + } + /** * @return int */ diff --git a/tests/SearchTest.php b/tests/SearchTest.php index e466350e..c5ff6ef6 100644 --- a/tests/SearchTest.php +++ b/tests/SearchTest.php @@ -29,6 +29,7 @@ use Couchbase\DurabilityLevel; use Couchbase\Exception\FeatureNotAvailableException; use Couchbase\Exception\IndexNotFoundException; +use Couchbase\Exception\InvalidArgumentException; use Couchbase\GeoBoundingBoxSearchQuery; use Couchbase\GeoDistanceSearchQuery; use Couchbase\Management\BucketSettings; @@ -48,6 +49,9 @@ use Couchbase\SearchHighlightMode; use Couchbase\SearchOptions; use Couchbase\SearchRequest; +use Couchbase\SearchScoringNone; +use Couchbase\SearchScoringReciprocalRankFusion; +use Couchbase\SearchScoringRelativeScoreFusion; use Couchbase\SearchSortField; use Couchbase\SearchSortGeoDistance; use Couchbase\SearchSortId; @@ -671,6 +675,88 @@ public function testVectorSearchEncodingWithBase64() $this->assertEquals(sprintf('[{"field":"foo","boost":0.5,"vector_base64":"%s","k":4},{"field":"bar","vector":[-0.00810353,0.6433,0.52364],"k":3}]', $base64EncodedVector), $encodedVectorQuery); } + public function testSearchScoringNoneExport() + { + $options = SearchOptions::build()->scoring(new SearchScoringNone()); + $exported = SearchOptions::export($options); + $this->assertEquals(['strategy' => 'none'], $exported['scoring']); + } + + public function testSearchScoringReciprocalRankFusionExport() + { + $options = SearchOptions::build()->scoring( + SearchScoringReciprocalRankFusion::build()->rankConstant(30)->windowSize(100) + ); + $exported = SearchOptions::export($options); + $this->assertEquals( + ['strategy' => 'rrf', 'rankConstant' => 30, 'windowSize' => 100], + $exported['scoring'] + ); + } + + public function testSearchScoringRelativeScoreFusionExport() + { + $options = SearchOptions::build()->scoring( + SearchScoringRelativeScoreFusion::build()->windowSize(50) + ); + $exported = SearchOptions::export($options); + $this->assertEquals(['strategy' => 'rsf', 'windowSize' => 50], $exported['scoring']); + } + + public function testSearchScoringAfterDisableScoringThrowsInvalidArgument() + { + $options = SearchOptions::build()->disableScoring(true); + $this->expectException(InvalidArgumentException::class); + $options->scoring(new SearchScoringNone()); + } + + public function testDisableScoringAfterScoringThrowsInvalidArgument() + { + $options = SearchOptions::build()->scoring(new SearchScoringNone()); + $this->expectException(InvalidArgumentException::class); + $options->disableScoring(true); + } + + public function testScoringAfterDisableScoringFalseDoesNotThrow() + { + $options = SearchOptions::build()->disableScoring(false)->scoring(new SearchScoringNone()); + $exported = SearchOptions::export($options); + $this->assertEquals(['strategy' => 'none'], $exported['scoring']); + } + + public function testDisableScoringFalseAfterScoringDoesNotThrow() + { + $options = SearchOptions::build()->scoring(new SearchScoringNone())->disableScoring(false); + $exported = SearchOptions::export($options); + $this->assertEquals(['strategy' => 'none'], $exported['scoring']); + } + + public function testSearchWithScoreFusion() + { + $this->skipIfCaves(); + $this->skipIfUnsupported($this->version()->supportsScoreFusion()); + + $query = new MatchPhraseSearchQuery("hop beer"); + $options = SearchOptions::build()->scoring(SearchScoringReciprocalRankFusion::build()->windowSize(50)); + + $result = $this->cluster->searchQuery($this->indexName, $query, $options); + + $this->assertNotNull($result); + $this->assertNotEmpty($result->rows()); + } + + public function testSearchWithScoreFusionThrowsFeatureNotAvailable() + { + $this->skipIfCaves(); + $this->skipIfUnsupported(!$this->version()->supportsScoreFusion()); + + $query = new MatchPhraseSearchQuery("hop beer"); + $options = SearchOptions::build()->scoring(SearchScoringReciprocalRankFusion::build()); + + $this->expectException(FeatureNotAvailableException::class); + $this->cluster->searchQuery($this->indexName, $query, $options); + } + public function testScopeSearch() { $this->skipIfCaves();