02. Vector Database
이번 포스트에서는 Vector Database에 대해 알아보자.
Vector Database
개요
Vector DB는 AI 애플리케이션에서 RAG 서비스를 위해 필수적인 역할을 수행하는 특수한 유형의 DB이다. RAG는 모델이 가지고 있지 않은 데이터를 DB 등에서 검색해서 컨텍스트를 보완 후 사용하는 서비스다. 모델이 학습한 데이터는 벡터화 되어있기 때문에 보완할 데이터 역시 벡터 형태로 저장되어야 한다. Vector DB는 기존의 RDBMS처럼 정확히 일치하는 데이터를 찾는 대신 유사도 검색을 수행한다. 쿼리가 벡터로 주어지면 벡터 DB는 쿼리 벡터와 유사한 벡터를 반환한다.
벡터 DB는 데이터를 AI 모델과 통합하는데 사용된다. 사용의 첫 번째 단계는 데이터를 벡터 DB에 로드하는 것이다. 그런 다음 사용자 쿼리가 AI 모델에 전송될 때 먼저 유사한 문서 세트가 검색된다. 검색된 문서는 사용자 질문에 대한 컨텍스트 역할을 수행하며 사용자 쿼리와 함께 AI 모델에 전송된다. 이 기술이 RAG(검색 증강 생성)이다.
Redis Vector Database 설치
최근에는 많은 데이터베이스들이 벡터 기반 검색을 지원한다. 여기서는 실습을 위해 Redis Vector Store를 사용해보자.
Redis를 사용하기 위해 Docker를 활용해보자. 다음 명령을 이용해 도커 이미지를 내려받는다. (사전에 docker desktop 설치가 필요하다.)
docker pull redis/redis-stack:latest
다음으로 GUI를 실행하거나 다음의 명령을 이용해서 이미지를 실행시키자. 6379는 서비스 포트이고 8001은 redis 클라이언트를 위한 포트이다.
docker run -d --name redis-vector -p 6379:6379 -p 8001:8001 redis/redis-stack:latest
RedisClient
위 설정에서 2번째로 등록한 port는 RedisClient를 위한 포트이다. http://localhost:8001로 요청하면 다음과 같은 창을 볼 수 있다. 옵션을 설정하고 [submit]을 클릭하자.
RedisClient에 들어가면 좌측 하단의 >_CLI를 이용해 cli 기반의 명령창을 활성화 시킬 수 있다.
간단한 redis 명령은 다음과 같다.
- 추가/수정: set [key1] [value1] , JSON.SET [key] $ [JSON_문자열]
- 조회: get [key], JSON.GET [key] $
- 삭제: del [key]
- 전체 키 확인: keys *
- 전체 키 삭제: flushall
- index 확인: FT._LIST
-
index 정보 조회: FT.info index_name
-
정보 검색: FT.search 'index_name' "@category:{simple}"
-
FT.search 'index_name' "@category:{simple}" RETURN 1 content
-
FT.SEARCH 'index_name' '@meta_num:[3000 +inf]'
VectorStore 설정
embedding model 설정
각각의 모델은 나름대로의 최적화된 방식으로 데이터를 embedding 해서 학습한다. 따라서 Vector Database에 저장되는 데이터 역시 모델과 동일한 방식으로 embedding 되어야 한다. OpenAI등은 단일 모델이므로 추가적으로 embedding model을 가져올 필요가 없지만 Ollama의 경우는 모델이 다양하므로 공통적으로 사용할 embedding model이 필요하다.
Ollama에서 기본적으로 제공하는 embedding model은 mxbai-embed-large 모데이다. 다음 명령으로 ollama에 추가해주자.
ollama pull mxbai-embed-large
의존성 설정
Spring AI는 기본적으로 다음의 의존성을 추가하면 자동으로 RedisVectorStore 타입의 빈을 생성한다.
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-redis-store-spring-boot-starter</artifactId>
</dependency>
이때 수정 가능한 propert들은 다음과 같다.(모두 다 default로 충분하긴 하다.)
Property | Description | Default value |
spring.ai.vectorstore.redis.uri | 서버 URL | redis://localhost:6379 |
spring.ai.vectorstore.redis.index | 인덱스 이름 | spring-ai-index |
spring.ai.vectorstore.redis.initialize-schema | 스키마 초기화 여부 | false |
spring.ai.vectorstore.redis.prefix | 접두사 | embedding: |
하지만 여러 가지 embedding model이 로드되는 경우라든가 추가로 MetadataField를 설정하기 위해서는 직접 빈을 만들어 주어야 한다.
수동으로 빈을 구성하기 위해서는 다음의 과정이 필요하다.
먼저 필요한 의존성을 설정하자.
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-redis-store</artifactId>
</dependency>
빈 구성
다음으로 VectorStore 타입의 빈을 구성해준다.
@Bean
VectorStore vectorStore(@Qualifier("ollamaEmbeddingModel") EmbeddingModel embeddingModel) {
return RedisVectorStore.builder(new JedisPooled("localhost", 6379), embeddingModel)
.initializeSchema(true) // Optional: schema 초기화 여부(default: false)
.metadataFields( // Optional: filtering을 위한 metadata field 설정
MetadataField.tag("category"),
MetadataField.numeric("meta_num"),
MetadataField.text("meta_txt"))
.build();
}
@Bean
@ConditionalOnMissingBean(VectorStore.class)
VectorStore inMemoryVectorStore(@Qualifier("ollamaEmbeddingModel")EmbeddingModel embeddingModel) {
log.info("InMemoryVectorStore is used");
return SimpleVectorStore.builder(embeddingModel).build();
}
참고로 간단한 테스트를 위해서는 SimpleVectorStore를 사용할 수도 있다. 여기서는 @ConditionalOnMissingBean을 통해 다른 VectorStore가 없을 때만 구성되도록 처리해 주었다.
VectorStore 사용
API Overview
Spring AI는 VectorStore 인터페이스를 통해 벡터 데이터베이스와 상호작용할 수 있는 추상화된 API를 제공한다.
public interface VectorStore {
void add(List<Document> documents);
Optional<Boolean> delete(List<String> idList);
List<Document> similaritySearch(String query);
List<Document> similaritySearch(SearchRequest request);
}
벡터 데이터베이스에 데이터를 삽입하려면 Document 객체 안에 데이터를 캡슐화한다. Document 클래스는 PDF 또는 Word 같은 데이터 소스의 컨텐츠를 캡슐화하며 문자열로 표시되는 텍스트를 포함한다. 또한 파일 이름과 같은 세부 정보를 포함하는 key-value 쌍 형식의 메타 데이터를 포함하는데 이는 정보 filtering에 사용된다.
벡터 데이터베이스에 삽입되면 임베딩 모델을 사용하여 텍스트 콘텐츠가 벡터 임베딩으로 알려진 float[] 형태로 변환된다. 단어, 문장 또는 단락을 이런 임베딩으로 변환하기 위해 Word2Vec, GloVE, BERT, text-embedding-ada-002 등과 같은 임베딩 모델이 사용된다.
벡터 데이터베이스의 역할은 이러한 임베딩에 대한 유사성 검색을 저장하고 용이하게 하는 것이며 임베딩 자체를 생성하지는 않는다. 벡터 임베딩을 생성하려면 임베딩 모델을 사용해야 한다.
SearchRequest
similaritySearch 메서드는 주어진 쿼리 문자열과 유사한 문서를 검색할 수 있는데 상세 설정이 필요하면 다음의 SearchRequest를 활용할 수 있다. SearchRequest는 내장된 Builder를 통해서 구성한다.
public class SearchRequest {
private String query = ""; // 문자열 형태의 쿼리
private int topK = 4; // 조회 대상 개수: K nearest neghibors
private double similarityThreshold = 0.0; // 검색 대상 최소 유사도
private Filter.Expression filterExpression; // filter 표현식
public static Builder builder() {
return new Builder();
}
public static class Builder {
private final SearchRequest searchRequest = new SearchRequest();
public Builder query(String query) {…} // query 설정 - not null
public Builder topK(int topK) {…} // topK 설정: 음수 불가
public Builder similarityThreshold(double threshold) {…} // 유사도 설정 [0, 1]
// Metadata를 활용하는 filter 설정
public Builder filterExpression(Filter.Expression expression) {…}
// 문자열을 활용하는 filter 설정
public Builder filterExpression(String expression) {…}
}
}
- topK(=K nearest neighbors, KNN) : 반환할 유사 문서의 최대 개수
- similarityThreshold: 0~1 사이의 double 값으로 1에 가까울 수록 유사성이 높음. 지정한 임계치 이상의 유사도를 가진 문서만 검색됨
- filterExpression: sql의 where 절 처럼 조건을 명시하기 위한 표현식
활용 예
@SpringBootTest
@Slf4j
public class VectorStoreTest {
@Autowired
VectorStore store;
String category = "simple";
//@AfterEach
void clearStore() {
store.delete(String.format("category == '%s'", category));
}
@BeforeEach
void setupStore() {
clearStore();
List<Document> documents = List.of(
new Document("Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!! Spring AI rocks!!",
Map.of("category", category, "meta_txt", "hong", "meta_num", 2000)),
new Document("The World is Big and Salvation Lurks Around the Corner",
Map.of("category", category, "meta_txt", "jang", "meta_num", 2001)),
new Document("You walk forward facing the past and you turn back toward the future.",
Map.of("category", category, "meta_num", 2001)));
// VectorStore에 데이터 저장하기
store.add(documents);
}
. . .
}
데이터가 삽입되면 content와 함께 embedding 된 값을 확인할 수 있다.
다음으로 유사도 검사를 해보자.
유사도 검사 시 SQL의 where 조건 처럼 filter를 설정할 수 있다. filter는 Filter.Expression을 사용하거나 문자열 형태로 작성 가능하다.
Filter.Expression은 FilterExpressionBuilder를 통해 생성 가능하다.
FilterExpressionBuilder b = new FilterExpressionBuilder();
Expression exp1 = b.eq("country", "BG").build();
Expression exp2 = b.and(b.eq("genre", "drama"), b.gte("year", 2020)).build();
Expression exp3 = b.in("genre", "comery", "documentary", "drama" ).build();
멋지긴 한데 조건이 복잡해지면 코드의 가독성이 떨어진다. 개인적으로는 문자열 형태로 조건을 작성하는게 간단해 보이기도 한다.
"country == 'BG'"
"genre == 'drama' && year >= 2020"
"genre in ['comedy', 'documentary', 'drama']"
다음은 유사도 검사의 활용 예이다. 출력된 내용에서 개수와 함께 score(유사도 점수)도 확인해보자.
@Test // filter 없이 0.75이상의 유사도로 상위 3개까지 조회
void simularitySearchNoFilter() {
var searchRequest = SearchRequest.builder().query("The World")
.topK(3).similarityThreshold(0.75).build();
List<Document> result = store.similaritySearch(searchRequest);
log.debug("no filter: {}", result.size());
result.forEach(doc -> System.out.println(doc));
}
@Test // filter 없이 0.75이상의 유사도로 상위 3개까지 조회된 것 중 expression filter 적용
void simularitySearchUseFilter1() {
FilterExpressionBuilder builder = new FilterExpressionBuilder();
Expression expression = builder.and(builder.eq("category", "simple"),
builder.and(builder.in("meta_txt", "hong", "jang"),
builder.gte("meta_num", 2000)))
.build();
var searchRequest = SearchRequest.builder().query("The World")
.topK(3).similarityThreshold(0.75)
.filterExpression(expression).build();
List<Document> result = store.similaritySearch(searchRequest);
log.debug("no filter: {}", result.size());
result.forEach(doc -> System.out.println(doc));
}
@Test // expression filter를 문자열로 처리
void simularitySearchUseFilter2() {
String strExpression="category=='simple' && meta_txt in ['hong','jang'] && meta_num>=2000";
var searchRequest = SearchRequest.builder().query("The World")
.topK(3).similarityThreshold(0.75)
.filterExpression(strExpression).build();
List<Document> result = store.similaritySearch(searchRequest);
log.debug("no filter: {}", result.size());
result.forEach(doc -> System.out.println(doc));
}