From 303587d588d0dbad53e34c5a8d394c7b5af7f291 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 21 Jul 2026 17:03:03 +0900 Subject: [PATCH 1/3] =?UTF-8?q?chore:=20pgvector=20=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=B0=8F=20seed=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V32: embeddings.vector, search_queries.query_vector TEXT → vector(1024) 변환 및 HNSW 인덱스 추가 - R__seed_bge_m3_embedding_model: mock 모델 seed를 실제 BAAI/bge-m3(HUGGINGFACE) seed로 교체 - R__seed_test_fixtures: 벡터 검색 동작 확인용 개발 더미 데이터 추가 (문서 2개, 청크 4개, 임베딩 4개) Co-Authored-By: Claude Sonnet 4.6 --- ...32__convert_vector_columns_to_pgvector.sql | 10 ++ .../seed/R__seed_bge_m3_embedding_model.sql | 25 ++++ .../db/seed/R__seed_mock_embedding_model.sql | 32 ----- .../db/seed/R__seed_test_fixtures.sql | 110 ++++++++++++++++++ 4 files changed, 145 insertions(+), 32 deletions(-) create mode 100644 src/main/resources/db/migration/V32__convert_vector_columns_to_pgvector.sql create mode 100644 src/main/resources/db/seed/R__seed_bge_m3_embedding_model.sql delete mode 100644 src/main/resources/db/seed/R__seed_mock_embedding_model.sql create mode 100644 src/main/resources/db/seed/R__seed_test_fixtures.sql diff --git a/src/main/resources/db/migration/V32__convert_vector_columns_to_pgvector.sql b/src/main/resources/db/migration/V32__convert_vector_columns_to_pgvector.sql new file mode 100644 index 00000000..ac43c5ad --- /dev/null +++ b/src/main/resources/db/migration/V32__convert_vector_columns_to_pgvector.sql @@ -0,0 +1,10 @@ +-- embeddings.vector: TEXT NOT NULL → vector(1024) NOT NULL +-- 개발 환경 기준 실 데이터 없음을 전제로 drop/add 방식 사용 +ALTER TABLE embeddings DROP COLUMN vector; +ALTER TABLE embeddings ADD COLUMN vector vector(1024) NOT NULL; + +-- search_queries.query_vector: TEXT → vector(1024) (nullable 유지) +ALTER TABLE search_queries ALTER COLUMN query_vector TYPE vector(1024) USING query_vector::vector; + +-- HNSW 인덱스: 코사인 거리 기반 ANN 검색 +CREATE INDEX ON embeddings USING hnsw (vector vector_cosine_ops); diff --git a/src/main/resources/db/seed/R__seed_bge_m3_embedding_model.sql b/src/main/resources/db/seed/R__seed_bge_m3_embedding_model.sql new file mode 100644 index 00000000..1a816e5c --- /dev/null +++ b/src/main/resources/db/seed/R__seed_bge_m3_embedding_model.sql @@ -0,0 +1,25 @@ +-- BAAI/bge-m3 임베딩 모델 seed (HUGGINGFACE, 1024차원, COSINE) +-- 기존 active+searchable 모델을 비활성화한 뒤 bge-m3를 활성 모델로 등록한다. +-- ON CONFLICT: provider+model_name+model_version unique 제약 기준으로 upsert. +UPDATE embedding_models +SET is_active = FALSE, is_searchable = FALSE +WHERE is_active = TRUE AND is_searchable = TRUE + AND model_name != 'BAAI/bge-m3'; + +INSERT INTO embedding_models ( + provider, model_name, model_version, dimension, + distance_metric, is_active, is_searchable, vector_storage_strategy, + created_at, updated_at +) VALUES ( + 'HUGGINGFACE', 'BAAI/bge-m3', '1.0', 1024, + 'COSINE', TRUE, TRUE, 'SINGLE_DIMENSION', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +) +ON CONFLICT (provider, model_name, model_version) +DO UPDATE SET + is_active = TRUE, + is_searchable = TRUE, + dimension = EXCLUDED.dimension, + distance_metric = EXCLUDED.distance_metric, + vector_storage_strategy = EXCLUDED.vector_storage_strategy, + updated_at = CURRENT_TIMESTAMP; diff --git a/src/main/resources/db/seed/R__seed_mock_embedding_model.sql b/src/main/resources/db/seed/R__seed_mock_embedding_model.sql deleted file mode 100644 index f443a2df..00000000 --- a/src/main/resources/db/seed/R__seed_mock_embedding_model.sql +++ /dev/null @@ -1,32 +0,0 @@ --- local/test에서 실제 BGE-M3와 같은 차원의 흐름을 검증하기 위한 기본 Mock 모델이다. -INSERT INTO embedding_models ( - provider, - model_name, - model_version, - dimension, - distance_metric, - is_active, - is_searchable, - vector_storage_strategy, - config_json -) -VALUES ( - 'MOCK', - 'mock-bge-m3', - 'v1', - 1024, - 'COSINE', - TRUE, - TRUE, - 'SINGLE_DIMENSION', - NULL -) -ON CONFLICT (provider, model_name, model_version) -DO UPDATE SET - dimension = EXCLUDED.dimension, - distance_metric = EXCLUDED.distance_metric, - is_active = EXCLUDED.is_active, - is_searchable = EXCLUDED.is_searchable, - vector_storage_strategy = EXCLUDED.vector_storage_strategy, - config_json = EXCLUDED.config_json, - updated_at = CURRENT_TIMESTAMP; diff --git a/src/main/resources/db/seed/R__seed_test_fixtures.sql b/src/main/resources/db/seed/R__seed_test_fixtures.sql new file mode 100644 index 00000000..9659051c --- /dev/null +++ b/src/main/resources/db/seed/R__seed_test_fixtures.sql @@ -0,0 +1,110 @@ +-- 개발용 테스트 픽스처: documents → document_versions → document_chunks → embeddings +-- 벡터 검색 동작 확인용 더미 데이터 (PUBLIC 문서 2개, 청크 4개, 임베딩 4개) +-- ON CONFLICT: 재실행 시 중복 삽입을 방지한다. + +-- 1. 문서 2개 (PUBLIC, INDEXED) +INSERT INTO documents (owner_user_id, title, description, document_type, source_type, status, visibility, created_at, updated_at) +SELECT id, 'Spring Boot 개발 가이드', 'Spring Boot 핵심 개념 및 사용법 정리', 'TXT', 'UPLOAD', 'INDEXED', 'PUBLIC', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM users WHERE email = 'kcw130502@gmail.com' +ON CONFLICT DO NOTHING; + +INSERT INTO documents (owner_user_id, title, description, document_type, source_type, status, visibility, created_at, updated_at) +SELECT id, 'Python 데이터 분석 입문', 'pandas, numpy 활용 데이터 분석 가이드', 'TXT', 'UPLOAD', 'INDEXED', 'PUBLIC', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM users WHERE email = 'kcw130502@gmail.com' +ON CONFLICT DO NOTHING; + +-- 2. 문서 버전 (각 문서당 1개) +INSERT INTO document_versions (document_id, version_no, title_snapshot, status, created_by, created_at, updated_at) +SELECT d.id, 1, d.title, 'INDEXED', u.id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM documents d, users u +WHERE d.title IN ('Spring Boot 개발 가이드', 'Python 데이터 분석 입문') + AND u.email = 'kcw130502@gmail.com' + AND NOT EXISTS ( + SELECT 1 FROM document_versions dv WHERE dv.document_id = d.id AND dv.version_no = 1 + ); + +-- 3. current_version_id 업데이트 +UPDATE documents d +SET current_version_id = dv.id +FROM document_versions dv +WHERE dv.document_id = d.id + AND d.title IN ('Spring Boot 개발 가이드', 'Python 데이터 분석 입문') + AND d.current_version_id IS NULL; + +-- 4. 청크 (문서당 2개) +INSERT INTO document_chunks (document_version_id, chunk_index, chunk_text, token_count, char_start, char_end, created_at, updated_at) +SELECT dv.id, 0, + 'Spring Boot는 Java 기반 웹 애플리케이션 프레임워크입니다. 자동 설정과 내장 서버를 제공하여 빠른 개발이 가능합니다.', + 32, 0, 80, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM document_versions dv JOIN documents d ON dv.document_id = d.id +WHERE d.title = 'Spring Boot 개발 가이드' +ON CONFLICT (document_version_id, chunk_index) DO NOTHING; + +INSERT INTO document_chunks (document_version_id, chunk_index, chunk_text, token_count, char_start, char_end, created_at, updated_at) +SELECT dv.id, 1, + 'Spring Boot Starter는 의존성 관리를 단순화합니다. @SpringBootApplication 어노테이션으로 애플리케이션을 시작합니다.', + 30, 81, 165, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM document_versions dv JOIN documents d ON dv.document_id = d.id +WHERE d.title = 'Spring Boot 개발 가이드' +ON CONFLICT (document_version_id, chunk_index) DO NOTHING; + +INSERT INTO document_chunks (document_version_id, chunk_index, chunk_text, token_count, char_start, char_end, created_at, updated_at) +SELECT dv.id, 0, + 'Python은 데이터 분석에 널리 사용되는 프로그래밍 언어입니다. pandas 라이브러리로 데이터를 효율적으로 처리합니다.', + 30, 0, 82, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM document_versions dv JOIN documents d ON dv.document_id = d.id +WHERE d.title = 'Python 데이터 분석 입문' +ON CONFLICT (document_version_id, chunk_index) DO NOTHING; + +INSERT INTO document_chunks (document_version_id, chunk_index, chunk_text, token_count, char_start, char_end, created_at, updated_at) +SELECT dv.id, 1, + 'numpy는 수치 계산을 위한 Python 라이브러리입니다. 다차원 배열 연산과 선형대수 기능을 제공합니다.', + 28, 83, 158, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM document_versions dv JOIN documents d ON dv.document_id = d.id +WHERE d.title = 'Python 데이터 분석 입문' +ON CONFLICT (document_version_id, chunk_index) DO NOTHING; + +-- 5. 임베딩 (청크당 1개, 개발용 임의 벡터) +INSERT INTO embeddings (chunk_id, document_id, document_version_id, embedding_model_id, vector, dimension, status, created_at, updated_at) +SELECT dc.id, d.id, dv.id, em.id, + ('[' || (SELECT string_agg(CASE WHEN n BETWEEN 1 AND 10 THEN '0.3' ELSE '0.001' END, ',') FROM generate_series(1, 1024) n) || ']')::vector(1024), + 1024, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM document_chunks dc +JOIN document_versions dv ON dc.document_version_id = dv.id +JOIN documents d ON dv.document_id = d.id +JOIN embedding_models em ON em.model_name = 'BAAI/bge-m3' +WHERE d.title = 'Spring Boot 개발 가이드' AND dc.chunk_index = 0 +ON CONFLICT (chunk_id, embedding_model_id) DO NOTHING; + +INSERT INTO embeddings (chunk_id, document_id, document_version_id, embedding_model_id, vector, dimension, status, created_at, updated_at) +SELECT dc.id, d.id, dv.id, em.id, + ('[' || (SELECT string_agg(CASE WHEN n BETWEEN 11 AND 20 THEN '0.3' ELSE '0.001' END, ',') FROM generate_series(1, 1024) n) || ']')::vector(1024), + 1024, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM document_chunks dc +JOIN document_versions dv ON dc.document_version_id = dv.id +JOIN documents d ON dv.document_id = d.id +JOIN embedding_models em ON em.model_name = 'BAAI/bge-m3' +WHERE d.title = 'Spring Boot 개발 가이드' AND dc.chunk_index = 1 +ON CONFLICT (chunk_id, embedding_model_id) DO NOTHING; + +INSERT INTO embeddings (chunk_id, document_id, document_version_id, embedding_model_id, vector, dimension, status, created_at, updated_at) +SELECT dc.id, d.id, dv.id, em.id, + ('[' || (SELECT string_agg(CASE WHEN n BETWEEN 21 AND 30 THEN '0.3' ELSE '0.001' END, ',') FROM generate_series(1, 1024) n) || ']')::vector(1024), + 1024, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM document_chunks dc +JOIN document_versions dv ON dc.document_version_id = dv.id +JOIN documents d ON dv.document_id = d.id +JOIN embedding_models em ON em.model_name = 'BAAI/bge-m3' +WHERE d.title = 'Python 데이터 분석 입문' AND dc.chunk_index = 0 +ON CONFLICT (chunk_id, embedding_model_id) DO NOTHING; + +INSERT INTO embeddings (chunk_id, document_id, document_version_id, embedding_model_id, vector, dimension, status, created_at, updated_at) +SELECT dc.id, d.id, dv.id, em.id, + ('[' || (SELECT string_agg(CASE WHEN n BETWEEN 31 AND 40 THEN '0.3' ELSE '0.001' END, ',') FROM generate_series(1, 1024) n) || ']')::vector(1024), + 1024, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP +FROM document_chunks dc +JOIN document_versions dv ON dc.document_version_id = dv.id +JOIN documents d ON dv.document_id = d.id +JOIN embedding_models em ON em.model_name = 'BAAI/bge-m3' +WHERE d.title = 'Python 데이터 분석 입문' AND dc.chunk_index = 1 +ON CONFLICT (chunk_id, embedding_model_id) DO NOTHING; From 68a9ba49ec858c29781de1d081b6ee40e6a31a53 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 21 Jul 2026 17:03:11 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20Hibernate=20vector=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EB=A7=A4=ED=95=91=20=EA=B5=AC=ED=98=84=20=E2=80=94?= =?UTF-8?q?=20VectorType(UserType),=20=EC=97=94=ED=8B=B0=ED=8B=B0=20float[?= =?UTF-8?q?]=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VectorType: pgvector의 vector(1024) SQL 타입을 float[]로 매핑하는 커스텀 Hibernate UserType - Embedding.vector, SearchQuery.queryVector: String → float[] + @Type(VectorType.class)로 교체 - postgresql 의존성을 runtimeOnly → implementation으로 변경 (PGobject 컴파일 타임 사용) Co-Authored-By: Claude Sonnet 4.6 --- build.gradle | 2 +- .../domain/embedding/entity/Embedding.java | 14 +-- .../domain/search/entity/SearchQuery.java | 14 +-- .../global/common/type/VectorType.java | 89 +++++++++++++++++++ 4 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/opensource/docgrid/global/common/type/VectorType.java diff --git a/build.gradle b/build.gradle index c3db7556..31182a1c 100644 --- a/build.gradle +++ b/build.gradle @@ -31,7 +31,7 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' - runtimeOnly 'org.postgresql:postgresql' + implementation 'org.postgresql:postgresql' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/entity/Embedding.java b/src/main/java/com/opensource/docgrid/domain/embedding/entity/Embedding.java index 37674de1..56df2acc 100644 --- a/src/main/java/com/opensource/docgrid/domain/embedding/entity/Embedding.java +++ b/src/main/java/com/opensource/docgrid/domain/embedding/entity/Embedding.java @@ -6,6 +6,8 @@ import com.opensource.docgrid.domain.embedding.enums.EmbeddingStatus; import com.opensource.docgrid.global.common.entity.BaseEntity; +import com.opensource.docgrid.global.common.type.VectorType; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -23,6 +25,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.Type; /** * 임베딩(벡터) 테이블. @@ -36,8 +39,7 @@ * unique 제약: (chunk_id, embedding_model_id) 조합은 유일해야 한다 — 같은 chunk를 같은 모델로 중복 임베딩 금지. * index: (embedding_model_id, status), document_id, document_version_id. * - *

주의사항: vector 컬럼은 이 프로젝트에 아직 Hibernate vector 타입 매핑이 없어 TEXT로 임시 매핑했다. - * TODO: 추후 OpenSQL vector 타입(예: vector(768/1024/1536))으로 반드시 교체해야 한다. + *

주의사항: vector 컬럼은 VectorType(커스텀 Hibernate UserType)으로 float[]에 매핑한다. * dimension은 vector 값의 차원 수 검증용으로 별도 저장하며 embedding_models.dimension과 일치해야 한다. * MVP는 단일 active/searchable 모델 + 고정 dimension을 전제로 한다. */ @@ -84,9 +86,9 @@ public class Embedding extends BaseEntity { @JoinColumn(name = "embedding_model_id", nullable = false) private EmbeddingModel embeddingModel; - // TODO: 추후 OpenSQL vector 타입(예: vector(768/1024/1536))으로 교체 필요. 현재는 TEXT 임시 매핑. - @Column(nullable = false, columnDefinition = "TEXT") - private String vector; + @Type(VectorType.class) + @Column(nullable = false, columnDefinition = "vector(1024)") + private float[] vector; @Column(nullable = false) private int dimension; @@ -100,7 +102,7 @@ public class Embedding extends BaseEntity { @Builder public Embedding(DocumentChunk chunk, Document document, DocumentVersion documentVersion, - EmbeddingModel embeddingModel, String vector, int dimension, String vectorHash, + EmbeddingModel embeddingModel, float[] vector, int dimension, String vectorHash, EmbeddingStatus status) { this.chunk = chunk; this.document = document; diff --git a/src/main/java/com/opensource/docgrid/domain/search/entity/SearchQuery.java b/src/main/java/com/opensource/docgrid/domain/search/entity/SearchQuery.java index dfef0a4d..43779357 100644 --- a/src/main/java/com/opensource/docgrid/domain/search/entity/SearchQuery.java +++ b/src/main/java/com/opensource/docgrid/domain/search/entity/SearchQuery.java @@ -6,6 +6,7 @@ import com.opensource.docgrid.domain.search.enums.SearchType; import com.opensource.docgrid.domain.user.entity.User; import com.opensource.docgrid.global.common.entity.BaseEntity; +import com.opensource.docgrid.global.common.type.VectorType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -23,6 +24,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.Type; /** * 검색 요청 루트 테이블. @@ -35,8 +37,8 @@ * index: user_id, collection_id, query_embedding_model_id, search_type, created_at. * *

주의사항: MVP는 SearchType.VECTOR 중심으로 동작하며 KEYWORD/HYBRID는 확장 여지로 남겨둔다. - * queryVector는 추후 OpenSQL vector 타입으로 교체 필요(TODO), filtersJson은 Hibernate JSON 매핑이 없어 - * TEXT로 임시 매핑했다. + * queryVector는 VectorType(커스텀 Hibernate UserType)으로 float[]에 매핑한다. + * filtersJson은 Hibernate JSON 매핑이 없어 TEXT로 임시 매핑했다. */ @Getter @Entity @@ -75,9 +77,9 @@ public class SearchQuery extends BaseEntity { @JoinColumn(name = "query_embedding_model_id") private EmbeddingModel queryEmbeddingModel; - // TODO: 추후 OpenSQL vector 타입(예: vector(768/1024/1536))으로 교체 필요. 현재는 TEXT 임시 매핑. - @Column(name = "query_vector", columnDefinition = "TEXT") - private String queryVector; + @Type(VectorType.class) + @Column(name = "query_vector", columnDefinition = "vector(1024)") + private float[] queryVector; @Enumerated(EnumType.STRING) @Column(name = "search_type", nullable = false, length = 20) @@ -102,7 +104,7 @@ public class SearchQuery extends BaseEntity { @Builder public SearchQuery(User user, DocumentCollection collection, String queryText, - EmbeddingModel queryEmbeddingModel, String queryVector, SearchType searchType, int topK, + EmbeddingModel queryEmbeddingModel, float[] queryVector, SearchType searchType, int topK, String filtersJson, Integer latencyMs, ResultStatus status, String errorMessage) { this.user = user; this.collection = collection; diff --git a/src/main/java/com/opensource/docgrid/global/common/type/VectorType.java b/src/main/java/com/opensource/docgrid/global/common/type/VectorType.java new file mode 100644 index 00000000..eb56816b --- /dev/null +++ b/src/main/java/com/opensource/docgrid/global/common/type/VectorType.java @@ -0,0 +1,89 @@ +package com.opensource.docgrid.global.common.type; + +import org.hibernate.engine.spi.SharedSessionContractImplementor; +import org.hibernate.usertype.UserType; +import org.postgresql.util.PGobject; + +import java.io.Serializable; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.Arrays; + +public class VectorType implements UserType { + + @Override + public int getSqlType() { + return Types.OTHER; + } + + @Override + public Class returnedClass() { + return float[].class; + } + + @Override + public boolean equals(float[] x, float[] y) { + return Arrays.equals(x, y); + } + + @Override + public int hashCode(float[] x) { + return Arrays.hashCode(x); + } + + @Override + public float[] nullSafeGet(ResultSet rs, int position, SharedSessionContractImplementor session, Object owner) + throws SQLException { + String value = rs.getString(position); + if (rs.wasNull() || value == null) { + return null; + } + String[] parts = value.substring(1, value.length() - 1).split(","); + float[] result = new float[parts.length]; + for (int i = 0; i < parts.length; i++) { + result[i] = Float.parseFloat(parts[i].trim()); + } + return result; + } + + @Override + public void nullSafeSet(PreparedStatement st, float[] value, int index, SharedSessionContractImplementor session) + throws SQLException { + if (value == null) { + st.setNull(index, Types.OTHER); + return; + } + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < value.length; i++) { + if (i > 0) sb.append(","); + sb.append(value[i]); + } + sb.append("]"); + PGobject pgObject = new PGobject(); + pgObject.setType("vector"); + pgObject.setValue(sb.toString()); + st.setObject(index, pgObject); + } + + @Override + public float[] deepCopy(float[] value) { + return value == null ? null : Arrays.copyOf(value, value.length); + } + + @Override + public boolean isMutable() { + return true; + } + + @Override + public Serializable disassemble(float[] value) { + return deepCopy(value); + } + + @Override + public float[] assemble(Serializable cached, Object owner) { + return deepCopy((float[]) cached); + } +} From 8d8cb2b5c71144175b99452879d603542877f736 Mon Sep 17 00:00:00 2001 From: kangcheolung Date: Tue, 21 Jul 2026 17:03:15 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EB=B2=A1=ED=84=B0=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20DB=20=EC=9D=B8=ED=94=84=EB=9D=BC=20=EA=B5=AC?= =?UTF-8?q?=EC=B6=95=20=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- ...ung-#41-vector-search-db-infrastructure.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 docs/design/kangcheolung-#41-vector-search-db-infrastructure.md diff --git a/docs/design/kangcheolung-#41-vector-search-db-infrastructure.md b/docs/design/kangcheolung-#41-vector-search-db-infrastructure.md new file mode 100644 index 00000000..042b6f8b --- /dev/null +++ b/docs/design/kangcheolung-#41-vector-search-db-infrastructure.md @@ -0,0 +1,58 @@ +# #41 벡터 검색 DB 인프라 구축 + +closes #41 + +## 배경 + +벡터 검색 기능 구현을 위해 DB 스키마와 Java 엔티티를 pgvector에 맞게 정비한다. +기존에 `embeddings.vector`, `search_queries.query_vector` 컬럼이 임시로 `TEXT` 타입으로 매핑되어 있었고, +Hibernate 스키마 검증(`ddl-auto=validate`)이 실패하는 상태였다. + +## 작업 내용 + +### 1. Flyway V32 — vector 컬럼 타입 변환 및 HNSW 인덱스 추가 + +```sql +ALTER TABLE embeddings DROP COLUMN vector; +ALTER TABLE embeddings ADD COLUMN vector vector(1024) NOT NULL; +ALTER TABLE search_queries ALTER COLUMN query_vector TYPE vector(1024) USING query_vector::vector; +CREATE INDEX ON embeddings USING hnsw (vector vector_cosine_ops); +``` + +- `embeddings.vector`: TEXT → vector(1024) +- `search_queries.query_vector`: TEXT → vector(1024) +- HNSW 인덱스: 코사인 거리 기반 ANN 검색 가속 + +### 2. db/seed 이관 + +기존 `R__seed_mock_embedding_model.sql`(MOCK 모델)을 제거하고, 실제 BAAI/bge-m3 모델 seed로 교체했다. + +- `R__seed_bge_m3_embedding_model.sql`: HUGGINGFACE/BAAI/bge-m3(1024차원, COSINE) 활성 모델 등록. ON CONFLICT upsert. +- `R__seed_test_fixtures.sql`: PUBLIC 문서 2개, 청크 4개, 임베딩 4개(개발용 더미 벡터). ON CONFLICT DO NOTHING으로 멱등 처리. + +### 3. VectorType — 커스텀 Hibernate UserType 구현 + +`global/common/type/VectorType.java` + +- pgvector의 `vector(1024)` SQL 타입(Types#OTHER)을 Java `float[]`로 매핑 +- PostgreSQL JDBC의 `PGobject`로 write, `getString` + 파싱으로 read +- Hibernate 스키마 검증 통과: DB의 `Types#OTHER`와 UserType의 `getSqlType() = Types.OTHER`가 일치 + +### 4. 엔티티 수정 + +| 엔티티 | 변경 전 | 변경 후 | +|--------|---------|---------| +| `Embedding.vector` | `String` / `columnDefinition="TEXT"` | `float[]` / `@Type(VectorType.class)` / `columnDefinition="vector(1024)"` | +| `SearchQuery.queryVector` | `String` / `columnDefinition="TEXT"` | `float[]` / `@Type(VectorType.class)` / `columnDefinition="vector(1024)"` | + +### 5. build.gradle + +`runtimeOnly 'org.postgresql:postgresql'` → `implementation` + +PGobject를 컴파일 타임에 사용하기 위해 스코프 변경. + +## 설계 결정 + +- **seed를 db/migration이 아닌 db/seed에 배치**: 데이터 seed는 스키마 변경이 아니므로 분리. `R__`(repeatable) 방식으로 idempotent하게 관리. +- **VectorType 직접 구현**: 외부 라이브러리(pgvector-java, hypersistence-utils) 없이 JDBC 드라이버만으로 처리. 의존성 최소화. +- **float[] 선택**: 검색 시 임베딩 서버 응답(`List`)을 직접 담을 수 있고, pgvector 연산과 자연스럽게 연결됨.