02 pgvector&SpringAI
02 pgvector&SpringAI
1. Note
- 필요한 정보를 벡터DB에 넣은 뒤에 사용하는 방식
- 기존에는 프롬프트를 사용해서 LLM이 가지고 잇는 데이터를 가지고 답변을 생성했음.
- RAG 방식은 RAG데이터를 바탕으로 검사하고 그것을 중심으로 답변하게됨.
- 만약 특정한 서류기준으로 조회를 하고자 한다면
- 서류를 조회할때 특정 UUID값 기준으로 조회하고
- 그게 아니면 전체 서류에서 조회하면 됨
2. SpringAI + pgVector
1. RAG 방식(Retrieval-Augmented Generation)
- LLM이 모르는 내용(우리 회사 문서, 최신 자료 등)을 검색해서 끌어와 답변하게 만드는 방식
- 사용할 데이터를 정해주고, LLM이 그것을 바탕으로 응답을 생성하도록 유도하는 것.
2. pgVector이점
- PostgreSQL 안에서 바로 벡터 검색 가능 (별도 DB 필요 없음)
- SQL로 유사도 검색 가능 → 개발 난이도 낮음
- 일반 데이터 + 임베딩을 한 번에 관리 (트랜잭션 보장)
- 필터링 + 유사도 검색을 동시에 처리 가능
- 비용 효율 좋음 (추가 인프라 없음)
3. 흐름
1
2
3
4
5
6
7
8
9
10
11
12
# 지금 소스에서는 LLM이 변환 X , 자료 조회 O
파일 업로드
↓
vector_documents에 원본 저장
↓
텍스트를 청크로 분할 (Spring AI TextSplitter)
↓
각 청크를 임베딩 모델로 벡터화
↓
vector_store에 청크 + 벡터 저장
↓
사용자 질문 → 질문도 벡터화 → 유사도 검색 (pgvector)
3. 테이블 생성
1. 커스터마이징
- 가능한 영역
- 테이블명 변경
- 컬럼 추가 (예: category, created_at 등)
- metadata 구조 확장
- 비권장 영역
- embedding 컬럼 제거/변경
- content 구조 완전 변경
- 벡터 타입 변경 (pgvector 기준 깨짐)
2. vector_documents 생성
1
2
3
4
5
6
7
8
9
10
11
12
-- 업로드된 파일의 원본 정보를 통째로 보관하는 테이블
CREATE TABLE vector_documents
(
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 문서 고유 식별자 (자동 생성)
file_name VARCHAR(255) NOT NULL, -- 업로드된 파일명 (예: manual.pdf)
content TEXT NOT NULL, -- 파일 전체 텍스트 내용
content_type VARCHAR(50) NOT NULL, -- 파일 형식 (PDF, TXT 등)
metadata TEXT, -- 부가 정보 (저자, 페이지 수 등 자유 형식)
chunk_count INTEGER NOT NULL DEFAULT 0, -- 이 문서가 몇 개의 청크로 분할됐는지 카운트
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 문서 최초 등록 시각
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -- 문서 최종 수정 시각
);
3. vector_store 생성
1
2
3
4
5
6
7
8
9
-- 검색대상 벡터 테이블
CREATE TABLE vector_store
(
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 테이블 내 고유 식별자
content TEXT NOT NULL, -- 잘린 텍스트
metadata JSONB, -- 청크정보(JSON) / 서류명,페이지,청크정보
embedding vector(3072), -- 벡터
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
4. 기타 설정
1
2
3
4
5
-- [Step 3] 검색 성능 최적화 인덱스
CREATE INDEX idx_documents_filename ON vector_documents (file_name);
-- 메타데이터 필터링 가속화
CREATE INDEX idx_vector_store_metadata ON vector_store USING gin (metadata);
5. Spring 셋팅
1. 의존성
1
2
3
4
5
6
7
8
9
10
11
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:2023.0.2"
mavenBom "org.springframework.ai:spring-ai-bom:1.0.0"
}
}
dependencies {
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
implementation 'org.springframework.ai:spring-ai-starter-vector-store-pgvector'
}
2. 프로퍼티스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
autoconfigure:
exclude:
# Spring Boot 자동설정을 꺼서, pgvector 설정을 내가 직접 하겠다는 의미
- org.springframework.ai.vectorstore.pgvector.autoconfigure.PgVectorStoreAutoConfiguration
ai:
openai:
# API 키: Google AI Studio에서 발급받은 키를 입력
# ${GOOGLE_AI_GEMINI_API_KEY}
api-key: GOOGLE_AI_GEMINI_API_KEY
# 임베딩 모델 설정(데이터의 벡터화)
embedding:
base-url: https://generativelanguage.googleapis.com/v1beta/openai/
options:
model: gemini-embedding-001
chat:
# Base URL: Google Gemini가 제공하는 OpenAI 호환 API 주소
base-url: "https://generativelanguage.googleapis.com/v1beta/openai/"
options:
# 모델명: 현재 가장 최신/경량 모델인 gemini-2.0-flash-lite 등을 지정합니다.
model: "gemini-2.5-flash-lite"
# 온전성(Temperature): 0.0은 가장 보수적이고 사실적인 답변을 생성합니다.
# (분석, 요약, 데이터 추출에 최적화된 설정)
temperature: 0.7
# 엔드포인트 경로: 대화형 API를 호출하기 위한 표준 경로입니다.
completions-path: "/chat/completions"
# pgvector 설정
vectorstore:
pgvector:
initialize-schema: false # 직접 SQL로 테이블을 만들었으므로 false 권장
dimensions: 3072 # 임베딩 모델 최적화 차원
distance-type: COSINE_DISTANCE # 유사도 계산 방식 (cosine, l2, inner_product)
index-type: hnsw # 고속 검색을 위한 인덱스 방식
3. VectorStoreConfig
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Setter
@Configuration
@ConfigurationProperties(prefix = "spring.ai.vectorstore.pgvector")
public class VectorStoreConfig {
private int dimensions;
@Bean
public VectorStore vectorStore(DataSource dataSource, EmbeddingModel embeddingModel) {
JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
// 직접만든 벡터스토어를 쓰기 위해서 JdbcTemplate를 파라미터로 제공함.
return PgVectorStore.builder(jdbcTemplate, embeddingModel)
// 임베딩 벡터의 차원 수를 설정
// 프로퍼티스에서 가져옴
.dimensions(dimensions)
// 벡터 간의 유사도를 계산하는 방식을 설정
.distanceType(PgVectorStore.PgDistanceType.COSINE_DISTANCE)
// 검색 속도를 높이기 위한 인덱스 알고리즘을 설정
// HNSW는 대규모 데이터셋에서 빠르고 정확한 근사 최근접 이웃 검색을 지원
.indexType(PgVectorStore.PgIndexType.HNSW)
// 애플리케이션 시작 시 자동으로 테이블 스키마를 생성할지 여부를 결정
// 직접 SQL로 테이블을 관리하므로 false로 설정하여 기존 구조를 유지
.initializeSchema(false)
// 기존에 존재하는 벡터 테이블을 삭제하고 새로 만들지 설정
// 데이터 유실 방지를 위해 false로 설정하는 것이 안전
.removeExistingVectorStoreTable(false)
// 시작 시 DB 테이블의 컬럼 구성이나 차원이 설정값과 일치하는지 검증
// 차원 설정과 실제 DB의 vector 타입 일치 여부를 체크
.vectorTableValidationsEnabled(true)
// 데이터가 저장될 PostgreSQL의 스키마 이름을 지정
// 별도의 커스텀 스키마를 쓰지 않는다면 기본값인 "public"을 사용
.schemaName("public")
// 벡터 데이터가 실제로 저장될 테이블의 이름을 지정
// 여기서는 직접 생성한 "vector_store" 테이블과 연결
.vectorTableName("vector_store")
.build();
}
}
6. Spring
1. 문서 업로드 및 벡터화
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
@Transactional
public DocumentUploadResponse uploadDocument(MultipartFile file) throws IOException {
String filename = file.getOriginalFilename();
String contentType = file.getContentType();
String content = new String(file.getBytes(), StandardCharsets.UTF_8);
/* 원본 엔티티 생성 (ID 선발급)
* - 애플리케이션에서 UUID를 미리 생성하여 사용
* - 청크 단위 업로드 시 동일한 document_id를 사용하기 위해 필요
* - DB 생성 전략을 사용할 경우 save 이후에만 ID 확인 가능하므로 부적합
*/
VectorDocument vectorDocument = VectorDocument.builder()
.id(UUID.randomUUID())
.fileName(filename)
.content(content)
.contentType(contentType)
.build();
// DB에 저장하여 확정된 ID 획득
VectorDocument savedDocument = vectorDocumentRepository.save(vectorDocument);
/* private 함수
* - 확정된 ID를 전달하여 문서 분할 및 메타데이터 설정
* - 문서를 한번에 넣으면 크기 때문에 분할함
*/
List<Document> chunks = createChunks(content, savedDocument);
/* 청크 개수 업데이트 및 Vector Store 저장
* - 실제로 쓰이는 값은 X
* - 개발자 또는 운영자가 관리 하기 위한 용도
* - 청크로 쪼개고 하나씩 저장하면서 성공 개수를 직접 세는 느낌.(해당 로직에는 X)
*/
vectorDocument.setChunkCount(chunks.size());
// 주입받은 벡터스토어
vectorStore.add(chunks);
return DocumentUploadResponse.builder()
.documentId(savedDocument.getId().toString())
.filename(savedDocument.getFileName())
.chunkCount(savedDocument.getChunkCount())
.build();
}
// 2. 문서 분할 로직 (추상화)
private List<Document> createChunks(String content, VectorDocument entity) {
/* new TokenTextSplitter(
* 500, // defaultChunkSize: 한 청크당 약 500 토큰(단어 조각)씩 자름
* 100, // minChunkSizeChars: 최소 100자 이상은 되어야 의미가 있다고 판단
* 5, // minChunkLengthToEmbed: 너무 짧은 문장(예: "네.")은 임베딩 제외
* 10000, // maxNumChunks: 한 파일당 생성될 수 있는 최대 조각 수
* true // keepSeparator: 문단 구분자를 유지하여 가독성 보존
* )
*/
TextSplitter splitter = new TokenTextSplitter(500, 100, 5, 10000, true);
// 공통 메타데이터 생성
Map<String, Object> metadata = Map.of(
"document_id", entity.getId().toString(),
"filename", entity.getFileName(),
"source", "user_upload"
);
// Spring AI Document 객체 생성 후 분할
Document rawVectorDocument = new Document(content, metadata);
List<Document> splitChunks = splitter.split(rawVectorDocument);
// 각 청크에 랜덤 UUID 부여 (충돌 방지 및 고유 식별자 확보)
return splitChunks.stream()
.map(chunk -> new Document(UUID.randomUUID().toString(), chunk.getText(),
chunk.getMetadata()))
.toList();
}
2. 특정 문서 유사도 검색
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public SimilaritySearchResponse similaritySearchByDocument(UUID documentId, String query,
Integer topK) {
// Vector Store에서 특정 문서 범위(documentID) 내에서, 입력 쿼리와 가장 가까운 chunk K개 가져오기
List<Document> searchResults = vectorStore.similaritySearch(
SearchRequest.builder()
// 서류를 구분하는 작업을 제거하면 전체 범위에서 수행함.
.filterExpression(new Filter.Expression(
Filter.ExpressionType.EQ,
new Filter.Key("document_id"),
new Filter.Value(documentId.toString())
))
.query(query)
.topK(topK)
.build()
);
/* 정리/포맷팅
* 벡터 검색 결과를 chunk(문자열) + metadata를 -> 객체(DTO) 형태로 전환
*/
List<SimilaritySearchResponse.SearchResult> results = searchResults.stream()
.map(doc -> SimilaritySearchResponse.SearchResult.builder()
.id(doc.getId())
.content(doc.getText())
.metadata(doc.getMetadata())
.build())
.toList();
// 정리된 DTO 리스트를 최종 응답 객체로 감싸서 반환
return SimilaritySearchResponse.builder()
.query(query)
.resultCount(results.size())
.results(results)
.build();
}
3. 특정 문서 제거
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Transactional
public void deleteDocument(UUID documentId) {
VectorDocument entity = vectorDocumentRepository.findById(documentId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 문서입니다."));
// DB 삭제 (Cascade 설정이 없다면 수동 삭제 혹은 외래키 정책 활용)
vectorDocumentRepository.delete(entity);
// Vector Store 삭제
// Spring AI의 Filter 기능을 활용해 해당 document_id를 가진 모든 청크 조회
try {
List<String> chunkIds = vectorStore.similaritySearch(
SearchRequest.builder()
.query("*")
.filterExpression(new Filter.Expression(
Filter.ExpressionType.EQ,
new Filter.Key("document_id"),
new Filter.Value(documentId.toString())
))
.topK(10000)
.build()
).stream().map(Document::getId).toList();
/* 해당 청크 id들을 삭제함
* - vectorStore.delete() 메서드는 컬렉션(리스트, 배열 등)을 입력받아 내부에서 한 번에 처리하도록 구현됨
* - 별도로 for문 작업 불필요!
*/
if (!chunkIds.isEmpty()) {
vectorStore.delete(chunkIds);
log.info("Vector Store 내 관련 청크 {}개 삭제 완료", chunkIds.size());
}
} catch (Exception e) {
log.error("Vector Store 삭제 중 오류 발생 (DB는 삭제됨): {}", e.getMessage());
}
}
This post is licensed under CC BY 4.0 by the author.