CrownChu commented on code in PR #10777: URL: https://github.com/apache/paimon/pull/10777#discussion_r4376939021
########## paimon-elasticsearch/docs/paimon-es-search.html: ########## @@ -0,0 +1,178 @@ +<!doctype html> +<!-- +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +--> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>paimon_es_search: Java search for Spark, Flink and SDK</title> + <style> + body { max-width: 960px; margin: 40px auto; padding: 0 24px; color: #202a35; + font: 16px/1.65 system-ui, sans-serif; } + h1, h2 { line-height: 1.25; } + h2 { margin-top: 36px; } + code, pre { font-family: ui-monospace, monospace; font-size: .9em; } + pre { overflow-x: auto; padding: 20px; background: #f1f5f8; border-radius: 6px; } + table { border-collapse: collapse; width: 100%; } + td, th { padding: 10px; border: 1px solid #d6dfe7; text-align: left; vertical-align: top; } + a { color: #1459a6; } + </style> +</head> +<body> +<h1>Query ESM1 indexes with <code>paimon_es_search</code></h1> +<p>Spark, Flink and the Java SDK share the same version-1 request, snapshot planning, +field-ID binding, global reduction and result records. The Java module builds and queries Lucene archives using the published ESLib dependency.</p> +<table><tr><th>Entry</th><th>Execution</th></tr> +<tr><td>Spark <code>paimon_es_search(catalog, request_json)</code></td><td>Shard queries on executors; bounded global top-K and source fetch on driver.</td></tr> +<tr><td>Flink <code>CALL catalog.sys.paimon_es_search(request_json)</code></td><td>Shard groups on TaskManagers; bounded worker top-K followed by client merge/source fetch. Returns one JSON string per HIT/PAGE_END row.</td></tr> +<tr><td>Java <code>EsIndexSearch.search(catalog, requestJson)</code></td><td>Local SDK execution with the same planning, readers and bounded result.</td></tr></table> +<h2>Runtime and index prerequisites</h2> +<p>Use JDK 11+, the matching Paimon connector and <code>org.apache.paimon:paimon-elasticsearch</code> +with its POM runtime dependencies (published ESLib 1.0.7 and Lucene 9.12.0). The former module, +artifact and profile were named <code>paimon-eslib</code>; Java package names and <code>es-index</code> +remain unchanged. Keep the connector, index module and dependencies together on every worker. +IK requires an additional compatible analyzer JAR.</p> +<p>The existing Java archive format and persisted field mappings remain compatible. +The module rename does not require an index rebuild.</p> +<p>Use an append-only data-evolution table with tracked row IDs and construction-snapshot metadata +emitted by <code>create_global_index</code>. Spark additionally needs +<code>org.apache.paimon.spark.extensions.PaimonSparkSessionExtensions</code>. No Elasticsearch server is required for this search entry.</p> +<pre><code>CREATE DATABASE IF NOT EXISTS paimon.demo; +CREATE TABLE paimon.demo.docs ( + id INT, embedding ARRAY<FLOAT>, title STRING, tag STRING +) USING paimon TBLPROPERTIES ( + 'bucket'='-1', + 'row-tracking.enabled'='true', + 'data-evolution.enabled'='true', + 'global-index.es-index.fields.embedding.dimension'='3', + 'global-index.es-index.fields.embedding.algorithm'='hnsw', + 'global-index.es-index.fields.embedding.metric'='l2', + 'global-index.es-index.fields.title.type'='fulltext', + 'global-index.es-index.fields.tag.type'='keyword' +); +INSERT INTO paimon.demo.docs VALUES + (1, array(1.0f, 0.0f, 0.0f), 'paimon lake', 'active'), + (2, array(0.0f, 1.0f, 0.0f), 'river meadow', 'inactive'); +CALL paimon.sys.create_global_index( + table => 'demo.docs', index_column => 'embedding,title,tag,id', index_type => 'es-index' +);</code></pre> +<p>The new schema above has field IDs <code>id=0, embedding=1, title=2, tag=3</code>. +For an existing table, read its schema IDs; do not infer them from current column positions. +The index field group must match the persisted order. Query aliases bind to these stable IDs.</p> + +<h2>Text and filter-KNN examples</h2> +<pre><code>SELECT row_id, score, rank, source_schema_json, source_binary +FROM paimon_es_search('paimon', '{ + "version": 1, "operation": "SEARCH", + "targets": [{ + "name": "docs", "database": "demo", "table": "docs", + "indexes": [[1, 2, 3, 0]], + "bindings": {"text": 2, "label": 3, "v": 1, "number": 0} + }], + "request": { + "query": {"match": {"text": "paimon"}}, + "size": 10, "fetch": "SOURCE" + } +}') WHERE record_kind = 'HIT' ORDER BY rank;</code></pre> +<p>To run filter-KNN, replace the <code>request</code> object with:</p> +<pre><code>{ + "query": {"knn": { + "field": "v", "query_vector": [1.0, 0.0, 0.0], + "k": 3, "num_candidates": 100, + "filter": {"term": {"label": "active"}} + }}, + "size": 3, "fetch": "SOURCE" +}</code></pre> +<p>Put vector prefilters inside <code>knn.filter</code>. An outer SQL <code>WHERE</code> +filters the returned result rows and does not change the KNN candidate set.</p> + +<h2>Supported contract</h2> +<table> + <tr><th>Area</th><th>Behavior</th></tr> + <tr><td>Arguments</td><td>Two non-null constant strings: catalog name and JSON envelope. + Duplicate members, trailing JSON and unknown options are rejected before catalog IO.</td></tr> + <tr><td>Targets</td><td>One table on <code>main</code>, one ordered index field group. + Latest snapshot by default; explicit selector: + <code>{"kind":"snapshot","snapshot_id":"123","tag":null}</code>.</td></tr> + <tr><td>DSL</td><td><code>match_all</code>, <code>match_none</code>, scalar <code>term</code>, + <code>terms</code>, <code>range</code>, <code>exists</code>, <code>match</code>, + <code>match_phrase</code>, <code>bool</code> and root-level <code>knn</code> with an optional + filter. The persisted field mappings and analyzers determine query semantics. Java numeric terms are typed, integer fields require integral values, and date bounds use epoch milliseconds.</td></tr> Review Comment: Addressed in aa86d32. The guide now documents the stored units explicitly: `DATE` uses days since 1970-01-01, `TIME` uses milliseconds since midnight, and `TIMESTAMP` / `TIMESTAMP_LTZ` use Paimon's epoch-millisecond representation. The parser's validation message no longer incorrectly describes every temporal value as epoch milliseconds. The writer encoding is unchanged. Added four parameterized persisted writer/reader cases covering exact `term` and bounded `range` queries for these types. All passed. ########## paimon-elasticsearch/src/main/java/org/apache/paimon/eslib/javaindex/JavaEsQueryParser.java: ########## @@ -0,0 +1,368 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.paimon.eslib.javaindex; + +import org.apache.paimon.globalindex.EsSearchQuery; + +import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.databind.JsonNode; + +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.document.DoublePoint; +import org.apache.lucene.document.FloatPoint; +import org.apache.lucene.document.IntPoint; +import org.apache.lucene.document.LongPoint; +import org.apache.lucene.index.Term; +import org.apache.lucene.search.BooleanClause; +import org.apache.lucene.search.BooleanQuery; +import org.apache.lucene.search.ConstantScoreQuery; +import org.apache.lucene.search.FieldExistsQuery; +import org.apache.lucene.search.KnnFloatVectorQuery; +import org.apache.lucene.search.MatchAllDocsQuery; +import org.apache.lucene.search.MatchNoDocsQuery; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.TermInSetQuery; +import org.apache.lucene.search.TermQuery; +import org.apache.lucene.search.TermRangeQuery; +import org.apache.lucene.util.BytesRef; +import org.apache.lucene.util.QueryBuilder; +import org.apache.lucene.util.VectorUtil; +import org.elasticsearch.eslib.analyzer.BuiltinAnalyzers; +import org.elasticsearch.eslib.api.model.BuiltinAnalyzer; +import org.elasticsearch.eslib.api.model.FieldIndexConfig; +import org.elasticsearch.eslib.api.model.ScalarFieldType; +import org.elasticsearch.eslib.api.model.VectorAlgorithm; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.apache.paimon.globalindex.EsSearchQuery.require; + +/** Translates the validated, bounded search subset to the Java archive's Lucene queries. */ +final class JavaEsQueryParser { + private final Map<String, FieldIndexConfig> fields; + + JavaEsQueryParser(Map<String, FieldIndexConfig> fields) { + this.fields = fields; + } + + Query parse(EsSearchQuery query) { + return clause(EsSearchQuery.parse(query.queryJson())); + } + + float[] vector(JsonNode body) { + String name = body.get("field").textValue(); + FieldIndexConfig field = field(name); + require( + field.indexType() == FieldIndexConfig.IndexType.VECTOR, + "knn requires a vector field"); + JsonNode values = body.get("query_vector"); + require( + values.size() == field.dimension(), + "Query vector dimension does not match the index"); + float[] vector = new float[values.size()]; + for (int i = 0; i < vector.length; i++) { + vector[i] = values.get(i).floatValue(); + } + if (field.algorithm() == VectorAlgorithm.DISKBBQ + && "cosine".equalsIgnoreCase(field.metric())) { + VectorUtil.l2normalize(vector); + } + return vector; + } + + private Query clause(JsonNode node) { + String kind = node.fieldNames().next(); + JsonNode body = node.get(kind); + switch (kind) { + case "match_all": + return new MatchAllDocsQuery(); + case "match_none": + return new MatchNoDocsQuery(); + case "bool": + return bool(body); + case "exists": + String name = body.get("field").textValue(); + field(name); + return new FieldExistsQuery(name); + case "knn": + return new KnnFloatVectorQuery( + body.get("field").textValue(), + vector(body), + body.get("num_candidates").intValue(), + body.has("filter") ? clause(body.get("filter")) : null); + default: + String fieldName = body.fieldNames().next(); + FieldIndexConfig field = field(fieldName); + JsonNode value = body.get(fieldName); + switch (kind) { + case "term": + return field.indexType() == FieldIndexConfig.IndexType.FULLTEXT + ? new TermQuery(new Term(fieldName, value.asText())) + : terms(field, Collections.singletonList(value)); + case "terms": + List<JsonNode> values = new ArrayList<>(); + value.forEach(values::add); + return terms(field, values); + case "range": + return range(field, value); + case "match": + case "match_phrase": + return match(field, value, "match_phrase".equals(kind)); + default: + throw new IllegalArgumentException("Unsupported Java ES query: " + kind); + } + } + } + + private FieldIndexConfig field(String name) { + FieldIndexConfig field = fields.get(name); + require(field != null, "Field is absent from the selected ES index: " + name); + return field; + } + + private Query bool(JsonNode body) { + BooleanQuery.Builder result = new BooleanQuery.Builder(); + int positive = 0; + int required = 0; + int should = 0; + int total = 0; + for (String key : Arrays.asList("must", "filter", "should", "must_not")) { + JsonNode value = body.get(key); + if (value == null) { + continue; + } + BooleanClause.Occur occur; + switch (key) { + case "must": + occur = BooleanClause.Occur.MUST; + break; + case "filter": + occur = BooleanClause.Occur.FILTER; + break; + case "should": + occur = BooleanClause.Occur.SHOULD; + break; + default: + occur = BooleanClause.Occur.MUST_NOT; + } + Iterable<JsonNode> children = + value.isArray() ? value : Collections.singletonList(value); + for (JsonNode child : children) { + result.add(clause(child), occur); + total++; + if (occur != BooleanClause.Occur.MUST_NOT) { + positive++; + } + if (occur == BooleanClause.Occur.MUST || occur == BooleanClause.Occur.FILTER) { + required++; + } + if (occur == BooleanClause.Occur.SHOULD) { + should++; + } + } + } + int minimum = + body.has("minimum_should_match") + ? body.get("minimum_should_match").intValue() + : (required == 0 && should > 0 ? 1 : 0); + if (minimum > should) { + return new MatchNoDocsQuery(); + } + if (total == 0) { + return new MatchAllDocsQuery(); + } + if (positive == 0) { + result.add(new MatchAllDocsQuery(), BooleanClause.Occur.MUST); Review Comment: Addressed in aa86d32. The synthetic match-all clause now uses `FILTER` instead of `MUST`. The persisted-index regression verifies that the example produces scores of 1.0 and 0.0 and retains row 0 for Top-1. It also verifies that a standalone pure-negative query scores 0.0. This regression failed before the fix with an actual score of 2.0. A local Spark integration test additionally checks global Top-1/Top-2 ranking across multiple index shards and verifies Java SDK result parity. All cases pass after the fix -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
