Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions core/src/main/java/org/fao/geonet/util/SemanticUtils.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
16 changes: 16 additions & 0 deletions core/src/main/java/org/fao/geonet/util/XslUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
}
}
}
64 changes: 64 additions & 0 deletions core/src/test/java/org/fao/geonet/util/SemanticUtilsTest.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
131 changes: 131 additions & 0 deletions docs/manual/docs/install-guide/installing-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,134 @@ By default, GeoNetwork expects Elasticsearch to be running at <http://localhost:
```

Once the configuration is complete, you will need to restart the application.


## Using semantic search with Elasticsearch

To use semantic search with Elasticsearch, one option is to run a model to compute embeddings at index and search time.

The main benefits of the hybrid search are:
* when a lexical search does not return any results, the semantic search can still find records that are semantically related to the query terms.
* it can improve the ranking of the results by combining the lexical and semantic scores.
* it can support multilingual search by using a model that can compute embeddings for multiple languages.


### Setting up the model

This model (local or not) computes embeddings for the text fields of the records and stores them in Elasticsearch.
For the time being, only the record title is used to compute embeddings.

Then, at search time, the same model is used to compute embeddings for the query terms. Then the hybrid search combines a lexical search and a semantic (vector) search in Elasticsearch.
Computing embeddings has an overhead at index and search time, but it allows to find records that are semantically related to the query terms.

The following example uses [Ollama](https://ollama.com/) to run a local model `bge-m3` using Docker.
Create a `docker-compose.yml` file with the following content:

```yaml
services:
ollama:
image: ollama/ollama:latest
container_name: ollama
ports:
- "11434:11434"
volumes:
- ollama_data:/root/.ollama
entrypoint: [ "/bin/sh", "-c" ]
command:
- |
ollama serve &
echo "Waiting for Ollama server..."
while ! curl -s http://localhost:11434/api/tags > /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).
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1651,6 +1651,11 @@
<es.index.mapping.total_fields.limit>4000</es.index.mapping.total_fields.limit>
<es.index.ignore_above>2000</es.index.ignore_above>

<es.index.semantic.server.url>http://localhost:11434/v1/embeddings</es.index.semantic.server.url>
<es.index.semantic.server.model>bge-m3</es.index.semantic.server.model>
<es.index.semantic.server.apikey></es.index.semantic.server.apikey>
<es.index.semantic.server.model.dims>1024</es.index.semantic.server.model.dims>

<!-- If elasticsearch security is on,
add a read/write user for index features and records: -->
<es.username/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@
<xsl:copy-of select="gn-fn-index:add-object-field('resourceTitleObject', $resourceTitleObject)"/>
</xsl:for-each>

<xsl:variable name="embedding" select="util:buildEmbedding(dc:title[1]/text())"/>
<xsl:if test="$embedding != ''">
<text_vector type="object">
<xsl:value-of select="$embedding"/>
</text_vector>
</xsl:if>

<xsl:for-each select="dc:language[1]">
<xsl:copy-of select="gn-fn-index:add-field('resourceLanguage', current())"/>
</xsl:for-each>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,13 @@

<xsl:copy-of select="gn-fn-index:add-multilingual-field('resourceAbstract', mri:abstract, $allLanguages)"/>

<xsl:variable name="embedding" select="util:buildEmbedding(mri:citation/*/cit:title/gco:CharacterString/text())"/>
<xsl:if test="$embedding != ''">
<text_vector type="object">
<xsl:value-of select="$embedding"/>
</text_vector>
</xsl:if>

<xsl:for-each-group select="mri:defaultLocale/*/lan:characterEncoding/*[@codeListValue != '']"
group-by="@codeListValue">
<xsl:copy-of select="gn-fn-index:add-codelist-field(
Expand Down
Loading
Loading