This is an automated email from the ASF dual-hosted git repository.

mattcasters pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new f550576756 Issue #8629 : Let pgvector search skip filters whose stream 
value is empty (#8630)
f550576756 is described below

commit f5505767567c65924120abe41623ef51bb83fd80
Author: Bart Maertens <[email protected]>
AuthorDate: Thu Oct 1 11:35:27 2026 +0200

    Issue #8629 : Let pgvector search skip filters whose stream value is empty 
(#8630)
    
    Every pgvector search filter became "column = ?", so a row whose filter
    field was null or empty matched nothing and quietly returned no results.
    Filters get a "Skip if empty" option (skip_if_empty, default N, so
    existing pipelines are unchanged). When set, that filter is left out of
    the WHERE clause for rows where its value is empty. It is per filter so
    required filters, such as a tenant or access-control column, keep
    applying and can never widen a search.
    
    Each combination of active filters gets its own prepared statement,
    created on first use and cached, which keeps plain equality predicates.
    The statement with every filter is still prepared up front, so a bad
    table or column name fails on the first row as before.
    
    Also document which settings win when Language Model Chat uses an AI
    Provider: the provider's type, base URL, API key, chat model, timeout and
    temperature override the inline values, which are only fallbacks for
    empty provider fields. Correct the AI Provider page, which said the
    transform can override the model; that holds for the embedding
    transforms, not for Language Model Chat.
---
 .../ROOT/pages/metadata-types/ai-provider.adoc     |   8 +-
 .../pipeline/transforms/languagemodelchat.adoc     |  38 +++++-
 .../pages/pipeline/transforms/pgvector-search.adoc |  13 ++
 .../pgvector/transforms/search/PgVectorSearch.java |  98 +++++++++++----
 .../transforms/search/PgVectorSearchData.java      |  15 ++-
 .../transforms/search/PgVectorSearchDialog.java    |  19 ++-
 .../transforms/search/PgVectorSearchMeta.java      |   4 +-
 .../hop/pgvector/util/PgVectorSearchFilter.java    |  16 ++-
 .../search/messages/messages_en_US.properties      |   3 +
 .../transforms/search/PgVectorSearchMetaTest.java  |  28 ++++-
 .../transforms/search/PgVectorSearchTest.java      | 134 ++++++++++++++++++++-
 .../hop/pgvector/util/PgVectorSqlBuilderTest.java  |  12 ++
 12 files changed, 354 insertions(+), 34 deletions(-)

diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/ai-provider.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/ai-provider.adoc
index 3f78748fa2..b8432a3ea5 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/ai-provider.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/ai-provider.adoc
@@ -40,8 +40,10 @@ The same pattern applies to every other secret in the 
project: never hard-code i
 See 
xref:hop-gui/perspective-ai-advisor.adoc#secrets-and-personal-information[Secrets
 and personal information].
 
 The xref:pipeline/transforms/languagemodelchat.adoc[Language model chat] 
transform can optionally select a named AI Provider.
-Connection fields from the provider overlay the transform's inline values; 
empty provider fields keep the inline values.
+The provider's type, base URL, API key, chat model, timeout and temperature 
then take precedence over the transform's inline values; an inline value is 
only used when the provider leaves that field empty.
+In particular, a temperature set on the provider overrides the temperature on 
every transform that uses it, so leave *Temperature* empty here if transforms 
need their own.
 Input/output mapping, mock mode, proxy and retries stay on the transform.
+See xref:pipeline/transforms/languagemodelchat.adoc#ai-provider-precedence[AI 
Provider and inline settings] for the details.
 
 == Provider types
 
@@ -116,7 +118,7 @@ A GitHub Copilot-style OAuth provider is an extension 
point; it is not bundled.
 |HTTP timeout for completions.
 
 |Temperature
-|Sampling temperature, when the provider supports it.
+|Sampling temperature, when the provider supports it. When set, it overrides 
the temperature configured on a Language Model Chat transform that uses this 
provider.
 |===
 
 When Language Model Chat points at an AI Provider, extra transform options 
(proxy, retries, mock, I/O fields) are not taken from the provider.
@@ -148,7 +150,7 @@ Each row pairs a role with a model name:
 
 A transform never asks which role to use: it needs one kind of model and looks 
up that role. Point a chat transform and an embedding transform at the same 
provider and each finds its own model.
 
-Use at most one row per role, since that is what makes the lookup unambiguous. 
A transform that needs a different model than the provider's default for its 
role can override it on the transform itself.
+Use at most one row per role, since that is what makes the lookup unambiguous. 
An embedding transform that needs a different model than the provider's default 
for its role can override it on the transform itself. Language Model Chat works 
the other way round: the provider's `CHAT` model wins, and the model name on 
the transform is only used when the provider has none.
 
 === Relationship to Model name
 
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/languagemodelchat.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/languagemodelchat.adoc
index 65f416f1b1..eae2a61172 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/languagemodelchat.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/languagemodelchat.adoc
@@ -41,7 +41,7 @@ The following parameters are applicable to all language model 
selections.
 | Name of the transform. Must be unique within a single pipeline.
 
 | AI Provider (optional)
-| Named xref:metadata-types/ai-provider.adoc[AI Provider] metadata from the 
Metadata perspective. Connection fields (provider type, base URL, API key, 
model name, timeout, temperature) overlay the inline values below. Empty 
provider fields keep the inline values. Input/output mapping, mock mode, proxy 
and retries stay on this transform. Leave blank to use only the inline fields. 
Requires the `hop-tech-ai` plugin. Do not paste live API keys on this transform 
or on the provider: use an env [...]
+| Named xref:metadata-types/ai-provider.adoc[AI Provider] metadata from the 
Metadata perspective. When set, the provider's connection settings (provider 
type, base URL, API key, chat model, timeout, temperature) take precedence over 
the inline values below, which are only used for fields the provider leaves 
empty. See <<ai-provider-precedence>>. Input/output mapping, mock mode, proxy 
and retries stay on this transform. Leave blank to use only the inline fields. 
Requires the `hop-tech-ai` [...]
 
 | Input JSON Chat
 | When enabled, uses the chat completion JSON format (system, assistant, user 
messages). When disabled, treats the input as plain text. See "Chat Completion" 
section for more details.
@@ -71,6 +71,42 @@ The following parameters are applicable to all language 
model selections.
 | Defines the API used for communication with the endpoint (e.g., via OpenAI, 
Anthropic, Ollama, Hugging Face, Mistral). **Note:** The Open AI API option can 
be used with any Open AI compatible endpoint/server.  For example, vLLM (Kwon 
et al., 2023).
 |===
 
+[[ai-provider-precedence]]
+=== AI Provider and inline settings
+
+When *AI Provider* is set, the transform starts from its own settings and then 
copies the provider's connection settings over them. The provider wins wherever 
it has a value; an inline value is only a fallback for a field the provider 
leaves empty.
+
+[cols="2,4",options="header"]
+|===
+| Setting | Which value is used
+
+| Model API
+| Always the provider's type. The *Model API* selected on the transform is 
ignored. OpenAI, Grok, Gemini and Custom providers use the OpenAI API.
+
+| Base URL
+| The provider's *Base URL* if set, otherwise the inline base URL (for Ollama, 
the endpoint) of the provider's model type.
+
+| API key
+| The provider's *API key* if set, otherwise the inline key (for Hugging Face, 
the access token) of the provider's model type.
+
+| Model name
+| The provider's `CHAT` model: its `CHAT` row under *Models per role*, or else 
its *Model name*. If both are empty, the inline model name of the provider's 
model type. For Hugging Face, the provider's *Base URL* is used as the model id 
when it has no model name.
+
+| Temperature
+| The provider's *Temperature* if set, otherwise the inline temperature. A 
provider temperature always overrides the inline one, even when the inline 
value was chosen on purpose. Once the provider field holds anything, including 
a variable that resolves to nothing, it counts as set: a value that is not a 
number is sent as `0.3`, not replaced by the inline temperature.
+
+| Timeout
+| The provider's *Timeout (seconds)* if it resolves to a whole number, 
otherwise the inline timeout.
+|===
+
+Variables in provider fields are resolved first, so apart from *Temperature*, 
a field holding a variable that resolves to an empty value counts as empty. 
Empty provider fields fall back to the inline values, not to the defaults 
listed for each provider type on the xref:metadata-types/ai-provider.adoc[AI 
Provider] page.
+
+The inline fallbacks come from the provider's model type, not from the *Model 
API* selected on the transform. With an Anthropic provider, for example, an 
empty provider *Model name* falls back to the inline Anthropic model name, even 
when the transform itself was set up for OpenAI.
+
+All other options always come from the transform: mock mode, parallelism and 
the input and output fields, and the model-specific options such as Top P, Top 
K, maximum tokens, response format, proxy, retries and logging. The 
model-specific ones are read from the transform's fields for the provider's 
model type.
+
+To tune the temperature per transform while sharing one provider, leave 
*Temperature* empty on the provider and set it on each transform.
+
 === Model-Specific Options
 
 Below is a table detailing parameters specific to individual language model 
APIs that are currently supported with the Language model chat transform plugin.
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/pgvector-search.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/pgvector-search.adoc
index 37d2ea9a97..de5eab2a9c 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/pgvector-search.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/pgvector-search.adoc
@@ -80,6 +80,19 @@ The similarity score is normalised so that higher is better 
for every metric, bu
 
 The *Filters* tab restricts the search to rows where a table column equals the 
value of a stream field, which lets one index serve several document sets. The 
columns come from the mappings configured in pgvector upsert.
 
+[options="header"]
+|===
+|Column |Description
+
+|Table column|Column in the pgvector table to compare.
+|Stream field|Input field whose value the column must equal.
+|Skip if empty|`N` (the default) always applies the filter. An empty stream 
value is then compared as is and matches nothing, so the row finds no matches. 
`Y` leaves the filter out of the search for any row whose stream field is null 
or empty, so that row is searched without it.
+|===
+
+Use *Skip if empty* for optional narrowing, such as a category the user may or 
may not have picked. Leave it off for filters that must always hold, such as a 
tenant or access-control column: with it on, a row that arrives without a value 
would search every tenant's documents.
+
+Each combination of skipped filters gets its own prepared statement, created 
the first time a row needs it, so the query never carries a placeholder for a 
filter it does not use.
+
 == Notes
 
 *Top K* and *Minimum score* accept variables, so they can be set per 
environment rather than per pipeline.
diff --git 
a/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/transforms/search/PgVectorSearch.java
 
b/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/transforms/search/PgVectorSearch.java
index a5917134db..d1389c71b1 100644
--- 
a/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/transforms/search/PgVectorSearch.java
+++ 
b/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/transforms/search/PgVectorSearch.java
@@ -16,8 +16,10 @@
  */
 package org.apache.hop.pgvector.transforms.search;
 
+import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.util.ArrayList;
+import java.util.BitSet;
 import java.util.List;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.exception.HopException;
@@ -119,8 +121,11 @@ public class PgVectorSearch extends 
BaseTransform<PgVectorSearchMeta, PgVectorSe
 
     int matches = 0;
     try {
-      bindSearchParameters(row, embedding);
-      try (ResultSet resultSet = data.searchStatement.executeQuery()) {
+      String[] filterValues = readFilterValues(row);
+      BitSet activeFilters = activeFilters(filterValues);
+      PreparedStatement statement = searchStatement(activeFilters);
+      bindSearchParameters(statement, embedding, filterValues, activeFilters);
+      try (ResultSet resultSet = statement.executeQuery()) {
         while (resultSet.next()) {
           double score = resultSet.getDouble("similarity");
           // The minimum score is applied after the top-k limit, so a run can 
legitimately
@@ -181,17 +186,70 @@ public class PgVectorSearch extends 
BaseTransform<PgVectorSearchMeta, PgVectorSe
     data.resultScoreFieldIndex = 
data.outputRowMeta.indexOfValue(meta.getResultScoreField());
   }
 
-  private void bindSearchParameters(Object[] row, Object embedding) throws 
Exception {
+  private String[] readFilterValues(Object[] row) throws HopException {
+    String[] values = new String[data.filterBindings.size()];
+    for (int i = 0; i < values.length; i++) {
+      values[i] = data.inputRowMeta.getString(row, 
data.filterBindings.get(i).streamFieldIndex);
+    }
+    return values;
+  }
+
+  /**
+   * Works out which filters go into the WHERE clause for this row. A filter 
always applies unless
+   * it is set to be skipped when its stream value is empty and that value is 
null or empty.
+   */
+  private BitSet activeFilters(String[] filterValues) {
+    BitSet active = new BitSet(filterValues.length);
+    for (int i = 0; i < filterValues.length; i++) {
+      PgVectorSearchFilter filter = data.filterBindings.get(i).filter;
+      if (filter.isSkipIfEmpty() && Utils.isEmpty(filterValues[i])) {
+        if (isRowLevel()) {
+          logRowlevel(
+              BaseMessages.getString(
+                  PKG,
+                  "PgVectorSearch.Log.SkippedEmptyFilter",
+                  filter.getColumnName(),
+                  filter.getStreamField()));
+        }
+        continue;
+      }
+      active.set(i);
+    }
+    return active;
+  }
+
+  /**
+   * Returns the statement for this combination of filters, preparing it on 
first use. Leaving a
+   * skipped filter out of the SQL altogether, rather than binding {@code (? 
IS NULL OR column =
+   * ?)}, gives the planner a plain predicate to plan the vector index scan 
around.
+   */
+  private PreparedStatement searchStatement(BitSet activeFilters) throws 
Exception {
+    PreparedStatement statement = data.searchStatements.get(activeFilters);
+    if (statement == null) {
+      List<PgVectorSearchFilter> filters =
+          activeFilters.stream().mapToObj(i -> 
data.filterBindings.get(i).filter).toList();
+      statement =
+          data.database
+              .getConnection()
+              .prepareStatement(
+                  PgVectorSqlBuilder.searchSql(data.qualifiedTable, 
data.metric, filters));
+      data.searchStatements.put(activeFilters, statement);
+    }
+    return statement;
+  }
+
+  private void bindSearchParameters(
+      PreparedStatement statement, Object embedding, String[] filterValues, 
BitSet activeFilters)
+      throws Exception {
     String vectorLiteral = EmbeddingJsonParser.toPgVectorLiteral(embedding);
     int parameterIndex = 1;
     // Placeholder order must match searchSql(): score expression, filters, 
ORDER BY, LIMIT.
-    data.searchStatement.setString(parameterIndex++, vectorLiteral);
-    for (PgVectorSearchData.FilterBinding binding : data.filterBindings) {
-      String filterValue = data.inputRowMeta.getString(row, 
binding.streamFieldIndex);
-      data.searchStatement.setString(parameterIndex++, filterValue);
+    statement.setString(parameterIndex++, vectorLiteral);
+    for (int i = activeFilters.nextSetBit(0); i >= 0; i = 
activeFilters.nextSetBit(i + 1)) {
+      statement.setString(parameterIndex++, filterValues[i]);
     }
-    data.searchStatement.setString(parameterIndex++, vectorLiteral);
-    data.searchStatement.setInt(parameterIndex, data.topK);
+    statement.setString(parameterIndex++, vectorLiteral);
+    statement.setInt(parameterIndex, data.topK);
   }
 
   private void resolveFilterBindings() throws HopException {
@@ -219,18 +277,16 @@ public class PgVectorSearch extends 
BaseTransform<PgVectorSearchMeta, PgVectorSe
     try {
       data.database =
           PgVectorDatabase.connect(this, this, getMetadataProvider(), 
meta.getConnection());
-      VectorDistanceMetric metric =
+      data.metric =
           meta.getDistanceMetric() != null ? meta.getDistanceMetric() : 
VectorDistanceMetric.COSINE;
-      String qualifiedTable =
+      data.qualifiedTable =
           PgVectorSqlBuilder.qualifiedTable(
               resolve(meta.getSchemaName()), resolve(meta.getTableName()));
-      List<PgVectorSearchFilter> activeFilters =
-          data.filterBindings.stream().map(b -> b.filter).toList();
-      data.searchStatement =
-          data.database
-              .getConnection()
-              .prepareStatement(
-                  PgVectorSqlBuilder.searchSql(qualifiedTable, metric, 
activeFilters));
+      // Prepare the statement holding every filter up front, so a bad table 
or column name fails
+      // on the first row as before. Statements that leave out skipped filters 
follow on demand.
+      BitSet allFilters = new BitSet();
+      allFilters.set(0, data.filterBindings.size());
+      searchStatement(allFilters);
     } catch (Exception e) {
       closeDatabase();
       throw new HopException(BaseMessages.getString(PKG, 
"PgVectorSearch.Error.Initializing"), e);
@@ -244,14 +300,14 @@ public class PgVectorSearch extends 
BaseTransform<PgVectorSearchMeta, PgVectorSe
   }
 
   private void closeDatabase() {
-    if (data.searchStatement != null) {
+    for (PreparedStatement statement : data.searchStatements.values()) {
       try {
-        data.searchStatement.close();
+        statement.close();
       } catch (Exception e) {
         logError(BaseMessages.getString(PKG, 
"PgVectorSearch.Error.ClosingStatement"), e);
       }
-      data.searchStatement = null;
     }
+    data.searchStatements.clear();
     if (data.database != null) {
       data.database.disconnect();
       data.database = null;
diff --git 
a/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchData.java
 
b/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchData.java
index 8586b23284..d5416fc9ed 100644
--- 
a/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchData.java
+++ 
b/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchData.java
@@ -18,9 +18,13 @@ package org.apache.hop.pgvector.transforms.search;
 
 import java.sql.PreparedStatement;
 import java.util.ArrayList;
+import java.util.BitSet;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import org.apache.hop.core.row.IRowMeta;
 import org.apache.hop.pgvector.util.PgVectorSearchFilter;
+import org.apache.hop.pgvector.util.VectorDistanceMetric;
 import org.apache.hop.pipeline.transform.BaseTransformData;
 import org.apache.hop.pipeline.transform.ITransformData;
 
@@ -29,7 +33,16 @@ public class PgVectorSearchData extends BaseTransformData 
implements ITransformD
   public IRowMeta inputRowMeta;
   public IRowMeta outputRowMeta;
   public org.apache.hop.core.database.Database database;
-  public PreparedStatement searchStatement;
+
+  /**
+   * Prepared search statements keyed by the filters they contain: bit {@code 
i} is set when {@code
+   * filterBindings.get(i)} is part of the WHERE clause. Without "skip if 
empty" filters there is
+   * only ever the one statement holding every filter.
+   */
+  public Map<BitSet, PreparedStatement> searchStatements = new HashMap<>();
+
+  public String qualifiedTable;
+  public VectorDistanceMetric metric;
 
   /** Resolved once in init, so variables are not re-resolved per row. */
   public int topK;
diff --git 
a/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchDialog.java
 
b/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchDialog.java
index fc5d82b4a7..4507555b83 100644
--- 
a/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchDialog.java
+++ 
b/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchDialog.java
@@ -45,6 +45,8 @@ import org.eclipse.swt.widgets.TableItem;
 public class PgVectorSearchDialog extends BaseTransformDialog {
 
   private static final Class<?> PKG = PgVectorSearchMeta.class;
+  private static final String CONST_COMBO_YES = "System.Combo.Yes";
+  private static final String CONST_COMBO_NO = "System.Combo.No";
 
   private final PgVectorSearchMeta input;
   private GuiCompositeWidgets widgets;
@@ -123,8 +125,18 @@ public class PgVectorSearchDialog extends 
BaseTransformDialog {
               BaseMessages.getString(PKG, 
"PgVectorSearchDialog.Filters.Column.Stream"),
               ColumnInfo.COLUMN_TYPE_CCOMBO,
               new String[] {},
+              true),
+          new ColumnInfo(
+              BaseMessages.getString(PKG, 
"PgVectorSearchDialog.Filters.Column.SkipIfEmpty"),
+              ColumnInfo.COLUMN_TYPE_CCOMBO,
+              new String[] {
+                BaseMessages.getString(PKG, CONST_COMBO_YES),
+                BaseMessages.getString(PKG, CONST_COMBO_NO)
+              },
               true)
         };
+    columns[2].setToolTip(
+        BaseMessages.getString(PKG, 
"PgVectorSearchDialog.Filters.Column.SkipIfEmpty.Tooltip"));
 
     int rows = input.getFilters() != null ? input.getFilters().size() : 0;
     wFilters =
@@ -189,6 +201,9 @@ public class PgVectorSearchDialog extends 
BaseTransformDialog {
       TableItem item = new TableItem(wFilters.table, SWT.NONE);
       item.setText(1, Const.NVL(filter.getColumnName(), ""));
       item.setText(2, Const.NVL(filter.getStreamField(), ""));
+      item.setText(
+          3,
+          BaseMessages.getString(PKG, filter.isSkipIfEmpty() ? CONST_COMBO_YES 
: CONST_COMBO_NO));
     }
     wFilters.removeEmptyRows();
     wFilters.setRowNums();
@@ -202,8 +217,10 @@ public class PgVectorSearchDialog extends 
BaseTransformDialog {
     for (TableItem item : wFilters.getNonEmptyItems()) {
       String columnName = item.getText(1);
       String streamField = item.getText(2);
+      boolean skipIfEmpty =
+          BaseMessages.getString(PKG, 
CONST_COMBO_YES).equalsIgnoreCase(item.getText(3));
       if (!Utils.isEmpty(columnName) && !Utils.isEmpty(streamField)) {
-        filters.add(new PgVectorSearchFilter(columnName, streamField));
+        filters.add(new PgVectorSearchFilter(columnName, streamField, 
skipIfEmpty));
       }
     }
     return filters;
diff --git 
a/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchMeta.java
 
b/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchMeta.java
index da519a4623..b110cf54e6 100644
--- 
a/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchMeta.java
+++ 
b/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchMeta.java
@@ -258,7 +258,9 @@ public class PgVectorSearchMeta extends 
BaseTransformMeta<PgVectorSearch, PgVect
     copy.filters = new ArrayList<>();
     if (filters != null) {
       for (PgVectorSearchFilter filter : filters) {
-        copy.filters.add(new PgVectorSearchFilter(filter.getColumnName(), 
filter.getStreamField()));
+        copy.filters.add(
+            new PgVectorSearchFilter(
+                filter.getColumnName(), filter.getStreamField(), 
filter.isSkipIfEmpty()));
       }
     }
     return copy;
diff --git 
a/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/util/PgVectorSearchFilter.java
 
b/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/util/PgVectorSearchFilter.java
index d8cdcb353a..d2fd0cabb6 100644
--- 
a/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/util/PgVectorSearchFilter.java
+++ 
b/plugins/tech/pgvector/src/main/java/org/apache/hop/pgvector/util/PgVectorSearchFilter.java
@@ -20,7 +20,13 @@ import lombok.Getter;
 import lombok.Setter;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 
-/** Filters search results where a table column equals a value from an input 
stream field. */
+/**
+ * Filters search results where a table column equals a value from an input 
stream field.
+ *
+ * <p>By default a filter always applies, so an empty stream value is compared 
as is and matches
+ * nothing. With {@link #skipIfEmpty} set, the filter is left out of the query 
for any row whose
+ * stream value is empty, which makes it an optional narrowing rather than a 
required one.
+ */
 @Getter
 @Setter
 public class PgVectorSearchFilter {
@@ -31,10 +37,18 @@ public class PgVectorSearchFilter {
   @HopMetadataProperty(key = "stream", injectionKey = "STREAM")
   private String streamField;
 
+  @HopMetadataProperty(key = "skip_if_empty", injectionKey = "SKIP_IF_EMPTY")
+  private boolean skipIfEmpty;
+
   public PgVectorSearchFilter() {}
 
   public PgVectorSearchFilter(String columnName, String streamField) {
+    this(columnName, streamField, false);
+  }
+
+  public PgVectorSearchFilter(String columnName, String streamField, boolean 
skipIfEmpty) {
     this.columnName = columnName;
     this.streamField = streamField;
+    this.skipIfEmpty = skipIfEmpty;
   }
 }
diff --git 
a/plugins/tech/pgvector/src/main/resources/org/apache/hop/pgvector/transforms/search/messages/messages_en_US.properties
 
b/plugins/tech/pgvector/src/main/resources/org/apache/hop/pgvector/transforms/search/messages/messages_en_US.properties
index fe9508ecf4..bcfc4c44a0 100644
--- 
a/plugins/tech/pgvector/src/main/resources/org/apache/hop/pgvector/transforms/search/messages/messages_en_US.properties
+++ 
b/plugins/tech/pgvector/src/main/resources/org/apache/hop/pgvector/transforms/search/messages/messages_en_US.properties
@@ -42,6 +42,8 @@ PgVectorSearch.Tab.Main=Main
 PgVectorSearchDialog.Filters.Label=Filters
 PgVectorSearchDialog.Filters.Column.Table=Table column
 PgVectorSearchDialog.Filters.Column.Stream=Stream field
+PgVectorSearchDialog.Filters.Column.SkipIfEmpty=Skip if empty
+PgVectorSearchDialog.Filters.Column.SkipIfEmpty.Tooltip=Y leaves this filter 
out of the search for any row whose stream field is empty, so the row is 
searched without it. N (the default) always applies the filter, and an empty 
stream value then matches nothing.
 
 PgVectorSearch.Validation.ConnectionRequired=A database connection is required
 PgVectorSearch.Validation.TableRequired=A table name is required
@@ -56,6 +58,7 @@ PgVectorSearch.Error.Searching=Error searching the pgvector 
table
 PgVectorSearch.Error.ClosingStatement=Error closing the pgvector search 
statement
 PgVectorSearch.eatingRowOnNoMatch.Label=Do not pass the row if the search 
finds nothing
 PgVectorSearch.eatingRowOnNoMatch.Tooltip=When off, a row that matches nothing 
is still passed on with empty match fields. When on, the row is dropped. Rows 
with an empty embedding field are treated the same way.
+PgVectorSearch.Log.SkippedEmptyFilter=Filter on column ''{0}'' skipped: stream 
field ''{1}'' is empty
 PgVectorSearch.Log.EmptyEmbedding=Field ''{0}'' contains no query vector; 
treated as no match
 PgVectorSearchMeta.Injection.EAT_ROW_ON_NO_MATCH=Do not pass the row if the 
search finds nothing
 
diff --git 
a/plugins/tech/pgvector/src/test/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchMetaTest.java
 
b/plugins/tech/pgvector/src/test/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchMetaTest.java
index ec628a28b3..4838b47eb7 100644
--- 
a/plugins/tech/pgvector/src/test/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchMetaTest.java
+++ 
b/plugins/tech/pgvector/src/test/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchMetaTest.java
@@ -61,7 +61,10 @@ class PgVectorSearchMetaTest {
     original.setResultChunkIndexField("hit_idx");
     original.setResultContentField("hit_content");
     original.setResultScoreField("hit_score");
-    original.setFilters(List.of(new PgVectorSearchFilter("source_type", 
"wanted_type")));
+    original.setFilters(
+        List.of(
+            new PgVectorSearchFilter("source_type", "wanted_type"),
+            new PgVectorSearchFilter("category", "wanted_category", true)));
 
     PgVectorSearchMeta copy = roundTrip(original);
 
@@ -78,9 +81,12 @@ class PgVectorSearchMetaTest {
     assertEquals(original.getResultChunkIndexField(), 
copy.getResultChunkIndexField());
     assertEquals(original.getResultContentField(), 
copy.getResultContentField());
     assertEquals(original.getResultScoreField(), copy.getResultScoreField());
-    assertEquals(1, copy.getFilters().size());
+    assertEquals(2, copy.getFilters().size());
     assertEquals("source_type", copy.getFilters().get(0).getColumnName());
     assertEquals("wanted_type", copy.getFilters().get(0).getStreamField());
+    assertEquals(false, copy.getFilters().get(0).isSkipIfEmpty());
+    assertEquals("category", copy.getFilters().get(1).getColumnName());
+    assertEquals(true, copy.getFilters().get(1).isSkipIfEmpty());
   }
 
   /** The matched chunk index is a number in the table, so it must be a number 
in the stream too. */
@@ -159,6 +165,24 @@ class PgVectorSearchMetaTest {
         XmlHandler.getSubNode(document, "transform"), 
PgVectorSearchMeta.class, null);
   }
 
+  @Test
+  void cloneKeepsTheSkipIfEmptyFlag() {
+    PgVectorSearchMeta meta = new PgVectorSearchMeta();
+    meta.setDefault();
+    meta.setFilters(List.of(new PgVectorSearchFilter("category", 
"wanted_category", true)));
+
+    PgVectorSearchMeta copy = (PgVectorSearchMeta) meta.clone();
+
+    assertTrue(copy.getFilters().get(0).isSkipIfEmpty());
+  }
+
+  /** Filters written before the option existed keep applying to every row. */
+  @Test
+  void filtersAreNotSkippedByDefault() {
+    assertEquals(false, new PgVectorSearchFilter().isSkipIfEmpty());
+    assertEquals(false, new PgVectorSearchFilter("category", 
"wanted_category").isSkipIfEmpty());
+  }
+
   /** Matches Hop's Database Lookup: rows are passed on unless the option is 
explicitly enabled. */
   @Test
   void defaultsToPassingRowsThroughOnNoMatch() {
diff --git 
a/plugins/tech/pgvector/src/test/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchTest.java
 
b/plugins/tech/pgvector/src/test/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchTest.java
index 1144d1503a..e159ab8f71 100644
--- 
a/plugins/tech/pgvector/src/test/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchTest.java
+++ 
b/plugins/tech/pgvector/src/test/java/org/apache/hop/pgvector/transforms/search/PgVectorSearchTest.java
@@ -17,25 +17,35 @@
 package org.apache.hop.pgvector.transforms.search;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import java.sql.Connection;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.util.ArrayList;
+import java.util.BitSet;
 import java.util.Iterator;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import org.apache.hop.core.HopClientEnvironment;
+import org.apache.hop.core.database.Database;
 import org.apache.hop.core.row.IRowMeta;
 import org.apache.hop.core.row.RowMeta;
 import org.apache.hop.core.row.value.ValueMetaString;
 import org.apache.hop.core.variables.Variables;
+import org.apache.hop.pgvector.util.PgVectorSearchFilter;
+import org.apache.hop.pgvector.util.VectorDistanceMetric;
 import org.apache.hop.pipeline.transforms.mock.TransformMockHelper;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeAll;
@@ -48,6 +58,9 @@ class PgVectorSearchTest {
   private List<Object[]> output;
   private IRowMeta outputRowMeta;
 
+  /** SQL prepared on the stubbed connection, in order, with the statement 
handed out for it. */
+  private Map<String, PreparedStatement> prepared;
+
   @BeforeAll
   static void setUpClass() throws Exception {
     HopClientEnvironment.init();
@@ -61,6 +74,7 @@ class PgVectorSearchTest {
     when(helper.logChannelFactory.create(any(), 
any())).thenReturn(helper.iLogChannel);
     when(helper.pipeline.isRunning()).thenReturn(true);
     output = new ArrayList<>();
+    prepared = new LinkedHashMap<>();
   }
 
   @AfterEach
@@ -138,6 +152,75 @@ class PgVectorSearchTest {
     assertNull(output.get(0)[outputRowMeta.indexOfValue("match_id")]);
   }
 
+  /** Without "skip if empty" an empty filter value is still bound, as it 
always has been. */
+  @Test
+  void bindsAnEmptyFilterValueWhenSkippingIsOff() throws Exception {
+    runFiltered(false, new Object[] {"[0.1,0.2]", "q1", "acme", null});
+
+    assertEquals(1, prepared.size());
+    String sql = prepared.keySet().iterator().next();
+    assertTrue(sql.contains("WHERE \"tenant\" = ? AND \"category\" = ?"), sql);
+    PreparedStatement statement = prepared.get(sql);
+    verify(statement).setString(2, "acme");
+    verify(statement).setString(3, null);
+    verify(statement).setInt(5, 5);
+  }
+
+  @Test
+  void leavesAnEmptySkippableFilterOutOfTheQuery() throws Exception {
+    runFiltered(true, new Object[] {"[0.1,0.2]", "q1", "acme", ""});
+
+    String sql = lastPreparedSql();
+    assertTrue(sql.contains("WHERE \"tenant\" = ? ORDER BY"), sql);
+    assertFalse(sql.contains("\"category\""), sql);
+    PreparedStatement statement = prepared.get(sql);
+    verify(statement).setString(2, "acme");
+    verify(statement).setString(3, "[0.1,0.2]");
+    verify(statement).setInt(4, 5);
+    assertEquals(1, output.size());
+    assertEquals("doc-0", 
output.get(0)[outputRowMeta.indexOfValue("match_document_id")]);
+  }
+
+  @Test
+  void appliesASkippableFilterWhenItHasAValue() throws Exception {
+    runFiltered(true, new Object[] {"[0.1,0.2]", "q1", "acme", "manuals"});
+
+    assertEquals(1, prepared.size(), "only the statement with every filter is 
needed");
+    String sql = lastPreparedSql();
+    assertTrue(sql.contains("WHERE \"tenant\" = ? AND \"category\" = ?"), sql);
+    PreparedStatement statement = prepared.get(sql);
+    verify(statement).setString(2, "acme");
+    verify(statement).setString(3, "manuals");
+  }
+
+  /** Skipping only applies to filters that ask for it: an empty required 
filter still binds. */
+  @Test
+  void neverSkipsAFilterThatIsNotMarkedSkippable() throws Exception {
+    runFiltered(true, new Object[] {"[0.1,0.2]", "q1", null, null});
+
+    String sql = lastPreparedSql();
+    assertTrue(sql.contains("WHERE \"tenant\" = ? ORDER BY"), sql);
+    verify(prepared.get(sql)).setString(2, null);
+  }
+
+  /** Each combination of filters is prepared once and reused by later rows. */
+  @Test
+  void preparesEachFilterCombinationOnce() throws Exception {
+    runFiltered(
+        true,
+        new Object[] {"[0.1,0.2]", "q1", "acme", "manuals"},
+        new Object[] {"[0.1,0.2]", "q2", "acme", null},
+        new Object[] {"[0.1,0.2]", "q3", "acme", "faq"},
+        new Object[] {"[0.1,0.2]", "q4", "acme", ""});
+
+    assertEquals(2, prepared.size());
+    assertEquals(4, output.size());
+  }
+
+  private String lastPreparedSql() {
+    return new ArrayList<>(prepared.keySet()).get(prepared.size() - 1);
+  }
+
   private static PgVectorSearchMeta newMeta() {
     PgVectorSearchMeta meta = new PgVectorSearchMeta();
     meta.setDefault();
@@ -147,9 +230,51 @@ class PgVectorSearchTest {
   }
 
   private void run(PgVectorSearchMeta meta, String embedding, int matchCount) 
throws Exception {
+    PgVectorSearchData data = newData(meta, new String[0]);
+    data.searchStatements.put(new BitSet(), stubStatement(matchCount));
+    process(meta, data, List.<Object[]>of(new Object[] {embedding, "q1"}));
+  }
+
+  /**
+   * Runs rows of {@code embedding, marker, tenant, category} through a 
transform filtering on
+   * tenant (always applied) and category (skipped when empty, if {@code 
skipEmptyCategory}), with
+   * statements prepared on a stubbed connection.
+   */
+  private void runFiltered(boolean skipEmptyCategory, Object[]... rows) throws 
Exception {
+    PgVectorSearchMeta meta = newMeta();
+    meta.setFilters(
+        List.of(
+            new PgVectorSearchFilter("tenant", "tenant"),
+            new PgVectorSearchFilter("category", "category", 
skipEmptyCategory)));
+    PgVectorSearchData data = newData(meta, new String[] {"tenant", 
"category"});
+    for (int i = 0; i < meta.getFilters().size(); i++) {
+      data.filterBindings.add(
+          new PgVectorSearchData.FilterBinding(2 + i, 
meta.getFilters().get(i)));
+    }
+    data.metric = VectorDistanceMetric.COSINE;
+    data.qualifiedTable = "\"public\".\"chunks\"";
+    Connection connection = mock(Connection.class);
+    when(connection.prepareStatement(anyString()))
+        .thenAnswer(
+            invocation -> {
+              PreparedStatement statement = stubStatement(1);
+              prepared.put(invocation.getArgument(0), statement);
+              return statement;
+            });
+    data.database = mock(Database.class);
+    when(data.database.getConnection()).thenReturn(connection);
+
+    process(meta, data, List.of(rows));
+  }
+
+  private PgVectorSearchData newData(PgVectorSearchMeta meta, String[] 
extraFields)
+      throws Exception {
     IRowMeta inputRowMeta = new RowMeta();
     inputRowMeta.addValueMeta(new ValueMetaString("embedding"));
     inputRowMeta.addValueMeta(new ValueMetaString("marker"));
+    for (String field : extraFields) {
+      inputRowMeta.addValueMeta(new ValueMetaString(field));
+    }
 
     PgVectorSearchData data = new PgVectorSearchData();
     data.inputRowMeta = inputRowMeta;
@@ -162,18 +287,21 @@ class PgVectorSearchTest {
     data.resultContentFieldIndex = 
data.outputRowMeta.indexOfValue("match_content");
     data.resultScoreFieldIndex = 
data.outputRowMeta.indexOfValue("match_score");
     data.filterBindings = new ArrayList<>();
-    data.searchStatement = stubStatement(matchCount);
+    return data;
+  }
 
+  private void process(PgVectorSearchMeta meta, PgVectorSearchData data, 
List<Object[]> input)
+      throws Exception {
     PgVectorSearch transform =
         spy(
             new PgVectorSearch(
                 helper.transformMeta, meta, data, 0, helper.pipelineMeta, 
helper.pipeline));
     transform.init();
-    transform.setInputRowMeta(inputRowMeta);
+    transform.setInputRowMeta(data.inputRowMeta);
     // The database is already stubbed, so skip the one-time setup branch in 
processRow().
     transform.first = false;
 
-    Iterator<Object[]> rows = List.<Object[]>of(new Object[] {embedding, 
"q1"}).iterator();
+    Iterator<Object[]> rows = input.iterator();
     doAnswer(invocation -> rows.hasNext() ? rows.next() : 
null).when(transform).getRow();
     doAnswer(
             invocation -> {
diff --git 
a/plugins/tech/pgvector/src/test/java/org/apache/hop/pgvector/util/PgVectorSqlBuilderTest.java
 
b/plugins/tech/pgvector/src/test/java/org/apache/hop/pgvector/util/PgVectorSqlBuilderTest.java
index 68132e757b..9cf3eb3deb 100644
--- 
a/plugins/tech/pgvector/src/test/java/org/apache/hop/pgvector/util/PgVectorSqlBuilderTest.java
+++ 
b/plugins/tech/pgvector/src/test/java/org/apache/hop/pgvector/util/PgVectorSqlBuilderTest.java
@@ -17,6 +17,7 @@
 package org.apache.hop.pgvector.util;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.util.List;
@@ -103,6 +104,17 @@ class PgVectorSqlBuilderTest {
     assertTrue(sql.endsWith("LIMIT ?"));
   }
 
+  /** With every filter skipped for a row, the search runs without a WHERE 
clause. */
+  @Test
+  void searchSqlWithoutFiltersHasNoWhereClause() {
+    String sql =
+        PgVectorSqlBuilder.searchSql(
+            "\"public\".\"chunks\"", VectorDistanceMetric.COSINE, List.of());
+
+    assertFalse(sql.contains("WHERE"));
+    assertEquals(3, sql.chars().filter(c -> c == '?').count(), "score vector + 
order-by + limit");
+  }
+
   /** Identifiers are quoted and embedded quotes are doubled, so a crafted 
name cannot break out. */
   @Test
   void quotesAndEscapesIdentifiers() {

Reply via email to