Skip to content

Consolidate SQL for distance_function into one single source of truth - #21

Merged
ecairol merged 3 commits into
mainfrom
update/consolidate-vector-search-logic
Aug 18, 2026
Merged

Consolidate SQL for distance_function into one single source of truth#21
ecairol merged 3 commits into
mainfrom
update/consolidate-vector-search-logic

Conversation

@ecairol

@ecairol ecairol commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Vector search exists in three independent implementations that have drifted apart:

Location Visibility gate Over-fetch MySQL distance fn
REST::handle_query yes (LEFT JOIN wp_posts) no COSINE_DISTANCE()
Query::maybe_vector_search via WP_Query re-gate limit * 3 COSINE_DISTANCE()
admin/views/embeddings.php none no DISTANCE(..., 'COSINE')

Each carried its own native + PHP-fallback branches, its own SQL, and its own
thresholds.

This is done as a prerequisite for adding filtered search, ie: constraining results by post
type, taxonomy, author, date.

What changed

New WPVDB\Search service in includes/class-wpvdb-search.php:

WPVDB\Search::query( array(
    'text'               => 'query text',   // or 'vector' => [...]
    'model'              => 'text-embedding-3-small',
    'limit'              => 10,
    'over_fetch'         => 3,
    'distance_threshold' => 0.35,
    'respect_visibility' => true,
    'output'             => ARRAY_A,        // or OBJECT
    'explain'            => false,
) );
// => [ 'results' => [...], 'plan' => [...] ]  |  WP_Error

The Service owns the fallback values, the unified SQL, and the result, and all
three call sites now delegate to it. 

### Main Changes

1. All paths use Database::get_vector_distance_function(). The admin screen
and REST previously hardcoded their own distance SQL. This is the only helper
that does MariaDB version detection (VEC_DISTANCE_* vs VEC_DISTANCE), so
consolidating on it changes the admin screen's SQL on MySQL from
DISTANCE(..., 'COSINE') to COSINE_DISTANCE(...).
3. REST over-fetches candidates (QUERY_OVER_FETCH = 3). The visibility gate
runs in SQL with a bare LIMIT %d, so filtered-out rows could shrink the
response below the requested k. The WP_Query path already over-fetched by 3x
for the same reason; REST now matches.
4. wpvdb_similarity_threshold now applies on the PHP fallback too. It
previously only affected the native path, so the same query returned different
result sets depending on the database engine. Sites without native vector
support will see fewer results from vdb_vector_query where the old behavior
returned everything sorted by distance. This is the riskiest of the four —
easy to drop if you'd rather keep the old fallback semantics.

…truth.

Vector search from three independent implementations drifted apart. The new WPVDB/Search Class contains shared logic for the three.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request introduces a unified WPVDB\\Search service as the single source of truth for vector similarity search SQL and fallback behavior, and updates REST, WP_Query integration, and the embeddings admin screen to delegate to it. This reduces drift across call sites and centralizes distance-function selection via Database::get_vector_distance_function().

Changes:

  • Added WPVDB\\Search::query() service to centralize native-vs-fallback search behavior, SQL construction, and result shape.
  • Updated REST and WP_Query vector search paths to call the new service (including REST over-fetch and consistent thresholding in fallback where applicable).
  • Updated embeddings admin screen semantic search to delegate to the service and request explain data.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
wpvdb.php Loads the new Search service during plugin bootstrap.
includes/class-wpvdb-search.php New unified search implementation (native SQL + PHP fallback) and filterable clauses.
includes/class-wpvdb-rest.php Delegates REST query handling to Search::query() and adds over-fetch constant.
includes/class-wpvdb-query.php Delegates WP_Query vector search to Search::query() and applies similarity threshold to fallback too.
admin/views/embeddings.php Delegates admin semantic search to Search::query() with explain enabled.
tests/bootstrap.php Extends the wpdb stub for posts table name and loads the Search class for tests.
tests/unit/SearchTest.php Adds unit tests covering Search defaults, clause construction, filterability, and input validation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread includes/class-wpvdb-query.php Outdated
Comment on lines +78 to +80
$limit = $query->get( 'posts_per_page' );
$limit = $limit ? $limit : 10;
Logger::debug( 'Posts per page limit: ' . $limit );

$doc_ids = array();

if ( $has_vector ) {
try {
// Convert the embedding array to JSON.
$embedding_json = wp_json_encode( $embedding );

// Use Database class to get the appropriate vector function.
$vector_function = self::$database->get_vector_from_string_function( $embedding_json );
Logger::debug( 'Using vector function: ' . $vector_function );

// Use Database class to get the appropriate distance function.
$distance_function = self::$database->get_vector_distance_function( 'embedding', $vector_function, 'cosine' );
Logger::debug( 'Using distance function: ' . $distance_function );

// Set an appropriate similarity threshold - we discovered this is critical for performance
// Lower values (0.2-0.3) are more strict but faster, higher values (0.4-0.6) give more results.
$similarity_threshold = apply_filters( 'wpvdb_similarity_threshold', 0.35 );

// Optimized query that uses the vector index with a distance threshold.
// The threshold + ORDER BY + LIMIT pattern is what maximizes vector index usage.
$sql = $wpdb->prepare(
"
SELECT doc_id,
$distance_function AS distance
FROM $table_name
WHERE $distance_function < %f
AND model = %s
ORDER BY distance
LIMIT %d
",
$similarity_threshold,
$model,
$limit * 3 // fetch more candidates than needed.
);

Logger::debug( 'Vector search SQL: ' . $sql );

$rows = $wpdb->get_results( $sql, ARRAY_A );

if ( $wpdb->last_error ) {
Logger::error( 'Database error in vector search: ' . $wpdb->last_error );
}

if ( $rows ) {
Logger::debug( 'Found ' . count( $rows ) . ' results from vector search' );
foreach ( $rows as $r ) {
$doc_ids[] = (int) $r['doc_id'];
Logger::debug( 'Added doc_id: ' . $r['doc_id'] . ' with distance: ' . $r['distance'] );
}
} else {
Logger::debug( 'No results found from vector search' );
}
} catch ( \Exception $e ) {
Logger::error( 'Exception in vector search: ' . $e->getMessage() );
}
} else {
Logger::debug( 'No vector support, using PHP fallback search' );
// Fallback: do in PHP.
$all_rows = $wpdb->get_results(
$wpdb->prepare( "SELECT doc_id, embedding FROM $table_name WHERE model = %s", $model ),
ARRAY_A
);
$distances = array();
foreach ( $all_rows as $r ) {
$stored_emb = json_decode( $r['embedding'], true );
if ( ! is_array( $stored_emb ) ) {
continue;
}
$d = REST::cosine_distance( $embedding, $stored_emb );
$distances[] = array(
'doc_id' => (int) $r['doc_id'],
'distance' => $d,
);
}
usort(
$distances,
function ( $a, $b ) {
return $a['distance'] <=> $b['distance'];
}
);
$distances = array_slice( $distances, 0, $limit * 3 );
$doc_ids = wp_list_pluck( $distances, 'doc_id' );

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on 8cc1e45

Comment thread tests/unit/SearchTest.php
Comment on lines +104 to +124
add_filter(
'wpvdb_search_clauses',
function ( $clauses ) {
$clauses['where'][] = 'e.doc_type = %s';
$clauses['params'][] = 'product';
return $clauses;
}
);

$clauses = Search::build_clauses(
$this->args(
array(
'model' => 'm',
'respect_visibility' => false,
)
)
);

$this->assertSame( array( 'e.model = %s', 'e.doc_type = %s' ), $clauses['where'] );
$this->assertSame( array( 'm', 'product' ), $clauses['params'] );
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The repo already solves this globally. phpunit.xml:22 registers an extension:

<extension class="WPVDB_Filter_Reset_Hook" file="tests/filter-reset-hook.php" />

which implements BeforeTestHook and AfterTestHook and clears the registry around every test

Also removed logger that's storing vectorized data, quickly increasing the weight of debug.log.

Finally, fixed indentation issue on SQL.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

Suppressed comments (2)

includes/class-wpvdb-search.php:158

  • Search::query() treats any array passed in vector as a valid embedding without validation. Because this is a public service API (and the REST layer allows vector queries behind a capability gate), passing an empty vector, wrong dimension, or non-numeric/non-finite values can lead to SQL errors in the native path and inconsistent scoring in the PHP fallback. Normalize and validate the vector before proceeding, returning a 400 WP_Error when invalid.
		$embedding = $args['vector'];
		if ( ! is_array( $embedding ) ) {
			if ( '' === trim( (string) $args['text'] ) ) {
				return new \WP_Error(
					'wpvdb_search_no_input',
					__( 'A query text or a query vector is required.', 'wpvdb' ),
					array( 'status' => 400 )
				);
			}

			$started   = microtime( true );
			$embedding = self::resolve_embedding( $args );

			$plan['timings_ms']['embed'] = (int) round( ( microtime( true ) - $started ) * 1000 );

			if ( is_wp_error( $embedding ) ) {
				return $embedding;
			}
		}

includes/class-wpvdb-search.php:167

  • $plan includes a candidates key but it is never populated, even though the code computes $fetch = $limit * $over_fetch. This makes the returned plan inconsistent and less useful for debugging/explain output.
		$fetch = $args['limit'] * $args['over_fetch'];

@ecairol
ecairol merged commit 17428f8 into main Aug 18, 2026
1 check passed
@ecairol
ecairol deleted the update/consolidate-vector-search-logic branch August 18, 2026 15:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants