diff --git a/core/src/main/java/org/fao/geonet/util/SemanticUtils.java b/core/src/main/java/org/fao/geonet/util/SemanticUtils.java new file mode 100644 index 000000000000..a3983e151fbb --- /dev/null +++ b/core/src/main/java/org/fao/geonet/util/SemanticUtils.java @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang.StringUtils; +import org.fao.geonet.constants.Geonet; +import org.fao.geonet.utils.Log; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +@Component +public class SemanticUtils { + @Value("${semantic.server.url:http://localhost:11434/v1/embeddings}") + private String semanticServerUrl; + + @Value("${semantic.server.model:bge-m3}") + private String semanticServerModel; + + @Value("${semantic.server.apikey:}") + private String semanticServerApiKey; + + public String buildEmbedding(String text) { + if (StringUtils.isBlank(text)) { + return ""; + } + + HttpURLConnection connection = null; + try { + URL url = new URL(semanticServerUrl); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setConnectTimeout(10_000); + connection.setReadTimeout(30_000); + connection.setRequestProperty("Content-Type", "application/json"); + if (StringUtils.isNotBlank(semanticServerApiKey)) { + connection.setRequestProperty("Authorization", "Bearer " + semanticServerApiKey); + } + connection.setDoOutput(true); + + ObjectMapper objectMapper = new ObjectMapper(); + ObjectNode payload = objectMapper.createObjectNode(); + payload.put("model", semanticServerModel); + payload.put("input", text); + + try (OutputStream outputStream = connection.getOutputStream()) { + outputStream.write(objectMapper.writeValueAsBytes(payload)); + } + + int status = connection.getResponseCode(); + if (status != HttpURLConnection.HTTP_OK) { + InputStream errorStream = connection.getErrorStream(); + String error = errorStream != null ? IOUtils.toString(errorStream, StandardCharsets.UTF_8) : ""; + Log.error(Geonet.GEONETWORK, "Semantic embedding request failed with status " + status + ": " + error); + return ""; + } + + try (InputStream inputStream = connection.getInputStream()) { + JsonNode response = objectMapper.readTree(inputStream); + JsonNode embedding = extractEmbedding(response); + if (embedding == null) { + Log.error(Geonet.GEONETWORK, + "Semantic embedding response does not contain an embedding array."); + return ""; + } + return objectMapper.writeValueAsString(embedding); + } + } catch (IOException e) { + Log.error(Geonet.GEONETWORK, "Failed to build embedding: " + e.getMessage(), e); + return ""; + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + static JsonNode extractEmbedding(JsonNode response) { + if (response == null || response.isNull()) { + return null; + } + + JsonNode embedding = response.get("embedding"); + if (embedding != null && embedding.isArray()) { + return embedding; + } + + JsonNode data = response.get("data"); + if (data != null && data.isArray()) { + for (JsonNode item : data) { + JsonNode itemEmbedding = item.get("embedding"); + if (itemEmbedding != null && itemEmbedding.isArray()) { + return itemEmbedding; + } + } + } + + return null; + } +} diff --git a/core/src/main/java/org/fao/geonet/util/XslUtil.java b/core/src/main/java/org/fao/geonet/util/XslUtil.java index 2162a01e2d47..6b445478c068 100644 --- a/core/src/main/java/org/fao/geonet/util/XslUtil.java +++ b/core/src/main/java/org/fao/geonet/util/XslUtil.java @@ -1672,4 +1672,20 @@ public static String getWebAnalyticsJavascriptCode() { return webAnalyticsConfiguration.getJavascriptCode(); } + + + public static String buildEmbedding(String text) { + try { + ApplicationContext applicationContext = ApplicationContextHolder.get(); + if (applicationContext == null) { + Log.error(Geonet.GEONETWORK, "Application context is not available. Failed to build embedding."); + return ""; + } + SemanticUtils semanticUtils = applicationContext.getBean(SemanticUtils.class); + return semanticUtils.buildEmbedding(text); + } catch (BeanCreationException | IllegalStateException e) { + Log.error(Geonet.GEONETWORK, "Failed to access SemanticUtils bean to build embedding: " + e.getMessage(), e); + return ""; + } + } } diff --git a/core/src/test/java/org/fao/geonet/util/SemanticUtilsTest.java b/core/src/test/java/org/fao/geonet/util/SemanticUtilsTest.java new file mode 100644 index 000000000000..94899e09d067 --- /dev/null +++ b/core/src/test/java/org/fao/geonet/util/SemanticUtilsTest.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class SemanticUtilsTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + public void extractEmbeddingFromTopLevelResponse() throws Exception { + JsonNode response = objectMapper.readTree("{\"embedding\":[1.0,2.0,3.0]}"); + + JsonNode embedding = SemanticUtils.extractEmbedding(response); + + assertEquals("[1.0,2.0,3.0]", objectMapper.writeValueAsString(embedding)); + } + + @Test + public void extractEmbeddingFromOpenAiDataResponse() throws Exception { + JsonNode response = objectMapper.readTree( + "{\"object\":\"list\",\"data\":[{\"object\":\"embedding\",\"embedding\":[4.0,5.0,6.0],\"index\":0}]}" + ); + + JsonNode embedding = SemanticUtils.extractEmbedding(response); + + assertEquals("[4.0,5.0,6.0]", objectMapper.writeValueAsString(embedding)); + } + + @Test + public void extractEmbeddingReturnsNullWhenMissing() throws Exception { + JsonNode response = objectMapper.readTree("{\"data\":[{\"index\":0}]}"); + + JsonNode embedding = SemanticUtils.extractEmbedding(response); + + assertNull(embedding); + } +} diff --git a/docs/manual/docs/install-guide/installing-index.md b/docs/manual/docs/install-guide/installing-index.md index 87c5ec6b1e50..9a6124efa397 100644 --- a/docs/manual/docs/install-guide/installing-index.md +++ b/docs/manual/docs/install-guide/installing-index.md @@ -137,3 +137,134 @@ By default, GeoNetwork expects Elasticsearch to be running at /dev/null; do sleep 1; done + echo "Pulling bge-m3 model..." + ollama pull bge-m3 + echo "Model ready! Keeping server alive..." + wait +volumes: + ollama_data: +``` + +and then run the following command to start the Ollama server: + +```shell +docker-compose up -d +``` + +Once the Ollama server is running, you can check the model is available with http://localhost:11434/api/tags. +Then, you can configure GeoNetwork to use it for semantic search by setting the following properties in ```WEB-INF/config.properties```: + +```properties +semantic.server.url=http://localhost:11434/v1/embeddings +semantic.server.model=bge-m3 +semantic.server.apikey= +``` + +Depending on the model you are using, you may need to change the vector dimension in the index configuration file. For example, the `bge-m3` model uses 1024 dimensions. + +```json + "properties": { + "text_vector": { + "type": "dense_vector", + "dims": 1024 + } +``` + +Once updated, you need to recreate the index and reindex your records from the admin console. + + +### Searching + +When searching a [KNN search](https://www.elastic.co/docs/solutions/search/vector/knn) is used in combination with the standard search. + +Searching for "rivière en afrique" will return the "Hydrological basins of Africa" record even if the words "rivière" or "afrique" are not present in the record title. + +Explanation of the score for this search is the following: + +```json +{ + "hits": { + "hits": [ + { + "_id": "da165110-88fd-11da-a88f-000d939bc5d8", + "_explanation": { + "value": 0.7646189, + "description": "sum of:", + "details": [ + { + "value": 0.7646189, + "description": "sum of:", + "details": [ + { + "value": 0.7646189, + "description": "within top k documents", + "details": [] + } + ] + }, + { + "value": 0.0, + "description": "match on required clause, product of:", + "details": [ + { + "value": 0.0, + "description": "# clause", + "details": [] + }, + { + "value": 1.0, + "description": "FieldExistsQuery [field=_primary_term]", + "details": [] + } + ] + } + ] + }, +``` + +The total score is the sum of the KNN score (0.7646189) and the standard search score (0.0). +The KNN score is computed based on the similarity between the query embedding and the record embedding, +while the standard search score is based on the presence of query terms in the record fields. + + +### Indexing + +For the time being only the main language record title is used to compute embeddings (see `index.xsl` for each schema). diff --git a/pom.xml b/pom.xml index ce81aa43c13a..4acb0b5c5b01 100644 --- a/pom.xml +++ b/pom.xml @@ -1651,6 +1651,11 @@ 4000 2000 + http://localhost:11434/v1/embeddings + bge-m3 + + 1024 + diff --git a/schemas/dublin-core/src/main/plugin/dublin-core/index-fields/index.xsl b/schemas/dublin-core/src/main/plugin/dublin-core/index-fields/index.xsl index d2f9cdf4b26d..47711fc3bc3a 100644 --- a/schemas/dublin-core/src/main/plugin/dublin-core/index-fields/index.xsl +++ b/schemas/dublin-core/src/main/plugin/dublin-core/index-fields/index.xsl @@ -108,6 +108,13 @@ + + + + + + + diff --git a/schemas/iso19115-3.2018/src/main/plugin/iso19115-3.2018/index-fields/index.xsl b/schemas/iso19115-3.2018/src/main/plugin/iso19115-3.2018/index-fields/index.xsl index f9d22dd23a24..603a5f289574 100644 --- a/schemas/iso19115-3.2018/src/main/plugin/iso19115-3.2018/index-fields/index.xsl +++ b/schemas/iso19115-3.2018/src/main/plugin/iso19115-3.2018/index-fields/index.xsl @@ -428,6 +428,13 @@ + + + + + + + + + + + + + + diff --git a/services/src/main/java/org/fao/geonet/api/es/EsHTTPProxy.java b/services/src/main/java/org/fao/geonet/api/es/EsHTTPProxy.java index 6636533f33e0..feb33cf5b024 100644 --- a/services/src/main/java/org/fao/geonet/api/es/EsHTTPProxy.java +++ b/services/src/main/java/org/fao/geonet/api/es/EsHTTPProxy.java @@ -495,6 +495,8 @@ private void addFilterToQuery(ServiceContext context, || esQuery.toString().contains("-draft:")); JsonNode nodeFilter = objectMapper.readTree(esFilter); EsQueryFilterUtils.addFilterToQuery(objectMapper, esQuery, nodeFilter); + KnnUtils.addFilterToKnnQuery(esQuery, nodeFilter); + KnnUtils.replaceEmbeddingInKnnQuery(esQuery, objectMapper); } /** diff --git a/services/src/main/java/org/fao/geonet/api/es/KnnUtils.java b/services/src/main/java/org/fao/geonet/api/es/KnnUtils.java new file mode 100644 index 000000000000..2ed7bf104201 --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/es/KnnUtils.java @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.es; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.commons.lang.StringUtils; +import org.fao.geonet.constants.Geonet; +import org.fao.geonet.util.XslUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +public final class KnnUtils { + private static final Logger LOGGER = LoggerFactory.getLogger(Geonet.INDEX_ENGINE); + + private KnnUtils() { + } + + public static void replaceEmbeddingInKnnQuery(JsonNode node, ObjectMapper mapper) { + if (node == null || node.isNull() || node.isValueNode()) { + return; + } + + if (node.isObject()) { + ObjectNode objectNode = (ObjectNode) node; + JsonNode knnNode = objectNode.get("knn"); + if (knnNode != null && !updateKnnNode(knnNode, mapper)) { + objectNode.remove("knn"); + } + objectNode.fields().forEachRemaining(entry -> { + if (!"knn".equals(entry.getKey())) { + replaceEmbeddingInKnnQuery(entry.getValue(), mapper); + } + }); + return; + } + + if (node.isArray()) { + for (JsonNode item : node) { + replaceEmbeddingInKnnQuery(item, mapper); + } + } + } + + public static void addFilterToKnnQuery(JsonNode node, JsonNode nodeFilter) { + if (node == null || node.isNull()) { + return; + } + + if (node.isObject()) { + ObjectNode objectNode = (ObjectNode) node; + JsonNode knnNode = objectNode.get("knn"); + if (knnNode != null && !addFilterToKnnNode(knnNode, nodeFilter)) { + objectNode.remove("knn"); + } + + objectNode.fields().forEachRemaining(entry -> { + if (!"knn".equals(entry.getKey())) { + addFilterToKnnQuery(entry.getValue(), nodeFilter); + } + }); + return; + } + + if (node.isArray()) { + for (JsonNode item : node) { + addFilterToKnnQuery(item, nodeFilter); + } + } + } + + private static boolean updateKnnNode(JsonNode knnNode, ObjectMapper mapper) { + if (knnNode.isObject()) { + return replaceQueryVector((ObjectNode) knnNode, mapper); + } + if (knnNode.isArray()) { + ArrayNode arrayNode = (ArrayNode) knnNode; + boolean hasValidItem = false; + for (int i = 0; i < arrayNode.size(); i++) { + JsonNode knnItem = arrayNode.get(i); + if (knnItem.isObject()) { + if (!replaceQueryVector((ObjectNode) knnItem, mapper)) { + arrayNode.remove(i); + i--; + continue; + } + hasValidItem = true; + } + } + return hasValidItem; + } + return false; + } + + private static boolean replaceQueryVector(ObjectNode knnObject, ObjectMapper mapper) { + JsonNode queryVectorNode = knnObject.get("query_vector"); + if (queryVectorNode == null || !queryVectorNode.isTextual()) { + return false; + } + + String embeddingJson = XslUtil.buildEmbedding(queryVectorNode.asText()); + if (StringUtils.isBlank(embeddingJson)) { + LOGGER.warn("Could not build embedding for knn query_vector."); + return false; + } + + try { + JsonNode embeddingNode = mapper.readTree(embeddingJson); + if (!embeddingNode.isArray()) { + LOGGER.warn("Embedding payload is not an array: {}", embeddingNode.getNodeType()); + return false; + } + knnObject.set("query_vector", embeddingNode); + return true; + } catch (IOException e) { + LOGGER.error("Error parsing embedding payload.", e); + return false; + } + } + + private static boolean addFilterToKnnNode(JsonNode knnNode, JsonNode nodeFilter) { + if (knnNode.isObject()) { + insertFilter((ObjectNode) knnNode, nodeFilter.deepCopy()); + return true; + } + + if (knnNode.isArray()) { + boolean hasItem = false; + for (int i = 0; i < ((ArrayNode) knnNode).size(); i++) { + JsonNode knnItem = ((ArrayNode) knnNode).get(i); + if (knnItem.isObject()) { + insertFilter((ObjectNode) knnItem, nodeFilter.deepCopy()); + hasItem = true; + } + } + return hasItem; + } + + return false; + } + + private static void insertFilter(ObjectNode objectNode, JsonNode nodeFilter) { + JsonNode filter = objectNode.get("filter"); + if (filter == null || filter.isNull() || (filter.isObject() && filter.isEmpty())) { + objectNode.set("filter", nodeFilter); + } else if (filter.isArray()) { + ((ArrayNode) filter).add(nodeFilter); + } else { + ArrayNode arr = JsonNodeFactory.instance.arrayNode(); + arr.add(filter); + arr.add(nodeFilter); + objectNode.set("filter", arr); + } + } +} diff --git a/web-ui/src/main/resources/catalog/components/elasticsearch/EsService.js b/web-ui/src/main/resources/catalog/components/elasticsearch/EsService.js index ad9f01803c1e..5ca40a76f9ea 100644 --- a/web-ui/src/main/resources/catalog/components/elasticsearch/EsService.js +++ b/web-ui/src/main/resources/catalog/components/elasticsearch/EsService.js @@ -427,6 +427,22 @@ } }; + this.buildKnnQuery = function(params, any, luceneQueryString) { + if (gnGlobalSettings.gnCfg.mods.search.knn && any && any.length > 0) { + params.knn = angular.copy(gnGlobalSettings.gnCfg.mods.search.knn, {}); + params.knn.query_vector = any; + params.min_score = 0.75; + if (luceneQueryString) { + params.knn.filter = { + query_string: { + query: luceneQueryString + } + } + } + // params.explain = true; + } + } + this.generateEsRequest = function ( p, searchState, @@ -476,6 +492,7 @@ var queryHook = query.function_score.query.bool; this.buildQueryClauses(queryHook, p, luceneQueryString, searchState); + this.buildKnnQuery(params, p.any, luceneQueryString) if (p.from) { params.from = p.from - 1; diff --git a/web-ui/src/main/resources/catalog/js/CatController.js b/web-ui/src/main/resources/catalog/js/CatController.js index ed0cfb2707a7..a9edacd01540 100644 --- a/web-ui/src/main/resources/catalog/js/CatController.js +++ b/web-ui/src/main/resources/catalog/js/CatController.js @@ -344,6 +344,12 @@ ], score_mode: "multiply" }, + knn: { + field: "text_vector", + query_vector: "", + k: 10, + num_candidates: 100 + }, autocompleteConfig: { query: { bool: { diff --git a/web/src/main/webResources/WEB-INF/config.properties b/web/src/main/webResources/WEB-INF/config.properties index 060446291fe4..253cbb128360 100644 --- a/web/src/main/webResources/WEB-INF/config.properties +++ b/web/src/main/webResources/WEB-INF/config.properties @@ -77,3 +77,7 @@ analytics.web.jscode= # Configure the metadata publication notification mails to be sent as HTML (true) or TEXT (false) metadata.publicationmail.format.html=true + +semantic.server.url=${es.index.semantic.server.url} +semantic.server.model=${es.index.semantic.server.model} +semantic.server.apikey=${es.index.semantic.server.apikey} diff --git a/web/src/main/webResources/WEB-INF/data/config/index/records.json b/web/src/main/webResources/WEB-INF/data/config/index/records.json index 0a1969fe6c03..b8e825ee3176 100644 --- a/web/src/main/webResources/WEB-INF/data/config/index/records.json +++ b/web/src/main/webResources/WEB-INF/data/config/index/records.json @@ -1637,6 +1637,12 @@ } ], "properties": { + "text_vector": { + "type": "dense_vector", + "dims": ${es.index.semantic.server.model.dims}, + "index": true, + "similarity": "cosine" + }, "any": { "type": "object", "properties": {