Add builder pattern to HanaCloudVectorStore and refactor package name
This commit is contained in:
committed by
Mark Pollack
parent
7a7a914dc0
commit
71584e5238
@@ -142,7 +142,7 @@ import jakarta.persistence.Table;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
import org.springframework.ai.vectorstore.HanaVectorEntity;
|
||||
import org.springframework.ai.vectorstore.hanadb.HanaVectorEntity;
|
||||
|
||||
@Entity
|
||||
@Table(name = "CRICKET_WORLD_CUP")
|
||||
@@ -165,7 +165,7 @@ package com.interviewpedia.spring.ai.hana;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.springframework.ai.vectorstore.HanaVectorRepository;
|
||||
import org.springframework.ai.vectorstore.hanadb.HanaVectorRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
@@ -246,7 +246,7 @@ import org.springframework.ai.chat.prompt.SystemPromptTemplate;
|
||||
import org.springframework.ai.document.Document;
|
||||
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
|
||||
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
|
||||
import org.springframework.ai.vectorstore.HanaCloudVectorStore;
|
||||
import org.springframework.ai.vectorstore.hanadb.HanaCloudVectorStore;
|
||||
import org.springframework.ai.vectorstore.VectorStore;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.Resource;
|
||||
|
||||
@@ -21,10 +21,9 @@ import javax.sql.DataSource;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
|
||||
import org.springframework.ai.embedding.EmbeddingModel;
|
||||
import org.springframework.ai.vectorstore.HanaCloudVectorStore;
|
||||
import org.springframework.ai.vectorstore.HanaCloudVectorStoreConfig;
|
||||
import org.springframework.ai.vectorstore.HanaVectorEntity;
|
||||
import org.springframework.ai.vectorstore.HanaVectorRepository;
|
||||
import org.springframework.ai.vectorstore.hanadb.HanaCloudVectorStore;
|
||||
import org.springframework.ai.vectorstore.hanadb.HanaVectorEntity;
|
||||
import org.springframework.ai.vectorstore.hanadb.HanaVectorRepository;
|
||||
import org.springframework.ai.vectorstore.observation.VectorStoreObservationConvention;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
@@ -39,6 +38,7 @@ import org.springframework.context.annotation.Bean;
|
||||
*
|
||||
* @author Rahul Mittal
|
||||
* @author Christian Tzolov
|
||||
* @author Soby Chacko
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@AutoConfiguration(after = { JpaRepositoriesAutoConfiguration.class })
|
||||
@@ -53,13 +53,14 @@ public class HanaCloudVectorStoreAutoConfiguration {
|
||||
ObjectProvider<ObservationRegistry> observationRegistry,
|
||||
ObjectProvider<VectorStoreObservationConvention> customObservationConvention) {
|
||||
|
||||
return new HanaCloudVectorStore(repository, embeddingModel,
|
||||
HanaCloudVectorStoreConfig.builder()
|
||||
.tableName(properties.getTableName())
|
||||
.topK(properties.getTopK())
|
||||
.build(),
|
||||
observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP),
|
||||
customObservationConvention.getIfAvailable(() -> null));
|
||||
return HanaCloudVectorStore.builder()
|
||||
.repository(repository)
|
||||
.embeddingModel(embeddingModel)
|
||||
.tableName(properties.getTableName())
|
||||
.topK(properties.getTopK())
|
||||
.observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP))
|
||||
.customObservationConvention(customObservationConvention.getIfAvailable(() -> null))
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.ai.vectorstore;
|
||||
package org.springframework.ai.vectorstore.hanadb;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -34,9 +34,12 @@ import org.springframework.ai.model.EmbeddingUtils;
|
||||
import org.springframework.ai.observation.conventions.VectorStoreProvider;
|
||||
import org.springframework.ai.observation.conventions.VectorStoreSimilarityMetric;
|
||||
import org.springframework.ai.util.JacksonUtils;
|
||||
import org.springframework.ai.vectorstore.AbstractVectorStoreBuilder;
|
||||
import org.springframework.ai.vectorstore.SearchRequest;
|
||||
import org.springframework.ai.vectorstore.observation.AbstractObservationVectorStore;
|
||||
import org.springframework.ai.vectorstore.observation.VectorStoreObservationContext;
|
||||
import org.springframework.ai.vectorstore.observation.VectorStoreObservationConvention;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* The <b>SAP HANA Cloud vector engine</b> offers multiple use cases in AI scenarios.
|
||||
@@ -67,6 +70,7 @@ import org.springframework.ai.vectorstore.observation.VectorStoreObservationConv
|
||||
* @author Rahul Mittal
|
||||
* @author Christian Tzolov
|
||||
* @author Sebastien Deleuze
|
||||
* @author Soby Chacko
|
||||
* @see <a href=
|
||||
* "https://help.sap.com/docs/hana-cloud-database/sap-hana-cloud-sap-hana-database-vector-engine-guide/introduction">SAP
|
||||
* HANA Database Vector Engine Guide</a>
|
||||
@@ -78,30 +82,71 @@ public class HanaCloudVectorStore extends AbstractObservationVectorStore {
|
||||
|
||||
private final HanaVectorRepository<? extends HanaVectorEntity> repository;
|
||||
|
||||
private final EmbeddingModel embeddingModel;
|
||||
private final String tableName;
|
||||
|
||||
private final HanaCloudVectorStoreConfig config;
|
||||
private final int topK;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Creates a new HanaCloudVectorStore with basic configuration.
|
||||
* @param repository the HANA vector repository
|
||||
* @param embeddingModel the embedding model to use
|
||||
* @param config the vector store configuration
|
||||
* @deprecated Since 1.0.0-M5, use {@link #builder()} instead
|
||||
*/
|
||||
@Deprecated(since = "1.0.0-M5", forRemoval = true)
|
||||
public HanaCloudVectorStore(HanaVectorRepository<? extends HanaVectorEntity> repository,
|
||||
EmbeddingModel embeddingModel, HanaCloudVectorStoreConfig config) {
|
||||
|
||||
this(repository, embeddingModel, config, ObservationRegistry.NOOP, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new HanaCloudVectorStore with observation configuration.
|
||||
* @param repository the HANA vector repository
|
||||
* @param embeddingModel the embedding model to use
|
||||
* @param config the vector store configuration
|
||||
* @param observationRegistry the observation registry
|
||||
* @param customObservationConvention the custom observation convention
|
||||
* @deprecated Since 1.0.0-M5, use {@link #builder()} instead
|
||||
*/
|
||||
@Deprecated(since = "1.0.0-M5", forRemoval = true)
|
||||
public HanaCloudVectorStore(HanaVectorRepository<? extends HanaVectorEntity> repository,
|
||||
EmbeddingModel embeddingModel, HanaCloudVectorStoreConfig config, ObservationRegistry observationRegistry,
|
||||
VectorStoreObservationConvention customObservationConvention) {
|
||||
|
||||
super(observationRegistry, customObservationConvention);
|
||||
this(builder().repository(repository)
|
||||
.embeddingModel(embeddingModel)
|
||||
.tableName(config.getTableName())
|
||||
.topK(config.getTopK())
|
||||
.observationRegistry(observationRegistry)
|
||||
.customObservationConvention(customObservationConvention));
|
||||
}
|
||||
|
||||
this.repository = repository;
|
||||
this.embeddingModel = embeddingModel;
|
||||
this.config = config;
|
||||
/**
|
||||
* Protected constructor that accepts a builder instance. This is the preferred way to
|
||||
* create new HanaCloudVectorStore instances.
|
||||
* @param builder the configured builder instance
|
||||
*/
|
||||
protected HanaCloudVectorStore(HanaCloudBuilder builder) {
|
||||
super(builder);
|
||||
|
||||
Assert.notNull(builder.repository, "Repository must not be null");
|
||||
|
||||
this.repository = builder.repository;
|
||||
this.tableName = builder.tableName;
|
||||
this.topK = builder.topK;
|
||||
this.objectMapper = JsonMapper.builder().addModules(JacksonUtils.instantiateAvailableModules()).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new builder for configuring and creating HanaCloudVectorStore instances.
|
||||
* @return a new builder instance
|
||||
*/
|
||||
public static HanaCloudBuilder builder() {
|
||||
return new HanaCloudBuilder();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doAdd(List<Document> documents) {
|
||||
int count = 1;
|
||||
@@ -110,27 +155,27 @@ public class HanaCloudVectorStore extends AbstractObservationVectorStore {
|
||||
document.getId());
|
||||
String content = document.getContent().replaceAll("\\s+", " ");
|
||||
String embedding = getEmbedding(document);
|
||||
this.repository.save(this.config.getTableName(), document.getId(), embedding, content);
|
||||
this.repository.save(this.tableName, document.getId(), embedding, content);
|
||||
}
|
||||
logger.info("Embeddings saved in HanaCloudVectorStore for {} documents", count - 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Boolean> doDelete(List<String> idList) {
|
||||
int deleteCount = this.repository.deleteEmbeddingsById(this.config.getTableName(), idList);
|
||||
int deleteCount = this.repository.deleteEmbeddingsById(this.tableName, idList);
|
||||
logger.info("{} embeddings deleted", deleteCount);
|
||||
return Optional.of(deleteCount == idList.size());
|
||||
}
|
||||
|
||||
public int purgeEmbeddings() {
|
||||
int deleteCount = this.repository.deleteAllEmbeddings(this.config.getTableName());
|
||||
int deleteCount = this.repository.deleteAllEmbeddings(this.tableName);
|
||||
logger.info("{} embeddings deleted", deleteCount);
|
||||
return deleteCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Document> similaritySearch(String query) {
|
||||
return similaritySearch(SearchRequest.query(query).withTopK(this.config.getTopK()));
|
||||
return similaritySearch(SearchRequest.query(query).withTopK(this.topK));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -141,8 +186,8 @@ public class HanaCloudVectorStore extends AbstractObservationVectorStore {
|
||||
}
|
||||
|
||||
String queryEmbedding = getEmbedding(request);
|
||||
List<? extends HanaVectorEntity> searchResult = this.repository
|
||||
.cosineSimilaritySearch(this.config.getTableName(), request.getTopK(), queryEmbedding);
|
||||
List<? extends HanaVectorEntity> searchResult = this.repository.cosineSimilaritySearch(this.tableName,
|
||||
request.getTopK(), queryEmbedding);
|
||||
logger.info("Hana cosine-similarity for query={}, with topK={} returned {} results", request.getQuery(),
|
||||
request.getTopK(), searchResult.size());
|
||||
|
||||
@@ -175,8 +220,63 @@ public class HanaCloudVectorStore extends AbstractObservationVectorStore {
|
||||
|
||||
return VectorStoreObservationContext.builder(VectorStoreProvider.HANA.value(), operationName)
|
||||
.dimensions(this.embeddingModel.dimensions())
|
||||
.collectionName(this.config.getTableName())
|
||||
.collectionName(this.tableName)
|
||||
.similarityMetric(VectorStoreSimilarityMetric.COSINE.value());
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder class for creating {@link HanaCloudVectorStore} instances.
|
||||
* <p>
|
||||
* Provides a fluent API for configuring all aspects of the HANA Cloud vector store.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public static class HanaCloudBuilder extends AbstractVectorStoreBuilder<HanaCloudBuilder> {
|
||||
|
||||
private HanaVectorRepository<? extends HanaVectorEntity> repository;
|
||||
|
||||
private String tableName;
|
||||
|
||||
private int topK;
|
||||
|
||||
/**
|
||||
* Sets the HANA vector repository.
|
||||
* @param repository the repository to use
|
||||
* @return the builder instance
|
||||
* @throws IllegalArgumentException if repository is null
|
||||
*/
|
||||
public HanaCloudBuilder repository(HanaVectorRepository<? extends HanaVectorEntity> repository) {
|
||||
Assert.notNull(repository, "Repository must not be null");
|
||||
this.repository = repository;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the table name for vector storage.
|
||||
* @param tableName the name of the table to use
|
||||
* @return the builder instance
|
||||
*/
|
||||
public HanaCloudBuilder tableName(String tableName) {
|
||||
this.tableName = tableName;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the number of top results to return.
|
||||
* @param topK the number of results
|
||||
* @return the builder instance
|
||||
*/
|
||||
public HanaCloudBuilder topK(int topK) {
|
||||
this.topK = topK;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HanaCloudVectorStore build() {
|
||||
validate();
|
||||
return new HanaCloudVectorStore(this);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.ai.vectorstore;
|
||||
package org.springframework.ai.vectorstore.hanadb;
|
||||
|
||||
/**
|
||||
* The {@code HanaCloudVectorStoreConfig} class represents the configuration for the
|
||||
@@ -23,7 +23,9 @@ package org.springframework.ai.vectorstore;
|
||||
*
|
||||
* @author Rahul Mittal
|
||||
* @since 1.0.0
|
||||
* @deprecated Since 1.0.0-M5, use {@link HanaCloudVectorStore#builder()}
|
||||
*/
|
||||
@Deprecated(since = "1.0.0-M5", forRemoval = true)
|
||||
public final class HanaCloudVectorStoreConfig {
|
||||
|
||||
private String tableName;
|
||||
@@ -33,34 +35,64 @@ public final class HanaCloudVectorStoreConfig {
|
||||
private HanaCloudVectorStoreConfig() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new builder for HanaCloudVectorStoreConfig.
|
||||
* @return a new builder instance
|
||||
* @deprecated Since 1.0.0-M5, use {@link HanaCloudVectorStore#builder()}
|
||||
*/
|
||||
@Deprecated(since = "1.0.0-M5", forRemoval = true)
|
||||
public static HanaCloudVectorStoreConfigBuilder builder() {
|
||||
return new HanaCloudVectorStoreConfigBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Since 1.0.0-M5, use {@link HanaCloudVectorStore#builder()}
|
||||
*/
|
||||
@Deprecated(since = "1.0.0-M5", forRemoval = true)
|
||||
public String getTableName() {
|
||||
return this.tableName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Since 1.0.0-M5, use {@link HanaCloudVectorStore#builder()}
|
||||
*/
|
||||
@Deprecated(since = "1.0.0-M5", forRemoval = true)
|
||||
public int getTopK() {
|
||||
return this.topK;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Since 1.0.0-M5, use {@link HanaCloudVectorStore#builder()}
|
||||
*/
|
||||
@Deprecated(since = "1.0.0-M5", forRemoval = true)
|
||||
public static class HanaCloudVectorStoreConfigBuilder {
|
||||
|
||||
private String tableName;
|
||||
|
||||
private int topK;
|
||||
|
||||
/**
|
||||
* @deprecated Since 1.0.0-M5, use {@link HanaCloudVectorStore#builder()}
|
||||
*/
|
||||
@Deprecated(since = "1.0.0-M5", forRemoval = true)
|
||||
public HanaCloudVectorStoreConfigBuilder tableName(String tableName) {
|
||||
this.tableName = tableName;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Since 1.0.0-M5, use {@link HanaCloudVectorStore#builder()}
|
||||
*/
|
||||
@Deprecated(since = "1.0.0-M5", forRemoval = true)
|
||||
public HanaCloudVectorStoreConfigBuilder topK(int topK) {
|
||||
this.topK = topK;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Since 1.0.0-M5, use {@link HanaCloudVectorStore#builder()}
|
||||
*/
|
||||
@Deprecated(since = "1.0.0-M5", forRemoval = true)
|
||||
public HanaCloudVectorStoreConfig build() {
|
||||
HanaCloudVectorStoreConfig config = new HanaCloudVectorStoreConfig();
|
||||
config.tableName = this.tableName;
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.ai.vectorstore;
|
||||
package org.springframework.ai.vectorstore.hanadb;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Id;
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.ai.vectorstore;
|
||||
package org.springframework.ai.vectorstore.hanadb;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.ai.vectorstore;
|
||||
package org.springframework.ai.vectorstore.hanadb;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.ai.vectorstore;
|
||||
package org.springframework.ai.vectorstore.hanadb;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
@@ -33,6 +33,7 @@ import org.springframework.ai.chat.prompt.SystemPromptTemplate;
|
||||
import org.springframework.ai.document.Document;
|
||||
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
|
||||
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
|
||||
import org.springframework.ai.vectorstore.VectorStore;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.ai.vectorstore;
|
||||
package org.springframework.ai.vectorstore.hanadb;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.ai.vectorstore;
|
||||
package org.springframework.ai.vectorstore.hanadb;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
@@ -34,6 +34,7 @@ import org.springframework.ai.openai.OpenAiEmbeddingModel;
|
||||
import org.springframework.ai.openai.api.OpenAiApi;
|
||||
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
|
||||
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
|
||||
import org.springframework.ai.vectorstore.VectorStore;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
@@ -89,8 +90,13 @@ public class HanaCloudVectorStoreIT {
|
||||
@Bean
|
||||
public VectorStore hanaCloudVectorStore(CricketWorldCupRepository cricketWorldCupRepository,
|
||||
EmbeddingModel embeddingModel) {
|
||||
return new HanaCloudVectorStore(cricketWorldCupRepository, embeddingModel,
|
||||
HanaCloudVectorStoreConfig.builder().tableName("CRICKET_WORLD_CUP").topK(1).build());
|
||||
|
||||
return HanaCloudVectorStore.builder()
|
||||
.repository(cricketWorldCupRepository)
|
||||
.embeddingModel(embeddingModel)
|
||||
.tableName("CRICKET_WORLD_CUP")
|
||||
.topK(1)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.ai.vectorstore;
|
||||
package org.springframework.ai.vectorstore.hanadb;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -36,6 +36,8 @@ import org.springframework.ai.observation.conventions.VectorStoreProvider;
|
||||
import org.springframework.ai.observation.conventions.VectorStoreSimilarityMetric;
|
||||
import org.springframework.ai.openai.OpenAiEmbeddingModel;
|
||||
import org.springframework.ai.openai.api.OpenAiApi;
|
||||
import org.springframework.ai.vectorstore.SearchRequest;
|
||||
import org.springframework.ai.vectorstore.VectorStore;
|
||||
import org.springframework.ai.vectorstore.observation.DefaultVectorStoreObservationConvention;
|
||||
import org.springframework.ai.vectorstore.observation.VectorStoreObservationDocumentation.HighCardinalityKeyNames;
|
||||
import org.springframework.ai.vectorstore.observation.VectorStoreObservationDocumentation.LowCardinalityKeyNames;
|
||||
@@ -164,9 +166,15 @@ public class HanaVectorStoreObservationIT {
|
||||
@Bean
|
||||
public VectorStore hanaCloudVectorStore(CricketWorldCupRepository cricketWorldCupRepository,
|
||||
EmbeddingModel embeddingModel, ObservationRegistry observationRegistry) {
|
||||
return new HanaCloudVectorStore(cricketWorldCupRepository, embeddingModel,
|
||||
HanaCloudVectorStoreConfig.builder().tableName(TEST_TABLE_NAME).topK(1).build(),
|
||||
observationRegistry, null);
|
||||
|
||||
return HanaCloudVectorStore.builder()
|
||||
.repository(cricketWorldCupRepository)
|
||||
.embeddingModel(embeddingModel)
|
||||
.tableName(TEST_TABLE_NAME)
|
||||
.topK(1)
|
||||
.observationRegistry(observationRegistry)
|
||||
.customObservationConvention(null)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
Reference in New Issue
Block a user