Skip to content
Merged
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
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
58 changes: 58 additions & 0 deletions docs/design/kangcheolung-#41-vector-search-db-infrastructure.md
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +16 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find relevant files and inspect the doc plus migration references.
printf '\n== FILE LIST ==\n'
git ls-files | rg 'docs/design/kangcheolung-#41-vector-search-db-infrastructure\.md|V32|embeddings|vector'

printf '\n== DOC CONTEXT ==\n'
doc='docs/design/kangcheolung-#41-vector-search-db-infrastructure.md'
if [ -f "$doc" ]; then
  nl -ba "$doc" | sed -n '1,80p'
fi

printf '\n== SEARCH FOR V32 / EMBEDDINGS / VECTOR ==\n'
rg -n --hidden -S 'V32|embeddings|vector\(1024\)|ALTER TABLE embeddings|DROP COLUMN vector|ADD COLUMN vector' .

Repository: DocGrid/backend

Length of output: 429


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# If a V32 migration exists, print its path and relevant lines around vector changes.
rg -n --hidden -S 'CREATE TABLE embeddings|ALTER TABLE embeddings|vector\(1024\)|DROP COLUMN vector|ADD COLUMN vector|USING .*vector' . \
  | sed -n '1,200p'

Repository: DocGrid/backend

Length of output: 3592


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Narrow search for migration filenames and any vector-related schema definitions.
fd -a -t f 'V32|vector|embedding|embeddings' . | sed -n '1,200p'

Repository: DocGrid/backend

Length of output: 1063


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in src/main/resources/db/migration/V16__create_embeddings.sql \
         src/main/resources/db/migration/V32__convert_vector_columns_to_pgvector.sql \
         src/main/java/com/opensource/docgrid/domain/embedding/entity/Embedding.java \
         src/main/java/com/opensource/docgrid/domain/search/entity/SearchQuery.java
do
  printf '\n===== %s =====\n' "$f"
  if [ -f "$f" ]; then
    sed -n '1,220p' "$f"
  else
    echo "missing"
  fi
done

Repository: DocGrid/backend

Length of output: 11505


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -S 'insert into embeddings|into embeddings|save\(.*Embedding|new Embedding|`@Entity`\s*.*Embedding|embedding' \
  src/main/resources src/main/java | sed -n '1,240p'

Repository: DocGrid/backend

Length of output: 34404


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== MIGRATION REFERENCES ==\n'
rg -n -S 'embeddings' src/main/resources/db/migration src/main/resources/db/seed | sed -n '1,240p'

printf '\n== ENTITY/REPOSITORY REFERENCES ==\n'
rg -n -S 'EmbeddingRepository|EmbeddingService|saveAll?\(|save\(.*Embedding|new Embedding|Embeddings' src/main/java | sed -n '1,240p'

Repository: DocGrid/backend

Length of output: 3016


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in src/main/resources/db/migration/V16__create_embeddings.sql \
         src/main/resources/db/seed/R__seed_test_fixtures.sql \
         src/main/resources/db/seed/R__seed_bge_m3_embedding_model.sql
do
  printf '\n===== %s =====\n' "$f"
  if [ -f "$f" ]; then
    python3 - <<'PY' "$f"
import sys, pathlib
p = pathlib.Path(sys.argv[1])
for i, line in enumerate(p.read_text().splitlines(), 1):
    if i <= 200:
        print(f"{i:4d}: {line}")
PY
  else
    echo "missing"
  fi
done

Repository: DocGrid/backend

Length of output: 9978


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' src/main/resources/db/migration/V16__create_embeddings.sql

Repository: DocGrid/backend

Length of output: 1621


V32__convert_vector_columns_to_pgvector.sql는 drop/add 대신 변환으로 바꾸세요.
embeddings.vector를 삭제했다가 vector(1024) NOT NULL로 다시 추가하면 기존 행의 벡터가 유실되고, 기본값 없는 NOT NULL 추가도 실패합니다. ALTER COLUMN ... TYPE vector(1024) USING ...로 변환하거나 새 컬럼 추가 → 백필 → NOT NULL 적용 순서로 처리해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/design/kangcheolung-`#41-vector-search-db-infrastructure.md around lines
16 - 17, Update the embeddings.vector migration to preserve existing vectors by
converting the column in place with ALTER COLUMN ... TYPE vector(1024) USING
..., or add a temporary column, backfill it, then enforce NOT NULL before
replacing the original. Remove the DROP COLUMN and direct NOT NULL ADD COLUMN
sequence so existing rows are retained and the migration succeeds without a
default.

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<Float>`)을 직접 담을 수 있고, pgvector 연산과 자연스럽게 연결됨.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +25,7 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Type;

/**
* 임베딩(벡터) 테이블.
Expand All @@ -36,8 +39,7 @@
* unique 제약: (chunk_id, embedding_model_id) 조합은 유일해야 한다 — 같은 chunk를 같은 모델로 중복 임베딩 금지.
* index: (embedding_model_id, status), document_id, document_version_id.
*
* <p>주의사항: vector 컬럼은 이 프로젝트에 아직 Hibernate vector 타입 매핑이 없어 TEXT로 임시 매핑했다.
* TODO: 추후 OpenSQL vector 타입(예: vector(768/1024/1536))으로 반드시 교체해야 한다.
* <p>주의사항: vector 컬럼은 VectorType(커스텀 Hibernate UserType)으로 float[]에 매핑한다.
* dimension은 vector 값의 차원 수 검증용으로 별도 저장하며 embedding_models.dimension과 일치해야 한다.
* MVP는 단일 active/searchable 모델 + 고정 dimension을 전제로 한다.
*/
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +24,7 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Type;

/**
* 검색 요청 루트 테이블.
Expand All @@ -35,8 +37,8 @@
* index: user_id, collection_id, query_embedding_model_id, search_type, created_at.
*
* <p>주의사항: MVP는 SearchType.VECTOR 중심으로 동작하며 KEYWORD/HYBRID는 확장 여지로 남겨둔다.
* queryVector는 추후 OpenSQL vector 타입으로 교체 필요(TODO), filtersJson은 Hibernate JSON 매핑이 없어
* TEXT로 임시 매핑했다.
* queryVector는 VectorType(커스텀 Hibernate UserType)으로 float[]에 매핑한다.
* filtersJson은 Hibernate JSON 매핑이 없어 TEXT로 임시 매핑했다.
*/
@Getter
@Entity
Expand Down Expand Up @@ -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)
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<float[]> {

@Override
public int getSqlType() {
return Types.OTHER;
}

@Override
public Class<float[]> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +3 to +4

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

실 데이터 유실 위험이 있는 DROP COLUMN 대신 타입 캐스팅을 사용하세요.

개발 환경에만 적용된다는 가정이 있으나, 해당 Flyway 마이그레이션 스크립트가 운영 및 스테이징 환경에 배포될 경우 embeddings 테이블의 기존 벡터 데이터가 영구적으로 삭제됩니다. search_queries 테이블(7행)과 동일하게 USING 절을 통한 타입 변환을 권장합니다.

As per coding guidelines, do not make assumptions silently; state assumptions, surface uncertainty, and present multiple interpretations when applicable.

🛡️ Proposed fix to preserve data
-ALTER TABLE embeddings DROP COLUMN vector;
-ALTER TABLE embeddings ADD COLUMN vector vector(1024) NOT NULL;
+ALTER TABLE embeddings ALTER COLUMN vector TYPE vector(1024) USING vector::vector;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ALTER TABLE embeddings DROP COLUMN vector;
ALTER TABLE embeddings ADD COLUMN vector vector(1024) NOT NULL;
ALTER TABLE embeddings ALTER COLUMN vector TYPE vector(1024) USING vector::vector;
🧰 Tools
🪛 Squawk (2.59.0)

[warning] 3-3: Dropping a column may break existing clients.

(ban-drop-column)


[warning] 4-4: Adding a new column that is NOT NULL and has no default value to an existing table effectively makes it required. Make the field nullable or add a non-VOLATILE DEFAULT

(adding-required-field)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/resources/db/migration/V32__convert_vector_columns_to_pgvector.sql`
around lines 3 - 4, Replace the destructive DROP COLUMN/ADD COLUMN sequence in
migration V32 with an ALTER COLUMN TYPE conversion using a USING expression,
matching the existing search_queries migration pattern, so embeddings.vector
data is preserved while becoming vector(1024). Ensure the conversion handles the
column’s current type explicitly and retains the NOT NULL constraint.

Source: Coding guidelines


-- 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);
25 changes: 25 additions & 0 deletions src/main/resources/db/seed/R__seed_bge_m3_embedding_model.sql
Original file line number Diff line number Diff line change
@@ -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;
32 changes: 0 additions & 32 deletions src/main/resources/db/seed/R__seed_mock_embedding_model.sql

This file was deleted.

Loading