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
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
* pgvector 코사인 거리 검색 전용 Repository.
*
* <p>queryVector는 float[]를 서비스 레이어에서 '[v1,v2,...]' 문자열로 변환해 전달하며,
* SQL에서 CAST(:queryVector AS vector)로 pgvector 타입으로 변환한다.
* SQL에서 CAST(:queryVector AS vector)로 pgvector 타입으로 변환한다. 이 변환은 스칼라 서브쿼리
* {@code (SELECT CAST(:queryVector AS vector))}로 감싸 실행마다 한 번만 계산한다. 같은 SQL이 반복돼 Generic Plan이
* 쓰이면 감싸지 않은 CAST가 행마다 다시 실행돼 검색이 수십 배 느려지기 때문이다(#549).
* permittedIds는 권한 pre-filter(F-SEARCH-04) 결과이며, 빈 목록이면 서비스에서 조기 반환해야 한다.
*/
public interface VectorSearchRepository extends JpaRepository<Embedding, Long> {
Expand All @@ -27,7 +29,7 @@ public interface VectorSearchRepository extends JpaRepository<Embedding, Long> {
dc.section_title AS section_title,
dc.metadata_json AS metadata_json,
d.title AS document_title,
(e.vector <=> CAST(:queryVector AS vector)) AS distance
(e.vector <=> (SELECT CAST(:queryVector AS vector))) AS distance
FROM embeddings e
JOIN documents d ON d.id = e.document_id
JOIN document_chunks dc ON dc.id = e.chunk_id
Expand All @@ -37,7 +39,7 @@ AND e.document_id IN (:permittedIds)
AND d.deleted_at IS NULL
AND d.status = 'INDEXED'
AND d.current_version_id = e.document_version_id
ORDER BY e.vector <=> CAST(:queryVector AS vector)
ORDER BY e.vector <=> (SELECT CAST(:queryVector AS vector))
LIMIT :topK
""", nativeQuery = true)
List<VectorSearchRow> findTopK(
Expand Down Expand Up @@ -139,7 +141,7 @@ sel AS (
dc.section_title AS section_title,
dc.metadata_json AS metadata_json,
d.title AS document_title,
(e.vector <=> CAST(:queryVector AS vector)) AS distance,
(e.vector <=> (SELECT CAST(:queryVector AS vector))) AS distance,
sel.coverage AS coverage
FROM sel
JOIN embeddings e ON e.id = sel.embedding_id
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
package com.opensource.docgrid.domain.search.repository;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.data.jpa.repository.Query;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

/**
* 실제 PostgreSQL에서 벡터 검색 쿼리({@link VectorSearchRepository#findTopK})가 Generic Plan에서도 질문 Vector를
* 한 번만 계산하는지 검증한다(#549).
*
* <p>같은 SQL이 반복되면 PostgreSQL은 값과 무관한 Generic Plan을 재사용한다. 이때 {@code CAST(:queryVector AS vector)}를
* 그대로 두면 1024차원 문자열을 행마다 다시 파싱해 검색이 수십 배 느려진다. 스칼라 서브쿼리로 감싸 InitPlan으로 한 번만
* 계산하는지, 결과가 Custom Plan과 같은지, HNSW 정렬 Index Scan도 여전히 쓸 수 있는지를 실행 계획으로 확인한다.
* 실행 계획 검사는 운영 Repository의 {@code @Query} SQL을 그대로 읽어 PREPARE한다.
*/
@Tag("integration")
@DataJpaTest
@ActiveProfiles("test")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@DisplayName("벡터 검색 쿼리 Generic Plan 통합 테스트")
class VectorSearchRepositoryGenericPlanIntegrationTest {

private static final String TEST_SCHEMA = "docgrid_vector_search_generic_plan_test";
private static final int VECTOR_DIMENSION = 1024;
private static final int TOP_K = 10;
private static final String CONTENT_HASH =
"26e4a23eec4241e034f1b4631f0222f1895847637c35e77687d5945f75edb42c";
private static final String QUERY_VECTOR = vector(0.0);

@Autowired private JdbcTemplate jdbcTemplate;
@Autowired private VectorSearchRepository vectorSearchRepository;

private final ObjectMapper objectMapper = new ObjectMapper();
private Long userId;
private Long embeddingModelId;

@DynamicPropertySource
static void configureDatabase(DynamicPropertyRegistry registry) {
registry.add("TEST_DB_SCHEMA", () -> TEST_SCHEMA);
registry.add("jwt.secret", () -> "docgrid-vector-search-generic-plan-test-secret-key-2026");
}

@BeforeEach
void setUp() {
jdbcTemplate.execute("""
TRUNCATE TABLE embeddings, document_chunks, document_versions, documents, users
RESTART IDENTITY CASCADE
""");
userId = jdbcTemplate.queryForObject("""
INSERT INTO users (email, password_hash, name, status, created_at, updated_at)
VALUES (?, 'password-hash', 'Generic Plan Test User', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id
""", Long.class, "vector-search-generic-plan-" + UUID.randomUUID() + "@example.com");
embeddingModelId = jdbcTemplate.queryForObject("""
SELECT id FROM embedding_models WHERE is_active = TRUE AND is_searchable = TRUE
""", Long.class);
}

@AfterAll
void dropSchema() {
jdbcTemplate.execute("DROP SCHEMA IF EXISTS " + TEST_SCHEMA + " CASCADE");
}

@Test
@DisplayName("Generic Plan으로 실행해도 Custom Plan과 같은 청크를 같은 거리 순서로 돌려준다")
void findTopK_genericPlanReturnsSameRowsAsCustomPlan() {
Long documentId = documentWithChunks(4);

jdbcTemplate.execute("SET LOCAL plan_cache_mode = force_custom_plan");
List<VectorSearchRow> custom = findTopK(documentId);
jdbcTemplate.execute("SET LOCAL plan_cache_mode = force_generic_plan");
List<VectorSearchRow> generic = findTopK(documentId);

assertThat(custom).hasSize(4);
assertThat(generic).extracting(VectorSearchRow::getChunkId)
.containsExactlyElementsOf(custom.stream().map(VectorSearchRow::getChunkId).toList());
assertThat(generic).extracting(VectorSearchRow::getDistance)
.containsExactlyElementsOf(custom.stream().map(VectorSearchRow::getDistance).toList());
}

@Test
@DisplayName("Generic Plan의 정렬 키는 행마다 질문 Vector를 변환하지 않고 InitPlan 결과를 쓴다")
void findTopK_genericPlanEvaluatesQueryVectorOnce() throws Exception {
Long documentId = documentWithChunks(4);

JsonNode plan = explainGenericTopK(documentId);

List<String> orderingKeys = orderingKeys(plan);
assertThat(orderingKeys).isNotEmpty();
assertThat(orderingKeys).noneMatch(key -> key.contains("$1"));
assertThat(plan.toString()).contains("InitPlan");
}

@Test
@DisplayName("질문 Vector를 서브쿼리로 감싸도 HNSW 정렬 Index Scan을 쓸 수 있다")
void findTopK_genericPlanCanStillUseHnswIndex() throws Exception {
Long documentId = documentWithChunks(4);
String hnswIndex = jdbcTemplate.queryForObject("""
SELECT indexname FROM pg_indexes
WHERE schemaname = current_schema() AND tablename = 'embeddings' AND indexdef ILIKE '%USING hnsw%'
""", String.class);
jdbcTemplate.execute("SET LOCAL enable_sort = off");
jdbcTemplate.execute("SET LOCAL enable_seqscan = off");

JsonNode plan = explainGenericTopK(documentId);

assertThat(indexNames(plan)).contains(hnswIndex);
assertThat(orderingKeys(plan)).isNotEmpty().noneMatch(key -> key.contains("$1"));
}

private List<VectorSearchRow> findTopK(Long documentId) {
return vectorSearchRepository.findTopK(QUERY_VECTOR, embeddingModelId, List.of(documentId), TOP_K);
}

/**
* 운영 findTopK SQL을 허용 문서 1건짜리 Prepared Statement로 만들고 Generic Plan을 강제해 실행 계획을 읽는다.
*/
private JsonNode explainGenericTopK(Long documentId) throws Exception {
String sql = VectorSearchRepository.class
.getMethod("findTopK", String.class, Long.class, List.class, int.class)
.getAnnotation(Query.class)
.value()
.replace(":queryVector", "$1")
.replace(":modelId", "$2")
.replace("(:permittedIds)", "($3)")
.replace(":topK", "$4");
String statement = "generic_plan_" + UUID.randomUUID().toString().replace("-", "");
jdbcTemplate.execute("PREPARE " + statement + "(text, bigint, bigint, int) AS " + sql);
try {
jdbcTemplate.execute("SET LOCAL plan_cache_mode = force_generic_plan");
String explain = jdbcTemplate.queryForObject(
"EXPLAIN (VERBOSE, FORMAT JSON) EXECUTE " + statement + "('" + QUERY_VECTOR + "', "
+ embeddingModelId + ", " + documentId + ", " + TOP_K + ")",
String.class);
return objectMapper.readTree(explain).get(0).path("Plan");
} finally {
jdbcTemplate.execute("DEALLOCATE " + statement);
}
}

/** 실행 계획 전체에서 정렬 기준식(Sort Key, Index Scan의 Order By)을 모은다. */
private static List<String> orderingKeys(JsonNode node) {
List<String> keys = new ArrayList<>();
collect(node, keys, new ArrayList<>());
return keys;
}

private static List<String> indexNames(JsonNode node) {
List<String> names = new ArrayList<>();
collect(node, new ArrayList<>(), names);
return names;
}

private static void collect(JsonNode node, List<String> orderingKeys, List<String> indexNames) {
node.path("Sort Key").forEach(key -> orderingKeys.add(key.asText()));
if (node.has("Order By")) {
orderingKeys.add(node.path("Order By").asText());
}
if (node.has("Index Name")) {
indexNames.add(node.path("Index Name").asText());
}
node.path("Plans").forEach(child -> collect(child, orderingKeys, indexNames));
}

private Long documentWithChunks(int chunkCount) {
Long documentId = jdbcTemplate.queryForObject("""
INSERT INTO documents (
owner_user_id, title, document_type, source_type, status, visibility, created_at, updated_at
)
VALUES (?, '규정', 'DOCX', 'UPLOAD', 'INDEXED', 'PRIVATE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id
""", Long.class, userId);
Long versionId = jdbcTemplate.queryForObject("""
INSERT INTO document_versions (
document_id, version_no, title_snapshot, content_type, status, created_by, created_at, updated_at
)
VALUES (?, 1, '규정', 'text/plain', 'INDEXED', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id
""", Long.class, documentId, userId);
jdbcTemplate.update("UPDATE documents SET current_version_id = ? WHERE id = ?", versionId, documentId);

for (int index = 0; index < chunkCount; index++) {
String text = "규정 본문 " + index;
Long chunkId = jdbcTemplate.queryForObject("""
INSERT INTO document_chunks (
document_version_id, chunk_index, chunk_text, token_count, char_start, char_end,
content_hash, created_at, updated_at
)
VALUES (?, ?, ?, 1, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id
""", Long.class, versionId, index, text, index * 100, index * 100 + text.length(), CONTENT_HASH);
jdbcTemplate.update("""
INSERT INTO embeddings (
chunk_id, document_id, document_version_id, embedding_model_id,
vector, dimension, vector_hash, status, created_at, updated_at
)
VALUES (?, ?, ?, ?, CAST(? AS vector), ?, ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""", chunkId, documentId, versionId, embeddingModelId, vector(index * 0.5), VECTOR_DIMENSION,
CONTENT_HASH);
}
return documentId;
}

/** 첫 성분은 1, 둘째 성분은 secondComponent, 나머지는 0인 Vector 문자열이다. 0이면 질의 Vector가 된다. */
private static String vector(double secondComponent) {
StringBuilder builder = new StringBuilder("[1.0,").append(secondComponent);
for (int i = 2; i < VECTOR_DIMENSION; i++) {
builder.append(",0.0");
}
return builder.append(']').toString();
}
}
72 changes: 72 additions & 0 deletions docs/design/gimin-#549-generic-plan-query-vector-cast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Generic Plan에서 질문 Vector를 행마다 변환해 벡터 검색이 느려지는 문제 수정 (#549)

closes #549

## 1. 배경

#545(PR #547) 측정에서 같은 벡터 검색 SQL을 반복하면 6번째 실행부터 15~21배 느려지는 현상을 발견했다.

`VectorSearchRepository.findTopK`는 질문 Vector를 `'[v1,v2,...]'` 문자열로 받아 SQL 안에서 `CAST(:queryVector AS vector)`로 바꾼다.

- **Custom Plan:** PostgreSQL이 파라미터 값을 알고 계획을 세우면 이 CAST를 계획 단계에서 한 번 계산해 상수로 넣는다.
- **Generic Plan:** 같은 Prepared Statement가 반복되면 PostgreSQL은 값과 무관한 계획을 재사용한다. 이때 CAST는 `($1)::vector`라는 **식으로 남아 행마다 실행**된다. 1024차원 문자열 파싱이 검색 대상 청크 수만큼(정렬 키와 SELECT 거리 계산에서 각각) 반복된다.

운영에서도 일어나는 조건이다.

| 단계 | 기본값 | 효과 |
|---|---|---|
| JDBC 드라이버 | `prepareThreshold=5` | 같은 SQL을 5번 넘게 실행한 Connection은 Server Prepared Statement를 쓴다 |
| PostgreSQL | `plan_cache_mode=auto` | 그 Statement의 6번째 실행부터 Generic Plan을 고를 수 있다 |
| 검색 SQL | `IN (:permittedIds)` 목록 길이만 다르다 | 허용 문서 수가 같은 사용자의 검색은 SQL 문자열이 같아 Connection Pool에서 반복된다 |

#545 측정값(5만 청크, 허용 100%, topK 10)
- 서비스 호출 p50: Custom Plan 204.2ms, Generic Plan 4,226.3ms
- psql `auto` 모드: 같은 Statement 1~5회째 140.4~144.0ms → 6~8회째 2,099.0~2,114.9ms

## 2. 변경

질문 Vector 변환을 스칼라 서브쿼리로 감쌌다. 상관되지 않은 스칼라 서브쿼리는 PostgreSQL이 **InitPlan으로 실행마다 한 번만** 계산하고, 각 행은 그 결과를 참조한다.

```sql
-- findTopK
(e.vector <=> (SELECT CAST(:queryVector AS vector))) AS distance
...
ORDER BY e.vector <=> (SELECT CAST(:queryVector AS vector))

-- findLexicalCandidates (최종 SELECT의 거리 계산)
(e.vector <=> (SELECT CAST(:queryVector AS vector))) AS distance,
```

- `findLexicalCandidates`는 거리를 수십~수백 행에만 계산해 영향이 작다. 같은 규칙을 지키려고 함께 바꿨다.
- `countPatternMatches`는 질문 Vector를 쓰지 않는다.
- pgvector의 HNSW 정렬 Index Scan은 `ORDER BY 열 <=> (SELECT ...)` 형태도 쓸 수 있다. 회귀 테스트로 확인했다.
- 파라미터 타입, 서비스 코드, DB 스키마, 설정은 바꾸지 않았다.

### 고려한 다른 방법

| 방법 | 채택하지 않은 이유 |
|---|---|
| Vector 타입으로 Bind (pgvector-java 등) | 의존성과 Hibernate 타입 등록이 필요해 변경이 크다 |
| JDBC `prepareThreshold=0` 또는 DB 역할의 `plan_cache_mode=force_custom_plan` | 모든 쿼리의 계획 재사용을 끈다. 근본 원인(행마다 변환)은 남는다 |
| 검색 Transaction에서 `SET LOCAL plan_cache_mode` | DB 왕복이 늘고 서비스 코드가 바뀐다 |

## 3. API 계약과 오류

API 경로, 요청, 응답 필드, 오류 코드는 그대로다. 검색 결과(청크와 순서)도 같아야 한다.

| 상황 | 수정 전 | 수정 후 |
|---|---|---|
| Custom Plan으로 실행 | 질문 Vector 1회 변환 | 같음(InitPlan 1회) |
| Generic Plan으로 실행 | 질문 Vector를 행마다 변환 | InitPlan 1회 |
| 허용 문서가 없음 | 서비스가 DB를 조회하지 않고 빈 결과 | 같음 |
| 질문 Vector 문자열 형식 | 서비스가 `float[]`로 만든 `'[v1,...]'` 문자열만 전달 | 같음(외부 입력이 SQL에 직접 들어가지 않음) |

## 4. 검증

[측정 결과](../test-results/gimin-%23549-generic-plan-query-vector-cast.md)

- **회귀 통합 테스트** `VectorSearchRepositoryGenericPlanIntegrationTest` 3건
1. Generic Plan 결과가 Custom Plan과 같은 청크·거리 순서인지
2. Generic Plan 실행 계획의 정렬 키가 `$1`을 직접 쓰지 않고 InitPlan 결과를 쓰는지
3. `enable_sort=off`일 때 HNSW 정렬 Index Scan을 여전히 쓰는지
- **재측정:** #545와 같은 환경(격리 컨테이너, 같은 이미지 digest·데이터·Seed·질문·하네스)에서 수정 전후를 비교했다.
Binary file not shown.
Binary file not shown.
Loading
Loading