diff --git a/backend/src/test/java/com/opensource/docgrid/domain/search/benchmark/PermissionRecallBenchmark.java b/backend/src/test/java/com/opensource/docgrid/domain/search/benchmark/PermissionRecallBenchmark.java new file mode 100644 index 00000000..3fec8555 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/search/benchmark/PermissionRecallBenchmark.java @@ -0,0 +1,1114 @@ +package com.opensource.docgrid.domain.search.benchmark; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.SQLException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.Supplier; +import javax.sql.DataSource; + +import org.junit.jupiter.api.AfterAll; +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.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.postgresql.PGConnection; +import org.postgresql.copy.CopyIn; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.jpa.repository.Query; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +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 org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.opensource.docgrid.domain.document.service.DocumentParserRegistry; +import com.opensource.docgrid.domain.document.service.FixedSizeChunker; +import com.opensource.docgrid.domain.embedding.client.EmbeddingClient; +import com.opensource.docgrid.domain.embedding.service.AdaptiveEmbeddingBatchPlanner; +import com.opensource.docgrid.domain.embedding.service.EmbeddingVectorSupport; +import com.opensource.docgrid.domain.search.benchmark.PermissionRecallCorpus.RealChunk; +import com.opensource.docgrid.domain.search.benchmark.PermissionRecallDataset.DocumentSpec; +import com.opensource.docgrid.domain.search.config.HybridSearchProperties; +import com.opensource.docgrid.domain.search.config.VectorSearchProperties; +import com.opensource.docgrid.domain.search.dto.VectorSearchCandidate; +import com.opensource.docgrid.domain.search.repository.VectorSearchRepository; +import com.opensource.docgrid.domain.search.repository.VectorSearchRow; +import com.opensource.docgrid.domain.search.service.query.HybridSearchQueryService; +import com.opensource.docgrid.domain.search.service.query.VectorSearchQueryService; + +import jakarta.persistence.EntityManager; +import lombok.extern.slf4j.Slf4j; + +/** + * 권한 필터에 따른 벡터 검색 누락 재현과 인덱스 방식 비교 시험(#545). + * + *

운영 {@link VectorSearchQueryService}·{@link HybridSearchQueryService}를 그대로 호출하고, 그 아래 Repository만 + * 반환 행을 기록하는 Proxy로 감싸 "SQL이 돌려준 후보 수"와 "앱 필터 뒤 최종 건수"를 따로 잰다. 방식은 서비스 호출과 같은 + * Transaction 안에서 바꾼다. Exact는 HNSW Index를 임시로 지운 뒤 Rollback하고, 나머지는 {@code SET LOCAL}로 + * Plan Cache 모드·{@code hnsw.*}·(HNSW 강제 방식만) {@code enable_sort}를 바꾼다. 방식 목록은 {@link Method}에 있다. + * Exact 결과를 정답으로 Recall을 계산한다. 운영 코드·설정·마이그레이션은 바꾸지 않는다. + * + *

{@code vector.search.performance.permission.phase} 시스템 속성이 있을 때만 실행한다. {@code prepare}는 실제 문서와 + * 질문 Embedding 캐시만 만들고, {@code measure}는 캐시로 데이터를 적재해 측정한다. 격리 스키마 + * {@value #TEST_SCHEMA}만 쓰며, 접속 대상 DB는 {@code DB_HOST}·{@code DB_PORT} 등 환경변수로 반드시 격리 DB를 지정한다. + */ +@Slf4j +@Tag("integration") +@Tag("vector-search-performance") +@ActiveProfiles("test") +@SpringBootTest +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@DisplayName("권한 필터 × 벡터 검색 누락·인덱스 방식 비교 시험 (#545)") +class PermissionRecallBenchmark { + + static final String PROPERTY_PREFIX = "vector.search.performance.permission."; + private static final String TEST_SCHEMA = "docgrid_permission_recall_545"; + private static final int VECTOR_DIMENSION = 1024; + private static final String FALLBACK_HNSW_INDEX = "embeddings_vector_idx"; + private static final String TOP_K_SQL = topKSql(); + + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private NamedParameterJdbcTemplate namedParameterJdbcTemplate; + @Autowired private DataSource dataSource; + @Autowired private PlatformTransactionManager transactionManager; + @Autowired private EntityManager entityManager; + @Autowired private ObjectMapper objectMapper; + @Autowired private VectorSearchRepository vectorSearchRepository; + @Autowired private VectorSearchProperties vectorSearchProperties; + @Autowired private HybridSearchProperties hybridSearchProperties; + @Autowired private DocumentParserRegistry documentParserRegistry; + @Autowired private FixedSizeChunker fixedSizeChunker; + @Autowired private AdaptiveEmbeddingBatchPlanner adaptiveEmbeddingBatchPlanner; + @Autowired private EmbeddingClient embeddingClient; + + /** 진행 중인 방식 Transaction의 방식. Repository Proxy가 HNSW 강제 여부를 판단할 때 쓴다(단일 Thread 실행). */ + private Method currentMethod; + + @DynamicPropertySource + static void configureEnvironment(DynamicPropertyRegistry registry) { + registry.add("TEST_DB_SCHEMA", () -> TEST_SCHEMA); + registry.add("jwt.secret", () -> "docgrid-permission-recall-benchmark-secret-key-2026"); + registry.add( + "spring.datasource.hikari.data-source-properties.ApplicationName", + () -> "docgrid-permission-recall-benchmark" + ); + } + + @AfterAll + void dropIsolatedSchema() { + if (Boolean.parseBoolean(System.getProperty(PROPERTY_PREFIX + "drop-schema", "true"))) { + jdbcTemplate.execute("DROP SCHEMA IF EXISTS " + TEST_SCHEMA + " CASCADE"); + } + } + + @Test + @Timeout(7_200) + @EnabledIfSystemProperty(named = PROPERTY_PREFIX + "phase", matches = "prepare") + @DisplayName("실제 문서와 질문을 운영 처리 경로로 Embedding해 캐시한다") + void prepareEmbeddingCaches() throws Exception { + Configuration configuration = Configuration.fromSystemProperties(); + PermissionRecallCorpus corpus = loadCorpus(configuration); + log.info("[PERMISSION-RECALL] corpus documents={} chunks={} skipped={} embeddingSeconds={}", + corpus.documents().size(), corpus.chunkCount(), corpus.skippedSources(), corpus.embeddingSeconds()); + if (Files.exists(configuration.questionsPath())) { + Map vectors = loadQuestionVectors(configuration, loadQuestions(configuration)); + log.info("[PERMISSION-RECALL] question vectors={}", vectors.size()); + } + assertThat(corpus.chunkCount()).isPositive(); + } + + @Test + @Timeout(14_400) + @EnabledIfSystemProperty(named = PROPERTY_PREFIX + "phase", matches = "measure") + @DisplayName("허용 문서 비율 × topK × 방식별로 SQL 후보 수·최종 건수·Recall·지연·실행 계획을 잰다") + void measurePermissionRecall() throws Exception { + Configuration configuration = Configuration.fromSystemProperties(); + assertThat(configuration.methods().get(0)).isEqualTo(Method.EXACT); + + PermissionRecallCorpus corpus = loadCorpus(configuration); + List questions = loadQuestions(configuration); + Map questionVectors = loadQuestionVectors(configuration, questions); + Long modelId = jdbcTemplate.queryForObject( + "SELECT id FROM embedding_models WHERE is_active = TRUE AND is_searchable = TRUE", Long.class); + + HnswIndex hnswIndex = currentHnswIndex(); + Map settings = verifySessionSettings(); + + Recorder recorder = new Recorder(); + VectorSearchRepository countingRepository = countingRepository(recorder); + VectorSearchQueryService vectorService = + new VectorSearchQueryService(countingRepository, vectorSearchProperties); + HybridSearchQueryService hybridService = + new HybridSearchQueryService(countingRepository, vectorSearchProperties, hybridSearchProperties); + + // 1. 합성 Chunk와 원본의 유사도는 실제 Chunk의 "다른 문서 최근접 유사도" 분포 P50~P90 안에서 고른다. + double[] nearest = PermissionRecallDataset.crossDocumentNearestSimilarities(corpus.documents()); + Distribution nearestSimilarity = Distribution.of(nearest); + double similarityLow = nearestSimilarity.p50(); + double similarityHigh = nearestSimilarity.p90(); + + List scales = new ArrayList<>(); + List rows = new ArrayList<>(); + List planSamples = new ArrayList<>(); + for (String scale : configuration.scales()) { + int targetChunks = "real".equals(scale) ? 0 : Integer.parseInt(scale); + PermissionRecallDataset dataset = PermissionRecallDataset.expand( + corpus.documents(), targetChunks, similarityLow, similarityHigh, configuration.seed()); + LoadResult load = loadDataset(dataset, modelId, hnswIndex); + log.info("[PERMISSION-RECALL] scale={} documents={} chunks={} indexBuildSeconds={}", + scale, dataset.documents().size(), dataset.chunkCount(), load.indexBuildSeconds()); + + List ratioInfos = new ArrayList<>(); + for (double ratio : configuration.ratios()) { + List permittedIds = PermissionRecallDataset + .permittedDocumentIndexes(dataset.documents().size(), ratio, configuration.seed()) + .stream().map(index -> index + 1L).sorted().toList(); + int permittedChunks = permittedIds.stream() + .mapToInt(id -> dataset.source(dataset.documents().get((int) (id - 1))).chunks().size()) + .sum(); + ratioInfos.add(new RatioInfo(ratio, permittedIds.size(), permittedChunks)); + + for (int topK : configuration.topKs()) { + Map exact = new LinkedHashMap<>(); + for (Method method : configuration.methods()) { + for (int questionIndex = 0; questionIndex < questions.size(); questionIndex++) { + Question question = questions.get(questionIndex); + QuestionOutcome outcome = measureQuestion( + configuration, method, hnswIndex, question, questionVectors.get(question.text()), + modelId, permittedIds, topK, vectorService, hybridService, recorder); + if (method == Method.EXACT) { + exact.put(question.id(), outcome); + } + if (questionIndex == 0) { + planSamples.add(new PlanSample(scale, ratio, topK, method.label(), question.id(), + outcome.rawPlan())); + } + rows.add(MeasurementRow.of(scale, ratio, permittedIds.size(), permittedChunks, topK, + topK * vectorSearchProperties.getCandidatePoolMultiplier(), method, question, + outcome, exact.get(question.id()))); + } + log.info("[PERMISSION-RECALL] scale={} ratio={} topK={} method={} done", + scale, ratio, topK, method.label()); + } + } + } + scales.add(new ScaleResult( + scale, dataset.documents().size(), corpus.documents().size(), + dataset.documents().size() - corpus.documents().size(), dataset.chunkCount(), + dataset.realChunkCount(), 1.0 - (double) dataset.realChunkCount() / dataset.chunkCount(), + similarityLow, similarityHigh, load.loadSeconds(), load.indexBuildSeconds(), + load.tableBytes(), load.indexBytes(), load.totalBytes(), List.copyOf(ratioInfos))); + } + + Report report = new Report( + Instant.now().toString(), + jdbcTemplate.queryForObject("SHOW server_version", String.class), + jdbcTemplate.queryForObject("SELECT extversion FROM pg_extension WHERE extname = 'vector'", String.class), + settings, + System.getProperty("os.arch"), + System.getProperty("java.version"), + jdbcDriverVersion(), + corpus.embeddingModel(), + corpus.documents().size(), + corpus.chunkCount(), + corpus.skippedSources(), + corpus.embeddingSeconds(), + nearestSimilarity, + new SearchSettings( + vectorSearchProperties.getMinSimilarity().doubleValue(), + vectorSearchProperties.getMaxChunksPerDocument(), + vectorSearchProperties.getCandidatePoolMultiplier(), + hybridSearchProperties.isEnabled(), + hybridSearchProperties.getVectorMinSimilarity().doubleValue(), + hybridSearchProperties.getMinCoverage().doubleValue(), + hybridSearchProperties.getCoverageWeight().doubleValue(), + hybridSearchProperties.getLexicalCandidatePoolMultiplier()), + hnswIndex.definition(), + configuration, + List.copyOf(scales), + CombinationSummary.summarize(rows), + List.copyOf(rows) + ); + writeJson(configuration.outputPath(), report); + writeJson(configuration.planOutputPath(), planSamples); + assertThat(rows).isNotEmpty(); + } + + private QuestionOutcome measureQuestion( + Configuration configuration, + Method method, + HnswIndex hnswIndex, + Question question, + float[] vector, + Long modelId, + List permittedIds, + int topK, + VectorSearchQueryService vectorService, + HybridSearchQueryService hybridService, + Recorder recorder + ) throws IOException { + int candidateLimit = topK * vectorSearchProperties.getCandidatePoolMultiplier(); + + // 1. 같은 방식 설정으로 운영 findTopK SQL의 실행 계획을 한 번 남긴다(EXPLAIN ANALYZE는 사용자 지연과 별도). + String rawPlan = inMethodTransaction(method, hnswIndex, () -> { + setSortForTopK(method, false); + return namedParameterJdbcTemplate.queryForObject( + "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) " + TOP_K_SQL, + new MapSqlParameterSource() + .addValue("queryVector", vectorString(vector)) + .addValue("modelId", modelId) + .addValue("permittedIds", permittedIds) + .addValue("topK", candidateLimit), + String.class); + }); + PlanSummary plan = PlanSummary.parse(objectMapper.readTree(rawPlan), hnswIndex.name()); + + // 2. 예열 후 벡터 단독·하이브리드 경로를 같은 방식 설정으로 번갈아 호출하고, 첫 측정 결과를 기준으로 쓴다. + // Generic Plan 방식은 한 번이 수 초라 예열·반복을 별도 설정으로 줄인다. + boolean generic = method.planCacheMode() == PlanCacheMode.GENERIC; + int warmUpRuns = generic ? configuration.genericWarmUpRuns() : configuration.warmUpRuns(); + int measuredRuns = generic ? configuration.genericMeasuredRuns() : configuration.measuredRuns(); + for (int run = 0; run < warmUpRuns; run++) { + inMethodTransaction(method, hnswIndex, () -> vectorService.search(vector, modelId, permittedIds, topK)); + inMethodTransaction(method, hnswIndex, + () -> hybridService.search(vector, question.text(), modelId, permittedIds, topK)); + } + + List vectorMillis = new ArrayList<>(); + List hybridMillis = new ArrayList<>(); + List sqlChunkIds = null; + List vectorFinalIds = null; + List hybridFinalIds = null; + int hybridLexicalRows = 0; + boolean stable = true; + for (int run = 0; run < measuredRuns; run++) { + recorder.reset(); + Timed> vectorResult = inMethodTransaction(method, hnswIndex, + () -> timed(() -> vectorService.search(vector, modelId, permittedIds, topK))); + List runSqlIds = recorder.topKChunkIds(); + List runVectorIds = chunkIds(vectorResult.value()); + + recorder.reset(); + Timed> hybridResult = inMethodTransaction(method, hnswIndex, + () -> timed(() -> hybridService.search(vector, question.text(), modelId, permittedIds, topK))); + List runHybridIds = chunkIds(hybridResult.value()); + + vectorMillis.add(vectorResult.millis()); + hybridMillis.add(hybridResult.millis()); + if (run == 0) { + sqlChunkIds = runSqlIds; + vectorFinalIds = runVectorIds; + hybridFinalIds = runHybridIds; + hybridLexicalRows = recorder.lexicalRows(); + } else { + stable &= sqlChunkIds.equals(runSqlIds) && vectorFinalIds.equals(runVectorIds) + && hybridFinalIds.equals(runHybridIds); + } + } + return new QuestionOutcome(sqlChunkIds, vectorFinalIds, hybridFinalIds, hybridLexicalRows, + List.copyOf(vectorMillis), List.copyOf(hybridMillis), stable, plan, rawPlan); + } + + /** + * 서비스 호출과 같은 Transaction에서 방식을 바꾸고 항상 Rollback한다. Exact의 Index 삭제도 Rollback으로 되돌린다. + * + *

Plan Cache 모드는 매번 명시한다. 기본값({@code auto})이면 같은 SQL을 5번 넘게 실행한 Connection에서 Generic Plan으로 + * 바뀔 수 있어 실행 순서에 따라 지연이 달라지기 때문이다. HNSW 강제 방식의 {@code enable_sort=off}는 단어 갈래 조회에 + * 영향을 주지 않도록 Repository Proxy가 findTopK 직전에만 켜고 직후에 되돌린다. + */ + private T inMethodTransaction(Method method, HnswIndex hnswIndex, Supplier work) { + TransactionTemplate template = new TransactionTemplate(transactionManager); + return template.execute(status -> { + status.setRollbackOnly(); + jdbcTemplate.execute("SET LOCAL plan_cache_mode = " + method.planCacheMode().setting()); + if (method == Method.EXACT) { + jdbcTemplate.execute("DROP INDEX " + TEST_SCHEMA + "." + hnswIndex.name()); + } else { + jdbcTemplate.execute("SET LOCAL hnsw.ef_search = " + method.efSearch()); + jdbcTemplate.execute("SET LOCAL hnsw.iterative_scan = " + method.iterativeScan()); + } + currentMethod = method; + try { + return work.get(); + } finally { + currentMethod = null; + } + }); + } + + /** + * HNSW 강제 방식이면 findTopK 실행 동안만 정렬 계획을 밀어낸다. {@code enabled=false}가 강제 시작, {@code true}가 원복이다. + */ + private void setSortForTopK(Method method, boolean enabled) { + if (method != null && method.forceHnsw()) { + jdbcTemplate.execute("SET LOCAL enable_sort = " + (enabled ? "on" : "off")); + } + } + + /** + * JdbcTemplate의 {@code SET LOCAL}이 JPA Native Query와 같은 Connection·Transaction에 적용되는지 먼저 확인하고, + * 측정에 영향을 주는 PostgreSQL·pgvector 설정값을 기록한다. + */ + private Map verifySessionSettings() { + TransactionTemplate template = new TransactionTemplate(transactionManager); + return template.execute(status -> { + status.setRollbackOnly(); + jdbcTemplate.queryForObject("SELECT CAST('[1]' AS vector)::text", String.class); + jdbcTemplate.execute("SET LOCAL hnsw.ef_search = 123"); + Object observed = entityManager.createNativeQuery("SHOW hnsw.ef_search").getSingleResult(); + assertThat(observed).hasToString("123"); + jdbcTemplate.execute("RESET hnsw.ef_search"); + + Map settings = new LinkedHashMap<>(); + for (String name : List.of("hnsw.ef_search", "hnsw.iterative_scan", "hnsw.max_scan_tuples", + "hnsw.scan_mem_multiplier", "shared_buffers", "work_mem", "maintenance_work_mem", + "effective_cache_size", "random_page_cost", "max_parallel_workers_per_gather", + "max_parallel_maintenance_workers", "plan_cache_mode", "jit")) { + settings.put(name, jdbcTemplate.queryForObject("SHOW " + name, String.class)); + } + return settings; + }); + } + + private String jdbcDriverVersion() throws SQLException { + try (Connection connection = dataSource.getConnection()) { + return connection.getMetaData().getDriverVersion(); + } + } + + private HnswIndex currentHnswIndex() { + List indexes = jdbcTemplate.query(""" + SELECT indexname, indexdef FROM pg_indexes + WHERE schemaname = current_schema() AND tablename = 'embeddings' AND indexdef ILIKE '%USING hnsw%' + """, (resultSet, rowNumber) -> new HnswIndex(resultSet.getString(1), resultSet.getString(2))); + if (!indexes.isEmpty()) { + return indexes.get(0); + } + // 이전 실행이 Index를 지운 채 중단됐을 때를 위한 V32 정의 + return new HnswIndex(FALLBACK_HNSW_INDEX, "CREATE INDEX " + FALLBACK_HNSW_INDEX + " ON " + TEST_SCHEMA + + ".embeddings USING hnsw (vector vector_cosine_ops)"); + } + + /** + * 실제 문서와 합성 문서를 COPY로 적재하고, V32와 같은 정의로 HNSW Index를 다시 만들며 생성 시간을 잰다. + */ + private LoadResult loadDataset(PermissionRecallDataset dataset, Long modelId, HnswIndex hnswIndex) + throws Exception { + jdbcTemplate.execute("TRUNCATE TABLE embeddings, document_chunks, document_versions, documents, users " + + "RESTART IDENTITY CASCADE"); + jdbcTemplate.execute("DROP INDEX IF EXISTS " + TEST_SCHEMA + "." + hnswIndex.name()); + Long ownerId = jdbcTemplate.queryForObject(""" + INSERT INTO users (email, password_hash, name, status, created_at, updated_at) + VALUES ('permission-recall-545@example.com', 'password-hash', 'Permission Recall 545', 'ACTIVE', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class); + + long loadStartedAt = System.nanoTime(); + try (Connection connection = dataSource.getConnection()) { + PGConnection pgConnection = connection.unwrap(PGConnection.class); + + // 1. 문서·버전 id를 같게 두고 현재 버전을 연결한다. 모두 INDEXED·삭제 안 됨 상태다. + copy(pgConnection, "documents (id, owner_user_id, title, document_type, source_type, status, visibility)", + dataset.documents(), spec -> List.of(id(spec), ownerId, title(dataset, spec), + dataset.source(spec).documentType(), "UPLOAD", "INDEXED", "PRIVATE")); + copy(pgConnection, "document_versions (id, document_id, version_no, title_snapshot, content_type, status, " + + "created_by)", + dataset.documents(), spec -> List.of(id(spec), id(spec), 1, title(dataset, spec), "text/plain", + "INDEXED", ownerId)); + try (var statement = connection.createStatement()) { + statement.execute("UPDATE documents SET current_version_id = id"); + } + + // 2. Chunk 본문은 원본 그대로 복제하고, Embedding은 실제 값 또는 합성 Vector를 그 자리에서 계산해 보낸다. + CopyIn chunks = pgConnection.getCopyAPI().copyIn("COPY document_chunks (id, document_version_id, " + + "chunk_index, chunk_text, token_count, char_start, char_end, page_no, section_title, content_hash, " + + "metadata_json) FROM STDIN WITH (FORMAT csv)"); + CopyIn embeddings = null; + long chunkId = 0; + try { + for (DocumentSpec spec : dataset.documents()) { + for (RealChunk chunk : dataset.source(spec).chunks()) { + chunkId++; + writeRow(chunks, List.of(chunkId, id(spec), chunk.chunkIndex(), chunk.chunkText(), + chunk.tokenCount(), chunk.charStart(), chunk.charEnd(), nullable(chunk.pageNo()), + nullable(chunk.sectionTitle()), chunk.contentHash(), nullable(chunk.metadataJson()))); + } + } + chunks.endCopy(); + + embeddings = pgConnection.getCopyAPI().copyIn("COPY embeddings (id, chunk_id, document_id, " + + "document_version_id, embedding_model_id, vector, dimension, vector_hash, status) " + + "FROM STDIN WITH (FORMAT csv)"); + chunkId = 0; + for (DocumentSpec spec : dataset.documents()) { + List sourceChunks = dataset.source(spec).chunks(); + for (int index = 0; index < sourceChunks.size(); index++) { + chunkId++; + float[] vector = dataset.vector(spec, index); + writeRow(embeddings, List.of(chunkId, chunkId, id(spec), id(spec), modelId, + vectorString(vector), VECTOR_DIMENSION, EmbeddingVectorSupport.calculateHash(vector), + "ACTIVE")); + } + } + embeddings.endCopy(); + } finally { + if (chunks.isActive()) { + chunks.cancelCopy(); + } + if (embeddings != null && embeddings.isActive()) { + embeddings.cancelCopy(); + } + } + } + jdbcTemplate.execute("ANALYZE documents"); + jdbcTemplate.execute("ANALYZE document_versions"); + jdbcTemplate.execute("ANALYZE document_chunks"); + double loadSeconds = (System.nanoTime() - loadStartedAt) / 1_000_000_000.0; + + // 3. V32에서 만든 것과 같은 정의(m·ef_construction 기본값)로 Index를 다시 만든다. + long indexStartedAt = System.nanoTime(); + jdbcTemplate.execute(hnswIndex.definition()); + double indexBuildSeconds = (System.nanoTime() - indexStartedAt) / 1_000_000_000.0; + jdbcTemplate.execute("ANALYZE embeddings"); + + return new LoadResult( + loadSeconds, + indexBuildSeconds, + jdbcTemplate.queryForObject("SELECT pg_relation_size('embeddings')", Long.class), + jdbcTemplate.queryForObject("SELECT pg_relation_size('" + hnswIndex.name() + "')", Long.class), + jdbcTemplate.queryForObject("SELECT pg_total_relation_size('embeddings')", Long.class) + ); + } + + private static long id(DocumentSpec spec) { + return spec.index() + 1L; + } + + private static String title(PermissionRecallDataset dataset, DocumentSpec spec) { + String title = dataset.source(spec).title(); + String decorated = spec.synthetic() ? title + " · 합성 " + spec.index() : title; + return decorated.length() > 480 ? decorated.substring(0, 480) : decorated; + } + + private static Object nullable(Object value) { + return value == null ? CsvNull.INSTANCE : value; + } + + private static void copy(PGConnection connection, String target, List items, + Function> columns) throws Exception { + CopyIn copyIn = connection.getCopyAPI().copyIn("COPY " + target + " FROM STDIN WITH (FORMAT csv)"); + try { + for (T item : items) { + writeRow(copyIn, columns.apply(item)); + } + copyIn.endCopy(); + } finally { + if (copyIn.isActive()) { + copyIn.cancelCopy(); + } + } + } + + private static void writeRow(CopyIn copyIn, List values) throws Exception { + StringBuilder line = new StringBuilder(); + for (int index = 0; index < values.size(); index++) { + if (index > 0) { + line.append(','); + } + Object value = values.get(index); + if (value instanceof CsvNull) { + continue; + } + if (value instanceof Number) { + line.append(value); + } else { + line.append('"').append(value.toString().replace("\"", "\"\"")).append('"'); + } + } + byte[] bytes = line.append('\n').toString().getBytes(StandardCharsets.UTF_8); + copyIn.writeToCopy(bytes, 0, bytes.length); + } + + /** CSV에서 따옴표 없는 빈 칸(NULL)으로 쓸 값. */ + private enum CsvNull { INSTANCE } + + private PermissionRecallCorpus loadCorpus(Configuration configuration) throws IOException { + return PermissionRecallCorpus.loadOrBuild( + configuration.repositoryRoot(), + configuration.corpusCachePath(), + new PermissionRecallCorpus.Pipeline( + documentParserRegistry, fixedSizeChunker, adaptiveEmbeddingBatchPlanner, embeddingClient), + objectMapper + ); + } + + private List loadQuestions(Configuration configuration) throws IOException { + JsonNode root = objectMapper.readTree(configuration.questionsPath().toFile()); + List questions = new ArrayList<>(); + for (JsonNode node : root.path("questions")) { + questions.add(new Question(node.path("id").asText(), node.path("source").asText(), + node.path("question").asText())); + } + int limit = configuration.questionLimit() > 0 ? configuration.questionLimit() : questions.size(); + return List.copyOf(questions.subList(0, Math.min(limit, questions.size()))); + } + + /** + * 운영 첫 질문과 같은 경로(대화 문맥 없이 원문 질문, 단건 Embedding API)로 질문 Vector를 만들고 캐시한다. + */ + private Map loadQuestionVectors(Configuration configuration, List questions) + throws IOException { + Map vectors = new LinkedHashMap<>(); + Path cachePath = configuration.questionCachePath(); + if (Files.exists(cachePath)) { + JsonNode cached = objectMapper.readTree(cachePath.toFile()); + cached.path("vectors").fields().forEachRemaining(entry -> + vectors.put(entry.getKey(), objectMapper.convertValue(entry.getValue(), float[].class))); + } + boolean changed = false; + for (Question question : questions) { + if (!vectors.containsKey(question.text())) { + vectors.put(question.text(), embeddingClient.embed(question.text())); + changed = true; + } + } + if (changed) { + writeJson(cachePath, Map.of("vectors", vectors)); + } + return vectors; + } + + private void writeJson(Path path, Object value) throws IOException { + Files.createDirectories(path.toAbsolutePath().getParent()); + objectMapper.writerWithDefaultPrettyPrinter().writeValue(path.toFile(), value); + } + + /** + * 운영 Repository Bean에 위임하면서 findTopK·단어 갈래 조회의 반환 행을 기록하는 Proxy를 만든다. + */ + private VectorSearchRepository countingRepository(Recorder recorder) { + return (VectorSearchRepository) Proxy.newProxyInstance( + VectorSearchRepository.class.getClassLoader(), + new Class[] {VectorSearchRepository.class}, + (proxy, method, arguments) -> { + boolean topK = "findTopK".equals(method.getName()); + Object result; + try { + if (topK) { + setSortForTopK(currentMethod, false); + } + result = method.invoke(vectorSearchRepository, arguments); + } catch (InvocationTargetException exception) { + throw exception.getCause(); + } finally { + if (topK) { + setSortForTopK(currentMethod, true); + } + } + switch (method.getName()) { + case "findTopK" -> recorder.recordTopK((List) result); + case "findLexicalCandidates" -> recorder.recordLexical((List) result); + default -> { } + } + return result; + }); + } + + private static String topKSql() { + try { + return VectorSearchRepository.class + .getMethod("findTopK", String.class, Long.class, List.class, int.class) + .getAnnotation(Query.class) + .value(); + } catch (NoSuchMethodException exception) { + throw new IllegalStateException(exception); + } + } + + /** 운영 VectorSearchQueryService와 같은 규칙(StringBuilder의 float 표기)으로 Vector 문자열을 만든다. */ + static String vectorString(float[] vector) { + StringBuilder builder = new StringBuilder(vector.length * 12).append('['); + for (int index = 0; index < vector.length; index++) { + if (index > 0) { + builder.append(','); + } + builder.append(vector[index]); + } + return builder.append(']').toString(); + } + + private static List chunkIds(List candidates) { + return candidates.stream().map(VectorSearchCandidate::chunkId).toList(); + } + + private static Timed timed(Supplier work) { + long startedAt = System.nanoTime(); + T value = work.get(); + return new Timed<>(value, (System.nanoTime() - startedAt) / 1_000_000.0); + } + + /** Plan Cache 모드. */ + enum PlanCacheMode { + CUSTOM("force_custom_plan"), + GENERIC("force_generic_plan"); + + private final String setting; + + PlanCacheMode(String setting) { + this.setting = setting; + } + + String setting() { + return setting; + } + } + + /** + * 측정 방식. + * + *
    + *
  • {@code exact}: HNSW Index를 지운 상태. Recall 정답이다.
  • + *
  • {@code planner_default}·{@code planner_default_generic}: Index가 있고 설정은 기본값. 운영이 실제로 고르는 계획을 + * Custom Plan과 Generic Plan(Prepared Statement 재사용 후)으로 잰다.
  • + *
  • {@code hnsw_*}·{@code iterative_*}: {@code enable_sort=off}로 HNSW를 쓰게 한 뒤 ef_search·iterative_scan을 바꾼다.
  • + *
+ */ + enum Method { + EXACT("exact", PlanCacheMode.CUSTOM, false, 0, "off"), + PLANNER_DEFAULT("planner_default", PlanCacheMode.CUSTOM, false, 40, "off"), + PLANNER_DEFAULT_GENERIC("planner_default_generic", PlanCacheMode.GENERIC, false, 40, "off"), + HNSW_EF40("hnsw_ef40", PlanCacheMode.CUSTOM, true, 40, "off"), + HNSW_EF100("hnsw_ef100", PlanCacheMode.CUSTOM, true, 100, "off"), + HNSW_EF200("hnsw_ef200", PlanCacheMode.CUSTOM, true, 200, "off"), + ITERATIVE_RELAXED("iterative_relaxed", PlanCacheMode.CUSTOM, true, 40, "relaxed_order"), + ITERATIVE_STRICT("iterative_strict", PlanCacheMode.CUSTOM, true, 40, "strict_order"); + + private final String label; + private final PlanCacheMode planCacheMode; + private final boolean forceHnsw; + private final int efSearch; + private final String iterativeScan; + + Method(String label, PlanCacheMode planCacheMode, boolean forceHnsw, int efSearch, String iterativeScan) { + this.label = label; + this.planCacheMode = planCacheMode; + this.forceHnsw = forceHnsw; + this.efSearch = efSearch; + this.iterativeScan = iterativeScan; + } + + String label() { + return label; + } + + PlanCacheMode planCacheMode() { + return planCacheMode; + } + + boolean forceHnsw() { + return forceHnsw; + } + + int efSearch() { + return efSearch; + } + + String iterativeScan() { + return iterativeScan; + } + + static Method fromLabel(String label) { + return Arrays.stream(values()).filter(method -> method.label.equals(label)).findFirst() + .orElseThrow(() -> new IllegalArgumentException("알 수 없는 방식: " + label)); + } + } + + /** 서비스 호출 한 번이 Repository에서 받은 행. */ + static final class Recorder { + private List topKChunkIds = List.of(); + private int lexicalRows; + + void reset() { + topKChunkIds = List.of(); + lexicalRows = 0; + } + + void recordTopK(List rows) { + topKChunkIds = rows.stream().map(row -> ((VectorSearchRow) row).getChunkId()).toList(); + } + + void recordLexical(List rows) { + lexicalRows = rows.size(); + } + + List topKChunkIds() { + return topKChunkIds; + } + + int lexicalRows() { + return lexicalRows; + } + } + + /** 시스템 속성({@code vector.search.performance.permission.*})으로 정하는 실행 설정. 기본값은 이 시험(#545) 측정 조건이다. */ + record Configuration( + Path repositoryRoot, + Path corpusCachePath, + Path questionsPath, + Path questionCachePath, + Path outputPath, + Path planOutputPath, + List scales, + List ratios, + List topKs, + List methods, + int warmUpRuns, + int measuredRuns, + int genericWarmUpRuns, + int genericMeasuredRuns, + int questionLimit, + long seed + ) { + + static Configuration fromSystemProperties() { + return new Configuration( + Path.of(property("repository-root", "..")), + Path.of(property("corpus-cache", "build/reports/permission-recall/real-corpus.json")), + Path.of(property("questions", "../docs/test-results/evidence/issue-545/questions.json")), + Path.of(property("question-cache", "build/reports/permission-recall/question-vectors.json")), + Path.of(property("output", "build/reports/permission-recall/results.json")), + Path.of(property("plan-output", "build/reports/permission-recall/plans.json")), + split(property("scales", "real,50000")), + split(property("ratios", "1.0,0.1,0.01,0.001")).stream().map(Double::parseDouble).toList(), + split(property("top-ks", "5,10,20")).stream().map(Integer::parseInt).toList(), + split(property("methods", "exact,planner_default,planner_default_generic," + + "hnsw_ef40,hnsw_ef100,hnsw_ef200,iterative_relaxed,iterative_strict")) + .stream().map(Method::fromLabel).toList(), + Integer.parseInt(property("warm-up", "1")), + Integer.parseInt(property("measured-runs", "3")), + Integer.parseInt(property("generic-warm-up", "0")), + Integer.parseInt(property("generic-measured-runs", "1")), + Integer.parseInt(property("question-limit", "0")), + Long.parseLong(property("seed", "545")) + ); + } + + private static String property(String name, String defaultValue) { + return System.getProperty(PROPERTY_PREFIX + name, defaultValue); + } + + private static List split(String value) { + return Arrays.stream(value.split(",")).map(String::strip).filter(item -> !item.isEmpty()).toList(); + } + } + + /** 고정 질문 한 건과 그 질문을 만든 원본 문서 경로. */ + record Question(String id, String source, String text) { + } + + /** HNSW Index 이름과 다시 만들 때 쓰는 정의(V32와 같음). */ + record HnswIndex(String name, String definition) { + } + + /** 서비스 호출 결과와 걸린 시간(ms). */ + record Timed(T value, double millis) { + } + + /** 데이터 적재·Index 생성 시간과 크기. */ + record LoadResult(double loadSeconds, double indexBuildSeconds, long tableBytes, long indexBytes, + long totalBytes) { + } + + /** 질문 한 건 × 방식 한 개의 SQL 후보·최종 결과·지연·실행 계획. ID 목록은 첫 측정 결과다. */ + record QuestionOutcome( + List sqlChunkIds, + List vectorFinalIds, + List hybridFinalIds, + int hybridLexicalRows, + List vectorMillis, + List hybridMillis, + boolean stable, + PlanSummary plan, + String rawPlan + ) { + } + + /** 조합마다 첫 질문의 EXPLAIN 원문(별도 파일로 저장). */ + record PlanSample(String scale, double ratio, int topK, String method, String questionId, String plan) { + } + + /** 허용 비율별 실제 허용 문서·청크 수. */ + record RatioInfo(double ratio, int permittedDocuments, int permittedChunks) { + } + + /** 규모 한 개의 데이터 구성·합성 비율·Index 비용. */ + record ScaleResult( + String scale, + int documents, + int realDocuments, + int syntheticDocuments, + int chunks, + int realChunks, + double syntheticChunkRatio, + double syntheticSimilarityLow, + double syntheticSimilarityHigh, + double loadSeconds, + double indexBuildSeconds, + long tableBytes, + long indexBytes, + long totalBytes, + List ratios + ) { + } + + /** 측정 당시 운영 검색 설정값(application.yml 기본값). */ + record SearchSettings( + double minSimilarity, + int maxChunksPerDocument, + int candidatePoolMultiplier, + boolean hybridEnabled, + double hybridVectorMinSimilarity, + double hybridMinCoverage, + double hybridCoverageWeight, + int hybridLexicalCandidatePoolMultiplier + ) { + } + + /** 결과 파일(results.json) 전체 구조. */ + record Report( + String measuredAt, + String postgresVersion, + String pgvectorVersion, + Map postgresSettings, + String osArch, + String javaVersion, + String jdbcDriverVersion, + String embeddingModel, + int corpusDocuments, + int corpusChunks, + List skippedSources, + double corpusEmbeddingSeconds, + Distribution crossDocumentNearestSimilarity, + SearchSettings searchSettings, + String hnswIndexDefinition, + Configuration configuration, + List scales, + List summaries, + List rows + ) { + } + + /** 실행 계획에서 HNSW 사용 여부와 필터로 버려진 행 등 판정에 필요한 값만 추린 것. */ + record PlanSummary( + boolean usesHnsw, + List nodeTypes, + List indexNames, + Long hnswActualRows, + Long hnswRowsRemovedByFilter, + long resultRows, + double planningMillis, + double executionMillis, + long sharedHitBlocks, + long sharedReadBlocks + ) { + + static PlanSummary parse(JsonNode explain, String hnswIndexName) { + JsonNode root = explain.get(0); + JsonNode plan = root.path("Plan"); + List nodeTypes = new ArrayList<>(); + List indexNames = new ArrayList<>(); + List hnswNodes = new ArrayList<>(); + collect(plan, hnswIndexName, nodeTypes, indexNames, hnswNodes); + JsonNode hnswNode = hnswNodes.isEmpty() ? null : hnswNodes.get(0); + return new PlanSummary( + hnswNode != null, + List.copyOf(nodeTypes), + List.copyOf(indexNames), + hnswNode == null ? null : hnswNode.path("Actual Rows").asLong(), + hnswNode == null ? null : hnswNode.path("Rows Removed by Filter").asLong(0), + plan.path("Actual Rows").asLong(), + root.path("Planning Time").asDouble(), + root.path("Execution Time").asDouble(), + plan.path("Shared Hit Blocks").asLong(), + plan.path("Shared Read Blocks").asLong() + ); + } + + private static void collect(JsonNode node, String hnswIndexName, List nodeTypes, + List indexNames, List hnswNodes) { + nodeTypes.add(node.path("Node Type").asText()); + if (node.has("Index Name")) { + String indexName = node.path("Index Name").asText(); + indexNames.add(indexName); + if (indexName.equals(hnswIndexName)) { + hnswNodes.add(node); + } + } + for (JsonNode child : node.path("Plans")) { + collect(child, hnswIndexName, nodeTypes, indexNames, hnswNodes); + } + } + } + + /** 질문 한 건 × 조합 한 개의 측정 행. Recall은 같은 조건의 Exact 결과를 정답으로 계산한다. */ + record MeasurementRow( + String scale, + double ratio, + int permittedDocuments, + int permittedChunks, + int topK, + int candidateLimit, + String method, + String questionId, + String questionSource, + int sqlRows, + int vectorFinal, + int hybridFinal, + int hybridLexicalRows, + int exactSqlRows, + int exactVectorFinal, + int exactHybridFinal, + Double sqlRecall, + Double vectorFinalRecall, + Double hybridFinalRecall, + List vectorMillis, + List hybridMillis, + boolean stable, + PlanSummary plan + ) { + + static MeasurementRow of(String scale, double ratio, int permittedDocuments, int permittedChunks, int topK, + int candidateLimit, Method method, Question question, QuestionOutcome outcome, QuestionOutcome exact) { + return new MeasurementRow( + scale, ratio, permittedDocuments, permittedChunks, topK, candidateLimit, method.label(), + question.id(), question.source(), + outcome.sqlChunkIds().size(), outcome.vectorFinalIds().size(), outcome.hybridFinalIds().size(), + outcome.hybridLexicalRows(), + exact.sqlChunkIds().size(), exact.vectorFinalIds().size(), exact.hybridFinalIds().size(), + PermissionRecallMetrics.recall(outcome.sqlChunkIds(), exact.sqlChunkIds()), + PermissionRecallMetrics.recall(outcome.vectorFinalIds(), exact.vectorFinalIds()), + PermissionRecallMetrics.recall(outcome.hybridFinalIds(), exact.hybridFinalIds()), + outcome.vectorMillis(), outcome.hybridMillis(), outcome.stable(), outcome.plan() + ); + } + } + + /** 분포 요약(오름차순 정렬 후 최근접 순위 백분위). */ + record Distribution(int count, double min, double p10, double p50, double p90, double p95, double max, + double mean) { + + static Distribution of(double[] values) { + double[] sorted = values.clone(); + Arrays.sort(sorted); + return new Distribution(sorted.length, sorted[0], + PermissionRecallMetrics.percentile(sorted, 0.10), PermissionRecallMetrics.percentile(sorted, 0.50), + PermissionRecallMetrics.percentile(sorted, 0.90), PermissionRecallMetrics.percentile(sorted, 0.95), + sorted[sorted.length - 1], Arrays.stream(sorted).average().orElse(Double.NaN)); + } + } + + /** 규모 × 비율 × topK × 방식 한 조합의 질문 전체 요약. */ + record CombinationSummary( + String scale, + double ratio, + int permittedDocuments, + int permittedChunks, + int topK, + int candidateLimit, + String method, + int questions, + double sqlRowsMean, + int sqlRowsMin, + int sqlRowsMax, + double vectorFinalMean, + int vectorShortQuestions, + double hybridFinalMean, + int hybridShortQuestions, + double exactVectorFinalMean, + double exactHybridFinalMean, + Double sqlRecallMean, + Double sqlRecallMin, + Double vectorFinalRecallMean, + Double vectorFinalRecallMin, + Double hybridFinalRecallMean, + Double hybridFinalRecallMin, + double vectorP50Millis, + double vectorP95Millis, + double hybridP50Millis, + double hybridP95Millis, + int hnswPlanQuestions, + int unstableQuestions + ) { + + static List summarize(List rows) { + Map> groups = new LinkedHashMap<>(); + for (MeasurementRow row : rows) { + groups.computeIfAbsent(row.scale() + "|" + row.ratio() + "|" + row.topK() + "|" + row.method(), + key -> new ArrayList<>()).add(row); + } + List summaries = new ArrayList<>(); + for (List group : groups.values()) { + MeasurementRow first = group.get(0); + summaries.add(new CombinationSummary( + first.scale(), first.ratio(), first.permittedDocuments(), first.permittedChunks(), first.topK(), + first.candidateLimit(), first.method(), group.size(), + group.stream().mapToInt(MeasurementRow::sqlRows).average().orElse(0), + group.stream().mapToInt(MeasurementRow::sqlRows).min().orElse(0), + group.stream().mapToInt(MeasurementRow::sqlRows).max().orElse(0), + group.stream().mapToInt(MeasurementRow::vectorFinal).average().orElse(0), + (int) group.stream().filter(row -> row.vectorFinal() < row.topK()).count(), + group.stream().mapToInt(MeasurementRow::hybridFinal).average().orElse(0), + (int) group.stream().filter(row -> row.hybridFinal() < row.topK()).count(), + group.stream().mapToInt(MeasurementRow::exactVectorFinal).average().orElse(0), + group.stream().mapToInt(MeasurementRow::exactHybridFinal).average().orElse(0), + PermissionRecallMetrics.mean(group.stream().map(MeasurementRow::sqlRecall).toList()), + PermissionRecallMetrics.min(group.stream().map(MeasurementRow::sqlRecall).toList()), + PermissionRecallMetrics.mean(group.stream().map(MeasurementRow::vectorFinalRecall).toList()), + PermissionRecallMetrics.min(group.stream().map(MeasurementRow::vectorFinalRecall).toList()), + PermissionRecallMetrics.mean(group.stream().map(MeasurementRow::hybridFinalRecall).toList()), + PermissionRecallMetrics.min(group.stream().map(MeasurementRow::hybridFinalRecall).toList()), + PermissionRecallMetrics.percentile(latencies(group, true), 0.50), + PermissionRecallMetrics.percentile(latencies(group, true), 0.95), + PermissionRecallMetrics.percentile(latencies(group, false), 0.50), + PermissionRecallMetrics.percentile(latencies(group, false), 0.95), + (int) group.stream().filter(row -> row.plan().usesHnsw()).count(), + (int) group.stream().filter(row -> !row.stable()).count() + )); + } + return List.copyOf(summaries); + } + + private static double[] latencies(List group, boolean vector) { + return group.stream() + .flatMap(row -> (vector ? row.vectorMillis() : row.hybridMillis()).stream()) + .mapToDouble(Double::doubleValue) + .sorted() + .toArray(); + } + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/search/benchmark/PermissionRecallCorpus.java b/backend/src/test/java/com/opensource/docgrid/domain/search/benchmark/PermissionRecallCorpus.java new file mode 100644 index 00000000..4e6495bd --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/search/benchmark/PermissionRecallCorpus.java @@ -0,0 +1,269 @@ +package com.opensource.docgrid.domain.search.benchmark; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Stream; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.opensource.docgrid.domain.document.enums.DocumentType; +import com.opensource.docgrid.domain.document.service.DocumentChunkDraft; +import com.opensource.docgrid.domain.document.service.DocumentParserRegistry; +import com.opensource.docgrid.domain.document.service.FixedSizeChunker; +import com.opensource.docgrid.domain.document.service.ParsedDocument; +import com.opensource.docgrid.domain.document.service.SectionPath; +import com.opensource.docgrid.domain.embedding.client.EmbeddingClient; +import com.opensource.docgrid.domain.embedding.dto.response.EmbedBatchServerResponse; +import com.opensource.docgrid.domain.embedding.service.AdaptiveEmbeddingBatchPlanner; +import com.opensource.docgrid.domain.embedding.service.command.DocumentEmbeddingTransactionService.ChunkSnapshot; + +/** + * 권한 필터 검색 누락 시험(#545)에 쓰는 실제 문서 Chunk와 BGE-M3 Embedding 묶음. + * + *

저장소 안의 팀 작성 문서(예제 문서 15건, {@code docs/design}·{@code docs/test-results} 최상위 Markdown)를 + * 운영과 같은 Parser·Chunker·Embedding 입력 규칙(Section 경로 포함)·Batch 계획으로 처리해 만든다. + * Embedding 서버 호출은 오래 걸리므로 결과를 JSON 파일로 캐시하고, 원본 파일 SHA-256이 모두 같으면 다시 쓴다. + * DB 적재와 합성 확장은 하지 않는다. 그 역할은 {@link PermissionRecallDataset}과 {@link PermissionRecallBenchmark}가 맡는다. + * + * @param embeddingModel Embedding 서버가 응답한 모델 이름 + * @param createdAt 캐시 생성 시각(ISO-8601) + * @param embeddingSeconds 전체 Chunk Embedding에 걸린 시간 + * @param skippedSources 파싱·Chunking에 실패해 제외한 원본 경로와 사유 + * @param documents 원본 경로 순으로 정렬한 실제 문서 + */ +record PermissionRecallCorpus( + String embeddingModel, + String createdAt, + double embeddingSeconds, + List skippedSources, + List documents +) { + + private static final String DEMO_CORPUS_DIRECTORY = "examples/demo-corpus"; + private static final List MARKDOWN_DIRECTORIES = List.of("docs/design", "docs/test-results"); + private static final Map DEMO_TYPES = Map.of( + "pdf", DocumentType.PDF, + "docx", DocumentType.DOCX, + "hwpx", DocumentType.HWPX + ); + + /** + * 실제 문서 한 건과 그 Chunk. + * + * @param sourcePath 저장소 루트 기준 원본 경로 + * @param sourceSha256 원본 파일 SHA-256 + * @param title 예제 문서는 manifest 제목, Markdown은 첫 번째 {@code # } 제목(없으면 파일 이름) + * @param documentType 운영 Parser 선택에 쓴 문서 형식 + * @param chunks Chunk Index 순 Chunk + */ + record RealDocument( + String sourcePath, + String sourceSha256, + String title, + String documentType, + List chunks + ) { + } + + /** + * 운영 Chunker가 만든 Chunk와 운영 Embedding 입력으로 만든 Vector. + * + * @param vector Embedding 서버가 돌려준 값 그대로(정규화하지 않음) + */ + record RealChunk( + int chunkIndex, + String chunkText, + int tokenCount, + int charStart, + int charEnd, + Integer pageNo, + String sectionTitle, + String contentHash, + String metadataJson, + float[] vector + ) { + } + + /** 원본 파일 한 건의 경로·형식·제목. */ + record SourceFile(Path path, String relativePath, DocumentType documentType, String title) { + } + + int chunkCount() { + return documents.stream().mapToInt(document -> document.chunks().size()).sum(); + } + + /** + * 캐시가 있고 원본 SHA-256이 모두 같으면 캐시를 읽고, 아니면 운영 처리 경로로 다시 만들어 저장한다. + */ + static PermissionRecallCorpus loadOrBuild( + Path repositoryRoot, + Path cachePath, + Pipeline pipeline, + ObjectMapper objectMapper + ) throws IOException { + List sources = listSources(repositoryRoot, objectMapper); + if (Files.exists(cachePath)) { + PermissionRecallCorpus cached = objectMapper.readValue(cachePath.toFile(), PermissionRecallCorpus.class); + if (cached.matches(sources)) { + return cached; + } + } + PermissionRecallCorpus built = build(sources, pipeline); + Files.createDirectories(cachePath.toAbsolutePath().getParent()); + objectMapper.writeValue(cachePath.toFile(), built); + return built; + } + + /** + * 시험 대상 원본 목록을 정렬된 순서로 만든다. 예제 문서는 manifest 제목을 쓴다. + */ + static List listSources(Path repositoryRoot, ObjectMapper objectMapper) throws IOException { + List sources = new ArrayList<>(); + + // 1. 예제 문서: 가상 회사 규정 PDF·DOCX·HWPX 15건 (팀 작성, 저작권 문제 없음) + Path demoDirectory = repositoryRoot.resolve(DEMO_CORPUS_DIRECTORY); + JsonNode manifest = objectMapper.readTree(demoDirectory.resolve("manifest.json").toFile()); + for (JsonNode entry : manifest.path("documents")) { + String file = entry.path("file").asText(); + String extension = file.substring(file.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT); + sources.add(new SourceFile( + demoDirectory.resolve(file), + DEMO_CORPUS_DIRECTORY + "/" + file, + DEMO_TYPES.get(extension), + entry.path("title").asText() + )); + } + + // 2. 팀 작성 설계·시험 결과 Markdown (하위 evidence 폴더의 원시 로그는 제외) + for (String directory : MARKDOWN_DIRECTORIES) { + try (Stream files = Files.list(repositoryRoot.resolve(directory))) { + for (Path file : files.filter(path -> path.toString().endsWith(".md")).sorted().toList()) { + sources.add(new SourceFile( + file, + directory + "/" + file.getFileName(), + DocumentType.MD, + markdownTitle(file) + )); + } + } + } + return List.copyOf(sources); + } + + private boolean matches(List sources) throws IOException { + int matched = documents.size() + skippedSources.size(); + if (matched != sources.size()) { + return false; + } + Map cachedHashes = new HashMap<>(); + documents.forEach(document -> cachedHashes.put(document.sourcePath(), document.sourceSha256())); + for (SourceFile source : sources) { + String cachedHash = cachedHashes.get(source.relativePath()); + boolean skipped = skippedSources.stream().anyMatch(entry -> entry.startsWith(source.relativePath() + " ")); + if (!skipped && !sha256(Files.readAllBytes(source.path())).equals(cachedHash)) { + return false; + } + } + return true; + } + + private static PermissionRecallCorpus build(List sources, Pipeline pipeline) throws IOException { + List documents = new ArrayList<>(); + List skipped = new ArrayList<>(); + String model = null; + long startedAt = System.nanoTime(); + + for (SourceFile source : sources) { + byte[] content = Files.readAllBytes(source.path()); + List drafts; + try { + // 1. 운영 DocumentParsingService와 같은 순서로 파싱한 뒤 Segment 경계를 지키며 Chunking한다. + ParsedDocument parsed = pipeline.parserRegistry().parse(source.documentType(), content); + drafts = pipeline.chunker().chunk(parsed); + } catch (RuntimeException exception) { + skipped.add(source.relativePath() + " " + exception.getClass().getSimpleName()); + continue; + } + if (drafts.isEmpty()) { + skipped.add(source.relativePath() + " EMPTY"); + continue; + } + + // 2. 운영 Embedding 준비 단계처럼 Section 경로를 붙인 입력을 만들고 같은 Batch 계획으로 나눈다. + List snapshots = new ArrayList<>(drafts.size()); + for (DocumentChunkDraft draft : drafts) { + snapshots.add(new ChunkSnapshot( + (long) draft.chunkIndex(), + draft.chunkIndex(), + draft.chunkText(), + SectionPath.embeddingInput(draft.metadataJson(), draft.chunkText()), + draft.tokenCount(), + draft.contentHash() + )); + } + float[][] vectors = new float[drafts.size()][]; + for (List batch : pipeline.batchPlanner().plan(snapshots)) { + EmbedBatchServerResponse response = pipeline.embeddingClient().embedBatch( + batch.stream().map(ChunkSnapshot::embeddingText).toList(), + batch.size() + ); + model = response.model(); + for (int index = 0; index < batch.size(); index++) { + vectors[batch.get(index).chunkIndex()] = response.embeddings().get(index).vector(); + } + } + + List chunks = new ArrayList<>(drafts.size()); + for (DocumentChunkDraft draft : drafts) { + chunks.add(new RealChunk( + draft.chunkIndex(), draft.chunkText(), draft.tokenCount(), draft.charStart(), draft.charEnd(), + draft.pageNo(), draft.sectionTitle(), draft.contentHash(), draft.metadataJson(), + vectors[draft.chunkIndex()] + )); + } + documents.add(new RealDocument( + source.relativePath(), sha256(content), source.title(), source.documentType().name(), List.copyOf(chunks) + )); + } + + double seconds = (System.nanoTime() - startedAt) / 1_000_000_000.0; + return new PermissionRecallCorpus(model, Instant.now().toString(), seconds, List.copyOf(skipped), + List.copyOf(documents)); + } + + private static String markdownTitle(Path file) throws IOException { + try (Stream lines = Files.lines(file)) { + return lines.filter(line -> line.startsWith("# ")) + .findFirst() + .map(line -> line.substring(2).strip()) + .orElse(file.getFileName().toString()); + } + } + + static String sha256(byte[] content) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + /** 운영 문서 처리에 쓰는 Spring Bean 묶음. */ + record Pipeline( + DocumentParserRegistry parserRegistry, + FixedSizeChunker chunker, + AdaptiveEmbeddingBatchPlanner batchPlanner, + EmbeddingClient embeddingClient + ) { + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/search/benchmark/PermissionRecallDataset.java b/backend/src/test/java/com/opensource/docgrid/domain/search/benchmark/PermissionRecallDataset.java new file mode 100644 index 00000000..52eb3ea0 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/search/benchmark/PermissionRecallDataset.java @@ -0,0 +1,217 @@ +package com.opensource.docgrid.domain.search.benchmark; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Random; + +import com.opensource.docgrid.domain.search.benchmark.PermissionRecallCorpus.RealChunk; +import com.opensource.docgrid.domain.search.benchmark.PermissionRecallCorpus.RealDocument; + +/** + * 실제 Chunk 묶음을 시험 규모까지 늘리는 결정적 합성 계획과 권한 허용 문서 선택. + * + *

합성 문서 하나는 실제 문서 하나(원본)의 Chunk 본문·Section 정보를 그대로 복제한다. 각 Chunk Vector는 원본 Vector와의 + * 코사인 유사도가 정확히 {@code c}가 되도록, 다른 실제 문서 Chunk Vector에서 원본에 직교하는 성분만 섞어 만든다. + * 섞는 방향이 실제 Embedding에서 오므로 무작위 Gaussian 잡음보다 실제 분포에 가깝게 남는다. {@code c}는 문서마다 + * {@code [similarityLow, similarityHigh]}에서 고르며, 호출 측이 실제 Chunk의 "다른 문서 최근접 유사도" 분포로 정한다. + * + *

Vector는 계획만 들고 있다가 {@link #vector(DocumentSpec, int)} 호출 시 Seed로 다시 계산하므로, 수만 Chunk를 + * 메모리에 올리지 않는다. DB 적재·측정은 하지 않는다. + */ +final class PermissionRecallDataset { + + private final List realDocuments; + private final List unitVectors; + private final List documents; + private final int chunkCount; + + /** + * 시험 데이터의 문서 한 건. + * + * @param sourceDocument 본문을 복제한 실제 문서 Index + * @param mixDocument Vector에 방향을 섞은 다른 실제 문서 Index. 실제 문서면 -1 + * @param similarity 합성 Chunk와 원본 Chunk의 코사인 유사도. 실제 문서면 1 + * @param seed Chunk별 섞을 Chunk를 고르는 Seed + */ + record DocumentSpec(int index, int sourceDocument, int mixDocument, double similarity, long seed) { + + boolean synthetic() { + return mixDocument >= 0; + } + } + + private PermissionRecallDataset(List realDocuments, List documents) { + this.realDocuments = realDocuments; + this.unitVectors = realDocuments.stream() + .map(document -> document.chunks().stream().map(chunk -> normalize(chunk.vector())).toArray(float[][]::new)) + .toList(); + this.documents = List.copyOf(documents); + this.chunkCount = documents.stream() + .mapToInt(spec -> realDocuments.get(spec.sourceDocument()).chunks().size()) + .sum(); + } + + /** + * 실제 문서를 모두 넣고, 전체 Chunk 수가 {@code targetChunks} 이상이 될 때까지 합성 문서를 더한다. + * {@code targetChunks}가 실제 Chunk 수 이하면 실제 문서만 쓴다. + */ + static PermissionRecallDataset expand( + List realDocuments, + int targetChunks, + double similarityLow, + double similarityHigh, + long seed + ) { + if (realDocuments.size() < 2) { + throw new IllegalArgumentException("합성에는 실제 문서가 2건 이상 필요합니다."); + } + List specs = new ArrayList<>(); + int total = 0; + for (int index = 0; index < realDocuments.size(); index++) { + specs.add(new DocumentSpec(index, index, -1, 1.0, 0L)); + total += realDocuments.get(index).chunks().size(); + } + + Random random = new Random(seed); + while (total < targetChunks) { + int source = random.nextInt(realDocuments.size()); + int mix = random.nextInt(realDocuments.size() - 1); + if (mix >= source) { + mix++; + } + double similarity = similarityLow + random.nextDouble() * (similarityHigh - similarityLow); + specs.add(new DocumentSpec(specs.size(), source, mix, similarity, random.nextLong())); + total += realDocuments.get(source).chunks().size(); + } + return new PermissionRecallDataset(realDocuments, specs); + } + + List documents() { + return documents; + } + + int chunkCount() { + return chunkCount; + } + + int realChunkCount() { + return realDocuments.stream().mapToInt(document -> document.chunks().size()).sum(); + } + + RealDocument source(DocumentSpec spec) { + return realDocuments.get(spec.sourceDocument()); + } + + /** + * 문서의 {@code chunkIndex}번째 Chunk Vector. 실제 문서는 서버 응답 값 그대로, 합성 문서는 매번 같은 값으로 다시 계산한다. + */ + float[] vector(DocumentSpec spec, int chunkIndex) { + RealChunk sourceChunk = source(spec).chunks().get(chunkIndex); + if (!spec.synthetic()) { + return sourceChunk.vector(); + } + float[][] mixChunks = unitVectors.get(spec.mixDocument()); + Random random = new Random(spec.seed() * 31 + chunkIndex); + float[] other = mixChunks[random.nextInt(mixChunks.length)]; + return mix(unitVectors.get(spec.sourceDocument())[chunkIndex], other, spec.similarity(), random); + } + + /** + * 단위 Vector {@code source}와의 코사인 유사도가 정확히 {@code similarity}인 단위 Vector를 만든다. + * + *

{@code other}에서 {@code source} 방향을 뺀 직교 성분을 섞는다. 두 Vector가 거의 같은 방향이라 직교 성분이 없으면 + * 무작위 방향으로 대신한다. + */ + static float[] mix(float[] source, float[] other, double similarity, Random fallback) { + double projection = dot(source, other); + double[] orthogonal = new double[source.length]; + double squaredNorm = 0.0; + for (int index = 0; index < source.length; index++) { + orthogonal[index] = other[index] - projection * source[index]; + squaredNorm += orthogonal[index] * orthogonal[index]; + } + if (squaredNorm < 1e-12) { + squaredNorm = 0.0; + for (int index = 0; index < source.length; index++) { + orthogonal[index] = fallback.nextGaussian(); + } + double fallbackProjection = 0.0; + for (int index = 0; index < source.length; index++) { + fallbackProjection += orthogonal[index] * source[index]; + } + for (int index = 0; index < source.length; index++) { + orthogonal[index] -= fallbackProjection * source[index]; + squaredNorm += orthogonal[index] * orthogonal[index]; + } + } + + double orthogonalNorm = Math.sqrt(squaredNorm); + double orthogonalWeight = Math.sqrt(1.0 - similarity * similarity); + float[] mixed = new float[source.length]; + for (int index = 0; index < source.length; index++) { + mixed[index] = (float) (similarity * source[index] + orthogonalWeight * orthogonal[index] / orthogonalNorm); + } + return mixed; + } + + /** + * 실제 Chunk마다 "다른 문서 Chunk 중 가장 높은 코사인 유사도"를 구해 오름차순으로 돌려준다. 합성 유사도 범위를 정하는 근거다. + */ + static double[] crossDocumentNearestSimilarities(List realDocuments) { + List vectors = new ArrayList<>(); + List owners = new ArrayList<>(); + for (int documentIndex = 0; documentIndex < realDocuments.size(); documentIndex++) { + for (RealChunk chunk : realDocuments.get(documentIndex).chunks()) { + vectors.add(normalize(chunk.vector())); + owners.add(documentIndex); + } + } + double[] nearest = new double[vectors.size()]; + Arrays.fill(nearest, -1.0); + for (int left = 0; left < vectors.size(); left++) { + for (int right = left + 1; right < vectors.size(); right++) { + if (owners.get(left).equals(owners.get(right))) { + continue; + } + double similarity = dot(vectors.get(left), vectors.get(right)); + nearest[left] = Math.max(nearest[left], similarity); + nearest[right] = Math.max(nearest[right], similarity); + } + } + Arrays.sort(nearest); + return nearest; + } + + /** + * 문서 Index를 Seed로 섞은 뒤 앞에서부터 {@code ratio} 비율(최소 1건)을 허용 문서로 고른다. + * 같은 Seed면 낮은 비율의 허용 문서가 높은 비율의 허용 문서에 항상 포함된다. + */ + static List permittedDocumentIndexes(int documentCount, double ratio, long seed) { + List shuffled = new ArrayList<>(documentCount); + for (int index = 0; index < documentCount; index++) { + shuffled.add(index); + } + Collections.shuffle(shuffled, new Random(seed)); + int count = Math.max(1, (int) Math.round(documentCount * ratio)); + return List.copyOf(shuffled.subList(0, Math.min(count, documentCount))); + } + + static float[] normalize(float[] vector) { + double norm = Math.sqrt(dot(vector, vector)); + float[] normalized = new float[vector.length]; + for (int index = 0; index < vector.length; index++) { + normalized[index] = (float) (vector[index] / norm); + } + return normalized; + } + + static double dot(float[] left, float[] right) { + double sum = 0.0; + for (int index = 0; index < left.length; index++) { + sum += (double) left[index] * right[index]; + } + return sum; + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/search/benchmark/PermissionRecallDatasetTest.java b/backend/src/test/java/com/opensource/docgrid/domain/search/benchmark/PermissionRecallDatasetTest.java new file mode 100644 index 00000000..5509fbff --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/search/benchmark/PermissionRecallDatasetTest.java @@ -0,0 +1,145 @@ +package com.opensource.docgrid.domain.search.benchmark; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.opensource.docgrid.domain.search.benchmark.PermissionRecallCorpus.RealChunk; +import com.opensource.docgrid.domain.search.benchmark.PermissionRecallCorpus.RealDocument; +import com.opensource.docgrid.domain.search.benchmark.PermissionRecallDataset.DocumentSpec; + +/** + * 권한 필터 검색 누락 시험(#545) 하네스의 DB 없는 계산 단위 테스트. + * + *

합성 Vector 수식, 결정적 확장, 허용 문서 선택, Recall·백분위만 검증한다. 측정 자체는 {@link PermissionRecallBenchmark}가 한다. + */ +@DisplayName("권한 필터 검색 누락 시험 데이터·지표 계산 단위 테스트") +class PermissionRecallDatasetTest { + + private static final int DIMENSION = 64; + + @Test + @DisplayName("합성 Vector는 단위 길이이고 원본과의 코사인 유사도가 지정한 값과 같다") + void mix_keepsUnitLengthAndExactSimilarity() { + Random random = new Random(1L); + float[] source = PermissionRecallDataset.normalize(randomVector(random)); + float[] other = PermissionRecallDataset.normalize(randomVector(random)); + + float[] mixed = PermissionRecallDataset.mix(source, other, 0.83, new Random(2L)); + + assertThat(PermissionRecallDataset.dot(mixed, mixed)).isCloseTo(1.0, within(1e-5)); + assertThat(PermissionRecallDataset.dot(mixed, source)).isCloseTo(0.83, within(1e-5)); + } + + @Test + @DisplayName("섞을 Vector가 원본과 같은 방향이어도 무작위 직교 방향으로 유사도를 맞춘다") + void mix_fallsBackWhenOtherIsParallel() { + float[] source = PermissionRecallDataset.normalize(randomVector(new Random(3L))); + + float[] mixed = PermissionRecallDataset.mix(source, source.clone(), 0.9, new Random(4L)); + + assertThat(PermissionRecallDataset.dot(mixed, source)).isCloseTo(0.9, within(1e-5)); + } + + @Test + @DisplayName("확장은 실제 문서를 먼저 모두 넣고 목표 Chunk 수 이상이 될 때까지 같은 Seed로 같은 결과를 만든다") + void expand_isDeterministicAndReachesTarget() { + List documents = documents(5, 3); + + PermissionRecallDataset first = PermissionRecallDataset.expand(documents, 40, 0.7, 0.9, 7L); + PermissionRecallDataset second = PermissionRecallDataset.expand(documents, 40, 0.7, 0.9, 7L); + + assertThat(first.chunkCount()).isGreaterThanOrEqualTo(40); + assertThat(first.documents()).isEqualTo(second.documents()); + assertThat(first.documents().subList(0, 5)).noneMatch(DocumentSpec::synthetic); + DocumentSpec synthetic = first.documents().get(5); + assertThat(synthetic.mixDocument()).isNotEqualTo(synthetic.sourceDocument()); + assertThat(synthetic.similarity()).isBetween(0.7, 0.9); + assertThat(first.vector(synthetic, 1)).containsExactly(second.vector(synthetic, 1)); + } + + @Test + @DisplayName("목표가 실제 Chunk 수 이하면 실제 문서만 쓰고 Vector도 원본 그대로다") + void expand_usesOnlyRealDocumentsWhenTargetIsSmall() { + List documents = documents(4, 2); + + PermissionRecallDataset dataset = PermissionRecallDataset.expand(documents, 0, 0.7, 0.9, 7L); + + assertThat(dataset.documents()).hasSize(4).noneMatch(DocumentSpec::synthetic); + assertThat(dataset.vector(dataset.documents().get(2), 1)) + .containsExactly(documents.get(2).chunks().get(1).vector()); + } + + @Test + @DisplayName("허용 문서는 최소 1건이고 낮은 비율의 허용 문서가 높은 비율에 포함된다") + void permittedDocumentIndexes_areNestedAndAtLeastOne() { + List tenPercent = PermissionRecallDataset.permittedDocumentIndexes(1_000, 0.1, 545L); + List onePercent = PermissionRecallDataset.permittedDocumentIndexes(1_000, 0.01, 545L); + List tiny = PermissionRecallDataset.permittedDocumentIndexes(100, 0.001, 545L); + + assertThat(tenPercent).hasSize(100).containsAll(onePercent); + assertThat(onePercent).hasSize(10); + assertThat(tiny).hasSize(1); + } + + @Test + @DisplayName("다른 문서 최근접 유사도는 같은 문서 Chunk를 제외하고 계산한다") + void crossDocumentNearestSimilarities_excludesSameDocument() { + float[] base = {1F, 0F, 0F}; + List documents = List.of( + document("a", List.of(base, base.clone())), + document("b", List.of(new float[] {0F, 1F, 0F})) + ); + + double[] nearest = PermissionRecallDataset.crossDocumentNearestSimilarities(documents); + + assertThat(nearest).containsExactly(0.0, 0.0, 0.0); + } + + @Test + @DisplayName("Recall은 정답 집합 기준이고 정답이 없으면 계산하지 않는다") + void recall_isBasedOnExpectedSet() { + assertThat(PermissionRecallMetrics.recall(List.of(1L, 2L, 9L), List.of(1L, 2L, 3L, 4L))).isEqualTo(0.5); + assertThat(PermissionRecallMetrics.recall(List.of(1L), List.of())).isNull(); + assertThat(PermissionRecallMetrics.percentile(new double[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 0.95)) + .isEqualTo(10.0); + assertThat(PermissionRecallMetrics.mean(Arrays.asList(1.0, null, 0.0))).isEqualTo(0.5); + } + + private static List documents(int count, int chunksPerDocument) { + Random random = new Random(11L); + List documents = new ArrayList<>(); + for (int index = 0; index < count; index++) { + List vectors = new ArrayList<>(); + for (int chunk = 0; chunk < chunksPerDocument; chunk++) { + vectors.add(randomVector(random)); + } + documents.add(document("doc-" + index, vectors)); + } + return documents; + } + + private static RealDocument document(String name, List vectors) { + List chunks = new ArrayList<>(); + for (int index = 0; index < vectors.size(); index++) { + chunks.add(new RealChunk(index, name + " chunk " + index, 1, index * 10, index * 10 + 5, null, null, + "hash", null, vectors.get(index))); + } + return new RealDocument(name + ".md", "sha", name, "MD", chunks); + } + + private static float[] randomVector(Random random) { + float[] vector = new float[DIMENSION]; + for (int index = 0; index < DIMENSION; index++) { + vector[index] = (float) random.nextGaussian(); + } + return vector; + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/search/benchmark/PermissionRecallMetrics.java b/backend/src/test/java/com/opensource/docgrid/domain/search/benchmark/PermissionRecallMetrics.java new file mode 100644 index 00000000..08af9418 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/search/benchmark/PermissionRecallMetrics.java @@ -0,0 +1,45 @@ +package com.opensource.docgrid.domain.search.benchmark; + +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * 권한 필터 검색 누락 시험(#545)의 Recall·백분위·평균 계산. + * + *

Recall은 Chunk ID 집합 기준으로 {@code |측정 ∩ 정답| / |정답|}이며, 정답이 비어 있으면 정의하지 않고 {@code null}을 + * 돌려준다. 백분위는 오름차순 정렬된 값에서 최근접 순위(nearest-rank)로 고른다. DB나 Spring에 의존하지 않는다. + */ +final class PermissionRecallMetrics { + + private PermissionRecallMetrics() { + } + + static Double recall(List actual, List expected) { + if (expected.isEmpty()) { + return null; + } + Set expectedIds = new HashSet<>(expected); + long hits = actual.stream().filter(expectedIds::contains).distinct().count(); + return (double) hits / expectedIds.size(); + } + + static double percentile(double[] sortedValues, double quantile) { + if (sortedValues.length == 0) { + return Double.NaN; + } + int rank = (int) Math.ceil(quantile * sortedValues.length); + return sortedValues[Math.max(0, Math.min(sortedValues.length - 1, rank - 1))]; + } + + static Double mean(List values) { + return values.stream().filter(Objects::nonNull).mapToDouble(Double::doubleValue).average() + .stream().boxed().findFirst().orElse(null); + } + + static Double min(List values) { + return values.stream().filter(Objects::nonNull).mapToDouble(Double::doubleValue).min() + .stream().boxed().findFirst().orElse(null); + } +} diff --git a/docs/test-results/evidence/issue-545/explain_plans.py b/docs/test-results/evidence/issue-545/explain_plans.py new file mode 100644 index 00000000..dd3b4b6e --- /dev/null +++ b/docs/test-results/evidence/issue-545/explain_plans.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""#545 실행 계획 증빙용 psql 스크립트를 만든다. 표준 라이브러리만 쓴다. + +운영 VectorSearchRepository.findTopK SQL을 그대로 PREPARE하고, 시험이 쓴 허용 문서 ID와 질문 Vector로 EXECUTE한다. +Custom Plan과 Generic Plan, HNSW 강제(enable_sort=off) 계획을 EXPLAIN ANALYZE로 비교한다. + +사용법 (시험 DB에 50,000 청크 데이터가 적재된 상태에서): + python3 explain_plans.py > plans.sql + docker exec -i <격리 DB 컨테이너> psql -U -d -q < plans.sql > plans-raw.txt + python3 explain_plans.py --mask plans-raw.txt > plans-psql.txt +""" +import json +import re +import sys + +SCHEMA = "docgrid_permission_recall_545" +QUESTION_ID = "D01" +CASES = [ + # (제목, 허용 비율, LIMIT(topK×4), SET LOCAL 목록) + ("허용 100% · topK 10 · 현재 계획 · Custom Plan", 1.0, 40, ["plan_cache_mode = force_custom_plan"]), + ("허용 100% · topK 10 · 현재 계획 · Generic Plan", 1.0, 40, ["plan_cache_mode = force_generic_plan"]), + ("허용 10% · topK 10 · 현재 계획 · Custom Plan", 0.1, 40, ["plan_cache_mode = force_custom_plan"]), + ("허용 10% · topK 10 · 현재 계획 · Generic Plan", 0.1, 40, ["plan_cache_mode = force_generic_plan"]), + ("허용 100% · topK 20 · HNSW 강제 ef40", 1.0, 80, + ["plan_cache_mode = force_custom_plan", "enable_sort = off"]), + ("허용 100% · topK 20 · HNSW 강제 iterative relaxed", 1.0, 80, + ["plan_cache_mode = force_custom_plan", "enable_sort = off", "hnsw.iterative_scan = relaxed_order"]), + ("허용 10% · topK 10 · HNSW 강제 ef40", 0.1, 40, + ["plan_cache_mode = force_custom_plan", "enable_sort = off"]), + ("허용 10% · topK 10 · HNSW 강제 iterative relaxed", 0.1, 40, + ["plan_cache_mode = force_custom_plan", "enable_sort = off", "hnsw.iterative_scan = relaxed_order"]), +] + + +def permitted_ids(plans, ratio): + """시험이 남긴 Exact 계획의 Index Cond에서 해당 비율의 허용 문서 ID를 읽는다.""" + for sample in plans: + if sample["scale"] == "50000" and sample["ratio"] == ratio and sample["method"] == "exact": + match = re.search(r"document_id = ANY \('\{([0-9,]+)\}'", sample["plan"]) + if match: + return [int(value) for value in match.group(1).split(",")] + raise SystemExit(f"허용 비율 {ratio}의 Exact 계획을 찾지 못했습니다.") + + +def build(backend, plans_path): + with open(f"{backend}/build/reports/permission-recall/question-vectors.json", encoding="utf-8") as handle: + vectors = json.load(handle)["vectors"] + with open(f"{backend}/../docs/test-results/evidence/issue-545/questions.json", encoding="utf-8") as handle: + question = next(q["question"] for q in json.load(handle)["questions"] if q["id"] == QUESTION_ID) + with open(plans_path, encoding="utf-8") as handle: + plans = json.load(handle) + source = open(f"{backend}/src/main/java/com/opensource/docgrid/domain/search/repository/VectorSearchRepository.java", + encoding="utf-8").read() + sql = re.search(r'@Query\(value = """(.*?)""", nativeQuery = true\)\s*List findTopK', + source, re.S).group(1) + vector = "[" + ",".join(repr(float(value)) for value in vectors[question]) + "]" + + print(f"SET search_path = {SCHEMA}, public;") + prepared = {} + for title, ratio, limit, settings in CASES: + if ratio not in prepared: + ids = permitted_ids(plans, ratio) + name = f"top_k_{len(prepared)}" + placeholders = ", ".join(f"${index + 3}" for index in range(len(ids))) + statement = (sql.replace(":queryVector", "$1").replace(":modelId", "$2") + .replace("(:permittedIds)", f"({placeholders})").replace(":topK", f"${len(ids) + 3}")) + types = "text, bigint, " + ", ".join(["bigint"] * len(ids)) + ", int" + print(f"PREPARE {name}({types}) AS {statement};") + prepared[ratio] = (name, ids) + name, ids = prepared[ratio] + print(f"\\echo '=== {title} (허용 문서 {len(ids)}건, LIMIT {limit})'") + print("BEGIN;") + for setting in settings: + print(f"SET LOCAL {setting};") + values = f"'{vector}', 1, " + ", ".join(map(str, ids)) + f", {limit}" + print(f"EXPLAIN (ANALYZE, BUFFERS) EXECUTE {name}({values});") + print("ROLLBACK;") + + # 기본 plan_cache_mode(auto)에서 같은 Prepared Statement를 반복하면 몇 번째부터 Generic Plan으로 바뀌는지 본다. + name, ids = prepared[1.0] + values = f"'{vector}', 1, " + ", ".join(map(str, ids)) + ", 40" + print(f"DEALLOCATE {name};") + placeholders = ", ".join(f"${index + 3}" for index in range(len(ids))) + types = "text, bigint, " + ", ".join(["bigint"] * len(ids)) + ", int" + statement = (sql.replace(":queryVector", "$1").replace(":modelId", "$2") + .replace("(:permittedIds)", f"({placeholders})").replace(":topK", f"${len(ids) + 3}")) + print(f"PREPARE {name}({types}) AS {statement};") + print("SET plan_cache_mode = auto;") + for run in range(1, 9): + print(f"\\echo '=== auto 모드 반복 {run}회째 · 허용 100% · topK 10'") + print(f"EXPLAIN (ANALYZE, SUMMARY) EXECUTE {name}({values});") + + # 권고안 검증: 질문 Vector를 스칼라 서브쿼리(InitPlan)로 한 번만 계산하면 Generic Plan에서도 행마다 다시 계산하지 않는다. + rewritten = statement.replace("CAST($1 AS vector)", "(SELECT CAST($1 AS vector))") + print(f"PREPARE init_plan({types}) AS {rewritten};") + for mode in ("force_custom_plan", "force_generic_plan"): + print(f"\\echo '=== 권고안 검증 · 질문 Vector InitPlan · {mode} · 허용 100% · topK 10'") + print(f"SET plan_cache_mode = {mode};") + print(f"EXPLAIN (ANALYZE, SUMMARY) EXECUTE init_plan({values});") + print("RESET plan_cache_mode;") + + +def mask(path): + """질문 Vector와 허용 문서 ID 목록을 줄여 사람이 읽을 수 있게 한다.""" + text = open(path, encoding="utf-8").read() + text = re.sub(r"'\[[-0-9.,eE]+\]'(::vector)?", "'<질문 벡터 1024차원>'", text) + text = re.sub(r"'\{[0-9,]+\}'::bigint\[\]", "'<허용 문서 ID 목록>'::bigint[]", text) + text = re.sub(r"ARRAY\[\$3(, \$[0-9]+)+\]", "ARRAY[$3, ..., $n]", text) + # psql 표 머리글은 가리기 전 가장 긴 줄 폭으로 채워져 있으므로 구분선·빈 머리글을 지우고 줄 끝 공백을 없앤다. + lines = [line.rstrip() for line in text.splitlines()] + kept = [line for line in lines if line and not set(line) <= {"-", "+"} and line.strip() != "QUERY PLAN"] + print("\n".join(kept)) + + +if __name__ == "__main__": + if len(sys.argv) == 3 and sys.argv[1] == "--mask": + mask(sys.argv[2]) + elif len(sys.argv) == 3: + build(sys.argv[1], sys.argv[2]) + else: + print(__doc__) + sys.exit(2) diff --git a/docs/test-results/evidence/issue-545/h1-final-results.svg b/docs/test-results/evidence/issue-545/h1-final-results.svg new file mode 100644 index 00000000..fb09e4ec --- /dev/null +++ b/docs/test-results/evidence/issue-545/h1-final-results.svg @@ -0,0 +1,137 @@ + + + +허용 문서 비율별 벡터 검색 최종 결과 수 (50,000 청크) +질문 30개 평균. HNSW 계열은 enable_sort=off로 HNSW를 쓰게 한 값이며, 현재 운영 계획은 Exact와 같은 결과를 낸다. + +Exact = 현재 계획 + +HNSW 강제 ef40 + +HNSW 강제 ef200 + +HNSW 강제 iterative relaxed +topK 5 + +0 + +1 + +2 + +3 + +4 + +5 + +topK 5 +100% +10% +1% +0.1% + +HNSW 강제 ef40 · 100%: 5.0 +HNSW 강제 ef40 · 10%: 1.7 +HNSW 강제 ef40 · 1%: 0.2 +HNSW 강제 ef40 · 0.1%: 0.0 + +HNSW 강제 ef200 · 100%: 5.0 +HNSW 강제 ef200 · 10%: 4.0 +HNSW 강제 ef200 · 1%: 0.6 +HNSW 강제 ef200 · 0.1%: 0.0 + +HNSW 강제 iterative relaxed · 100%: 5.0 +HNSW 강제 iterative relaxed · 10%: 5.0 +HNSW 강제 iterative relaxed · 1%: 4.9 +HNSW 강제 iterative relaxed · 0.1%: 5.0 + +Exact = 현재 계획 · 100%: 5.0 +Exact = 현재 계획 · 10%: 5.0 +Exact = 현재 계획 · 1%: 4.9 +Exact = 현재 계획 · 0.1%: 5.0 +topK 10 + +0 + +2 + +4 + +6 + +8 + +10 + +topK 10 +100% +10% +1% +0.1% + +HNSW 강제 ef40 · 100%: 9.8 +HNSW 강제 ef40 · 10%: 1.8 +HNSW 강제 ef40 · 1%: 0.2 +HNSW 강제 ef40 · 0.1%: 0.0 + +HNSW 강제 ef200 · 100%: 9.9 +HNSW 강제 ef200 · 10%: 6.0 +HNSW 강제 ef200 · 1%: 0.6 +HNSW 강제 ef200 · 0.1%: 0.0 + +HNSW 강제 iterative relaxed · 100%: 9.8 +HNSW 강제 iterative relaxed · 10%: 10.0 +HNSW 강제 iterative relaxed · 1%: 10.0 +HNSW 강제 iterative relaxed · 0.1%: 6.7 + +Exact = 현재 계획 · 100%: 9.9 +Exact = 현재 계획 · 10%: 10.0 +Exact = 현재 계획 · 1%: 10.0 +Exact = 현재 계획 · 0.1%: 7.9 +topK 20 + +0 + +5 + +10 + +15 + +20 + +topK 20 +100% +10% +1% +0.1% + +HNSW 강제 ef40 · 100%: 17.3 +HNSW 강제 ef40 · 10%: 1.8 +HNSW 강제 ef40 · 1%: 0.2 +HNSW 강제 ef40 · 0.1%: 0.0 + +HNSW 강제 ef200 · 100%: 19.0 +HNSW 강제 ef200 · 10%: 6.3 +HNSW 강제 ef200 · 1%: 0.6 +HNSW 강제 ef200 · 0.1%: 0.0 + +HNSW 강제 iterative relaxed · 100%: 19.0 +HNSW 강제 iterative relaxed · 10%: 20.0 +HNSW 강제 iterative relaxed · 1%: 20.0 +HNSW 강제 iterative relaxed · 0.1%: 6.7 + +Exact = 현재 계획 · 100%: 19.1 +Exact = 현재 계획 · 10%: 20.0 +Exact = 현재 계획 · 1%: 20.0 +Exact = 현재 계획 · 0.1%: 7.9 +y축: 앱 필터(최소 유사도·문서당 2개) 뒤 최종 결과 수 평균 + diff --git a/docs/test-results/evidence/issue-545/h2-sql-candidates.svg b/docs/test-results/evidence/issue-545/h2-sql-candidates.svg new file mode 100644 index 00000000..6cad1af2 --- /dev/null +++ b/docs/test-results/evidence/issue-545/h2-sql-candidates.svg @@ -0,0 +1,47 @@ + + + +허용 100%에서 SQL이 돌려준 후보 수 +50,000 청크, 질문 30개 평균. SQL 요청 수는 topK × 4이다. HNSW 강제 ef100은 Exact와 같은 값이라 겹친다. + +Exact = 요청 수 + +HNSW 강제 ef40 + +HNSW 강제 ef100 +topK별 SQL 반환 후보 + +0 + +20 + +40 + +60 + +80 + +topK 5 (요청 20) +topK 10 (요청 40) +topK 20 (요청 80) + +HNSW 강제 ef40 · topK 5 (요청 20): 20.0 +HNSW 강제 ef40 · topK 10 (요청 40): 40.0 +HNSW 강제 ef40 · topK 20 (요청 80): 40.0 + +HNSW 강제 ef100 · topK 5 (요청 20): 20.0 +HNSW 강제 ef100 · topK 10 (요청 40): 40.0 +HNSW 강제 ef100 · topK 20 (요청 80): 80.0 + +Exact = 요청 수 · topK 5 (요청 20): 20.0 +Exact = 요청 수 · topK 10 (요청 40): 40.0 +Exact = 요청 수 · topK 20 (요청 80): 80.0 +y축: SQL 반환 후보 수 평균 + diff --git a/docs/test-results/evidence/issue-545/make_report.py b/docs/test-results/evidence/issue-545/make_report.py new file mode 100644 index 00000000..4c3859e0 --- /dev/null +++ b/docs/test-results/evidence/issue-545/make_report.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""#545 권한 필터 × 벡터 검색 누락 시험 결과(results.json)를 CSV·Markdown 표로 바꾼다. + +표준 라이브러리만 쓴다. 사용법: + python3 make_report.py <출력 폴더> + +출력: + rows.csv.gz 질문 한 건 × 조합 한 개의 측정 행 (반복 측정 지연 표본 포함) + summary.csv 규모 × 허용 비율 × topK × 방식 조합별 요약 + tables.md 결과 문서에 옮겨 쓸 Markdown 표 + h1-final-results.svg 허용 비율별 최종 결과 수 (H1) + h2-sql-candidates.svg 허용 100%에서 SQL 반환 후보 수 (H2) +""" +import csv +import gzip +import json +import os +import statistics +import sys + +METHOD_ORDER = [ + "exact", "planner_default", "planner_default_generic", + "hnsw_ef40", "hnsw_ef100", "hnsw_ef200", "iterative_relaxed", "iterative_strict", +] +METHOD_NAMES = { + "exact": "Exact (인덱스 없음)", + "planner_default": "현재 계획 (Custom Plan)", + "planner_default_generic": "현재 계획 (Generic Plan)", + "hnsw_ef40": "HNSW 강제 ef40", + "hnsw_ef100": "HNSW 강제 ef100", + "hnsw_ef200": "HNSW 강제 ef200", + "iterative_relaxed": "HNSW 강제 iterative relaxed", + "iterative_strict": "HNSW 강제 iterative strict", +} + + +def ratio_label(ratio): + return f"{ratio * 100:g}%" + + +def fmt(value, digits=2): + if value is None: + return "-" + if isinstance(value, float): + return f"{value:.{digits}f}" + return str(value) + + +def write_rows(report, path): + columns = [ + "scale", "ratio", "permittedDocuments", "permittedChunks", "topK", "candidateLimit", "method", + "questionId", "questionSource", "sqlRows", "vectorFinal", "hybridFinal", "hybridLexicalRows", + "exactSqlRows", "exactVectorFinal", "exactHybridFinal", "sqlRecall", "vectorFinalRecall", + "hybridFinalRecall", "vectorMedianMillis", "hybridMedianMillis", "stable", "planUsesHnsw", + "planHnswActualRows", "planHnswRowsRemovedByFilter", "planResultRows", "planPlanningMillis", + "planExecutionMillis", "planSharedHitBlocks", "planSharedReadBlocks", "planNodeTypes", + "vectorMillisSamples", "hybridMillisSamples", + ] + with gzip.open(path, "wt", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow(columns) + for row in report["rows"]: + plan = row["plan"] + writer.writerow([ + row["scale"], row["ratio"], row["permittedDocuments"], row["permittedChunks"], row["topK"], + row["candidateLimit"], row["method"], row["questionId"], row["questionSource"], row["sqlRows"], + row["vectorFinal"], row["hybridFinal"], row["hybridLexicalRows"], row["exactSqlRows"], + row["exactVectorFinal"], row["exactHybridFinal"], row["sqlRecall"], row["vectorFinalRecall"], + row["hybridFinalRecall"], round(statistics.median(row["vectorMillis"]), 3), + round(statistics.median(row["hybridMillis"]), 3), row["stable"], plan["usesHnsw"], + plan["hnswActualRows"], plan["hnswRowsRemovedByFilter"], plan["resultRows"], + plan["planningMillis"], plan["executionMillis"], plan["sharedHitBlocks"], + plan["sharedReadBlocks"], " > ".join(plan["nodeTypes"]), + ";".join(f"{value:.3f}" for value in row["vectorMillis"]), + ";".join(f"{value:.3f}" for value in row["hybridMillis"]), + ]) + + +def write_summary(report, path): + summaries = report["summaries"] + columns = list(summaries[0].keys()) + with open(path, "w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=columns) + writer.writeheader() + for summary in summaries: + writer.writerow(summary) + + +def summary_index(report): + return {(s["scale"], s["ratio"], s["topK"], s["method"]): s for s in report["summaries"]} + + +def tables(report): + index = summary_index(report) + lines = [] + scales = [scale["scale"] for scale in report["scales"]] + ratios = report["configuration"]["ratios"] + top_ks = report["configuration"]["topKs"] + methods = [m for m in METHOD_ORDER if any(s["method"] == m for s in report["summaries"])] + + for scale in scales: + info = next(s for s in report["scales"] if s["scale"] == scale) + lines.append(f"### 규모 {scale}: 문서 {info['documents']:,}건, 청크 {info['chunks']:,}개 " + f"(합성 비율 {info['syntheticChunkRatio'] * 100:.1f}%)") + lines.append("") + lines.append("| 허용 비율 | 허용 문서 | 허용 청크 |") + lines.append("|---:|---:|---:|") + for ratio in info["ratios"]: + lines.append(f"| {ratio_label(ratio['ratio'])} | {ratio['permittedDocuments']:,} | " + f"{ratio['permittedChunks']:,} |") + lines.append("") + + for top_k in top_ks: + lines.append(f"#### {scale} · topK {top_k} (SQL 요청 {top_k * report['searchSettings']['candidatePoolMultiplier']}개)") + lines.append("") + lines.append("| 허용 비율 | 방식 | HNSW 사용 | SQL 후보 평균 (최소~최대) | 벡터 최종 평균 | topK 미달 질문 " + "| 하이브리드 최종 평균 | SQL Recall | 벡터 최종 Recall (최소) | 하이브리드 최종 Recall | 벡터 p50 / p95 ms " + "| 하이브리드 p50 / p95 ms |") + lines.append("|---:|---|---:|---|---:|---:|---:|---:|---|---:|---|---|") + for ratio in ratios: + for method in methods: + s = index.get((scale, ratio, top_k, method)) + if s is None: + continue + lines.append( + f"| {ratio_label(ratio)} | {METHOD_NAMES[method]} | {s['hnswPlanQuestions']}/{s['questions']} | " + f"{s['sqlRowsMean']:.1f} ({s['sqlRowsMin']}~{s['sqlRowsMax']}) | {s['vectorFinalMean']:.1f} | " + f"{s['vectorShortQuestions']}/{s['questions']} | {s['hybridFinalMean']:.1f} | " + f"{fmt(s['sqlRecallMean'])} | {fmt(s['vectorFinalRecallMean'])} ({fmt(s['vectorFinalRecallMin'])}) | " + f"{fmt(s['hybridFinalRecallMean'])} | {s['vectorP50Millis']:.1f} / {s['vectorP95Millis']:.1f} | " + f"{s['hybridP50Millis']:.1f} / {s['hybridP95Millis']:.1f} |") + lines.append("") + return "\n".join(lines) + + +# 범주형 색은 고정 순서로만 쓴다(dataviz 기준 팔레트 1~4번, 밝은/어두운 화면 각각 검증). +SERIES_COLORS = [("#2a78d6", "#3987e5"), ("#eb6834", "#d95926"), ("#1baf7a", "#199e70"), ("#eda100", "#c98500")] +SVG_STYLE = """""" + + +def svg_style(count): + # 선(.l)은 stroke만, 표식(.d)은 fill만 계열 색을 쓴다. 표식 테두리는 .ring(화면 색)이 맡는다. + light = "".join(f".l{i}{{stroke:{c[0]}}}.d{i}{{fill:{c[0]}}}" for i, c in enumerate(SERIES_COLORS[:count])) + dark = "".join(f".l{i}{{stroke:{c[1]}}}.d{i}{{fill:{c[1]}}}" for i, c in enumerate(SERIES_COLORS[:count])) + return SVG_STYLE % (light, dark) + + +def text_width(text, size=12): + """한글은 글자 크기만큼, 그 밖은 0.58배로 어림한 글자 폭.""" + return sum(size if ord(char) > 0x1100 else size * 0.58 for char in str(text)) + + +def esc(text): + return str(text).replace("&", "&").replace("<", "<").replace(">", ">") + + +def line_panels_svg(title, subtitle, panels, series_names, x_labels, y_label, path, panel_width=280): + """패널마다 같은 x 범주에 여러 계열의 선을 그린다. panels = [(패널 제목, y 최댓값, 참조선 값|None, [[값...] 계열별])].""" + panel_height = 210 + left, top, gap = 56, 112, 36 + legend_width = 20 + sum(24 + text_width(name) + 22 for name in series_names) + width = int(max(left + len(panels) * panel_width + (len(panels) - 1) * gap + 24, legend_width, + 40 + text_width(title, 16), 40 + text_width(subtitle))) + height = top + panel_height + 64 + out = [f'', svg_style(len(series_names)), + f'', + f'{esc(title)}', + f'{esc(subtitle)}'] + # 범례: 색 표식 옆에 글자(글자는 계열 색을 쓰지 않는다) + legend_x = 20 + for index, name in enumerate(series_names): + out.append(f'' + (f'' + if index == 0 else f'')) + out.append(f'{esc(name)}') + legend_x += 24 + text_width(name) + 22 + for panel_index, (panel_title, y_max, reference, values) in enumerate(panels): + x0 = left + panel_index * (panel_width + gap) + plot_bottom = top + panel_height + step = panel_width / (len(x_labels) - 1) if len(x_labels) > 1 else 0 + out.append(f'{esc(panel_title)}') + ticks = nice_ticks(y_max) + for tick in ticks: + y = plot_bottom - tick / ticks[-1] * panel_height + out.append(f'') + # 패널마다 y 범위(topK)가 다르므로 눈금 글자를 패널마다 붙인다. + out.append(f'{tick:g}') + out.append(f'') + if reference is not None: + y = plot_bottom - reference / ticks[-1] * panel_height + out.append(f'topK {reference:g}') + for label_index, label in enumerate(x_labels): + x = x0 + label_index * step + # 양 끝 글자가 그림 밖으로 잘리지 않게 첫 글자는 왼쪽, 마지막 글자는 오른쪽에 맞춘다. + anchor = "start" if label_index == 0 else "end" if label_index == len(x_labels) - 1 else "middle" + out.append(f'{esc(label)}') + # 첫 계열(기준선)은 다른 계열과 겹쳐도 보이도록 맨 나중에, 속이 빈 큰 표식으로 그린다. + for series_index in list(range(1, len(values))) + [0]: + series_values = values[series_index] + points = [(x0 + i * step, plot_bottom - (v or 0) / ticks[-1] * panel_height) for i, v in enumerate(series_values)] + out.append(f'') + for (x, y), value, label in zip(points, series_values, x_labels): + tip = f'{esc(series_names[series_index])} · {esc(label)}: {value:.1f}' + if series_index == 0: + out.append(f'{tip}') + else: + out.append(f'{tip}') + out.append(f'{esc(y_label)}') + out.append("") + with open(path, "w", encoding="utf-8") as handle: + handle.write("\n".join(out) + "\n") + + +def nice_ticks(maximum): + for step in (1, 2, 5, 10, 20, 25, 50, 100): + if maximum / step <= 5: + top = step * -(-maximum // step) + return [step * i for i in range(int(top / step) + 1)] + return [0, maximum] + + +def charts(report, output): + index = summary_index(report) + scale = "50000" if any(s["scale"] == "50000" for s in report["scales"]) else report["scales"][-1]["scale"] + ratios = report["configuration"]["ratios"] + top_ks = report["configuration"]["topKs"] + multiplier = report["searchSettings"]["candidatePoolMultiplier"] + + # 그림 1 (H1): 허용 비율이 낮아질 때 사용자에게 돌아가는 최종 결과 수 + methods = ["exact", "hnsw_ef40", "hnsw_ef200", "iterative_relaxed"] + names = ["Exact = 현재 계획", "HNSW 강제 ef40", "HNSW 강제 ef200", "HNSW 강제 iterative relaxed"] + panels = [] + for top_k in top_ks: + values = [[index[(scale, ratio, top_k, method)]["vectorFinalMean"] for ratio in ratios] for method in methods] + panels.append((f"topK {top_k}", top_k, top_k, values)) + line_panels_svg( + f"허용 문서 비율별 벡터 검색 최종 결과 수 ({int(scale):,} 청크)", + "질문 30개 평균. HNSW 계열은 enable_sort=off로 HNSW를 쓰게 한 값이며, 현재 운영 계획은 Exact와 같은 결과를 낸다.", + panels, names, [ratio_label(r) for r in ratios], "y축: 앱 필터(최소 유사도·문서당 2개) 뒤 최종 결과 수 평균", + os.path.join(output, "h1-final-results.svg")) + + # 그림 2 (H2): 권한 필터가 없는(100%) 경우 SQL이 돌려준 후보 수 + methods = ["exact", "hnsw_ef40", "hnsw_ef100"] + names = ["Exact = 요청 수", "HNSW 강제 ef40", "HNSW 강제 ef100"] + values = [[index[(scale, 1.0, top_k, method)]["sqlRowsMean"] for top_k in top_ks] for method in methods] + line_panels_svg( + "허용 100%에서 SQL이 돌려준 후보 수", + f"{int(scale):,} 청크, 질문 30개 평균. SQL 요청 수는 topK × {multiplier}이다. HNSW 강제 ef100은 Exact와 같은 값이라 겹친다.", + [("topK별 SQL 반환 후보", max(top_ks) * multiplier, None, values)], names, + [f"topK {k} (요청 {k * multiplier})" for k in top_ks], "y축: SQL 반환 후보 수 평균", + os.path.join(output, "h2-sql-candidates.svg"), panel_width=480) + + +def main(): + if len(sys.argv) != 3: + print(__doc__) + sys.exit(2) + opener = gzip.open if sys.argv[1].endswith(".gz") else open + with opener(sys.argv[1], "rt", encoding="utf-8") as handle: + report = json.load(handle) + output = sys.argv[2] + os.makedirs(output, exist_ok=True) + write_rows(report, os.path.join(output, "rows.csv.gz")) + write_summary(report, os.path.join(output, "summary.csv")) + with open(os.path.join(output, "tables.md"), "w", encoding="utf-8") as handle: + handle.write(tables(report) + "\n") + charts(report, output) + + +if __name__ == "__main__": + main() diff --git a/docs/test-results/evidence/issue-545/plans-psql.txt b/docs/test-results/evidence/issue-545/plans-psql.txt new file mode 100644 index 00000000..c8befcbc --- /dev/null +++ b/docs/test-results/evidence/issue-545/plans-psql.txt @@ -0,0 +1,444 @@ +=== 허용 100% · topK 10 · 현재 계획 · Custom Plan (허용 문서 3559건, LIMIT 40) + Limit (cost=2083.56..2083.59 rows=9 width=1039) (actual time=338.748..338.754 rows=40 loops=1) + Buffers: shared hit=326857 read=41323 + -> Sort (cost=2083.56..2083.59 rows=9 width=1039) (actual time=338.747..338.752 rows=40 loops=1) + Sort Key: ((e.vector <=> '<질문 벡터 1024차원>')) + Sort Method: top-N heapsort Memory: 100kB + Buffers: shared hit=326857 read=41323 + -> Nested Loop (cost=254.45..2083.42 rows=9 width=1039) (actual time=1.693..331.536 rows=50008 loops=1) + Buffers: shared hit=326854 read=41323 + -> Hash Join (cost=254.16..2072.58 rows=9 width=117) (actual time=1.547..21.472 rows=50008 loops=1) + Hash Cond: ((e.document_id = d.id) AND (e.document_version_id = d.current_version_id)) + Buffers: shared hit=955 read=474 + -> Index Scan using idx_embeddings_document_id on embeddings e (cost=0.29..1651.20 rows=31908 width=50) (actual time=0.092..14.376 rows=50008 loops=1) + Index Cond: (document_id = ANY ('<허용 문서 ID 목록>'::bigint[])) + Filter: ((embedding_model_id = '1'::bigint) AND ((status)::text = 'ACTIVE'::text)) + Buffers: shared hit=799 read=474 + -> Hash (cost=200.49..200.49 rows=3559 width=91) (actual time=1.429..1.430 rows=3559 loops=1) + Buckets: 4096 Batches: 1 Memory Usage: 473kB + Buffers: shared hit=156 + -> Seq Scan on documents d (cost=0.00..200.49 rows=3559 width=91) (actual time=0.024..1.015 rows=3559 loops=1) + Filter: ((deleted_at IS NULL) AND ((status)::text = 'INDEXED'::text)) + Buffers: shared hit=156 + -> Index Scan using document_chunks_pkey on document_chunks dc (cost=0.29..1.20 rows=1 width=940) (actual time=0.001..0.001 rows=1 loops=50008) + Index Cond: (id = e.chunk_id) + Buffers: shared hit=142915 read=7109 + Planning: + Buffers: shared hit=532 read=84 + Planning Time: 10.585 ms + Execution Time: 338.816 ms +(28 rows) +=== 허용 100% · topK 10 · 현재 계획 · Generic Plan (허용 문서 3559건, LIMIT 40) + Limit (cost=2085.68..2085.68 rows=1 width=1039) (actual time=2129.221..2129.231 rows=40 loops=1) + Buffers: shared hit=325849 read=42305 + -> Sort (cost=2085.68..2085.70 rows=9 width=1039) (actual time=2129.220..2129.226 rows=40 loops=1) + Sort Key: ((e.vector <=> ($1)::vector)) + Sort Method: top-N heapsort Memory: 100kB + Buffers: shared hit=325849 read=42305 + -> Nested Loop (cost=254.45..2085.53 rows=9 width=1039) (actual time=1.268..2122.201 rows=50008 loops=1) + Buffers: shared hit=325849 read=42305 + -> Hash Join (cost=254.16..2074.66 rows=9 width=117) (actual time=1.204..14.793 rows=50008 loops=1) + Hash Cond: ((e.document_id = d.id) AND (e.document_version_id = d.current_version_id)) + Buffers: shared hit=2 read=1427 + -> Index Scan using idx_embeddings_document_id on embeddings e (cost=0.29..1653.07 rows=31948 width=50) (actual time=0.097..8.108 rows=50008 loops=1) + Index Cond: (document_id = ANY (ARRAY[$3, ..., $n])) + Filter: ((embedding_model_id = $2) AND ((status)::text = 'ACTIVE'::text)) + Buffers: shared hit=2 read=1271 + -> Hash (cost=200.49..200.49 rows=3559 width=91) (actual time=1.098..1.098 rows=3559 loops=1) + Buckets: 4096 Batches: 1 Memory Usage: 473kB + Buffers: shared read=156 + -> Seq Scan on documents d (cost=0.00..200.49 rows=3559 width=91) (actual time=0.015..0.781 rows=3559 loops=1) + Filter: ((deleted_at IS NULL) AND ((status)::text = 'INDEXED'::text)) + Buffers: shared read=156 + -> Index Scan using document_chunks_pkey on document_chunks dc (cost=0.29..1.20 rows=1 width=940) (actual time=0.001..0.001 rows=1 loops=50008) + Index Cond: (id = e.chunk_id) + Buffers: shared hit=142896 read=7128 + Planning: + Buffers: shared hit=18 read=19 + Planning Time: 2.635 ms + Execution Time: 2129.413 ms +(28 rows) +=== 허용 10% · topK 10 · 현재 계획 · Custom Plan (허용 문서 356건, LIMIT 40) + Limit (cost=606.33..606.34 rows=1 width=1039) (actual time=16.706..16.710 rows=40 loops=1) + Buffers: shared hit=35199 read=3926 + -> Sort (cost=606.33..606.34 rows=1 width=1039) (actual time=16.705..16.708 rows=40 loops=1) + Sort Key: ((e.vector <=> '<질문 벡터 1024차원>')) + Sort Method: top-N heapsort Memory: 125kB + Buffers: shared hit=35199 read=3926 + -> Nested Loop (cost=254.45..606.32 rows=1 width=1039) (actual time=0.648..15.917 rows=5244 loops=1) + Buffers: shared hit=35199 read=3926 + -> Hash Join (cost=254.16..602.99 rows=1 width=117) (actual time=0.634..2.268 rows=5244 loops=1) + Hash Cond: ((e.document_id = d.id) AND (e.document_version_id = d.current_version_id)) + Buffers: shared hit=90 read=568 + -> Index Scan using idx_embeddings_document_id on embeddings e (cost=0.29..321.89 rows=5186 width=50) (actual time=0.009..1.123 rows=5244 loops=1) + Index Cond: (document_id = ANY ('<허용 문서 ID 목록>'::bigint[])) + Filter: ((embedding_model_id = '1'::bigint) AND ((status)::text = 'ACTIVE'::text)) + Buffers: shared hit=90 read=412 + -> Hash (cost=200.49..200.49 rows=3559 width=91) (actual time=0.624..0.624 rows=3559 loops=1) + Buckets: 4096 Batches: 1 Memory Usage: 473kB + Buffers: shared read=156 + -> Seq Scan on documents d (cost=0.00..200.49 rows=3559 width=91) (actual time=0.007..0.442 rows=3559 loops=1) + Filter: ((deleted_at IS NULL) AND ((status)::text = 'INDEXED'::text)) + Buffers: shared read=156 + -> Index Scan using document_chunks_pkey on document_chunks dc (cost=0.29..3.33 rows=1 width=940) (actual time=0.001..0.001 rows=1 loops=5244) + Index Cond: (id = e.chunk_id) + Buffers: shared hit=15268 read=464 + Planning: + Buffers: shared hit=18 read=19 + Planning Time: 0.572 ms + Execution Time: 16.726 ms +(28 rows) +=== 허용 10% · topK 10 · 현재 계획 · Generic Plan (허용 문서 356건, LIMIT 40) + Limit (cost=601.82..601.82 rows=1 width=1039) (actual time=219.719..219.725 rows=40 loops=1) + Buffers: shared hit=39125 + -> Sort (cost=601.82..601.82 rows=1 width=1039) (actual time=219.718..219.722 rows=40 loops=1) + Sort Key: ((e.vector <=> ($1)::vector)) + Sort Method: top-N heapsort Memory: 125kB + Buffers: shared hit=39125 + -> Nested Loop (cost=254.45..601.81 rows=1 width=1039) (actual time=0.481..218.842 rows=5244 loops=1) + Buffers: shared hit=39125 + -> Hash Join (cost=254.16..598.45 rows=1 width=117) (actual time=0.434..1.957 rows=5244 loops=1) + Hash Cond: ((e.document_id = d.id) AND (e.document_version_id = d.current_version_id)) + Buffers: shared hit=658 + -> Index Scan using idx_embeddings_document_id on embeddings e (cost=0.29..317.83 rows=5094 width=50) (actual time=0.010..0.953 rows=5244 loops=1) + Index Cond: (document_id = ANY (ARRAY[$3, ..., $n])) + Filter: ((embedding_model_id = $2) AND ((status)::text = 'ACTIVE'::text)) + Buffers: shared hit=502 + -> Hash (cost=200.49..200.49 rows=3559 width=91) (actual time=0.422..0.423 rows=3559 loops=1) + Buckets: 4096 Batches: 1 Memory Usage: 473kB + Buffers: shared hit=156 + -> Seq Scan on documents d (cost=0.00..200.49 rows=3559 width=91) (actual time=0.003..0.249 rows=3559 loops=1) + Filter: ((deleted_at IS NULL) AND ((status)::text = 'INDEXED'::text)) + Buffers: shared hit=156 + -> Index Scan using document_chunks_pkey on document_chunks dc (cost=0.29..3.35 rows=1 width=940) (actual time=0.000..0.000 rows=1 loops=5244) + Index Cond: (id = e.chunk_id) + Buffers: shared hit=15732 + Planning: + Buffers: shared hit=37 + Planning Time: 0.436 ms + Execution Time: 219.750 ms +(28 rows) +=== 허용 100% · topK 20 · HNSW 강제 ef40 (허용 문서 3559건, LIMIT 80) + Limit (cost=2549.99..208973.32 rows=9 width=1039) (actual time=11.998..12.349 rows=40 loops=1) + Buffers: shared hit=358 read=324 + -> Nested Loop (cost=2549.99..208973.32 rows=9 width=1039) (actual time=4.593..4.942 rows=40 loops=1) + Buffers: shared hit=358 read=324 + -> Nested Loop (cost=2549.70..208962.48 rows=9 width=117) (actual time=4.571..4.719 rows=40 loops=1) + Buffers: shared hit=115 read=273 + -> Index Scan using embeddings_vector_idx on embeddings e (cost=2549.41..206540.12 rows=31908 width=50) (actual time=4.451..4.482 rows=40 loops=1) + Order By: (vector <=> '<질문 벡터 1024차원>') + Filter: ((embedding_model_id = '1'::bigint) AND ((status)::text = 'ACTIVE'::text) AND (document_id = ANY ('<허용 문서 ID 목록>'::bigint[]))) + Buffers: shared hit=92 read=266 + -> Memoize (cost=0.29..0.33 rows=1 width=91) (actual time=0.006..0.006 rows=1 loops=40) + Cache Key: e.document_id, e.document_version_id + Cache Mode: logical + Hits: 30 Misses: 10 Evictions: 0 Overflows: 0 Memory Usage: 2kB + Buffers: shared hit=23 read=7 + -> Index Scan using idx_documents_current_version_id on documents d (cost=0.28..0.32 rows=1 width=91) (actual time=0.011..0.011 rows=1 loops=10) + Index Cond: (current_version_id = e.document_version_id) + Filter: ((deleted_at IS NULL) AND ((status)::text = 'INDEXED'::text) AND (id = e.document_id)) + Buffers: shared hit=23 read=7 + -> Index Scan using document_chunks_pkey on document_chunks dc (cost=0.29..1.20 rows=1 width=940) (actual time=0.002..0.002 rows=1 loops=40) + Index Cond: (id = e.chunk_id) + Buffers: shared hit=112 read=8 + Planning: + Buffers: shared hit=37 + Planning Time: 2.843 ms + JIT: + Functions: 25 + Options: Inlining false, Optimization false, Expressions true, Deforming true + Timing: Generation 0.823 ms (Deform 0.291 ms), Inlining 0.000 ms, Optimization 0.525 ms, Emission 6.899 ms, Total 8.247 ms + Execution Time: 39.148 ms +(30 rows) +=== 허용 100% · topK 20 · HNSW 강제 iterative relaxed (허용 문서 3559건, LIMIT 80) + Limit (cost=2549.99..208973.32 rows=9 width=1039) (actual time=5.129..6.010 rows=80 loops=1) + Buffers: shared hit=1021 read=95 + -> Nested Loop (cost=2549.99..208973.32 rows=9 width=1039) (actual time=0.312..1.189 rows=80 loops=1) + Buffers: shared hit=1021 read=95 + -> Nested Loop (cost=2549.70..208962.48 rows=9 width=117) (actual time=0.300..0.924 rows=80 loops=1) + Buffers: shared hit=458 read=72 + -> Index Scan using embeddings_vector_idx on embeddings e (cost=2549.41..206540.12 rows=31908 width=50) (actual time=0.274..0.857 rows=80 loops=1) + Order By: (vector <=> '<질문 벡터 1024차원>') + Filter: ((embedding_model_id = '1'::bigint) AND ((status)::text = 'ACTIVE'::text) AND (document_id = ANY ('<허용 문서 ID 목록>'::bigint[]))) + Buffers: shared hit=426 read=71 + -> Memoize (cost=0.29..0.33 rows=1 width=91) (actual time=0.001..0.001 rows=1 loops=80) + Cache Key: e.document_id, e.document_version_id + Cache Mode: logical + Hits: 69 Misses: 11 Evictions: 0 Overflows: 0 Memory Usage: 3kB + Buffers: shared hit=32 read=1 + -> Index Scan using idx_documents_current_version_id on documents d (cost=0.28..0.32 rows=1 width=91) (actual time=0.002..0.002 rows=1 loops=11) + Index Cond: (current_version_id = e.document_version_id) + Filter: ((deleted_at IS NULL) AND ((status)::text = 'INDEXED'::text) AND (id = e.document_id)) + Buffers: shared hit=32 read=1 + -> Index Scan using document_chunks_pkey on document_chunks dc (cost=0.29..1.20 rows=1 width=940) (actual time=0.001..0.001 rows=1 loops=80) + Index Cond: (id = e.chunk_id) + Buffers: shared hit=239 read=1 + Planning: + Buffers: shared hit=37 + Planning Time: 3.156 ms + JIT: + Functions: 25 + Options: Inlining false, Optimization false, Expressions true, Deforming true + Timing: Generation 0.378 ms (Deform 0.190 ms), Inlining 0.000 ms, Optimization 0.248 ms, Emission 4.577 ms, Total 5.204 ms + Execution Time: 6.420 ms +(30 rows) +=== 허용 10% · topK 10 · HNSW 강제 ef40 (허용 문서 356건, LIMIT 40) + Limit (cost=2541.97..208868.07 rows=1 width=1039) (actual time=4.256..4.259 rows=1 loops=1) + Buffers: shared hit=362 + -> Nested Loop (cost=2541.97..208868.07 rows=1 width=1039) (actual time=0.164..0.167 rows=1 loops=1) + Buffers: shared hit=362 + -> Nested Loop (cost=2541.68..208864.73 rows=1 width=117) (actual time=0.157..0.160 rows=1 loops=1) + Buffers: shared hit=355 + -> Index Scan using embeddings_vector_idx on embeddings e (cost=2541.40..206532.11 rows=5186 width=50) (actual time=0.153..0.156 rows=1 loops=1) + Order By: (vector <=> '<질문 벡터 1024차원>') + Filter: ((embedding_model_id = '1'::bigint) AND ((status)::text = 'ACTIVE'::text) AND (document_id = ANY ('<허용 문서 ID 목록>'::bigint[]))) + Rows Removed by Filter: 39 + Buffers: shared hit=352 + -> Index Scan using idx_documents_current_version_id on documents d (cost=0.28..0.44 rows=1 width=91) (actual time=0.003..0.003 rows=1 loops=1) + Index Cond: (current_version_id = e.document_version_id) + Filter: ((deleted_at IS NULL) AND ((status)::text = 'INDEXED'::text) AND (id = e.document_id)) + Buffers: shared hit=3 + -> Index Scan using document_chunks_pkey on document_chunks dc (cost=0.29..3.33 rows=1 width=940) (actual time=0.001..0.001 rows=1 loops=1) + Index Cond: (id = e.chunk_id) + Buffers: shared hit=3 + Planning: + Buffers: shared hit=37 + Planning Time: 0.463 ms + JIT: + Functions: 19 + Options: Inlining false, Optimization false, Expressions true, Deforming true + Timing: Generation 0.293 ms (Deform 0.170 ms), Inlining 0.000 ms, Optimization 0.194 ms, Emission 3.902 ms, Total 4.389 ms + Execution Time: 4.569 ms +(26 rows) +=== 허용 10% · topK 10 · HNSW 강제 iterative relaxed (허용 문서 356건, LIMIT 40) + Limit (cost=2541.97..208868.07 rows=1 width=1039) (actual time=4.017..40.570 rows=40 loops=1) + Buffers: shared hit=2285 read=4192 + -> Nested Loop (cost=2541.97..208868.07 rows=1 width=1039) (actual time=0.148..36.698 rows=40 loops=1) + Buffers: shared hit=2285 read=4192 + -> Nested Loop (cost=2541.68..208864.73 rows=1 width=117) (actual time=0.142..36.502 rows=40 loops=1) + Buffers: shared hit=1988 read=4192 + -> Index Scan using embeddings_vector_idx on embeddings e (cost=2541.40..206532.11 rows=5186 width=50) (actual time=0.138..36.395 rows=40 loops=1) + Order By: (vector <=> '<질문 벡터 1024차원>') + Filter: ((embedding_model_id = '1'::bigint) AND ((status)::text = 'ACTIVE'::text) AND (document_id = ANY ('<허용 문서 ID 목록>'::bigint[]))) + Rows Removed by Filter: 775 + Buffers: shared hit=1873 read=4187 + -> Index Scan using idx_documents_current_version_id on documents d (cost=0.28..0.44 rows=1 width=91) (actual time=0.002..0.002 rows=1 loops=40) + Index Cond: (current_version_id = e.document_version_id) + Filter: ((deleted_at IS NULL) AND ((status)::text = 'INDEXED'::text) AND (id = e.document_id)) + Buffers: shared hit=115 read=5 + -> Index Scan using document_chunks_pkey on document_chunks dc (cost=0.29..3.33 rows=1 width=940) (actual time=0.001..0.001 rows=1 loops=40) + Index Cond: (id = e.chunk_id) + Buffers: shared hit=120 + Planning: + Buffers: shared hit=37 + Planning Time: 0.450 ms + JIT: + Functions: 19 + Options: Inlining false, Optimization false, Expressions true, Deforming true + Timing: Generation 0.290 ms (Deform 0.155 ms), Inlining 0.000 ms, Optimization 0.188 ms, Emission 3.685 ms, Total 4.163 ms + Execution Time: 40.913 ms +(26 rows) +=== auto 모드 반복 1회째 · 허용 100% · topK 10 + Limit (cost=2083.56..2083.59 rows=9 width=1039) (actual time=140.933..140.938 rows=40 loops=1) + -> Sort (cost=2083.56..2083.59 rows=9 width=1039) (actual time=140.932..140.936 rows=40 loops=1) + Sort Key: ((e.vector <=> '<질문 벡터 1024차원>')) + Sort Method: top-N heapsort Memory: 100kB + -> Nested Loop (cost=254.45..2083.42 rows=9 width=1039) (actual time=0.471..134.728 rows=50008 loops=1) + -> Hash Join (cost=254.16..2072.58 rows=9 width=117) (actual time=0.465..11.812 rows=50008 loops=1) + Hash Cond: ((e.document_id = d.id) AND (e.document_version_id = d.current_version_id)) + -> Index Scan using idx_embeddings_document_id on embeddings e (cost=0.29..1651.20 rows=31908 width=50) (actual time=0.027..6.802 rows=50008 loops=1) + Index Cond: (document_id = ANY ('<허용 문서 ID 목록>'::bigint[])) + Filter: ((embedding_model_id = '1'::bigint) AND ((status)::text = 'ACTIVE'::text)) + -> Hash (cost=200.49..200.49 rows=3559 width=91) (actual time=0.435..0.435 rows=3559 loops=1) + Buckets: 4096 Batches: 1 Memory Usage: 473kB + -> Seq Scan on documents d (cost=0.00..200.49 rows=3559 width=91) (actual time=0.004..0.260 rows=3559 loops=1) + Filter: ((deleted_at IS NULL) AND ((status)::text = 'INDEXED'::text)) + -> Index Scan using document_chunks_pkey on document_chunks dc (cost=0.29..1.20 rows=1 width=940) (actual time=0.000..0.000 rows=1 loops=50008) + Index Cond: (id = e.chunk_id) + Planning Time: 3.004 ms + Execution Time: 140.956 ms +(18 rows) +=== auto 모드 반복 2회째 · 허용 100% · topK 10 + Limit (cost=2083.56..2083.59 rows=9 width=1039) (actual time=140.531..140.537 rows=40 loops=1) + -> Sort (cost=2083.56..2083.59 rows=9 width=1039) (actual time=140.531..140.535 rows=40 loops=1) + Sort Key: ((e.vector <=> '<질문 벡터 1024차원>')) + Sort Method: top-N heapsort Memory: 100kB + -> Nested Loop (cost=254.45..2083.42 rows=9 width=1039) (actual time=0.613..134.443 rows=50008 loops=1) + -> Hash Join (cost=254.16..2072.58 rows=9 width=117) (actual time=0.603..11.844 rows=50008 loops=1) + Hash Cond: ((e.document_id = d.id) AND (e.document_version_id = d.current_version_id)) + -> Index Scan using idx_embeddings_document_id on embeddings e (cost=0.29..1651.20 rows=31908 width=50) (actual time=0.025..6.725 rows=50008 loops=1) + Index Cond: (document_id = ANY ('<허용 문서 ID 목록>'::bigint[])) + Filter: ((embedding_model_id = '1'::bigint) AND ((status)::text = 'ACTIVE'::text)) + -> Hash (cost=200.49..200.49 rows=3559 width=91) (actual time=0.576..0.577 rows=3559 loops=1) + Buckets: 4096 Batches: 1 Memory Usage: 473kB + -> Seq Scan on documents d (cost=0.00..200.49 rows=3559 width=91) (actual time=0.006..0.400 rows=3559 loops=1) + Filter: ((deleted_at IS NULL) AND ((status)::text = 'INDEXED'::text)) + -> Index Scan using document_chunks_pkey on document_chunks dc (cost=0.29..1.20 rows=1 width=940) (actual time=0.000..0.000 rows=1 loops=50008) + Index Cond: (id = e.chunk_id) + Planning Time: 2.758 ms + Execution Time: 140.553 ms +(18 rows) +=== auto 모드 반복 3회째 · 허용 100% · topK 10 + Limit (cost=2083.56..2083.59 rows=9 width=1039) (actual time=140.392..140.398 rows=40 loops=1) + -> Sort (cost=2083.56..2083.59 rows=9 width=1039) (actual time=140.392..140.395 rows=40 loops=1) + Sort Key: ((e.vector <=> '<질문 벡터 1024차원>')) + Sort Method: top-N heapsort Memory: 100kB + -> Nested Loop (cost=254.45..2083.42 rows=9 width=1039) (actual time=0.631..134.315 rows=50008 loops=1) + -> Hash Join (cost=254.16..2072.58 rows=9 width=117) (actual time=0.620..11.824 rows=50008 loops=1) + Hash Cond: ((e.document_id = d.id) AND (e.document_version_id = d.current_version_id)) + -> Index Scan using idx_embeddings_document_id on embeddings e (cost=0.29..1651.20 rows=31908 width=50) (actual time=0.025..6.720 rows=50008 loops=1) + Index Cond: (document_id = ANY ('<허용 문서 ID 목록>'::bigint[])) + Filter: ((embedding_model_id = '1'::bigint) AND ((status)::text = 'ACTIVE'::text)) + -> Hash (cost=200.49..200.49 rows=3559 width=91) (actual time=0.594..0.594 rows=3559 loops=1) + Buckets: 4096 Batches: 1 Memory Usage: 473kB + -> Seq Scan on documents d (cost=0.00..200.49 rows=3559 width=91) (actual time=0.007..0.420 rows=3559 loops=1) + Filter: ((deleted_at IS NULL) AND ((status)::text = 'INDEXED'::text)) + -> Index Scan using document_chunks_pkey on document_chunks dc (cost=0.29..1.20 rows=1 width=940) (actual time=0.000..0.000 rows=1 loops=50008) + Index Cond: (id = e.chunk_id) + Planning Time: 2.896 ms + Execution Time: 140.413 ms +(18 rows) +=== auto 모드 반복 4회째 · 허용 100% · topK 10 + Limit (cost=2083.56..2083.59 rows=9 width=1039) (actual time=143.944..143.949 rows=40 loops=1) + -> Sort (cost=2083.56..2083.59 rows=9 width=1039) (actual time=143.943..143.947 rows=40 loops=1) + Sort Key: ((e.vector <=> '<질문 벡터 1024차원>')) + Sort Method: top-N heapsort Memory: 100kB + -> Nested Loop (cost=254.45..2083.42 rows=9 width=1039) (actual time=0.636..137.745 rows=50008 loops=1) + -> Hash Join (cost=254.16..2072.58 rows=9 width=117) (actual time=0.625..12.195 rows=50008 loops=1) + Hash Cond: ((e.document_id = d.id) AND (e.document_version_id = d.current_version_id)) + -> Index Scan using idx_embeddings_document_id on embeddings e (cost=0.29..1651.20 rows=31908 width=50) (actual time=0.026..6.897 rows=50008 loops=1) + Index Cond: (document_id = ANY ('<허용 문서 ID 목록>'::bigint[])) + Filter: ((embedding_model_id = '1'::bigint) AND ((status)::text = 'ACTIVE'::text)) + -> Hash (cost=200.49..200.49 rows=3559 width=91) (actual time=0.597..0.597 rows=3559 loops=1) + Buckets: 4096 Batches: 1 Memory Usage: 473kB + -> Seq Scan on documents d (cost=0.00..200.49 rows=3559 width=91) (actual time=0.006..0.412 rows=3559 loops=1) + Filter: ((deleted_at IS NULL) AND ((status)::text = 'INDEXED'::text)) + -> Index Scan using document_chunks_pkey on document_chunks dc (cost=0.29..1.20 rows=1 width=940) (actual time=0.001..0.001 rows=1 loops=50008) + Index Cond: (id = e.chunk_id) + Planning Time: 2.902 ms + Execution Time: 143.969 ms +(18 rows) +=== auto 모드 반복 5회째 · 허용 100% · topK 10 + Limit (cost=2083.56..2083.59 rows=9 width=1039) (actual time=141.145..141.150 rows=40 loops=1) + -> Sort (cost=2083.56..2083.59 rows=9 width=1039) (actual time=141.144..141.148 rows=40 loops=1) + Sort Key: ((e.vector <=> '<질문 벡터 1024차원>')) + Sort Method: top-N heapsort Memory: 100kB + -> Nested Loop (cost=254.45..2083.42 rows=9 width=1039) (actual time=0.605..135.018 rows=50008 loops=1) + -> Hash Join (cost=254.16..2072.58 rows=9 width=117) (actual time=0.595..11.855 rows=50008 loops=1) + Hash Cond: ((e.document_id = d.id) AND (e.document_version_id = d.current_version_id)) + -> Index Scan using idx_embeddings_document_id on embeddings e (cost=0.29..1651.20 rows=31908 width=50) (actual time=0.024..6.727 rows=50008 loops=1) + Index Cond: (document_id = ANY ('<허용 문서 ID 목록>'::bigint[])) + Filter: ((embedding_model_id = '1'::bigint) AND ((status)::text = 'ACTIVE'::text)) + -> Hash (cost=200.49..200.49 rows=3559 width=91) (actual time=0.569..0.570 rows=3559 loops=1) + Buckets: 4096 Batches: 1 Memory Usage: 473kB + -> Seq Scan on documents d (cost=0.00..200.49 rows=3559 width=91) (actual time=0.006..0.399 rows=3559 loops=1) + Filter: ((deleted_at IS NULL) AND ((status)::text = 'INDEXED'::text)) + -> Index Scan using document_chunks_pkey on document_chunks dc (cost=0.29..1.20 rows=1 width=940) (actual time=0.000..0.000 rows=1 loops=50008) + Index Cond: (id = e.chunk_id) + Planning Time: 2.825 ms + Execution Time: 141.165 ms +(18 rows) +=== auto 모드 반복 6회째 · 허용 100% · topK 10 + Limit (cost=2085.68..2085.68 rows=1 width=1039) (actual time=2114.771..2114.779 rows=40 loops=1) + -> Sort (cost=2085.68..2085.70 rows=9 width=1039) (actual time=2114.770..2114.776 rows=40 loops=1) + Sort Key: ((e.vector <=> ($1)::vector)) + Sort Method: top-N heapsort Memory: 100kB + -> Nested Loop (cost=254.45..2085.53 rows=9 width=1039) (actual time=0.871..2107.921 rows=50008 loops=1) + -> Hash Join (cost=254.16..2074.66 rows=9 width=117) (actual time=0.805..13.932 rows=50008 loops=1) + Hash Cond: ((e.document_id = d.id) AND (e.document_version_id = d.current_version_id)) + -> Index Scan using idx_embeddings_document_id on embeddings e (cost=0.29..1653.07 rows=31948 width=50) (actual time=0.055..7.709 rows=50008 loops=1) + Index Cond: (document_id = ANY (ARRAY[$3, ..., $n])) + Filter: ((embedding_model_id = $2) AND ((status)::text = 'ACTIVE'::text)) + -> Hash (cost=200.49..200.49 rows=3559 width=91) (actual time=0.748..0.749 rows=3559 loops=1) + Buckets: 4096 Batches: 1 Memory Usage: 473kB + -> Seq Scan on documents d (cost=0.00..200.49 rows=3559 width=91) (actual time=0.007..0.416 rows=3559 loops=1) + Filter: ((deleted_at IS NULL) AND ((status)::text = 'INDEXED'::text)) + -> Index Scan using document_chunks_pkey on document_chunks dc (cost=0.29..1.20 rows=1 width=940) (actual time=0.001..0.001 rows=1 loops=50008) + Index Cond: (id = e.chunk_id) + Planning Time: 2.056 ms + Execution Time: 2114.887 ms +(18 rows) +=== auto 모드 반복 7회째 · 허용 100% · topK 10 + Limit (cost=2085.68..2085.68 rows=1 width=1039) (actual time=2098.872..2098.879 rows=40 loops=1) + -> Sort (cost=2085.68..2085.70 rows=9 width=1039) (actual time=2098.871..2098.877 rows=40 loops=1) + Sort Key: ((e.vector <=> ($1)::vector)) + Sort Method: top-N heapsort Memory: 100kB + -> Nested Loop (cost=254.45..2085.53 rows=9 width=1039) (actual time=0.754..2092.132 rows=50008 loops=1) + -> Hash Join (cost=254.16..2074.66 rows=9 width=117) (actual time=0.691..13.377 rows=50008 loops=1) + Hash Cond: ((e.document_id = d.id) AND (e.document_version_id = d.current_version_id)) + -> Index Scan using idx_embeddings_document_id on embeddings e (cost=0.29..1653.07 rows=31948 width=50) (actual time=0.064..7.428 rows=50008 loops=1) + Index Cond: (document_id = ANY (ARRAY[$3, ..., $n])) + Filter: ((embedding_model_id = $2) AND ((status)::text = 'ACTIVE'::text)) + -> Hash (cost=200.49..200.49 rows=3559 width=91) (actual time=0.624..0.625 rows=3559 loops=1) + Buckets: 4096 Batches: 1 Memory Usage: 473kB + -> Seq Scan on documents d (cost=0.00..200.49 rows=3559 width=91) (actual time=0.009..0.449 rows=3559 loops=1) + Filter: ((deleted_at IS NULL) AND ((status)::text = 'INDEXED'::text)) + -> Index Scan using document_chunks_pkey on document_chunks dc (cost=0.29..1.20 rows=1 width=940) (actual time=0.001..0.001 rows=1 loops=50008) + Index Cond: (id = e.chunk_id) + Planning Time: 1.362 ms + Execution Time: 2099.007 ms +(18 rows) +=== auto 모드 반복 8회째 · 허용 100% · topK 10 + Limit (cost=2085.68..2085.68 rows=1 width=1039) (actual time=2101.027..2101.041 rows=40 loops=1) + -> Sort (cost=2085.68..2085.70 rows=9 width=1039) (actual time=2101.027..2101.039 rows=40 loops=1) + Sort Key: ((e.vector <=> ($1)::vector)) + Sort Method: top-N heapsort Memory: 100kB + -> Nested Loop (cost=254.45..2085.53 rows=9 width=1039) (actual time=0.722..2094.283 rows=50008 loops=1) + -> Hash Join (cost=254.16..2074.66 rows=9 width=117) (actual time=0.669..13.312 rows=50008 loops=1) + Hash Cond: ((e.document_id = d.id) AND (e.document_version_id = d.current_version_id)) + -> Index Scan using idx_embeddings_document_id on embeddings e (cost=0.29..1653.07 rows=31948 width=50) (actual time=0.062..7.448 rows=50008 loops=1) + Index Cond: (document_id = ANY (ARRAY[$3, ..., $n])) + Filter: ((embedding_model_id = $2) AND ((status)::text = 'ACTIVE'::text)) + -> Hash (cost=200.49..200.49 rows=3559 width=91) (actual time=0.605..0.606 rows=3559 loops=1) + Buckets: 4096 Batches: 1 Memory Usage: 473kB + -> Seq Scan on documents d (cost=0.00..200.49 rows=3559 width=91) (actual time=0.009..0.438 rows=3559 loops=1) + Filter: ((deleted_at IS NULL) AND ((status)::text = 'INDEXED'::text)) + -> Index Scan using document_chunks_pkey on document_chunks dc (cost=0.29..1.20 rows=1 width=940) (actual time=0.001..0.001 rows=1 loops=50008) + Index Cond: (id = e.chunk_id) + Planning Time: 1.184 ms + Execution Time: 2101.166 ms +(18 rows) +=== 권고안 검증 · 질문 Vector InitPlan · force_custom_plan · 허용 100% · topK 10 + Limit (cost=2083.57..2083.60 rows=9 width=1039) (actual time=142.463..142.468 rows=40 loops=1) + InitPlan 1 + -> Result (cost=0.00..0.01 rows=1 width=32) (actual time=0.006..0.006 rows=1 loops=1) + -> Sort (cost=2083.56..2083.59 rows=9 width=1039) (actual time=142.462..142.466 rows=40 loops=1) + Sort Key: ((e.vector <=> (InitPlan 1).col1)) + Sort Method: top-N heapsort Memory: 100kB + -> Nested Loop (cost=254.45..2083.42 rows=9 width=1039) (actual time=0.693..136.240 rows=50008 loops=1) + -> Hash Join (cost=254.16..2072.58 rows=9 width=117) (actual time=0.653..11.884 rows=50008 loops=1) + Hash Cond: ((e.document_id = d.id) AND (e.document_version_id = d.current_version_id)) + -> Index Scan using idx_embeddings_document_id on embeddings e (cost=0.29..1651.20 rows=31908 width=50) (actual time=0.028..6.682 rows=50008 loops=1) + Index Cond: (document_id = ANY ('<허용 문서 ID 목록>'::bigint[])) + Filter: ((embedding_model_id = '1'::bigint) AND ((status)::text = 'ACTIVE'::text)) + -> Hash (cost=200.49..200.49 rows=3559 width=91) (actual time=0.623..0.623 rows=3559 loops=1) + Buckets: 4096 Batches: 1 Memory Usage: 473kB + -> Seq Scan on documents d (cost=0.00..200.49 rows=3559 width=91) (actual time=0.007..0.429 rows=3559 loops=1) + Filter: ((deleted_at IS NULL) AND ((status)::text = 'INDEXED'::text)) + -> Index Scan using document_chunks_pkey on document_chunks dc (cost=0.29..1.20 rows=1 width=940) (actual time=0.001..0.001 rows=1 loops=50008) + Index Cond: (id = e.chunk_id) + Planning Time: 3.107 ms + Execution Time: 142.486 ms +(20 rows) +=== 권고안 검증 · 질문 Vector InitPlan · force_generic_plan · 허용 100% · topK 10 + Limit (cost=2085.65..2085.65 rows=1 width=1039) (actual time=162.628..162.634 rows=40 loops=1) + InitPlan 1 + -> Result (cost=0.00..0.01 rows=1 width=32) (actual time=0.050..0.050 rows=1 loops=1) + -> Sort (cost=2085.63..2085.66 rows=9 width=1039) (actual time=162.627..162.631 rows=40 loops=1) + Sort Key: ((e.vector <=> (InitPlan 1).col1)) + Sort Method: top-N heapsort Memory: 100kB + -> Nested Loop (cost=254.45..2085.49 rows=9 width=1039) (actual time=0.863..155.460 rows=50008 loops=1) + -> Hash Join (cost=254.16..2074.66 rows=9 width=117) (actual time=0.800..14.465 rows=50008 loops=1) + Hash Cond: ((e.document_id = d.id) AND (e.document_version_id = d.current_version_id)) + -> Index Scan using idx_embeddings_document_id on embeddings e (cost=0.29..1653.07 rows=31948 width=50) (actual time=0.078..8.206 rows=50008 loops=1) + Index Cond: (document_id = ANY (ARRAY[$3, ..., $n])) + Filter: ((embedding_model_id = $2) AND ((status)::text = 'ACTIVE'::text)) + -> Hash (cost=200.49..200.49 rows=3559 width=91) (actual time=0.713..0.713 rows=3559 loops=1) + Buckets: 4096 Batches: 1 Memory Usage: 473kB + -> Seq Scan on documents d (cost=0.00..200.49 rows=3559 width=91) (actual time=0.009..0.422 rows=3559 loops=1) + Filter: ((deleted_at IS NULL) AND ((status)::text = 'INDEXED'::text)) + -> Index Scan using document_chunks_pkey on document_chunks dc (cost=0.29..1.20 rows=1 width=940) (actual time=0.001..0.001 rows=1 loops=50008) + Index Cond: (id = e.chunk_id) + Planning Time: 2.133 ms + Execution Time: 162.764 ms +(20 rows) diff --git a/docs/test-results/evidence/issue-545/questions.json b/docs/test-results/evidence/issue-545/questions.json new file mode 100644 index 00000000..f701588e --- /dev/null +++ b/docs/test-results/evidence/issue-545/questions.json @@ -0,0 +1,35 @@ +{ + "description": "#545 권한 필터 × 벡터 검색 누락 시험용 고정 질문 30개. 예제 문서 15건(파일당 1개)과 저장소 설계 문서 15건의 실제 내용으로 만들었다. 측정 전에 고정했고 결과를 본 뒤 바꾸지 않는다. Recall은 Exact 결과를 정답으로 계산하므로 정답 청크 표시는 두지 않는다.", + "questions": [ + {"id": "D01", "source": "examples/demo-corpus/01-remote-work-policy.pdf", "question": "재택근무는 한 주에 몇 번까지 할 수 있어?"}, + {"id": "D02", "source": "examples/demo-corpus/02-business-travel-expense.pdf", "question": "출장 숙박비 상한은 얼마야?"}, + {"id": "D03", "source": "examples/demo-corpus/03-annual-leave-guide.pdf", "question": "미사용 연차는 다음 해로 이월돼?"}, + {"id": "D04", "source": "examples/demo-corpus/04-information-security.pdf", "question": "USB 같은 이동식 저장매체를 써도 돼?"}, + {"id": "D05", "source": "examples/demo-corpus/05-it-asset-management.pdf", "question": "노트북 교체 주기는 몇 년이야?"}, + {"id": "D06", "source": "examples/demo-corpus/06-family-event-support.docx", "question": "결혼하면 경조휴가를 며칠 받아?"}, + {"id": "D07", "source": "examples/demo-corpus/07-training-support.docx", "question": "교육비는 1인당 1년에 얼마까지 지원돼?"}, + {"id": "D08", "source": "examples/demo-corpus/08-corporate-card.docx", "question": "법인카드는 얼마 이상이면 사전 승인을 받아야 해?"}, + {"id": "D09", "source": "examples/demo-corpus/09-onboarding-guide.docx", "question": "신규 입사자 수습 기간은 얼마나 돼?"}, + {"id": "D10", "source": "examples/demo-corpus/10-meeting-room-booking.docx", "question": "회의실 예약하고 안 가면 어떻게 돼?"}, + {"id": "D11", "source": "examples/demo-corpus/11-records-retention.hwpx", "question": "회계 문서 보존 기간은 몇 년이야?"}, + {"id": "D12", "source": "examples/demo-corpus/12-incident-response.hwpx", "question": "장애 등급별로 최초 공지는 언제까지 해야 해?"}, + {"id": "D13", "source": "examples/demo-corpus/13-flexible-work.hwpx", "question": "선택적 근로시간제의 코어타임은 언제야?"}, + {"id": "D14", "source": "examples/demo-corpus/14-personal-data-handling.hwpx", "question": "개인정보를 대량으로 다운로드하려면 누구 승인이 필요해?"}, + {"id": "D15", "source": "examples/demo-corpus/15-club-support.hwpx", "question": "동호회 활동비는 1인당 얼마 지원돼?"}, + {"id": "R01", "source": "docs/design/gimin-#131-hnsw-exact-scale-benchmark.md", "question": "HNSW ef_search가 40일 때 데이터가 많아지면 recall이 얼마나 떨어져?"}, + {"id": "R02", "source": "docs/design/gimin-#128-bge-m3-batch-size-performance.md", "question": "BGE-M3 배치 크기는 왜 그렇게 정했어?"}, + {"id": "R03", "source": "docs/design/gimin-#147-chunk-size-overlap-quality-benchmark.md", "question": "청크 크기와 overlap은 어떤 기준으로 골랐어?"}, + {"id": "R04", "source": "docs/design/barman-wal-follow-primary.md", "question": "OpenSQL 리더가 바뀌면 Barman WAL 수신은 어떻게 전환돼?"}, + {"id": "R05", "source": "docs/design/gcs-original-continuous-preservation.md", "question": "GCS 문서 원본을 별도 버킷에 보존하는 이유가 뭐야?"}, + {"id": "R06", "source": "docs/design/gimin-#517-reconciler-consistency-check.md", "question": "정합성 점검기는 어떤 불일치를 자동으로 복구해?"}, + {"id": "R07", "source": "docs/design/Gimini-3-#90-indexing-job-lease-recovery.md", "question": "인덱싱 Job lease가 만료되면 어떻게 복구돼?"}, + {"id": "R08", "source": "docs/design/gimin-#356-stomp-connect-authentication.md", "question": "STOMP 연결할 때 JWT 인증은 어떻게 검사해?"}, + {"id": "R09", "source": "docs/design/kangcheolung-#226-role-revoke-redis-live-permission.md", "question": "역할을 회수하면 권한이 Redis 캐시에 바로 반영돼?"}, + {"id": "R10", "source": "docs/design/kangcheolung-#456-hybrid-search.md", "question": "하이브리드 검색에서 희귀 단어 가중치는 어떻게 계산해?"}, + {"id": "R11", "source": "docs/design/gimin-#216-embedding-provider-load-protection.md", "question": "임베딩 서버에 요청이 몰리면 어떻게 보호해?"}, + {"id": "R12", "source": "docs/design/kangcheolung-#286-rag-processing-timeout-sweep.md", "question": "RAG 답변이 PROCESSING 상태로 멈추는 문제는 어떻게 고쳤어?"}, + {"id": "R13", "source": "docs/design/issue-505-hwp-hwpx-parsing.md", "question": "HWPX 표 안의 텍스트도 검색돼?"}, + {"id": "R14", "source": "docs/design/opensql-permission-replica-lag.md", "question": "OpenProxy에서 권한 조회가 복제 지연 때문에 틀릴 수 있어?"}, + {"id": "R15", "source": "docs/design/kangcheolung-#96-mcp-token-auth-infra.md", "question": "MCP API 키는 어떻게 발급하고 폐기해?"} + ] +} diff --git a/docs/test-results/evidence/issue-545/results.json.gz b/docs/test-results/evidence/issue-545/results.json.gz new file mode 100644 index 00000000..2f31e7b3 Binary files /dev/null and b/docs/test-results/evidence/issue-545/results.json.gz differ diff --git a/docs/test-results/evidence/issue-545/rows.csv.gz b/docs/test-results/evidence/issue-545/rows.csv.gz new file mode 100644 index 00000000..26801a7b Binary files /dev/null and b/docs/test-results/evidence/issue-545/rows.csv.gz differ diff --git a/docs/test-results/evidence/issue-545/summary.csv b/docs/test-results/evidence/issue-545/summary.csv new file mode 100644 index 00000000..9f0bbf3c --- /dev/null +++ b/docs/test-results/evidence/issue-545/summary.csv @@ -0,0 +1,193 @@ +scale,ratio,permittedDocuments,permittedChunks,topK,candidateLimit,method,questions,sqlRowsMean,sqlRowsMin,sqlRowsMax,vectorFinalMean,vectorShortQuestions,hybridFinalMean,hybridShortQuestions,exactVectorFinalMean,exactHybridFinalMean,sqlRecallMean,sqlRecallMin,vectorFinalRecallMean,vectorFinalRecallMin,hybridFinalRecallMean,hybridFinalRecallMin,vectorP50Millis,vectorP95Millis,hybridP50Millis,hybridP95Millis,hnswPlanQuestions,unstableQuestions +real,1.0,251,3572,5,20,exact,30,20.0,20,20,4.933333333333334,2,4.933333333333334,2,4.933333333333334,4.933333333333334,1.0,1.0,1.0,1.0,1.0,1.0,15.530542,18.703333,98.351458,143.365083,0,0 +real,1.0,251,3572,5,20,planner_default,30,20.0,20,20,4.933333333333334,2,4.933333333333334,2,4.933333333333334,4.933333333333334,1.0,1.0,1.0,1.0,1.0,1.0,15.888708,19.100083,100.321958,150.12325,0,0 +real,1.0,251,3572,5,20,planner_default_generic,30,20.0,20,20,4.933333333333334,2,4.933333333333334,2,4.933333333333334,4.933333333333334,1.0,1.0,1.0,1.0,1.0,1.0,307.084291,374.938125,393.650167,526.828583,0,0 +real,1.0,251,3572,5,20,hnsw_ef40,30,20.0,20,20,4.933333333333334,2,4.633333333333334,5,4.933333333333334,4.933333333333334,0.855,0.0,0.8533333333333334,0.0,0.88,0.0,5.0245,7.034541,84.999958,121.9795,30,0 +real,1.0,251,3572,5,20,hnsw_ef100,30,20.0,20,20,4.933333333333334,2,4.633333333333334,5,4.933333333333334,4.933333333333334,0.91,0.1,0.8866666666666667,0.0,0.9,0.0,4.917209,6.215958,86.288959,122.918375,30,0 +real,1.0,251,3572,5,20,hnsw_ef200,30,20.0,20,20,4.933333333333334,2,4.8,3,4.933333333333334,4.933333333333334,0.9683333333333334,0.15,0.9600000000000001,0.0,0.9666666666666667,0.2,5.529208,7.174625,86.566583,124.131833,30,0 +real,1.0,251,3572,5,20,iterative_relaxed,30,20.0,20,20,4.933333333333334,2,4.633333333333334,5,4.933333333333334,4.933333333333334,0.855,0.0,0.8533333333333334,0.0,0.88,0.0,5.047292,6.941458,87.871833,122.902834,30,0 +real,1.0,251,3572,5,20,iterative_strict,30,20.0,20,20,4.933333333333334,2,4.633333333333334,5,4.933333333333334,4.933333333333334,0.855,0.0,0.8533333333333334,0.0,0.88,0.0,4.722917,6.264333,83.850541,125.883875,30,0 +real,1.0,251,3572,10,40,exact,30,40.0,40,40,10.0,0,9.866666666666667,3,10.0,9.866666666666667,1.0,1.0,1.0,1.0,1.0,1.0,18.007166,23.836417,100.576334,146.492875,0,0 +real,1.0,251,3572,10,40,planner_default,30,40.0,40,40,10.0,0,9.866666666666667,3,10.0,9.866666666666667,1.0,1.0,1.0,1.0,1.0,1.0,17.767666,23.87325,102.074458,148.92425,0,0 +real,1.0,251,3572,10,40,planner_default_generic,30,40.0,40,40,10.0,0,9.866666666666667,3,10.0,9.866666666666667,1.0,1.0,1.0,1.0,1.0,1.0,300.136791,306.263458,382.709917,427.960375,0,0 +real,1.0,251,3572,10,40,hnsw_ef40,30,40.0,40,40,10.0,0,9.133333333333333,4,10.0,9.866666666666667,0.8625,0.325,0.8200000000000001,0.0,0.8482407407407407,0.1,6.414167,8.37375,87.523375,127.447834,30,0 +real,1.0,251,3572,10,40,hnsw_ef100,30,40.0,40,40,10.0,0,9.166666666666666,4,10.0,9.866666666666667,0.9333333333333333,0.4,0.8866666666666667,0.0,0.898611111111111,0.1,6.938541,8.802208,90.49525,126.522125,30,0 +real,1.0,251,3572,10,40,hnsw_ef200,30,40.0,40,40,10.0,0,9.633333333333333,3,10.0,9.866666666666667,0.975,0.4,0.9600000000000001,0.0,0.9674999999999999,0.125,7.064458,9.009791,89.090125,125.848417,30,0 +real,1.0,251,3572,10,40,iterative_relaxed,30,40.0,40,40,10.0,0,9.133333333333333,4,10.0,9.866666666666667,0.8625,0.325,0.8200000000000001,0.0,0.8482407407407407,0.1,6.34025,8.148875,88.15375,127.027833,30,0 +real,1.0,251,3572,10,40,iterative_strict,30,40.0,40,40,10.0,0,9.133333333333333,4,10.0,9.866666666666667,0.8625,0.325,0.8200000000000001,0.0,0.8482407407407407,0.1,6.412625,7.64975,86.878209,124.161583,30,0 +real,1.0,251,3572,20,80,exact,30,80.0,80,80,20.0,0,18.633333333333333,4,20.0,18.633333333333333,1.0,1.0,1.0,1.0,1.0,1.0,20.296375,22.67725,102.272375,140.76575,0,0 +real,1.0,251,3572,20,80,planner_default,30,80.0,80,80,20.0,0,18.633333333333333,4,20.0,18.633333333333333,1.0,1.0,1.0,1.0,1.0,1.0,20.463083,22.902209,103.484708,141.822459,0,0 +real,1.0,251,3572,20,80,planner_default_generic,30,80.0,80,80,20.0,0,18.633333333333333,4,20.0,18.633333333333333,1.0,1.0,1.0,1.0,1.0,1.0,301.167209,312.088917,383.5855,421.849834,0,0 +real,1.0,251,3572,20,80,hnsw_ef40,30,40.0,40,40,18.566666666666666,10,16.833333333333332,11,20.0,18.633333333333333,0.49875,0.475,0.7683333333333333,0.15,0.7467022792022793,0.125,6.550584,7.947583,87.7295,126.072833,30,0 +real,1.0,251,3572,20,80,hnsw_ef100,30,80.0,80,80,20.0,0,17.833333333333332,4,20.0,18.633333333333333,0.9270833333333334,0.5375,0.9166666666666666,0.3,0.8987393162393162,0.125,10.574333,12.606166,93.095667,140.151417,30,0 +real,1.0,251,3572,20,80,hnsw_ef200,30,80.0,80,80,20.0,0,18.4,4,20.0,18.633333333333333,0.9783333333333334,0.6,0.9733333333333333,0.35,0.9674999999999999,0.125,11.299834,13.188292,93.14675,133.986625,30,0 +real,1.0,251,3572,20,80,iterative_relaxed,30,80.0,80,80,20.0,0,17.8,4,20.0,18.633333333333333,0.9070833333333332,0.5375,0.8333333333333334,0.15,0.8867022792022792,0.125,10.354,14.158834,94.839041,132.157709,30,0 +real,1.0,251,3572,20,80,iterative_strict,30,80.0,80,80,20.0,0,17.8,4,20.0,18.633333333333333,0.865,0.475,0.8266666666666667,0.15,0.8367022792022792,0.125,10.608833,12.126916,93.46475,130.819792,30,0 +real,0.1,25,301,5,20,exact,30,20.0,20,20,4.833333333333333,2,4.266666666666667,11,4.833333333333333,4.266666666666667,1.0,1.0,1.0,1.0,1.0,1.0,3.998375,5.705208,12.766542,17.956959,0,0 +real,0.1,25,301,5,20,planner_default,30,20.0,20,20,4.833333333333333,2,4.266666666666667,11,4.833333333333333,4.266666666666667,1.0,1.0,1.0,1.0,1.0,1.0,4.552667,6.3665,14.228292,19.266458,0,0 +real,0.1,25,301,5,20,planner_default_generic,30,20.0,20,20,4.833333333333333,2,4.266666666666667,11,4.833333333333333,4.266666666666667,1.0,1.0,1.0,1.0,1.0,1.0,28.856917,30.807833,38.145667,43.450708,0,0 +real,0.1,25,301,5,20,hnsw_ef40,30,4.366666666666666,0,20,2.3333333333333335,26,3.1,21,4.833333333333333,4.266666666666667,0.21833333333333332,0.0,0.44222222222222224,0.0,0.625,0.0,1.96,4.6015,10.792041,18.465708,30,0 +real,0.1,25,301,5,20,hnsw_ef100,30,9.2,1,20,4.1,11,3.8666666666666667,15,4.833333333333333,4.266666666666667,0.4533333333333333,0.05,0.8,0.2,0.8672222222222222,0.4,3.232875,5.138667,12.280458,16.251291,30,0 +real,0.1,25,301,5,20,hnsw_ef200,30,15.6,8,20,4.7,4,4.1,13,4.833333333333333,4.266666666666667,0.7716666666666666,0.4,0.9533333333333334,0.4,0.9433333333333334,0.4,4.052541,6.572375,12.984333,19.495541,30,0 +real,0.1,25,301,5,20,iterative_relaxed,30,20.0,20,20,4.8,3,4.166666666666667,12,4.833333333333333,4.266666666666667,0.9616666666666667,0.65,0.9133333333333333,0.4,0.9533333333333334,0.5,4.365625,5.577458,12.85575,17.096583,30,0 +real,0.1,25,301,5,20,iterative_strict,30,20.0,20,20,4.8,3,4.066666666666666,12,4.833333333333333,4.266666666666667,0.8983333333333333,0.3,0.86,0.4,0.8694444444444444,0.5,5.376667,7.034208,15.072042,20.592042,30,0 +real,0.1,25,301,10,40,exact,30,40.0,40,40,10.0,0,7.6,12,10.0,7.6,1.0,1.0,1.0,1.0,1.0,1.0,5.514958,6.808917,14.814333,21.191042,0,0 +real,0.1,25,301,10,40,planner_default,30,40.0,40,40,10.0,0,7.6,12,10.0,7.6,1.0,1.0,1.0,1.0,1.0,1.0,5.570958,7.179166,15.340208,21.618,0,0 +real,0.1,25,301,10,40,planner_default_generic,30,40.0,40,40,10.0,0,7.6,12,10.0,7.6,1.0,1.0,1.0,1.0,1.0,1.0,31.789375,33.457834,41.730209,46.3735,0,0 +real,0.1,25,301,10,40,hnsw_ef40,30,4.366666666666666,0,20,2.466666666666667,30,3.8,28,10.0,7.6,0.10916666666666666,0.0,0.2366666666666667,0.0,0.47361111111111115,0.0,2.235291,4.414792,10.95,15.896167,30,0 +real,0.1,25,301,10,40,hnsw_ef100,30,9.433333333333334,1,25,5.666666666666667,25,5.433333333333334,25,10.0,7.6,0.23583333333333334,0.025,0.55,0.1,0.7005555555555555,0.2,3.109917,5.181,11.999667,17.42975,30,0 +real,0.1,25,301,10,40,hnsw_ef200,30,17.8,8,33,8.566666666666666,14,6.633333333333334,18,10.0,7.6,0.445,0.2,0.8433333333333334,0.2,0.85,0.2,4.555125,6.427833,14.010084,18.450833,30,0 +real,0.1,25,301,10,40,iterative_relaxed,30,40.0,40,40,10.0,0,7.6,12,10.0,7.6,0.9941666666666666,0.95,0.94,0.6,0.9933333333333334,0.9,6.779458,8.628833,15.289,22.471292,30,0 +real,0.1,25,301,10,40,iterative_strict,30,40.0,40,40,10.0,0,7.266666666666667,14,10.0,7.6,0.9341666666666666,0.625,0.85,0.3,0.8544444444444445,0.5,7.28375,10.88925,16.825959,24.136958,30,0 +real,0.1,25,301,20,80,exact,30,80.0,80,80,20.0,0,12.3,19,20.0,12.3,1.0,1.0,1.0,1.0,1.0,1.0,4.998833,6.189458,14.170042,21.003334,0,0 +real,0.1,25,301,20,80,planner_default,30,80.0,80,80,20.0,0,12.3,19,20.0,12.3,1.0,1.0,1.0,1.0,1.0,1.0,4.822333,6.82075,13.492125,21.323375,0,0 +real,0.1,25,301,20,80,planner_default_generic,30,80.0,80,80,20.0,0,12.3,19,20.0,12.3,1.0,1.0,1.0,1.0,1.0,1.0,35.685417,37.947542,47.334625,56.23725,0,0 +real,0.1,25,301,20,80,hnsw_ef40,30,4.366666666666666,0,20,2.466666666666667,30,4.2,29,20.0,12.3,0.05458333333333333,0.0,0.12166666666666666,0.0,0.40590916909028363,0.0,2.2555,4.446375,10.79575,15.34675,30,0 +real,0.1,25,301,20,80,hnsw_ef100,30,9.433333333333334,1,25,5.833333333333333,30,6.033333333333333,29,20.0,12.3,0.11791666666666667,0.0125,0.2916666666666667,0.05,0.5989009287925697,0.1,3.175334,4.901584,12.44375,17.260708,30,0 +real,0.1,25,301,20,80,hnsw_ef200,30,17.8,8,33,10.333333333333334,30,8.2,29,20.0,12.3,0.2225,0.1,0.5166666666666667,0.1,0.7573321840698001,0.1,4.491375,6.241667,13.719542,19.28375,30,0 +real,0.1,25,301,20,80,iterative_relaxed,30,80.0,80,80,20.0,0,12.3,19,20.0,12.3,0.9975,0.9875,0.985,0.9,0.9983333333333333,0.95,7.820167,11.015333,17.735417,23.173708,30,0 +real,0.1,25,301,20,80,iterative_strict,30,80.0,80,80,20.0,0,11.833333333333334,19,20.0,12.3,0.9612499999999999,0.8125,0.8916666666666667,0.55,0.8764104278074867,0.5,7.702625,10.200125,16.666166,24.730375,30,0 +real,0.01,3,34,5,20,exact,30,20.0,20,20,4.933333333333334,2,2.3,24,4.933333333333334,2.3,1.0,1.0,1.0,1.0,1.0,1.0,2.674375,4.666916,5.3935,10.027667,0,0 +real,0.01,3,34,5,20,planner_default,30,20.0,20,20,4.933333333333334,2,2.3,24,4.933333333333334,2.3,1.0,1.0,1.0,1.0,1.0,1.0,3.251,5.036334,6.324584,9.005333,0,0 +real,0.01,3,34,5,20,planner_default_generic,30,20.0,20,20,4.933333333333334,2,2.3,24,4.933333333333334,2.3,1.0,1.0,1.0,1.0,1.0,1.0,5.302291,8.189292,7.227459,10.235292,0,0 +real,0.01,3,34,5,20,hnsw_ef40,30,0.4,0,2,0.4,30,1.4666666666666666,29,4.933333333333334,2.3,0.020000000000000004,0.0,0.08166666666666668,0.0,0.65,0.0,1.495709,2.533542,3.852333,5.720833,30,0 +real,0.01,3,34,5,20,hnsw_ef100,30,0.7,0,8,0.5333333333333333,30,1.5,29,4.933333333333334,2.3,0.035,0.0,0.11000000000000001,0.0,0.6727272727272727,0.0,1.723875,3.034875,3.837292,5.814834,30,0 +real,0.01,3,34,5,20,hnsw_ef200,30,1.3666666666666667,0,9,0.9666666666666667,30,1.6333333333333333,28,4.933333333333334,2.3,0.06833333333333333,0.0,0.19666666666666668,0.0,0.7545454545454546,0.0,2.193,3.745667,4.512875,6.74675,30,0 +real,0.01,3,34,5,20,iterative_relaxed,30,20.0,20,20,4.933333333333334,2,2.3,24,4.933333333333334,2.3,1.0,1.0,1.0,1.0,1.0,1.0,8.866708,11.094667,11.56,13.995292,30,0 +real,0.01,3,34,5,20,iterative_strict,30,20.0,20,20,4.933333333333334,2,2.2333333333333334,24,4.933333333333334,2.3,0.9883333333333333,0.95,0.9533333333333334,0.8,0.9568181818181819,0.5,9.148166,10.713791,12.092334,15.070458,30,0 +real,0.01,3,34,10,40,exact,30,34.0,34,34,6.0,30,2.4,30,6.0,2.4,1.0,1.0,1.0,1.0,1.0,1.0,3.631709,4.829958,6.717292,9.662042,0,0 +real,0.01,3,34,10,40,planner_default,30,34.0,34,34,6.0,30,2.4,30,6.0,2.4,1.0,1.0,1.0,1.0,1.0,1.0,3.397667,5.048417,5.610334,9.267458,0,0 +real,0.01,3,34,10,40,planner_default_generic,30,34.0,34,34,6.0,30,2.4,30,6.0,2.4,1.0,1.0,1.0,1.0,1.0,1.0,7.103792,10.485208,9.579041,13.541417,0,0 +real,0.01,3,34,10,40,hnsw_ef40,30,0.4,0,2,0.4,30,1.5,30,6.0,2.4,0.01176470588235294,0.0,0.06666666666666667,0.0,0.6439393939393939,0.0,1.698917,2.712958,4.189208,6.932875,30,0 +real,0.01,3,34,10,40,hnsw_ef100,30,0.7,0,8,0.5333333333333333,30,1.5333333333333334,30,6.0,2.4,0.020588235294117647,0.0,0.08888888888888888,0.0,0.6666666666666666,0.0,1.841666,2.99075,4.343959,6.46275,30,0 +real,0.01,3,34,10,40,hnsw_ef200,30,1.3666666666666667,0,9,0.9666666666666667,30,1.6666666666666667,30,6.0,2.4,0.04019607843137255,0.0,0.1611111111111111,0.0,0.7454545454545454,0.0,2.265792,3.457875,4.733042,6.6945,30,0 +real,0.01,3,34,10,40,iterative_relaxed,30,34.0,34,34,6.0,30,2.4,30,6.0,2.4,1.0,1.0,1.0,1.0,1.0,1.0,13.104792,14.618541,15.830917,18.541,30,0 +real,0.01,3,34,10,40,iterative_strict,30,33.766666666666666,33,34,5.966666666666667,30,2.3333333333333335,30,6.0,2.4,0.9931372549019607,0.9705882352941176,0.961111111111111,0.8333333333333334,0.9568181818181819,0.5,12.756042,14.52775,15.903209,18.745875,30,0 +real,0.01,3,34,20,80,exact,30,34.0,34,34,6.0,30,2.4,30,6.0,2.4,1.0,1.0,1.0,1.0,1.0,1.0,3.828208,4.990208,6.957209,9.1925,0,0 +real,0.01,3,34,20,80,planner_default,30,34.0,34,34,6.0,30,2.4,30,6.0,2.4,1.0,1.0,1.0,1.0,1.0,1.0,2.350833,4.137,5.363041,8.233,0,0 +real,0.01,3,34,20,80,planner_default_generic,30,34.0,34,34,6.0,30,2.4,30,6.0,2.4,1.0,1.0,1.0,1.0,1.0,1.0,4.91125,8.219,6.681541,10.182792,0,0 +real,0.01,3,34,20,80,hnsw_ef40,30,0.4,0,2,0.4,30,1.5,30,6.0,2.4,0.01176470588235294,0.0,0.06666666666666667,0.0,0.6439393939393939,0.0,1.514917,3.258917,3.952916,5.7115,30,0 +real,0.01,3,34,20,80,hnsw_ef100,30,0.7,0,8,0.5333333333333333,30,1.5333333333333334,30,6.0,2.4,0.020588235294117647,0.0,0.08888888888888888,0.0,0.6666666666666666,0.0,1.810209,3.198667,4.161333,6.069375,30,0 +real,0.01,3,34,20,80,hnsw_ef200,30,1.3666666666666667,0,9,0.9666666666666667,30,1.6666666666666667,30,6.0,2.4,0.04019607843137255,0.0,0.1611111111111111,0.0,0.7454545454545454,0.0,2.567166,4.361334,5.078167,7.48375,30,0 +real,0.01,3,34,20,80,iterative_relaxed,30,34.0,34,34,6.0,30,2.4,30,6.0,2.4,1.0,1.0,1.0,1.0,1.0,1.0,12.065667,26.146625,14.725083,30.98025,30,0 +real,0.01,3,34,20,80,iterative_strict,30,33.766666666666666,33,34,5.966666666666667,30,2.3333333333333335,30,6.0,2.4,0.9931372549019607,0.9705882352941176,0.961111111111111,0.8333333333333334,0.9568181818181819,0.5,13.147417,15.115584,16.816916,19.193917,30,0 +real,0.001,1,10,5,20,exact,30,10.0,10,10,2.0,30,0.8666666666666667,30,2.0,0.8666666666666667,1.0,1.0,1.0,1.0,1.0,1.0,2.222792,3.304834,3.892125,6.933667,0,0 +real,0.001,1,10,5,20,planner_default,30,10.0,10,10,2.0,30,0.8666666666666667,30,2.0,0.8666666666666667,1.0,1.0,1.0,1.0,1.0,1.0,2.106,3.352875,3.736125,5.945625,0,0 +real,0.001,1,10,5,20,planner_default_generic,30,10.0,10,10,2.0,30,0.8666666666666667,30,2.0,0.8666666666666667,1.0,1.0,1.0,1.0,1.0,1.0,3.6415,4.423416,4.650667,6.082125,0,0 +real,0.001,1,10,5,20,hnsw_ef40,30,0.23333333333333334,0,2,0.23333333333333334,30,0.6,30,2.0,0.8666666666666667,0.023333333333333334,0.0,0.11666666666666667,0.0,0.7333333333333333,0.0,1.516584,3.063708,2.789958,4.846084,30,0 +real,0.001,1,10,5,20,hnsw_ef100,30,0.26666666666666666,0,2,0.26666666666666666,30,0.6,30,2.0,0.8666666666666667,0.02666666666666667,0.0,0.13333333333333333,0.0,0.7333333333333333,0.0,1.682459,2.841792,2.97775,5.557542,30,0 +real,0.001,1,10,5,20,hnsw_ef200,30,0.5333333333333333,0,3,0.4666666666666667,30,0.7,30,2.0,0.8666666666666667,0.05333333333333334,0.0,0.23333333333333334,0.0,0.8333333333333334,0.0,2.121666,3.579542,3.2825,5.514416,30,0 +real,0.001,1,10,5,20,iterative_relaxed,30,10.0,10,10,2.0,30,0.8666666666666667,30,2.0,0.8666666666666667,1.0,1.0,1.0,1.0,1.0,1.0,9.599291,11.065792,10.709541,13.544042,30,0 +real,0.001,1,10,5,20,iterative_strict,30,9.933333333333334,9,10,2.0,30,0.8666666666666667,30,2.0,0.8666666666666667,0.9933333333333334,0.9,0.9666666666666667,0.5,0.9666666666666667,0.5,9.787833,11.279917,11.09725,13.076667,30,0 +real,0.001,1,10,10,40,exact,30,10.0,10,10,2.0,30,0.8666666666666667,30,2.0,0.8666666666666667,1.0,1.0,1.0,1.0,1.0,1.0,2.171292,3.195292,3.713709,6.117708,0,0 +real,0.001,1,10,10,40,planner_default,30,10.0,10,10,2.0,30,0.8666666666666667,30,2.0,0.8666666666666667,1.0,1.0,1.0,1.0,1.0,1.0,1.845375,3.32875,3.076708,6.034125,0,0 +real,0.001,1,10,10,40,planner_default_generic,30,10.0,10,10,2.0,30,0.8666666666666667,30,2.0,0.8666666666666667,1.0,1.0,1.0,1.0,1.0,1.0,4.178583,5.495542,4.675375,7.119792,0,0 +real,0.001,1,10,10,40,hnsw_ef40,30,0.23333333333333334,0,2,0.23333333333333334,30,0.6,30,2.0,0.8666666666666667,0.023333333333333334,0.0,0.11666666666666667,0.0,0.7333333333333333,0.0,1.487333,3.02375,2.809167,4.675833,30,0 +real,0.001,1,10,10,40,hnsw_ef100,30,0.26666666666666666,0,2,0.26666666666666666,30,0.6,30,2.0,0.8666666666666667,0.02666666666666667,0.0,0.13333333333333333,0.0,0.7333333333333333,0.0,1.741,3.091666,2.837583,5.742708,30,0 +real,0.001,1,10,10,40,hnsw_ef200,30,0.5333333333333333,0,3,0.4666666666666667,30,0.7,30,2.0,0.8666666666666667,0.05333333333333334,0.0,0.23333333333333334,0.0,0.8333333333333334,0.0,2.188875,3.376709,3.421791,5.620084,30,0 +real,0.001,1,10,10,40,iterative_relaxed,30,10.0,10,10,2.0,30,0.8666666666666667,30,2.0,0.8666666666666667,1.0,1.0,1.0,1.0,1.0,1.0,9.557542,11.897708,11.122,15.490625,30,0 +real,0.001,1,10,10,40,iterative_strict,30,9.933333333333334,9,10,2.0,30,0.8666666666666667,30,2.0,0.8666666666666667,0.9933333333333334,0.9,0.9666666666666667,0.5,0.9666666666666667,0.5,9.400458,10.935333,10.861875,13.696792,30,0 +real,0.001,1,10,20,80,exact,30,10.0,10,10,2.0,30,0.8666666666666667,30,2.0,0.8666666666666667,1.0,1.0,1.0,1.0,1.0,1.0,2.153041,3.901458,3.570084,6.808333,0,0 +real,0.001,1,10,20,80,planner_default,30,10.0,10,10,2.0,30,0.8666666666666667,30,2.0,0.8666666666666667,1.0,1.0,1.0,1.0,1.0,1.0,2.057375,3.55225,3.546,6.328333,0,0 +real,0.001,1,10,20,80,planner_default_generic,30,10.0,10,10,2.0,30,0.8666666666666667,30,2.0,0.8666666666666667,1.0,1.0,1.0,1.0,1.0,1.0,2.757875,4.211083,3.717709,6.289125,0,0 +real,0.001,1,10,20,80,hnsw_ef40,30,0.23333333333333334,0,2,0.23333333333333334,30,0.6,30,2.0,0.8666666666666667,0.023333333333333334,0.0,0.11666666666666667,0.0,0.7333333333333333,0.0,1.514458,3.020625,2.965542,5.65925,30,0 +real,0.001,1,10,20,80,hnsw_ef100,30,0.26666666666666666,0,2,0.26666666666666666,30,0.6,30,2.0,0.8666666666666667,0.02666666666666667,0.0,0.13333333333333333,0.0,0.7333333333333333,0.0,1.819792,3.010917,3.0775,4.790083,30,0 +real,0.001,1,10,20,80,hnsw_ef200,30,0.5333333333333333,0,3,0.4666666666666667,30,0.7,30,2.0,0.8666666666666667,0.05333333333333334,0.0,0.23333333333333334,0.0,0.8333333333333334,0.0,2.353708,3.918542,3.844417,5.353834,30,0 +real,0.001,1,10,20,80,iterative_relaxed,30,10.0,10,10,2.0,30,0.8666666666666667,30,2.0,0.8666666666666667,1.0,1.0,1.0,1.0,1.0,1.0,9.919292,11.769959,11.236417,13.24525,30,0 +real,0.001,1,10,20,80,iterative_strict,30,9.933333333333334,9,10,2.0,30,0.8666666666666667,30,2.0,0.8666666666666667,0.9933333333333334,0.9,0.9666666666666667,0.5,0.9666666666666667,0.5,10.062084,12.234,11.460333,13.984292,30,0 +50000,1.0,3559,50008,5,20,exact,30,20.0,20,20,5.0,0,5.0,0,5.0,5.0,1.0,1.0,1.0,1.0,1.0,1.0,209.081541,258.515958,1304.960333,1848.363625,0,0 +50000,1.0,3559,50008,5,20,planner_default,30,20.0,20,20,5.0,0,5.0,0,5.0,5.0,1.0,1.0,1.0,1.0,1.0,1.0,223.17325,282.183417,1352.653208,1906.70975,0,0 +50000,1.0,3559,50008,5,20,planner_default_generic,30,20.0,20,20,5.0,0,5.0,0,5.0,5.0,1.0,1.0,1.0,1.0,1.0,1.0,4280.916292,4793.333708,5479.385958,6034.354,0,0 +50000,1.0,3559,50008,5,20,hnsw_ef40,30,20.0,20,20,5.0,0,5.0,0,5.0,5.0,0.9216666666666666,0.0,0.9133333333333333,0.0,0.9266666666666666,0.0,14.236916,18.979833,1154.444292,1682.005291,30,0 +50000,1.0,3559,50008,5,20,hnsw_ef100,30,20.0,20,20,5.0,0,5.0,0,5.0,5.0,0.975,0.85,0.9666666666666667,0.6,0.98,0.8,14.508208,17.10875,1119.739916,1665.420292,30,0 +50000,1.0,3559,50008,5,20,hnsw_ef200,30,20.0,20,20,5.0,0,5.0,0,5.0,5.0,0.98,0.85,0.9733333333333333,0.6,0.9866666666666667,0.8,14.6045,17.378834,1147.5325,1675.310292,30,0 +50000,1.0,3559,50008,5,20,iterative_relaxed,30,20.0,20,20,5.0,0,5.0,0,5.0,5.0,0.9216666666666666,0.0,0.9133333333333333,0.0,0.9266666666666666,0.0,14.035167,16.583167,1088.348,1657.876542,30,0 +50000,1.0,3559,50008,5,20,iterative_strict,30,20.0,20,20,5.0,0,5.0,0,5.0,5.0,0.9216666666666666,0.0,0.9133333333333333,0.0,0.9266666666666666,0.0,13.674083,16.110834,1088.203833,1623.530042,30,0 +50000,1.0,3559,50008,10,40,exact,30,40.0,40,40,9.933333333333334,1,10.0,0,9.933333333333334,10.0,1.0,1.0,1.0,1.0,1.0,1.0,212.874792,268.302541,1328.271875,1913.334167,0,0 +50000,1.0,3559,50008,10,40,planner_default,30,40.0,40,40,9.933333333333334,1,10.0,0,9.933333333333334,10.0,1.0,1.0,1.0,1.0,1.0,1.0,204.235,264.684083,1338.411875,1869.184042,0,0 +50000,1.0,3559,50008,10,40,planner_default_generic,30,40.0,40,40,9.933333333333334,1,10.0,0,9.933333333333334,10.0,1.0,1.0,1.0,1.0,1.0,1.0,4226.314583,4674.723458,5444.565375,6157.011625,0,0 +50000,1.0,3559,50008,10,40,hnsw_ef40,30,40.0,40,40,9.833333333333334,3,10.0,0,9.933333333333334,10.0,0.9191666666666667,0.0,0.9058333333333334,0.0,0.9233333333333333,0.0,15.741709,20.40025,1128.395083,1730.92125,30,0 +50000,1.0,3559,50008,10,40,hnsw_ef100,30,40.0,40,40,9.9,1,10.0,0,9.933333333333334,10.0,0.9741666666666667,0.875,0.9625,0.8,0.9700000000000001,0.8,16.284,18.804083,1120.31375,1629.551083,30,0 +50000,1.0,3559,50008,10,40,hnsw_ef200,30,40.0,40,40,9.9,1,10.0,0,9.933333333333334,10.0,0.9825,0.875,0.9691666666666666,0.8,0.9833333333333333,0.8,17.030875,23.662667,1137.446583,1633.041,30,0 +50000,1.0,3559,50008,10,40,iterative_relaxed,30,40.0,40,40,9.833333333333334,3,10.0,0,9.933333333333334,10.0,0.9191666666666667,0.0,0.9058333333333334,0.0,0.9233333333333333,0.0,15.935625,18.824792,1123.101583,1676.057,30,0 +50000,1.0,3559,50008,10,40,iterative_strict,30,40.0,40,40,9.833333333333334,3,10.0,0,9.933333333333334,10.0,0.9191666666666667,0.0,0.9058333333333334,0.0,0.9233333333333333,0.0,16.057542,20.988084,1101.91725,1687.79425,30,0 +50000,1.0,3559,50008,20,80,exact,30,80.0,80,80,19.1,5,20.0,0,19.1,20.0,1.0,1.0,1.0,1.0,1.0,1.0,211.061625,295.217333,1319.523083,1890.254292,0,0 +50000,1.0,3559,50008,20,80,planner_default,30,80.0,80,80,19.1,5,20.0,0,19.1,20.0,1.0,1.0,1.0,1.0,1.0,1.0,213.134459,276.680709,1332.686833,1908.47,0,0 +50000,1.0,3559,50008,20,80,planner_default_generic,30,80.0,80,80,19.1,5,20.0,0,19.1,20.0,1.0,1.0,1.0,1.0,1.0,1.0,4340.192417,4562.290375,5586.952334,6081.294542,0,0 +50000,1.0,3559,50008,20,80,hnsw_ef40,30,40.0,40,40,17.266666666666666,15,19.633333333333333,3,19.1,20.0,0.48374999999999996,0.0125,0.8455808884098358,0.0,0.865,0.0,16.05625,19.633,1132.688333,1678.640166,30,0 +50000,1.0,3559,50008,20,80,hnsw_ef100,30,80.0,80,80,19.033333333333335,5,20.0,0,19.1,20.0,0.9679166666666668,0.8375,0.9586858974358974,0.8,0.9600000000000001,0.8,18.494791,21.288459,1098.970625,1628.331834,30,0 +50000,1.0,3559,50008,20,80,hnsw_ef200,30,80.0,80,80,19.033333333333335,5,20.0,0,19.1,20.0,0.9825,0.875,0.9653525641025641,0.8,0.9683333333333334,0.8,19.148416,21.959792,1113.599584,1626.7695,30,0 +50000,1.0,3559,50008,20,80,iterative_relaxed,30,80.0,80,80,19.033333333333335,5,20.0,0,19.1,20.0,0.9450000000000001,0.5125,0.9270192307692308,0.6,0.9533333333333334,0.8,18.256083,20.321666,1094.774958,1631.103875,30,0 +50000,1.0,3559,50008,20,80,iterative_strict,30,80.0,80,80,19.033333333333335,5,20.0,0,19.1,20.0,0.91625,0.0125,0.9032692307692308,0.0,0.9149999999999999,0.0,18.529333,22.218666,1110.784166,1627.702125,30,0 +50000,0.1,356,5244,5,20,exact,30,20.0,20,20,5.0,0,5.0,0,5.0,5.0,1.0,1.0,1.0,1.0,1.0,1.0,24.714167,30.958625,153.44125,222.179333,0,0 +50000,0.1,356,5244,5,20,planner_default,30,20.0,20,20,5.0,0,5.0,0,5.0,5.0,1.0,1.0,1.0,1.0,1.0,1.0,24.842834,30.2445,151.410125,213.507792,0,0 +50000,0.1,356,5244,5,20,planner_default_generic,30,20.0,20,20,5.0,0,5.0,0,5.0,5.0,1.0,1.0,1.0,1.0,1.0,1.0,464.400625,570.493916,586.129209,800.502958,0,0 +50000,0.1,356,5244,5,20,hnsw_ef40,30,2.6333333333333333,0,12,1.7,27,3.466666666666667,17,5.0,5.0,0.13166666666666668,0.0,0.3333333333333333,0.0,0.5399999999999999,0.0,8.049958,11.958708,140.929125,203.598541,30,0 +50000,0.1,356,5244,5,20,hnsw_ef100,30,7.1,0,20,3.066666666666667,17,3.933333333333333,11,5.0,5.0,0.35500000000000004,0.0,0.6,0.0,0.7333333333333333,0.0,8.930209,13.05775,141.886958,216.120333,30,0 +50000,0.1,356,5244,5,20,hnsw_ef200,30,12.533333333333333,0,20,4.033333333333333,11,4.4,8,5.0,5.0,0.61,0.0,0.7799999999999999,0.0,0.8133333333333332,0.2,10.78575,14.956333,137.871042,210.239542,30,0 +50000,0.1,356,5244,5,20,iterative_relaxed,30,20.0,20,20,5.0,0,5.0,0,5.0,5.0,0.9149999999999999,0.7,0.9066666666666666,0.4,0.9533333333333334,0.6,12.092958,15.118416,139.802958,208.7085,30,0 +50000,0.1,356,5244,5,20,iterative_strict,30,20.0,20,20,5.0,0,5.0,0,5.0,5.0,0.8083333333333333,0.0,0.78,0.0,0.8333333333333334,0.4,12.690542,17.190958,143.945667,229.016583,30,0 +50000,0.1,356,5244,10,40,exact,30,40.0,40,40,10.0,0,10.0,0,10.0,10.0,1.0,1.0,1.0,1.0,1.0,1.0,27.656291,40.099125,160.693416,250.182042,0,0 +50000,0.1,356,5244,10,40,planner_default,30,40.0,40,40,10.0,0,10.0,0,10.0,10.0,1.0,1.0,1.0,1.0,1.0,1.0,28.115583,37.808792,164.114416,241.357042,0,0 +50000,0.1,356,5244,10,40,planner_default_generic,30,40.0,40,40,10.0,0,10.0,0,10.0,10.0,1.0,1.0,1.0,1.0,1.0,1.0,480.472042,597.380541,609.368833,777.374083,0,0 +50000,0.1,356,5244,10,40,hnsw_ef40,30,2.6333333333333333,0,12,1.7666666666666666,30,5.1,22,10.0,10.0,0.06583333333333334,0.0,0.17666666666666667,0.0,0.35333333333333333,0.0,8.078541,14.552875,132.9595,197.1145,30,0 +50000,0.1,356,5244,10,40,hnsw_ef100,30,7.266666666666667,0,25,3.6,30,6.1,21,10.0,10.0,0.18166666666666667,0.0,0.36000000000000004,0.0,0.49333333333333335,0.0,9.303875,13.1655,137.244667,221.512042,30,0 +50000,0.1,356,5244,10,40,hnsw_ef200,30,14.333333333333334,0,38,5.966666666666667,23,7.2,16,10.0,10.0,0.35833333333333334,0.0,0.5733333333333334,0.0,0.6366666666666667,0.1,10.647875,14.777083,138.325333,198.641542,30,0 +50000,0.1,356,5244,10,40,iterative_relaxed,30,40.0,40,40,10.0,0,10.0,0,10.0,10.0,0.9116666666666667,0.75,0.8666666666666667,0.6,0.9433333333333334,0.8,15.837292,23.2545,151.478292,215.356125,30,0 +50000,0.1,356,5244,10,40,iterative_strict,30,40.0,40,40,10.0,0,10.0,0,10.0,10.0,0.7758333333333333,0.375,0.7233333333333333,0.0,0.7733333333333333,0.3,16.081042,23.687333,147.873208,212.223875,30,0 +50000,0.1,356,5244,20,80,exact,30,80.0,80,80,20.0,0,20.0,0,20.0,20.0,1.0,1.0,1.0,1.0,1.0,1.0,29.974584,34.680916,157.272209,219.341333,0,0 +50000,0.1,356,5244,20,80,planner_default,30,80.0,80,80,20.0,0,20.0,0,20.0,20.0,1.0,1.0,1.0,1.0,1.0,1.0,30.78725,36.18425,157.770167,217.077209,0,0 +50000,0.1,356,5244,20,80,planner_default_generic,30,80.0,80,80,20.0,0,20.0,0,20.0,20.0,1.0,1.0,1.0,1.0,1.0,1.0,474.088791,561.39025,614.371708,692.685167,0,0 +50000,0.1,356,5244,20,80,hnsw_ef40,30,2.6333333333333333,0,12,1.7666666666666666,30,6.6,26,20.0,20.0,0.03291666666666667,0.0,0.08833333333333333,0.0,0.22333333333333333,0.0,7.491875,8.858167,132.12125,192.23575,30,0 +50000,0.1,356,5244,20,80,hnsw_ef100,30,7.266666666666667,0,25,3.6,30,7.9,26,20.0,20.0,0.09083333333333334,0.0,0.18000000000000002,0.0,0.3016666666666667,0.0,8.362458,10.718833,131.951,196.909084,30,0 +50000,0.1,356,5244,20,80,hnsw_ef200,30,14.333333333333334,0,38,6.3,30,9.633333333333333,25,20.0,20.0,0.17916666666666667,0.0,0.31333333333333335,0.0,0.4066666666666666,0.05,10.107625,13.147375,135.49625,202.151,30,0 +50000,0.1,356,5244,20,80,iterative_relaxed,30,80.0,80,80,20.0,0,20.0,0,20.0,20.0,0.9450000000000001,0.8125,0.8583333333333333,0.65,0.9666666666666667,0.9,22.514708,27.124458,151.860041,219.177959,30,0 +50000,0.1,356,5244,20,80,iterative_strict,30,80.0,80,80,20.0,0,19.866666666666667,1,20.0,20.0,0.795,0.55,0.6716666666666666,0.3,0.7316666666666667,0.35,23.427167,31.773708,150.53775,230.899208,30,0 +50000,0.01,36,434,5,20,exact,30,20.0,20,20,4.933333333333334,1,4.9,2,4.933333333333334,4.9,1.0,1.0,1.0,1.0,1.0,1.0,5.593083,7.669792,18.938291,26.03825,0,0 +50000,0.01,36,434,5,20,planner_default,30,20.0,20,20,4.933333333333334,1,4.9,2,4.933333333333334,4.9,1.0,1.0,1.0,1.0,1.0,1.0,5.251625,7.395208,19.088666,24.803,0,0 +50000,0.01,36,434,5,20,planner_default_generic,30,20.0,20,20,4.933333333333334,1,4.9,2,4.933333333333334,4.9,1.0,1.0,1.0,1.0,1.0,1.0,40.804958,44.090208,52.70575,57.577208,0,0 +50000,0.01,36,434,5,20,hnsw_ef40,30,0.23333333333333334,0,4,0.16666666666666666,30,2.5,21,4.933333333333334,4.9,0.011666666666666667,0.0,0.042222222222222223,0.0,0.33722222222222226,0.0,6.617791,8.155125,19.020791,27.098917,30,0 +50000,0.01,36,434,5,20,hnsw_ef100,30,0.8333333333333334,0,10,0.43333333333333335,30,2.6,21,4.933333333333334,4.9,0.041666666666666664,0.0,0.1,0.0,0.36833333333333335,0.0,7.085125,8.373708,19.496,26.148667,30,0 +50000,0.01,36,434,5,20,hnsw_ef200,30,1.7,0,20,0.6,30,2.7,19,4.933333333333334,4.9,0.08333333333333333,0.0,0.13333333333333333,0.0,0.38833333333333336,0.0,7.436083,9.613292,19.94875,26.426042,30,0 +50000,0.01,36,434,5,20,iterative_relaxed,30,20.0,20,20,4.933333333333334,1,4.9,2,4.933333333333334,4.9,0.94,0.75,0.8666666666666667,0.6,0.9533333333333334,0.6,41.656292,68.46075,51.65825,82.875625,30,0 +50000,0.01,36,434,5,20,iterative_strict,30,20.0,20,20,4.933333333333334,1,4.866666666666666,3,4.933333333333334,4.9,0.8316666666666667,0.45,0.7066666666666667,0.0,0.805,0.2,41.736208,58.7895,53.811458,73.3225,30,0 +50000,0.01,36,434,10,40,exact,30,40.0,40,40,9.966666666666667,1,9.366666666666667,6,9.966666666666667,9.366666666666667,1.0,1.0,1.0,1.0,1.0,1.0,7.2575,9.25225,21.075,28.108666,0,0 +50000,0.01,36,434,10,40,planner_default,30,40.0,40,40,9.966666666666667,1,9.366666666666667,6,9.966666666666667,9.366666666666667,1.0,1.0,1.0,1.0,1.0,1.0,7.263167,11.905542,21.702208,37.675208,0,0 +50000,0.01,36,434,10,40,planner_default_generic,30,40.0,40,40,9.966666666666667,1,9.366666666666667,6,9.966666666666667,9.366666666666667,1.0,1.0,1.0,1.0,1.0,1.0,43.209042,48.96925,56.310625,65.478125,0,0 +50000,0.01,36,434,10,40,hnsw_ef40,30,0.23333333333333334,0,4,0.16666666666666666,30,3.1666666666666665,28,9.966666666666667,9.366666666666667,0.005833333333333334,0.0,0.01740740740740741,0.0,0.2439814814814815,0.0,6.832375,10.94925,19.912833,29.673834,30,0 +50000,0.01,36,434,10,40,hnsw_ef100,30,0.8333333333333334,0,10,0.43333333333333335,30,3.3,28,9.966666666666667,9.366666666666667,0.020833333333333332,0.0,0.044444444444444446,0.0,0.2576851851851852,0.0,7.053792,9.429417,19.413792,27.388208,30,0 +50000,0.01,36,434,10,40,hnsw_ef200,30,1.8666666666666667,0,25,0.6,30,3.4,28,9.966666666666667,9.366666666666667,0.04666666666666666,0.0,0.061111111111111116,0.0,0.2676851851851852,0.0,8.10825,12.585208,20.928708,31.438,30,0 +50000,0.01,36,434,10,40,iterative_relaxed,30,40.0,40,40,9.966666666666667,1,9.366666666666667,6,9.966666666666667,9.366666666666667,0.9308333333333334,0.7,0.94,0.8,0.9666666666666667,0.8,48.992167,73.208959,64.468291,85.159167,30,0 +50000,0.01,36,434,10,40,iterative_strict,30,40.0,40,40,9.933333333333334,1,9.133333333333333,6,9.966666666666667,9.366666666666667,0.85,0.7,0.7592592592592593,0.3,0.814537037037037,0.5,48.270459,55.367625,62.575625,70.961334,30,0 +50000,0.01,36,434,20,80,exact,30,80.0,80,80,20.0,0,17.333333333333332,7,20.0,17.333333333333332,1.0,1.0,1.0,1.0,1.0,1.0,10.253167,14.148334,26.039125,38.648291,0,0 +50000,0.01,36,434,20,80,planner_default,30,80.0,80,80,20.0,0,17.333333333333332,7,20.0,17.333333333333332,1.0,1.0,1.0,1.0,1.0,1.0,7.7645,11.456583,23.02975,31.691709,0,0 +50000,0.01,36,434,20,80,planner_default_generic,30,80.0,80,80,20.0,0,17.333333333333332,7,20.0,17.333333333333332,1.0,1.0,1.0,1.0,1.0,1.0,46.787542,52.707167,59.863042,66.559916,0,0 +50000,0.01,36,434,20,80,hnsw_ef40,30,0.23333333333333334,0,4,0.16666666666666666,30,3.533333333333333,29,20.0,17.333333333333332,0.002916666666666667,0.0,0.008333333333333333,0.0,0.20535304020598139,0.0,6.913,8.261584,19.78225,24.835667,30,0 +50000,0.01,36,434,20,80,hnsw_ef100,30,0.8333333333333334,0,10,0.43333333333333335,30,3.6666666666666665,29,20.0,17.333333333333332,0.010416666666666666,0.0,0.021666666666666667,0.0,0.21201970687264807,0.0,7.250416,8.92525,20.306084,26.594583,30,0 +50000,0.01,36,434,20,80,hnsw_ef200,30,1.8666666666666667,0,25,0.6,30,3.7666666666666666,29,20.0,17.333333333333332,0.02333333333333333,0.0,0.030000000000000002,0.0,0.21701970687264804,0.0,7.436667,11.1885,19.94075,26.666,30,0 +50000,0.01,36,434,20,80,iterative_relaxed,30,80.0,80,80,20.0,0,17.266666666666666,7,20.0,17.333333333333332,0.8520833333333333,0.5125,0.9083333333333333,0.7,0.9274509803921569,0.75,56.124167,70.719291,72.862291,84.718333,30,0 +50000,0.01,36,434,20,80,iterative_strict,30,80.0,80,80,20.0,0,17.066666666666666,7,20.0,17.333333333333332,0.81,0.5125,0.7916666666666666,0.6,0.8177376708259061,0.65,57.260666,72.858541,73.847625,88.272792,30,0 +50000,0.001,4,40,5,20,exact,30,20.0,20,20,4.966666666666667,1,4.033333333333333,12,4.966666666666667,4.033333333333333,1.0,1.0,1.0,1.0,1.0,1.0,3.923375,5.384709,8.466542,12.156458,0,0 +50000,0.001,4,40,5,20,planner_default,30,20.0,20,20,4.966666666666667,1,4.033333333333333,12,4.966666666666667,4.033333333333333,1.0,1.0,1.0,1.0,1.0,1.0,3.864167,5.080917,8.133333,11.453042,0,0 +50000,0.001,4,40,5,20,planner_default_generic,30,20.0,20,20,4.966666666666667,1,4.033333333333333,12,4.966666666666667,4.033333333333333,1.0,1.0,1.0,1.0,1.0,1.0,6.93875,7.940291,9.884875,12.459084,0,0 +50000,0.001,4,40,5,20,hnsw_ef40,30,0.0,0,0,0.0,30,1.4333333333333333,29,4.966666666666667,4.033333333333333,0.0,0.0,0.0,0.0,0.3761111111111111,0.0,6.229875,7.70425,10.470167,13.443625,30,0 +50000,0.001,4,40,5,20,hnsw_ef100,30,0.03333333333333333,0,1,0.03333333333333333,30,1.4333333333333333,29,4.966666666666667,4.033333333333333,0.0016666666666666668,0.0,0.006666666666666667,0.0,0.3761111111111111,0.0,6.189542,7.900166,10.467875,13.491292,30,0 +50000,0.001,4,40,5,20,hnsw_ef200,30,0.03333333333333333,0,1,0.03333333333333333,30,1.4333333333333333,29,4.966666666666667,4.033333333333333,0.0016666666666666668,0.0,0.006666666666666667,0.0,0.3761111111111111,0.0,6.643041,8.034417,10.915125,13.652584,30,0 +50000,0.001,4,40,5,20,iterative_relaxed,30,15.9,10,20,4.966666666666667,1,3.966666666666667,14,4.966666666666667,4.033333333333333,0.65,0.35,0.8666666666666667,0.6,0.9316666666666666,0.75,49.534791,78.147583,55.116917,87.850292,30,0 +50000,0.001,4,40,5,20,iterative_strict,30,15.533333333333333,10,20,4.966666666666667,1,3.8666666666666667,14,4.966666666666667,4.033333333333333,0.615,0.35,0.7533333333333334,0.6,0.7894444444444445,0.0,48.003584,53.490416,53.884416,61.700083,30,0 +50000,0.001,4,40,10,40,exact,30,40.0,40,40,7.9,30,5.066666666666666,30,7.9,5.066666666666666,1.0,1.0,1.0,1.0,1.0,1.0,4.287959,5.940875,9.445625,13.776458,0,0 +50000,0.001,4,40,10,40,planner_default,30,40.0,40,40,7.9,30,5.066666666666666,30,7.9,5.066666666666666,1.0,1.0,1.0,1.0,1.0,1.0,3.591541,5.494375,9.066,12.006334,0,0 +50000,0.001,4,40,10,40,planner_default_generic,30,40.0,40,40,7.9,30,5.066666666666666,30,7.9,5.066666666666666,1.0,1.0,1.0,1.0,1.0,1.0,7.866542,8.883167,10.896542,12.463625,0,0 +50000,0.001,4,40,10,40,hnsw_ef40,30,0.0,0,0,0.0,30,1.4666666666666666,30,7.9,5.066666666666666,0.0,0.0,0.0,0.0,0.34460317460317463,0.0,5.998708,7.170625,10.268458,12.913209,30,0 +50000,0.001,4,40,10,40,hnsw_ef100,30,0.03333333333333333,0,1,0.03333333333333333,30,1.4666666666666666,30,7.9,5.066666666666666,0.0008333333333333334,0.0,0.004166666666666667,0.0,0.34460317460317463,0.0,6.18675,7.765459,10.319209,12.798791,30,0 +50000,0.001,4,40,10,40,hnsw_ef200,30,0.03333333333333333,0,1,0.03333333333333333,30,1.4666666666666666,30,7.9,5.066666666666666,0.0008333333333333334,0.0,0.004166666666666667,0.0,0.34460317460317463,0.0,6.581083,8.224041,10.647,13.14325,30,0 +50000,0.001,4,40,10,40,iterative_relaxed,30,16.533333333333335,10,27,6.733333333333333,30,4.633333333333334,30,7.9,5.066666666666666,0.41333333333333333,0.25,0.6791666666666667,0.375,0.8779365079365079,0.625,50.086042,71.956417,56.070166,84.618916,30,0 +50000,0.001,4,40,10,40,iterative_strict,30,15.833333333333334,10,25,6.733333333333333,30,4.533333333333333,30,7.9,5.066666666666666,0.3958333333333333,0.25,0.6058333333333333,0.375,0.7458333333333333,0.0,48.381333,70.138084,55.068291,66.323125,30,0 +50000,0.001,4,40,20,80,exact,30,40.0,40,40,7.9,30,5.066666666666666,30,7.9,5.066666666666666,1.0,1.0,1.0,1.0,1.0,1.0,4.121875,6.11725,9.324708,12.957333,0,0 +50000,0.001,4,40,20,80,planner_default,30,40.0,40,40,7.9,30,5.066666666666666,30,7.9,5.066666666666666,1.0,1.0,1.0,1.0,1.0,1.0,4.509417,6.727791,9.695834,14.543167,0,0 +50000,0.001,4,40,20,80,planner_default_generic,30,40.0,40,40,7.9,30,5.066666666666666,30,7.9,5.066666666666666,1.0,1.0,1.0,1.0,1.0,1.0,8.243209,10.982375,11.282,16.613084,0,0 +50000,0.001,4,40,20,80,hnsw_ef40,30,0.0,0,0,0.0,30,1.4666666666666666,30,7.9,5.066666666666666,0.0,0.0,0.0,0.0,0.34460317460317463,0.0,6.086583,8.330875,10.65625,12.457084,30,0 +50000,0.001,4,40,20,80,hnsw_ef100,30,0.03333333333333333,0,1,0.03333333333333333,30,1.4666666666666666,30,7.9,5.066666666666666,0.0008333333333333334,0.0,0.004166666666666667,0.0,0.34460317460317463,0.0,6.254583,7.517833,10.4125,13.416167,30,0 +50000,0.001,4,40,20,80,hnsw_ef200,30,0.03333333333333333,0,1,0.03333333333333333,30,1.4666666666666666,30,7.9,5.066666666666666,0.0008333333333333334,0.0,0.004166666666666667,0.0,0.34460317460317463,0.0,6.572958,7.849167,11.088458,13.651792,30,0 +50000,0.001,4,40,20,80,iterative_relaxed,30,16.533333333333335,10,27,6.733333333333333,30,4.633333333333334,30,7.9,5.066666666666666,0.41333333333333333,0.25,0.6791666666666667,0.375,0.8779365079365079,0.625,48.980583,53.874625,54.910417,62.776709,30,0 +50000,0.001,4,40,20,80,iterative_strict,30,15.833333333333334,10,25,6.733333333333333,30,4.533333333333333,30,7.9,5.066666666666666,0.3958333333333333,0.25,0.6058333333333333,0.375,0.7458333333333333,0.0,48.364875,51.982917,54.253042,59.072,30,0 diff --git a/docs/test-results/evidence/issue-545/tables.md b/docs/test-results/evidence/issue-545/tables.md new file mode 100644 index 00000000..62435d20 --- /dev/null +++ b/docs/test-results/evidence/issue-545/tables.md @@ -0,0 +1,240 @@ +### 규모 real: 문서 251건, 청크 3,572개 (합성 비율 0.0%) + +| 허용 비율 | 허용 문서 | 허용 청크 | +|---:|---:|---:| +| 100% | 251 | 3,572 | +| 10% | 25 | 301 | +| 1% | 3 | 34 | +| 0.1% | 1 | 10 | + +#### real · topK 5 (SQL 요청 20개) + +| 허용 비율 | 방식 | HNSW 사용 | SQL 후보 평균 (최소~최대) | 벡터 최종 평균 | topK 미달 질문 | 하이브리드 최종 평균 | SQL Recall | 벡터 최종 Recall (최소) | 하이브리드 최종 Recall | 벡터 p50 / p95 ms | 하이브리드 p50 / p95 ms | +|---:|---|---:|---|---:|---:|---:|---:|---|---:|---|---| +| 100% | Exact (인덱스 없음) | 0/30 | 20.0 (20~20) | 4.9 | 2/30 | 4.9 | 1.00 | 1.00 (1.00) | 1.00 | 15.5 / 18.7 | 98.4 / 143.4 | +| 100% | 현재 계획 (Custom Plan) | 0/30 | 20.0 (20~20) | 4.9 | 2/30 | 4.9 | 1.00 | 1.00 (1.00) | 1.00 | 15.9 / 19.1 | 100.3 / 150.1 | +| 100% | 현재 계획 (Generic Plan) | 0/30 | 20.0 (20~20) | 4.9 | 2/30 | 4.9 | 1.00 | 1.00 (1.00) | 1.00 | 307.1 / 374.9 | 393.7 / 526.8 | +| 100% | HNSW 강제 ef40 | 30/30 | 20.0 (20~20) | 4.9 | 2/30 | 4.6 | 0.85 | 0.85 (0.00) | 0.88 | 5.0 / 7.0 | 85.0 / 122.0 | +| 100% | HNSW 강제 ef100 | 30/30 | 20.0 (20~20) | 4.9 | 2/30 | 4.6 | 0.91 | 0.89 (0.00) | 0.90 | 4.9 / 6.2 | 86.3 / 122.9 | +| 100% | HNSW 강제 ef200 | 30/30 | 20.0 (20~20) | 4.9 | 2/30 | 4.8 | 0.97 | 0.96 (0.00) | 0.97 | 5.5 / 7.2 | 86.6 / 124.1 | +| 100% | HNSW 강제 iterative relaxed | 30/30 | 20.0 (20~20) | 4.9 | 2/30 | 4.6 | 0.85 | 0.85 (0.00) | 0.88 | 5.0 / 6.9 | 87.9 / 122.9 | +| 100% | HNSW 강제 iterative strict | 30/30 | 20.0 (20~20) | 4.9 | 2/30 | 4.6 | 0.85 | 0.85 (0.00) | 0.88 | 4.7 / 6.3 | 83.9 / 125.9 | +| 10% | Exact (인덱스 없음) | 0/30 | 20.0 (20~20) | 4.8 | 2/30 | 4.3 | 1.00 | 1.00 (1.00) | 1.00 | 4.0 / 5.7 | 12.8 / 18.0 | +| 10% | 현재 계획 (Custom Plan) | 0/30 | 20.0 (20~20) | 4.8 | 2/30 | 4.3 | 1.00 | 1.00 (1.00) | 1.00 | 4.6 / 6.4 | 14.2 / 19.3 | +| 10% | 현재 계획 (Generic Plan) | 0/30 | 20.0 (20~20) | 4.8 | 2/30 | 4.3 | 1.00 | 1.00 (1.00) | 1.00 | 28.9 / 30.8 | 38.1 / 43.5 | +| 10% | HNSW 강제 ef40 | 30/30 | 4.4 (0~20) | 2.3 | 26/30 | 3.1 | 0.22 | 0.44 (0.00) | 0.62 | 2.0 / 4.6 | 10.8 / 18.5 | +| 10% | HNSW 강제 ef100 | 30/30 | 9.2 (1~20) | 4.1 | 11/30 | 3.9 | 0.45 | 0.80 (0.20) | 0.87 | 3.2 / 5.1 | 12.3 / 16.3 | +| 10% | HNSW 강제 ef200 | 30/30 | 15.6 (8~20) | 4.7 | 4/30 | 4.1 | 0.77 | 0.95 (0.40) | 0.94 | 4.1 / 6.6 | 13.0 / 19.5 | +| 10% | HNSW 강제 iterative relaxed | 30/30 | 20.0 (20~20) | 4.8 | 3/30 | 4.2 | 0.96 | 0.91 (0.40) | 0.95 | 4.4 / 5.6 | 12.9 / 17.1 | +| 10% | HNSW 강제 iterative strict | 30/30 | 20.0 (20~20) | 4.8 | 3/30 | 4.1 | 0.90 | 0.86 (0.40) | 0.87 | 5.4 / 7.0 | 15.1 / 20.6 | +| 1% | Exact (인덱스 없음) | 0/30 | 20.0 (20~20) | 4.9 | 2/30 | 2.3 | 1.00 | 1.00 (1.00) | 1.00 | 2.7 / 4.7 | 5.4 / 10.0 | +| 1% | 현재 계획 (Custom Plan) | 0/30 | 20.0 (20~20) | 4.9 | 2/30 | 2.3 | 1.00 | 1.00 (1.00) | 1.00 | 3.3 / 5.0 | 6.3 / 9.0 | +| 1% | 현재 계획 (Generic Plan) | 0/30 | 20.0 (20~20) | 4.9 | 2/30 | 2.3 | 1.00 | 1.00 (1.00) | 1.00 | 5.3 / 8.2 | 7.2 / 10.2 | +| 1% | HNSW 강제 ef40 | 30/30 | 0.4 (0~2) | 0.4 | 30/30 | 1.5 | 0.02 | 0.08 (0.00) | 0.65 | 1.5 / 2.5 | 3.9 / 5.7 | +| 1% | HNSW 강제 ef100 | 30/30 | 0.7 (0~8) | 0.5 | 30/30 | 1.5 | 0.04 | 0.11 (0.00) | 0.67 | 1.7 / 3.0 | 3.8 / 5.8 | +| 1% | HNSW 강제 ef200 | 30/30 | 1.4 (0~9) | 1.0 | 30/30 | 1.6 | 0.07 | 0.20 (0.00) | 0.75 | 2.2 / 3.7 | 4.5 / 6.7 | +| 1% | HNSW 강제 iterative relaxed | 30/30 | 20.0 (20~20) | 4.9 | 2/30 | 2.3 | 1.00 | 1.00 (1.00) | 1.00 | 8.9 / 11.1 | 11.6 / 14.0 | +| 1% | HNSW 강제 iterative strict | 30/30 | 20.0 (20~20) | 4.9 | 2/30 | 2.2 | 0.99 | 0.95 (0.80) | 0.96 | 9.1 / 10.7 | 12.1 / 15.1 | +| 0.1% | Exact (인덱스 없음) | 0/30 | 10.0 (10~10) | 2.0 | 30/30 | 0.9 | 1.00 | 1.00 (1.00) | 1.00 | 2.2 / 3.3 | 3.9 / 6.9 | +| 0.1% | 현재 계획 (Custom Plan) | 0/30 | 10.0 (10~10) | 2.0 | 30/30 | 0.9 | 1.00 | 1.00 (1.00) | 1.00 | 2.1 / 3.4 | 3.7 / 5.9 | +| 0.1% | 현재 계획 (Generic Plan) | 0/30 | 10.0 (10~10) | 2.0 | 30/30 | 0.9 | 1.00 | 1.00 (1.00) | 1.00 | 3.6 / 4.4 | 4.7 / 6.1 | +| 0.1% | HNSW 강제 ef40 | 30/30 | 0.2 (0~2) | 0.2 | 30/30 | 0.6 | 0.02 | 0.12 (0.00) | 0.73 | 1.5 / 3.1 | 2.8 / 4.8 | +| 0.1% | HNSW 강제 ef100 | 30/30 | 0.3 (0~2) | 0.3 | 30/30 | 0.6 | 0.03 | 0.13 (0.00) | 0.73 | 1.7 / 2.8 | 3.0 / 5.6 | +| 0.1% | HNSW 강제 ef200 | 30/30 | 0.5 (0~3) | 0.5 | 30/30 | 0.7 | 0.05 | 0.23 (0.00) | 0.83 | 2.1 / 3.6 | 3.3 / 5.5 | +| 0.1% | HNSW 강제 iterative relaxed | 30/30 | 10.0 (10~10) | 2.0 | 30/30 | 0.9 | 1.00 | 1.00 (1.00) | 1.00 | 9.6 / 11.1 | 10.7 / 13.5 | +| 0.1% | HNSW 강제 iterative strict | 30/30 | 9.9 (9~10) | 2.0 | 30/30 | 0.9 | 0.99 | 0.97 (0.50) | 0.97 | 9.8 / 11.3 | 11.1 / 13.1 | + +#### real · topK 10 (SQL 요청 40개) + +| 허용 비율 | 방식 | HNSW 사용 | SQL 후보 평균 (최소~최대) | 벡터 최종 평균 | topK 미달 질문 | 하이브리드 최종 평균 | SQL Recall | 벡터 최종 Recall (최소) | 하이브리드 최종 Recall | 벡터 p50 / p95 ms | 하이브리드 p50 / p95 ms | +|---:|---|---:|---|---:|---:|---:|---:|---|---:|---|---| +| 100% | Exact (인덱스 없음) | 0/30 | 40.0 (40~40) | 10.0 | 0/30 | 9.9 | 1.00 | 1.00 (1.00) | 1.00 | 18.0 / 23.8 | 100.6 / 146.5 | +| 100% | 현재 계획 (Custom Plan) | 0/30 | 40.0 (40~40) | 10.0 | 0/30 | 9.9 | 1.00 | 1.00 (1.00) | 1.00 | 17.8 / 23.9 | 102.1 / 148.9 | +| 100% | 현재 계획 (Generic Plan) | 0/30 | 40.0 (40~40) | 10.0 | 0/30 | 9.9 | 1.00 | 1.00 (1.00) | 1.00 | 300.1 / 306.3 | 382.7 / 428.0 | +| 100% | HNSW 강제 ef40 | 30/30 | 40.0 (40~40) | 10.0 | 0/30 | 9.1 | 0.86 | 0.82 (0.00) | 0.85 | 6.4 / 8.4 | 87.5 / 127.4 | +| 100% | HNSW 강제 ef100 | 30/30 | 40.0 (40~40) | 10.0 | 0/30 | 9.2 | 0.93 | 0.89 (0.00) | 0.90 | 6.9 / 8.8 | 90.5 / 126.5 | +| 100% | HNSW 강제 ef200 | 30/30 | 40.0 (40~40) | 10.0 | 0/30 | 9.6 | 0.97 | 0.96 (0.00) | 0.97 | 7.1 / 9.0 | 89.1 / 125.8 | +| 100% | HNSW 강제 iterative relaxed | 30/30 | 40.0 (40~40) | 10.0 | 0/30 | 9.1 | 0.86 | 0.82 (0.00) | 0.85 | 6.3 / 8.1 | 88.2 / 127.0 | +| 100% | HNSW 강제 iterative strict | 30/30 | 40.0 (40~40) | 10.0 | 0/30 | 9.1 | 0.86 | 0.82 (0.00) | 0.85 | 6.4 / 7.6 | 86.9 / 124.2 | +| 10% | Exact (인덱스 없음) | 0/30 | 40.0 (40~40) | 10.0 | 0/30 | 7.6 | 1.00 | 1.00 (1.00) | 1.00 | 5.5 / 6.8 | 14.8 / 21.2 | +| 10% | 현재 계획 (Custom Plan) | 0/30 | 40.0 (40~40) | 10.0 | 0/30 | 7.6 | 1.00 | 1.00 (1.00) | 1.00 | 5.6 / 7.2 | 15.3 / 21.6 | +| 10% | 현재 계획 (Generic Plan) | 0/30 | 40.0 (40~40) | 10.0 | 0/30 | 7.6 | 1.00 | 1.00 (1.00) | 1.00 | 31.8 / 33.5 | 41.7 / 46.4 | +| 10% | HNSW 강제 ef40 | 30/30 | 4.4 (0~20) | 2.5 | 30/30 | 3.8 | 0.11 | 0.24 (0.00) | 0.47 | 2.2 / 4.4 | 10.9 / 15.9 | +| 10% | HNSW 강제 ef100 | 30/30 | 9.4 (1~25) | 5.7 | 25/30 | 5.4 | 0.24 | 0.55 (0.10) | 0.70 | 3.1 / 5.2 | 12.0 / 17.4 | +| 10% | HNSW 강제 ef200 | 30/30 | 17.8 (8~33) | 8.6 | 14/30 | 6.6 | 0.45 | 0.84 (0.20) | 0.85 | 4.6 / 6.4 | 14.0 / 18.5 | +| 10% | HNSW 강제 iterative relaxed | 30/30 | 40.0 (40~40) | 10.0 | 0/30 | 7.6 | 0.99 | 0.94 (0.60) | 0.99 | 6.8 / 8.6 | 15.3 / 22.5 | +| 10% | HNSW 강제 iterative strict | 30/30 | 40.0 (40~40) | 10.0 | 0/30 | 7.3 | 0.93 | 0.85 (0.30) | 0.85 | 7.3 / 10.9 | 16.8 / 24.1 | +| 1% | Exact (인덱스 없음) | 0/30 | 34.0 (34~34) | 6.0 | 30/30 | 2.4 | 1.00 | 1.00 (1.00) | 1.00 | 3.6 / 4.8 | 6.7 / 9.7 | +| 1% | 현재 계획 (Custom Plan) | 0/30 | 34.0 (34~34) | 6.0 | 30/30 | 2.4 | 1.00 | 1.00 (1.00) | 1.00 | 3.4 / 5.0 | 5.6 / 9.3 | +| 1% | 현재 계획 (Generic Plan) | 0/30 | 34.0 (34~34) | 6.0 | 30/30 | 2.4 | 1.00 | 1.00 (1.00) | 1.00 | 7.1 / 10.5 | 9.6 / 13.5 | +| 1% | HNSW 강제 ef40 | 30/30 | 0.4 (0~2) | 0.4 | 30/30 | 1.5 | 0.01 | 0.07 (0.00) | 0.64 | 1.7 / 2.7 | 4.2 / 6.9 | +| 1% | HNSW 강제 ef100 | 30/30 | 0.7 (0~8) | 0.5 | 30/30 | 1.5 | 0.02 | 0.09 (0.00) | 0.67 | 1.8 / 3.0 | 4.3 / 6.5 | +| 1% | HNSW 강제 ef200 | 30/30 | 1.4 (0~9) | 1.0 | 30/30 | 1.7 | 0.04 | 0.16 (0.00) | 0.75 | 2.3 / 3.5 | 4.7 / 6.7 | +| 1% | HNSW 강제 iterative relaxed | 30/30 | 34.0 (34~34) | 6.0 | 30/30 | 2.4 | 1.00 | 1.00 (1.00) | 1.00 | 13.1 / 14.6 | 15.8 / 18.5 | +| 1% | HNSW 강제 iterative strict | 30/30 | 33.8 (33~34) | 6.0 | 30/30 | 2.3 | 0.99 | 0.96 (0.83) | 0.96 | 12.8 / 14.5 | 15.9 / 18.7 | +| 0.1% | Exact (인덱스 없음) | 0/30 | 10.0 (10~10) | 2.0 | 30/30 | 0.9 | 1.00 | 1.00 (1.00) | 1.00 | 2.2 / 3.2 | 3.7 / 6.1 | +| 0.1% | 현재 계획 (Custom Plan) | 0/30 | 10.0 (10~10) | 2.0 | 30/30 | 0.9 | 1.00 | 1.00 (1.00) | 1.00 | 1.8 / 3.3 | 3.1 / 6.0 | +| 0.1% | 현재 계획 (Generic Plan) | 0/30 | 10.0 (10~10) | 2.0 | 30/30 | 0.9 | 1.00 | 1.00 (1.00) | 1.00 | 4.2 / 5.5 | 4.7 / 7.1 | +| 0.1% | HNSW 강제 ef40 | 30/30 | 0.2 (0~2) | 0.2 | 30/30 | 0.6 | 0.02 | 0.12 (0.00) | 0.73 | 1.5 / 3.0 | 2.8 / 4.7 | +| 0.1% | HNSW 강제 ef100 | 30/30 | 0.3 (0~2) | 0.3 | 30/30 | 0.6 | 0.03 | 0.13 (0.00) | 0.73 | 1.7 / 3.1 | 2.8 / 5.7 | +| 0.1% | HNSW 강제 ef200 | 30/30 | 0.5 (0~3) | 0.5 | 30/30 | 0.7 | 0.05 | 0.23 (0.00) | 0.83 | 2.2 / 3.4 | 3.4 / 5.6 | +| 0.1% | HNSW 강제 iterative relaxed | 30/30 | 10.0 (10~10) | 2.0 | 30/30 | 0.9 | 1.00 | 1.00 (1.00) | 1.00 | 9.6 / 11.9 | 11.1 / 15.5 | +| 0.1% | HNSW 강제 iterative strict | 30/30 | 9.9 (9~10) | 2.0 | 30/30 | 0.9 | 0.99 | 0.97 (0.50) | 0.97 | 9.4 / 10.9 | 10.9 / 13.7 | + +#### real · topK 20 (SQL 요청 80개) + +| 허용 비율 | 방식 | HNSW 사용 | SQL 후보 평균 (최소~최대) | 벡터 최종 평균 | topK 미달 질문 | 하이브리드 최종 평균 | SQL Recall | 벡터 최종 Recall (최소) | 하이브리드 최종 Recall | 벡터 p50 / p95 ms | 하이브리드 p50 / p95 ms | +|---:|---|---:|---|---:|---:|---:|---:|---|---:|---|---| +| 100% | Exact (인덱스 없음) | 0/30 | 80.0 (80~80) | 20.0 | 0/30 | 18.6 | 1.00 | 1.00 (1.00) | 1.00 | 20.3 / 22.7 | 102.3 / 140.8 | +| 100% | 현재 계획 (Custom Plan) | 0/30 | 80.0 (80~80) | 20.0 | 0/30 | 18.6 | 1.00 | 1.00 (1.00) | 1.00 | 20.5 / 22.9 | 103.5 / 141.8 | +| 100% | 현재 계획 (Generic Plan) | 0/30 | 80.0 (80~80) | 20.0 | 0/30 | 18.6 | 1.00 | 1.00 (1.00) | 1.00 | 301.2 / 312.1 | 383.6 / 421.8 | +| 100% | HNSW 강제 ef40 | 30/30 | 40.0 (40~40) | 18.6 | 10/30 | 16.8 | 0.50 | 0.77 (0.15) | 0.75 | 6.6 / 7.9 | 87.7 / 126.1 | +| 100% | HNSW 강제 ef100 | 30/30 | 80.0 (80~80) | 20.0 | 0/30 | 17.8 | 0.93 | 0.92 (0.30) | 0.90 | 10.6 / 12.6 | 93.1 / 140.2 | +| 100% | HNSW 강제 ef200 | 30/30 | 80.0 (80~80) | 20.0 | 0/30 | 18.4 | 0.98 | 0.97 (0.35) | 0.97 | 11.3 / 13.2 | 93.1 / 134.0 | +| 100% | HNSW 강제 iterative relaxed | 30/30 | 80.0 (80~80) | 20.0 | 0/30 | 17.8 | 0.91 | 0.83 (0.15) | 0.89 | 10.4 / 14.2 | 94.8 / 132.2 | +| 100% | HNSW 강제 iterative strict | 30/30 | 80.0 (80~80) | 20.0 | 0/30 | 17.8 | 0.86 | 0.83 (0.15) | 0.84 | 10.6 / 12.1 | 93.5 / 130.8 | +| 10% | Exact (인덱스 없음) | 0/30 | 80.0 (80~80) | 20.0 | 0/30 | 12.3 | 1.00 | 1.00 (1.00) | 1.00 | 5.0 / 6.2 | 14.2 / 21.0 | +| 10% | 현재 계획 (Custom Plan) | 0/30 | 80.0 (80~80) | 20.0 | 0/30 | 12.3 | 1.00 | 1.00 (1.00) | 1.00 | 4.8 / 6.8 | 13.5 / 21.3 | +| 10% | 현재 계획 (Generic Plan) | 0/30 | 80.0 (80~80) | 20.0 | 0/30 | 12.3 | 1.00 | 1.00 (1.00) | 1.00 | 35.7 / 37.9 | 47.3 / 56.2 | +| 10% | HNSW 강제 ef40 | 30/30 | 4.4 (0~20) | 2.5 | 30/30 | 4.2 | 0.05 | 0.12 (0.00) | 0.41 | 2.3 / 4.4 | 10.8 / 15.3 | +| 10% | HNSW 강제 ef100 | 30/30 | 9.4 (1~25) | 5.8 | 30/30 | 6.0 | 0.12 | 0.29 (0.05) | 0.60 | 3.2 / 4.9 | 12.4 / 17.3 | +| 10% | HNSW 강제 ef200 | 30/30 | 17.8 (8~33) | 10.3 | 30/30 | 8.2 | 0.22 | 0.52 (0.10) | 0.76 | 4.5 / 6.2 | 13.7 / 19.3 | +| 10% | HNSW 강제 iterative relaxed | 30/30 | 80.0 (80~80) | 20.0 | 0/30 | 12.3 | 1.00 | 0.98 (0.90) | 1.00 | 7.8 / 11.0 | 17.7 / 23.2 | +| 10% | HNSW 강제 iterative strict | 30/30 | 80.0 (80~80) | 20.0 | 0/30 | 11.8 | 0.96 | 0.89 (0.55) | 0.88 | 7.7 / 10.2 | 16.7 / 24.7 | +| 1% | Exact (인덱스 없음) | 0/30 | 34.0 (34~34) | 6.0 | 30/30 | 2.4 | 1.00 | 1.00 (1.00) | 1.00 | 3.8 / 5.0 | 7.0 / 9.2 | +| 1% | 현재 계획 (Custom Plan) | 0/30 | 34.0 (34~34) | 6.0 | 30/30 | 2.4 | 1.00 | 1.00 (1.00) | 1.00 | 2.4 / 4.1 | 5.4 / 8.2 | +| 1% | 현재 계획 (Generic Plan) | 0/30 | 34.0 (34~34) | 6.0 | 30/30 | 2.4 | 1.00 | 1.00 (1.00) | 1.00 | 4.9 / 8.2 | 6.7 / 10.2 | +| 1% | HNSW 강제 ef40 | 30/30 | 0.4 (0~2) | 0.4 | 30/30 | 1.5 | 0.01 | 0.07 (0.00) | 0.64 | 1.5 / 3.3 | 4.0 / 5.7 | +| 1% | HNSW 강제 ef100 | 30/30 | 0.7 (0~8) | 0.5 | 30/30 | 1.5 | 0.02 | 0.09 (0.00) | 0.67 | 1.8 / 3.2 | 4.2 / 6.1 | +| 1% | HNSW 강제 ef200 | 30/30 | 1.4 (0~9) | 1.0 | 30/30 | 1.7 | 0.04 | 0.16 (0.00) | 0.75 | 2.6 / 4.4 | 5.1 / 7.5 | +| 1% | HNSW 강제 iterative relaxed | 30/30 | 34.0 (34~34) | 6.0 | 30/30 | 2.4 | 1.00 | 1.00 (1.00) | 1.00 | 12.1 / 26.1 | 14.7 / 31.0 | +| 1% | HNSW 강제 iterative strict | 30/30 | 33.8 (33~34) | 6.0 | 30/30 | 2.3 | 0.99 | 0.96 (0.83) | 0.96 | 13.1 / 15.1 | 16.8 / 19.2 | +| 0.1% | Exact (인덱스 없음) | 0/30 | 10.0 (10~10) | 2.0 | 30/30 | 0.9 | 1.00 | 1.00 (1.00) | 1.00 | 2.2 / 3.9 | 3.6 / 6.8 | +| 0.1% | 현재 계획 (Custom Plan) | 0/30 | 10.0 (10~10) | 2.0 | 30/30 | 0.9 | 1.00 | 1.00 (1.00) | 1.00 | 2.1 / 3.6 | 3.5 / 6.3 | +| 0.1% | 현재 계획 (Generic Plan) | 0/30 | 10.0 (10~10) | 2.0 | 30/30 | 0.9 | 1.00 | 1.00 (1.00) | 1.00 | 2.8 / 4.2 | 3.7 / 6.3 | +| 0.1% | HNSW 강제 ef40 | 30/30 | 0.2 (0~2) | 0.2 | 30/30 | 0.6 | 0.02 | 0.12 (0.00) | 0.73 | 1.5 / 3.0 | 3.0 / 5.7 | +| 0.1% | HNSW 강제 ef100 | 30/30 | 0.3 (0~2) | 0.3 | 30/30 | 0.6 | 0.03 | 0.13 (0.00) | 0.73 | 1.8 / 3.0 | 3.1 / 4.8 | +| 0.1% | HNSW 강제 ef200 | 30/30 | 0.5 (0~3) | 0.5 | 30/30 | 0.7 | 0.05 | 0.23 (0.00) | 0.83 | 2.4 / 3.9 | 3.8 / 5.4 | +| 0.1% | HNSW 강제 iterative relaxed | 30/30 | 10.0 (10~10) | 2.0 | 30/30 | 0.9 | 1.00 | 1.00 (1.00) | 1.00 | 9.9 / 11.8 | 11.2 / 13.2 | +| 0.1% | HNSW 강제 iterative strict | 30/30 | 9.9 (9~10) | 2.0 | 30/30 | 0.9 | 0.99 | 0.97 (0.50) | 0.97 | 10.1 / 12.2 | 11.5 / 14.0 | + +### 규모 50000: 문서 3,559건, 청크 50,008개 (합성 비율 92.9%) + +| 허용 비율 | 허용 문서 | 허용 청크 | +|---:|---:|---:| +| 100% | 3,559 | 50,008 | +| 10% | 356 | 5,244 | +| 1% | 36 | 434 | +| 0.1% | 4 | 40 | + +#### 50000 · topK 5 (SQL 요청 20개) + +| 허용 비율 | 방식 | HNSW 사용 | SQL 후보 평균 (최소~최대) | 벡터 최종 평균 | topK 미달 질문 | 하이브리드 최종 평균 | SQL Recall | 벡터 최종 Recall (최소) | 하이브리드 최종 Recall | 벡터 p50 / p95 ms | 하이브리드 p50 / p95 ms | +|---:|---|---:|---|---:|---:|---:|---:|---|---:|---|---| +| 100% | Exact (인덱스 없음) | 0/30 | 20.0 (20~20) | 5.0 | 0/30 | 5.0 | 1.00 | 1.00 (1.00) | 1.00 | 209.1 / 258.5 | 1305.0 / 1848.4 | +| 100% | 현재 계획 (Custom Plan) | 0/30 | 20.0 (20~20) | 5.0 | 0/30 | 5.0 | 1.00 | 1.00 (1.00) | 1.00 | 223.2 / 282.2 | 1352.7 / 1906.7 | +| 100% | 현재 계획 (Generic Plan) | 0/30 | 20.0 (20~20) | 5.0 | 0/30 | 5.0 | 1.00 | 1.00 (1.00) | 1.00 | 4280.9 / 4793.3 | 5479.4 / 6034.4 | +| 100% | HNSW 강제 ef40 | 30/30 | 20.0 (20~20) | 5.0 | 0/30 | 5.0 | 0.92 | 0.91 (0.00) | 0.93 | 14.2 / 19.0 | 1154.4 / 1682.0 | +| 100% | HNSW 강제 ef100 | 30/30 | 20.0 (20~20) | 5.0 | 0/30 | 5.0 | 0.97 | 0.97 (0.60) | 0.98 | 14.5 / 17.1 | 1119.7 / 1665.4 | +| 100% | HNSW 강제 ef200 | 30/30 | 20.0 (20~20) | 5.0 | 0/30 | 5.0 | 0.98 | 0.97 (0.60) | 0.99 | 14.6 / 17.4 | 1147.5 / 1675.3 | +| 100% | HNSW 강제 iterative relaxed | 30/30 | 20.0 (20~20) | 5.0 | 0/30 | 5.0 | 0.92 | 0.91 (0.00) | 0.93 | 14.0 / 16.6 | 1088.3 / 1657.9 | +| 100% | HNSW 강제 iterative strict | 30/30 | 20.0 (20~20) | 5.0 | 0/30 | 5.0 | 0.92 | 0.91 (0.00) | 0.93 | 13.7 / 16.1 | 1088.2 / 1623.5 | +| 10% | Exact (인덱스 없음) | 0/30 | 20.0 (20~20) | 5.0 | 0/30 | 5.0 | 1.00 | 1.00 (1.00) | 1.00 | 24.7 / 31.0 | 153.4 / 222.2 | +| 10% | 현재 계획 (Custom Plan) | 0/30 | 20.0 (20~20) | 5.0 | 0/30 | 5.0 | 1.00 | 1.00 (1.00) | 1.00 | 24.8 / 30.2 | 151.4 / 213.5 | +| 10% | 현재 계획 (Generic Plan) | 0/30 | 20.0 (20~20) | 5.0 | 0/30 | 5.0 | 1.00 | 1.00 (1.00) | 1.00 | 464.4 / 570.5 | 586.1 / 800.5 | +| 10% | HNSW 강제 ef40 | 30/30 | 2.6 (0~12) | 1.7 | 27/30 | 3.5 | 0.13 | 0.33 (0.00) | 0.54 | 8.0 / 12.0 | 140.9 / 203.6 | +| 10% | HNSW 강제 ef100 | 30/30 | 7.1 (0~20) | 3.1 | 17/30 | 3.9 | 0.36 | 0.60 (0.00) | 0.73 | 8.9 / 13.1 | 141.9 / 216.1 | +| 10% | HNSW 강제 ef200 | 30/30 | 12.5 (0~20) | 4.0 | 11/30 | 4.4 | 0.61 | 0.78 (0.00) | 0.81 | 10.8 / 15.0 | 137.9 / 210.2 | +| 10% | HNSW 강제 iterative relaxed | 30/30 | 20.0 (20~20) | 5.0 | 0/30 | 5.0 | 0.91 | 0.91 (0.40) | 0.95 | 12.1 / 15.1 | 139.8 / 208.7 | +| 10% | HNSW 강제 iterative strict | 30/30 | 20.0 (20~20) | 5.0 | 0/30 | 5.0 | 0.81 | 0.78 (0.00) | 0.83 | 12.7 / 17.2 | 143.9 / 229.0 | +| 1% | Exact (인덱스 없음) | 0/30 | 20.0 (20~20) | 4.9 | 1/30 | 4.9 | 1.00 | 1.00 (1.00) | 1.00 | 5.6 / 7.7 | 18.9 / 26.0 | +| 1% | 현재 계획 (Custom Plan) | 0/30 | 20.0 (20~20) | 4.9 | 1/30 | 4.9 | 1.00 | 1.00 (1.00) | 1.00 | 5.3 / 7.4 | 19.1 / 24.8 | +| 1% | 현재 계획 (Generic Plan) | 0/30 | 20.0 (20~20) | 4.9 | 1/30 | 4.9 | 1.00 | 1.00 (1.00) | 1.00 | 40.8 / 44.1 | 52.7 / 57.6 | +| 1% | HNSW 강제 ef40 | 30/30 | 0.2 (0~4) | 0.2 | 30/30 | 2.5 | 0.01 | 0.04 (0.00) | 0.34 | 6.6 / 8.2 | 19.0 / 27.1 | +| 1% | HNSW 강제 ef100 | 30/30 | 0.8 (0~10) | 0.4 | 30/30 | 2.6 | 0.04 | 0.10 (0.00) | 0.37 | 7.1 / 8.4 | 19.5 / 26.1 | +| 1% | HNSW 강제 ef200 | 30/30 | 1.7 (0~20) | 0.6 | 30/30 | 2.7 | 0.08 | 0.13 (0.00) | 0.39 | 7.4 / 9.6 | 19.9 / 26.4 | +| 1% | HNSW 강제 iterative relaxed | 30/30 | 20.0 (20~20) | 4.9 | 1/30 | 4.9 | 0.94 | 0.87 (0.60) | 0.95 | 41.7 / 68.5 | 51.7 / 82.9 | +| 1% | HNSW 강제 iterative strict | 30/30 | 20.0 (20~20) | 4.9 | 1/30 | 4.9 | 0.83 | 0.71 (0.00) | 0.81 | 41.7 / 58.8 | 53.8 / 73.3 | +| 0.1% | Exact (인덱스 없음) | 0/30 | 20.0 (20~20) | 5.0 | 1/30 | 4.0 | 1.00 | 1.00 (1.00) | 1.00 | 3.9 / 5.4 | 8.5 / 12.2 | +| 0.1% | 현재 계획 (Custom Plan) | 0/30 | 20.0 (20~20) | 5.0 | 1/30 | 4.0 | 1.00 | 1.00 (1.00) | 1.00 | 3.9 / 5.1 | 8.1 / 11.5 | +| 0.1% | 현재 계획 (Generic Plan) | 0/30 | 20.0 (20~20) | 5.0 | 1/30 | 4.0 | 1.00 | 1.00 (1.00) | 1.00 | 6.9 / 7.9 | 9.9 / 12.5 | +| 0.1% | HNSW 강제 ef40 | 30/30 | 0.0 (0~0) | 0.0 | 30/30 | 1.4 | 0.00 | 0.00 (0.00) | 0.38 | 6.2 / 7.7 | 10.5 / 13.4 | +| 0.1% | HNSW 강제 ef100 | 30/30 | 0.0 (0~1) | 0.0 | 30/30 | 1.4 | 0.00 | 0.01 (0.00) | 0.38 | 6.2 / 7.9 | 10.5 / 13.5 | +| 0.1% | HNSW 강제 ef200 | 30/30 | 0.0 (0~1) | 0.0 | 30/30 | 1.4 | 0.00 | 0.01 (0.00) | 0.38 | 6.6 / 8.0 | 10.9 / 13.7 | +| 0.1% | HNSW 강제 iterative relaxed | 30/30 | 15.9 (10~20) | 5.0 | 1/30 | 4.0 | 0.65 | 0.87 (0.60) | 0.93 | 49.5 / 78.1 | 55.1 / 87.9 | +| 0.1% | HNSW 강제 iterative strict | 30/30 | 15.5 (10~20) | 5.0 | 1/30 | 3.9 | 0.61 | 0.75 (0.60) | 0.79 | 48.0 / 53.5 | 53.9 / 61.7 | + +#### 50000 · topK 10 (SQL 요청 40개) + +| 허용 비율 | 방식 | HNSW 사용 | SQL 후보 평균 (최소~최대) | 벡터 최종 평균 | topK 미달 질문 | 하이브리드 최종 평균 | SQL Recall | 벡터 최종 Recall (최소) | 하이브리드 최종 Recall | 벡터 p50 / p95 ms | 하이브리드 p50 / p95 ms | +|---:|---|---:|---|---:|---:|---:|---:|---|---:|---|---| +| 100% | Exact (인덱스 없음) | 0/30 | 40.0 (40~40) | 9.9 | 1/30 | 10.0 | 1.00 | 1.00 (1.00) | 1.00 | 212.9 / 268.3 | 1328.3 / 1913.3 | +| 100% | 현재 계획 (Custom Plan) | 0/30 | 40.0 (40~40) | 9.9 | 1/30 | 10.0 | 1.00 | 1.00 (1.00) | 1.00 | 204.2 / 264.7 | 1338.4 / 1869.2 | +| 100% | 현재 계획 (Generic Plan) | 0/30 | 40.0 (40~40) | 9.9 | 1/30 | 10.0 | 1.00 | 1.00 (1.00) | 1.00 | 4226.3 / 4674.7 | 5444.6 / 6157.0 | +| 100% | HNSW 강제 ef40 | 30/30 | 40.0 (40~40) | 9.8 | 3/30 | 10.0 | 0.92 | 0.91 (0.00) | 0.92 | 15.7 / 20.4 | 1128.4 / 1730.9 | +| 100% | HNSW 강제 ef100 | 30/30 | 40.0 (40~40) | 9.9 | 1/30 | 10.0 | 0.97 | 0.96 (0.80) | 0.97 | 16.3 / 18.8 | 1120.3 / 1629.6 | +| 100% | HNSW 강제 ef200 | 30/30 | 40.0 (40~40) | 9.9 | 1/30 | 10.0 | 0.98 | 0.97 (0.80) | 0.98 | 17.0 / 23.7 | 1137.4 / 1633.0 | +| 100% | HNSW 강제 iterative relaxed | 30/30 | 40.0 (40~40) | 9.8 | 3/30 | 10.0 | 0.92 | 0.91 (0.00) | 0.92 | 15.9 / 18.8 | 1123.1 / 1676.1 | +| 100% | HNSW 강제 iterative strict | 30/30 | 40.0 (40~40) | 9.8 | 3/30 | 10.0 | 0.92 | 0.91 (0.00) | 0.92 | 16.1 / 21.0 | 1101.9 / 1687.8 | +| 10% | Exact (인덱스 없음) | 0/30 | 40.0 (40~40) | 10.0 | 0/30 | 10.0 | 1.00 | 1.00 (1.00) | 1.00 | 27.7 / 40.1 | 160.7 / 250.2 | +| 10% | 현재 계획 (Custom Plan) | 0/30 | 40.0 (40~40) | 10.0 | 0/30 | 10.0 | 1.00 | 1.00 (1.00) | 1.00 | 28.1 / 37.8 | 164.1 / 241.4 | +| 10% | 현재 계획 (Generic Plan) | 0/30 | 40.0 (40~40) | 10.0 | 0/30 | 10.0 | 1.00 | 1.00 (1.00) | 1.00 | 480.5 / 597.4 | 609.4 / 777.4 | +| 10% | HNSW 강제 ef40 | 30/30 | 2.6 (0~12) | 1.8 | 30/30 | 5.1 | 0.07 | 0.18 (0.00) | 0.35 | 8.1 / 14.6 | 133.0 / 197.1 | +| 10% | HNSW 강제 ef100 | 30/30 | 7.3 (0~25) | 3.6 | 30/30 | 6.1 | 0.18 | 0.36 (0.00) | 0.49 | 9.3 / 13.2 | 137.2 / 221.5 | +| 10% | HNSW 강제 ef200 | 30/30 | 14.3 (0~38) | 6.0 | 23/30 | 7.2 | 0.36 | 0.57 (0.00) | 0.64 | 10.6 / 14.8 | 138.3 / 198.6 | +| 10% | HNSW 강제 iterative relaxed | 30/30 | 40.0 (40~40) | 10.0 | 0/30 | 10.0 | 0.91 | 0.87 (0.60) | 0.94 | 15.8 / 23.3 | 151.5 / 215.4 | +| 10% | HNSW 강제 iterative strict | 30/30 | 40.0 (40~40) | 10.0 | 0/30 | 10.0 | 0.78 | 0.72 (0.00) | 0.77 | 16.1 / 23.7 | 147.9 / 212.2 | +| 1% | Exact (인덱스 없음) | 0/30 | 40.0 (40~40) | 10.0 | 1/30 | 9.4 | 1.00 | 1.00 (1.00) | 1.00 | 7.3 / 9.3 | 21.1 / 28.1 | +| 1% | 현재 계획 (Custom Plan) | 0/30 | 40.0 (40~40) | 10.0 | 1/30 | 9.4 | 1.00 | 1.00 (1.00) | 1.00 | 7.3 / 11.9 | 21.7 / 37.7 | +| 1% | 현재 계획 (Generic Plan) | 0/30 | 40.0 (40~40) | 10.0 | 1/30 | 9.4 | 1.00 | 1.00 (1.00) | 1.00 | 43.2 / 49.0 | 56.3 / 65.5 | +| 1% | HNSW 강제 ef40 | 30/30 | 0.2 (0~4) | 0.2 | 30/30 | 3.2 | 0.01 | 0.02 (0.00) | 0.24 | 6.8 / 10.9 | 19.9 / 29.7 | +| 1% | HNSW 강제 ef100 | 30/30 | 0.8 (0~10) | 0.4 | 30/30 | 3.3 | 0.02 | 0.04 (0.00) | 0.26 | 7.1 / 9.4 | 19.4 / 27.4 | +| 1% | HNSW 강제 ef200 | 30/30 | 1.9 (0~25) | 0.6 | 30/30 | 3.4 | 0.05 | 0.06 (0.00) | 0.27 | 8.1 / 12.6 | 20.9 / 31.4 | +| 1% | HNSW 강제 iterative relaxed | 30/30 | 40.0 (40~40) | 10.0 | 1/30 | 9.4 | 0.93 | 0.94 (0.80) | 0.97 | 49.0 / 73.2 | 64.5 / 85.2 | +| 1% | HNSW 강제 iterative strict | 30/30 | 40.0 (40~40) | 9.9 | 1/30 | 9.1 | 0.85 | 0.76 (0.30) | 0.81 | 48.3 / 55.4 | 62.6 / 71.0 | +| 0.1% | Exact (인덱스 없음) | 0/30 | 40.0 (40~40) | 7.9 | 30/30 | 5.1 | 1.00 | 1.00 (1.00) | 1.00 | 4.3 / 5.9 | 9.4 / 13.8 | +| 0.1% | 현재 계획 (Custom Plan) | 0/30 | 40.0 (40~40) | 7.9 | 30/30 | 5.1 | 1.00 | 1.00 (1.00) | 1.00 | 3.6 / 5.5 | 9.1 / 12.0 | +| 0.1% | 현재 계획 (Generic Plan) | 0/30 | 40.0 (40~40) | 7.9 | 30/30 | 5.1 | 1.00 | 1.00 (1.00) | 1.00 | 7.9 / 8.9 | 10.9 / 12.5 | +| 0.1% | HNSW 강제 ef40 | 30/30 | 0.0 (0~0) | 0.0 | 30/30 | 1.5 | 0.00 | 0.00 (0.00) | 0.34 | 6.0 / 7.2 | 10.3 / 12.9 | +| 0.1% | HNSW 강제 ef100 | 30/30 | 0.0 (0~1) | 0.0 | 30/30 | 1.5 | 0.00 | 0.00 (0.00) | 0.34 | 6.2 / 7.8 | 10.3 / 12.8 | +| 0.1% | HNSW 강제 ef200 | 30/30 | 0.0 (0~1) | 0.0 | 30/30 | 1.5 | 0.00 | 0.00 (0.00) | 0.34 | 6.6 / 8.2 | 10.6 / 13.1 | +| 0.1% | HNSW 강제 iterative relaxed | 30/30 | 16.5 (10~27) | 6.7 | 30/30 | 4.6 | 0.41 | 0.68 (0.38) | 0.88 | 50.1 / 72.0 | 56.1 / 84.6 | +| 0.1% | HNSW 강제 iterative strict | 30/30 | 15.8 (10~25) | 6.7 | 30/30 | 4.5 | 0.40 | 0.61 (0.38) | 0.75 | 48.4 / 70.1 | 55.1 / 66.3 | + +#### 50000 · topK 20 (SQL 요청 80개) + +| 허용 비율 | 방식 | HNSW 사용 | SQL 후보 평균 (최소~최대) | 벡터 최종 평균 | topK 미달 질문 | 하이브리드 최종 평균 | SQL Recall | 벡터 최종 Recall (최소) | 하이브리드 최종 Recall | 벡터 p50 / p95 ms | 하이브리드 p50 / p95 ms | +|---:|---|---:|---|---:|---:|---:|---:|---|---:|---|---| +| 100% | Exact (인덱스 없음) | 0/30 | 80.0 (80~80) | 19.1 | 5/30 | 20.0 | 1.00 | 1.00 (1.00) | 1.00 | 211.1 / 295.2 | 1319.5 / 1890.3 | +| 100% | 현재 계획 (Custom Plan) | 0/30 | 80.0 (80~80) | 19.1 | 5/30 | 20.0 | 1.00 | 1.00 (1.00) | 1.00 | 213.1 / 276.7 | 1332.7 / 1908.5 | +| 100% | 현재 계획 (Generic Plan) | 0/30 | 80.0 (80~80) | 19.1 | 5/30 | 20.0 | 1.00 | 1.00 (1.00) | 1.00 | 4340.2 / 4562.3 | 5587.0 / 6081.3 | +| 100% | HNSW 강제 ef40 | 30/30 | 40.0 (40~40) | 17.3 | 15/30 | 19.6 | 0.48 | 0.85 (0.00) | 0.86 | 16.1 / 19.6 | 1132.7 / 1678.6 | +| 100% | HNSW 강제 ef100 | 30/30 | 80.0 (80~80) | 19.0 | 5/30 | 20.0 | 0.97 | 0.96 (0.80) | 0.96 | 18.5 / 21.3 | 1099.0 / 1628.3 | +| 100% | HNSW 강제 ef200 | 30/30 | 80.0 (80~80) | 19.0 | 5/30 | 20.0 | 0.98 | 0.97 (0.80) | 0.97 | 19.1 / 22.0 | 1113.6 / 1626.8 | +| 100% | HNSW 강제 iterative relaxed | 30/30 | 80.0 (80~80) | 19.0 | 5/30 | 20.0 | 0.95 | 0.93 (0.60) | 0.95 | 18.3 / 20.3 | 1094.8 / 1631.1 | +| 100% | HNSW 강제 iterative strict | 30/30 | 80.0 (80~80) | 19.0 | 5/30 | 20.0 | 0.92 | 0.90 (0.00) | 0.91 | 18.5 / 22.2 | 1110.8 / 1627.7 | +| 10% | Exact (인덱스 없음) | 0/30 | 80.0 (80~80) | 20.0 | 0/30 | 20.0 | 1.00 | 1.00 (1.00) | 1.00 | 30.0 / 34.7 | 157.3 / 219.3 | +| 10% | 현재 계획 (Custom Plan) | 0/30 | 80.0 (80~80) | 20.0 | 0/30 | 20.0 | 1.00 | 1.00 (1.00) | 1.00 | 30.8 / 36.2 | 157.8 / 217.1 | +| 10% | 현재 계획 (Generic Plan) | 0/30 | 80.0 (80~80) | 20.0 | 0/30 | 20.0 | 1.00 | 1.00 (1.00) | 1.00 | 474.1 / 561.4 | 614.4 / 692.7 | +| 10% | HNSW 강제 ef40 | 30/30 | 2.6 (0~12) | 1.8 | 30/30 | 6.6 | 0.03 | 0.09 (0.00) | 0.22 | 7.5 / 8.9 | 132.1 / 192.2 | +| 10% | HNSW 강제 ef100 | 30/30 | 7.3 (0~25) | 3.6 | 30/30 | 7.9 | 0.09 | 0.18 (0.00) | 0.30 | 8.4 / 10.7 | 132.0 / 196.9 | +| 10% | HNSW 강제 ef200 | 30/30 | 14.3 (0~38) | 6.3 | 30/30 | 9.6 | 0.18 | 0.31 (0.00) | 0.41 | 10.1 / 13.1 | 135.5 / 202.2 | +| 10% | HNSW 강제 iterative relaxed | 30/30 | 80.0 (80~80) | 20.0 | 0/30 | 20.0 | 0.95 | 0.86 (0.65) | 0.97 | 22.5 / 27.1 | 151.9 / 219.2 | +| 10% | HNSW 강제 iterative strict | 30/30 | 80.0 (80~80) | 20.0 | 0/30 | 19.9 | 0.80 | 0.67 (0.30) | 0.73 | 23.4 / 31.8 | 150.5 / 230.9 | +| 1% | Exact (인덱스 없음) | 0/30 | 80.0 (80~80) | 20.0 | 0/30 | 17.3 | 1.00 | 1.00 (1.00) | 1.00 | 10.3 / 14.1 | 26.0 / 38.6 | +| 1% | 현재 계획 (Custom Plan) | 0/30 | 80.0 (80~80) | 20.0 | 0/30 | 17.3 | 1.00 | 1.00 (1.00) | 1.00 | 7.8 / 11.5 | 23.0 / 31.7 | +| 1% | 현재 계획 (Generic Plan) | 0/30 | 80.0 (80~80) | 20.0 | 0/30 | 17.3 | 1.00 | 1.00 (1.00) | 1.00 | 46.8 / 52.7 | 59.9 / 66.6 | +| 1% | HNSW 강제 ef40 | 30/30 | 0.2 (0~4) | 0.2 | 30/30 | 3.5 | 0.00 | 0.01 (0.00) | 0.21 | 6.9 / 8.3 | 19.8 / 24.8 | +| 1% | HNSW 강제 ef100 | 30/30 | 0.8 (0~10) | 0.4 | 30/30 | 3.7 | 0.01 | 0.02 (0.00) | 0.21 | 7.3 / 8.9 | 20.3 / 26.6 | +| 1% | HNSW 강제 ef200 | 30/30 | 1.9 (0~25) | 0.6 | 30/30 | 3.8 | 0.02 | 0.03 (0.00) | 0.22 | 7.4 / 11.2 | 19.9 / 26.7 | +| 1% | HNSW 강제 iterative relaxed | 30/30 | 80.0 (80~80) | 20.0 | 0/30 | 17.3 | 0.85 | 0.91 (0.70) | 0.93 | 56.1 / 70.7 | 72.9 / 84.7 | +| 1% | HNSW 강제 iterative strict | 30/30 | 80.0 (80~80) | 20.0 | 0/30 | 17.1 | 0.81 | 0.79 (0.60) | 0.82 | 57.3 / 72.9 | 73.8 / 88.3 | +| 0.1% | Exact (인덱스 없음) | 0/30 | 40.0 (40~40) | 7.9 | 30/30 | 5.1 | 1.00 | 1.00 (1.00) | 1.00 | 4.1 / 6.1 | 9.3 / 13.0 | +| 0.1% | 현재 계획 (Custom Plan) | 0/30 | 40.0 (40~40) | 7.9 | 30/30 | 5.1 | 1.00 | 1.00 (1.00) | 1.00 | 4.5 / 6.7 | 9.7 / 14.5 | +| 0.1% | 현재 계획 (Generic Plan) | 0/30 | 40.0 (40~40) | 7.9 | 30/30 | 5.1 | 1.00 | 1.00 (1.00) | 1.00 | 8.2 / 11.0 | 11.3 / 16.6 | +| 0.1% | HNSW 강제 ef40 | 30/30 | 0.0 (0~0) | 0.0 | 30/30 | 1.5 | 0.00 | 0.00 (0.00) | 0.34 | 6.1 / 8.3 | 10.7 / 12.5 | +| 0.1% | HNSW 강제 ef100 | 30/30 | 0.0 (0~1) | 0.0 | 30/30 | 1.5 | 0.00 | 0.00 (0.00) | 0.34 | 6.3 / 7.5 | 10.4 / 13.4 | +| 0.1% | HNSW 강제 ef200 | 30/30 | 0.0 (0~1) | 0.0 | 30/30 | 1.5 | 0.00 | 0.00 (0.00) | 0.34 | 6.6 / 7.8 | 11.1 / 13.7 | +| 0.1% | HNSW 강제 iterative relaxed | 30/30 | 16.5 (10~27) | 6.7 | 30/30 | 4.6 | 0.41 | 0.68 (0.38) | 0.88 | 49.0 / 53.9 | 54.9 / 62.8 | +| 0.1% | HNSW 강제 iterative strict | 30/30 | 15.8 (10~25) | 6.7 | 30/30 | 4.5 | 0.40 | 0.61 (0.38) | 0.75 | 48.4 / 52.0 | 54.3 / 59.1 | + diff --git a/docs/test-results/gimin-#545-permission-filter-vector-recall.md b/docs/test-results/gimin-#545-permission-filter-vector-recall.md new file mode 100644 index 00000000..77d9815a --- /dev/null +++ b/docs/test-results/gimin-#545-permission-filter-vector-recall.md @@ -0,0 +1,386 @@ +# Issue #545 권한 필터에 따른 벡터 검색 누락 재현과 인덱스 방식 비교 결과 + +- 측정일: 2026-10-17 (KST) +- 관련 이슈: #545 +- 원시 데이터·재현 스크립트: [evidence/issue-545/](evidence/issue-545/) +- 코드 기준: `develop` `ed5d601`에서 측정했다. PR 기준 `develop` `39dbfb9`와 비교해 검색 Repository SQL·검색 서비스·검색 설정·`application*.yml`은 같다(바뀐 것은 live check 권한 조회 클래스뿐이며 측정 대상이 아니다). +- 범위: 로컬 pgvector 단계. 운영 코드·설정·마이그레이션은 바꾸지 않았다. DiskANN(pgvectorscale) 비교는 다음 단계다([7장](#7-다음-단계-diskannpgvectorscale-비교-절차)). + +## 1. 결론 + +### 1.1 가설 판정 + +| 가설 | 현재 운영 SQL | HNSW를 실제로 쓰게 했을 때 | 판정 | +|---|---|---|---| +| **H1** 허용 문서 비율이 낮으면 HNSW 후보가 권한 필터에 걸려 결과가 topK보다 적거나 Recall이 떨어진다 | **재현 안 됨.** 실행 계획이 HNSW를 쓰지 않는다(720개 질문·조합 중 0회). 결과가 Exact와 모두 같다(Recall 1.00) | **강하게 재현됨.** 5만 청크·topK 10·허용 10%에서 SQL이 평균 2.6개만 돌려주고 최종 결과는 1.77개(30/30 질문이 topK 미달), Recall 0.18 | **조건부** (HNSW가 선택될 때만) | +| **H2** topK ≥ 11이면 요청 후보 수(topK×4)가 `ef_search` 40을 넘어, 권한 필터가 없어도 후보가 40개 안팎으로 묶인다 | **재현 안 됨.** topK 20에서 SQL이 요청한 80개를 모두 돌려준다 | **재현됨.** 허용 100%·topK 20에서 SQL이 30/30 질문 모두 정확히 40개만 돌려준다(SQL Recall 0.48). 최종 결과 미달 질문이 5개 → 15개로 는다 | **조건부** (HNSW가 선택될 때만) | + +### 1.2 원인 분리 (DB 후보 부족 vs 앱 필터) + +- **현재 운영 계획**에서 DB 후보 부족은 **0**이다. 모든 조합에서 Exact와 같은 후보를 돌려준다. +- 결과가 topK보다 적은 경우(예: 5만 청크·허용 0.1%·topK 10에서 7.90개)는 **권한 범위와 앱 필터** 때문이다. 허용 문서가 4건뿐이고 앱이 문서당 2개로 자르므로 최대 8개다. 이것은 의도한 동작이다. +- **HNSW를 쓰면** 부족분의 대부분이 DB 후보 부족에서 생긴다. 5만 청크·topK 10·허용 10% 기준, topK 10개 중 8.23개가 DB 단계에서 빠진다. + +### 1.3 예상 밖으로 확인한 두 가지 + +1. **planner가 HNSW를 쓰지 않는 이유는 조인 행 수 오추정이다.** `documents`와의 조인 조건 두 개(`d.id = e.document_id`, `d.current_version_id = e.document_version_id`)를 서로 독립으로 보고 선택도를 곱한다. 그래서 조인 결과를 **9행으로 추정**하지만 실제로는 **50,008행**이다. 9행이면 정렬이 싸 보이므로 btree(`document_id`) → 해시 조인 → top-N 정렬, 즉 **사실상 Exact 검색**을 고른다. +2. **Prepared Statement가 Generic Plan으로 바뀌면 같은 SQL이 15~21배 느려진다.** 기본 `plan_cache_mode=auto`에서 같은 SQL을 **6번째 실행할 때부터** Generic Plan으로 바뀐다(psql로 확인: 1~5회 약 141ms → 6회째부터 약 2,100ms). Generic Plan에서는 `CAST(:queryVector AS vector)`가 상수로 접히지 않아, 1024차원 Vector 문자열을 **행마다 다시 파싱**한다. 서비스 경로 측정에서 5만 청크·허용 100%·topK 10의 벡터 검색 p50이 **204ms → 4,226ms**가 됐다. 권한과는 무관한 지연 원인이다. + +### 1.4 권고안 (운영 변경 아님) + +기능 동결(10/24)과 기능테스트(10/28)가 끝난 뒤 별도 [Fix]로 다시 측정하고 진행한다. 자세한 내용은 [5장](#5-권고안)에 있다. + +1. **Generic Plan 지연부터 없앤다.** 질문 Vector를 스칼라 서브쿼리로 한 번만 계산하게 바꾸면 Generic Plan에서도 **2,129ms → 163ms**가 된다(psql 검증, Custom Plan은 142ms). +2. **지금 규모에서는 현재 계획(사실상 Exact)이 권한 필터에 안전하다.** HNSW를 쓰도록 바꾸려면 `hnsw.iterative_scan = relaxed_order`가 필수다. `ef_search`만 올려서는 허용 10%에서도 결과가 모자란다. +3. **허용 비율에 따라 유리한 방식이 다르다.** 허용 청크가 많으면 HNSW + iterative가 빠르고(5만 청크·100%: 16ms vs 204ms), 적으면 Exact가 빠르다(허용 1% 이하: 4~7ms vs 49~50ms). + +## 2. 왜 이 시험을 했나 + +DocGrid 검색은 세 단계로 동작한다. + +1. `VectorSearchRepository.findTopK`가 권한 조건(`e.document_id IN (:permittedIds)`)과 상태 조건을 건 채, 질문과 가까운 순서(`ORDER BY e.vector <=> :queryVector`)로 `topK × 4`개(최대 80개)를 가져온다. +2. 앱이 유사도 0.30 미만(하이브리드는 0.45 미만)을 버린다. +3. 한 문서에서 최대 2개만 남기고 topK개를 채운다. + +pgvector HNSW는 기본 `ef_search = 40`이고 `iterative_scan`이 꺼져 있다. HNSW를 쓰면 Index가 먼저 가까운 후보를 40개 안팎 찾고, 권한 조건은 그 **뒤에** 거른다. 권한이 좁은 사용자는 볼 수 있는 문서에 답이 있어도 검색 결과에서 빠질 수 있다. 문서관리 시스템에서는 치명적인 문제라, 실제로 일어나는지와 원인을 실측으로 확인했다. + +## 3. 시험 방법 + +### 3.1 환경 + +| 항목 | 값 | +|---|---| +| DB | PostgreSQL 17.8 (Debian 17.8-1.pgdg12+1), pgvector 0.8.1 | +| DB 실행 | 새로 띄운 격리 Docker 컨테이너. CI와 같은 이미지 digest(`pgvector/pgvector:0.8.1-pg17@sha256:3e8b…b21`). 기존 컨테이너·사용자 데이터는 쓰지 않음 | +| DB 설정 | 이미지 기본값: `shared_buffers` 128MB, `work_mem` 4MB, `maintenance_work_mem` 64MB, `jit` on, `plan_cache_mode` auto, `hnsw.ef_search` 40, `hnsw.iterative_scan` off, `hnsw.max_scan_tuples` 20,000 | +| HNSW Index | V32와 같은 정의 `USING hnsw (vector vector_cosine_ops)` (`m`·`ef_construction` 기본값 16·64) | +| Application | Java 17.0.18, PostgreSQL JDBC 42.7.11, `aarch64` (Docker Desktop 10 CPU·8GB) | +| Embedding | `BAAI/bge-m3` 1024차원, 로컬 Embedding 서버 | +| 공개 서비스·GCP | 사용 안 함 | + +### 3.2 시험 데이터 + +**실제 데이터 (합성 0%)** + +- 저장소 안의 팀 작성 문서 251건을 썼다: 예제 문서 15건(PDF·DOCX·HWPX) + `docs/design` Markdown 120건 + `docs/test-results` 최상위 Markdown 116건. `evidence/` 하위의 원시 로그는 제외했다. +- 운영과 같은 Parser·Chunker(1000자, overlap 200)·Embedding 입력 규칙(Section 경로 포함)·Batch 계획으로 처리했다. 결과는 **청크 3,572개**(문서당 평균 14.2개, 최소 1개, 최대 102개)다. 파싱 실패는 0건이었다. +- Embedding에는 4,118초가 걸렸다. + +**5만 청크 데이터 (합성 92.9%)** + +- 실제 데이터만으로는 수만 청크가 되지 않아 합성 문서를 더했다. +- 합성 문서 하나는 실제 문서 하나의 청크 본문·Section 정보를 그대로 복제한다. +- 각 청크 Vector는 원본과의 코사인 유사도가 정확히 `c`가 되도록, **다른 실제 문서 청크 Vector**에서 원본에 직교하는 성분만 섞어 만든다. 무작위 잡음 대신 실제 Embedding 방향을 쓰므로 분포가 실제에 가깝게 남는다. +- `c`는 문서마다 실제 청크의 "다른 문서 최근접 유사도" 분포의 P50~P90(0.759~0.835)에서 고른다. 합성 문서가 실제 말뭉치 안의 "그럴듯한 이웃" 정도로 비슷하고, 거의 같은 복제본이 되지 않게 하려는 것이다. 같은 분포의 P10은 0.688, 평균은 0.760이다. +- 결과: 문서 3,559건(실제 251 + 합성 3,308), 청크 50,008개. Seed 545로 고정해 재현할 수 있다. + +**허용 문서** + +문서를 Seed로 섞어 앞에서부터 비율만큼(최소 1건) 골랐다. 낮은 비율의 허용 문서는 높은 비율에 항상 포함된다. 권한은 질문 주제와 무관하게 무작위다. + +| 규모 | 100% | 10% | 1% | 0.1% | +|---|---:|---:|---:|---:| +| 실제 3,572 청크 (문서/청크) | 251 / 3,572 | 25 / 301 | 3 / 34 | 1 / 10 | +| 5만 청크 (문서/청크) | 3,559 / 50,008 | 356 / 5,244 | 36 / 434 | 4 / 40 | + +### 3.3 질문 세트 + +- **#456 질문 세트는 다시 쓰지 않았다.** #456의 말뭉치는 개인 학습 자료 5건(교육팀 PDF 포함)이라 저장소에 없고 저작권도 확인되지 않았다. 또한 주제(디렉토리 명령, STOMP, SOLID)가 이번 말뭉치와 달라, 유사도 기준(0.30)에서 대부분 걸러져 누락 측정을 흐린다. +- 대신 [`questions.json`](evidence/issue-545/questions.json)에 질문 30개를 측정 전에 고정했다. 예제 문서마다 1개(15개), 설계 문서 15건에서 1개씩(15개)이다. +- Recall은 Exact 결과를 정답으로 계산하므로 정답 청크 표시는 두지 않았다. +- 질문 Vector는 운영 첫 질문과 같은 경로(대화 문맥 없는 원문, 단건 Embedding API)로 만들었다. + +### 3.4 측정 방식 8가지 + +| 방식 | 설정 | 의미 | +|---|---|---| +| `exact` | HNSW Index를 Transaction 안에서 지우고 Rollback | **Recall 정답** | +| `planner_default` | Index 있음, 기본 설정, Custom Plan | **현재 운영이 고르는 계획** | +| `planner_default_generic` | 위와 같고 `plan_cache_mode = force_generic_plan` | Prepared Statement가 재사용된 뒤의 현재 운영 | +| `hnsw_ef40`·`hnsw_ef100`·`hnsw_ef200` | `enable_sort = off`로 HNSW를 쓰게 하고 `ef_search` 변경 | HNSW가 선택됐을 때 | +| `iterative_relaxed`·`iterative_strict` | 위와 같고 `ef_search` 40 + `iterative_scan` | HNSW + 반복 스캔 | + +- **처음 계획은 `ef_search`·`iterative_scan`만 바꾸는 것이었다.** 예비 실행에서 planner가 어떤 설정에서도 HNSW를 쓰지 않아 모든 방식의 결과가 같았다. 그래서 가설을 확인하려면 HNSW를 쓰게 할 장치가 필요했다. +- `enable_sort = off`는 정렬이 필요한 계획에 큰 비용을 붙여 HNSW 정렬 Index Scan을 고르게 한다. 운영 설정이 아니라 실험 장치다. 단어 갈래 조회에는 영향을 주지 않도록 `findTopK` 직전에만 켜고 직후에 되돌렸다. +- **`plan_cache_mode`는 모든 방식에서 명시했다.** 기본값 `auto`로 둔 예비 실행에서 같은 SQL을 반복할수록 지연이 수 초로 뛰어 실행 순서에 따라 결과가 달라졌다. + +### 3.5 측정 항목 + +- **구조:** 운영 `VectorSearchQueryService`(벡터 단독)와 `HybridSearchQueryService`(하이브리드, 기본 경로)를 그대로 호출했다. 그 아래 `VectorSearchRepository`만 반환 행을 기록하는 Proxy로 감쌌다. +- **SQL 후보 수:** `findTopK`가 돌려준 행 수 +- **최종 건수:** 서비스가 최소 유사도·문서당 2개 상한을 적용한 뒤 돌려준 개수 +- **Recall:** `|측정 ∩ Exact| / |Exact|`(청크 ID 기준). SQL 단계와 최종 단계를 따로 계산하며, Exact 결과가 비면 계산하지 않는다. +- **지연:** 서비스 호출 한 번의 시간(JDBC·매핑 포함, 질문 Embedding 제외). 예열 1회 + 측정 3회의 nearest-rank p50·p95다(질문 30개 × 3회 = 표본 90개). Generic Plan 방식은 한 번이 수 초라 예열 0회·측정 1회다(표본 30개). +- **실행 계획:** 조합·질문마다 운영 `findTopK` SQL에 `EXPLAIN (ANALYZE, BUFFERS)`를 붙여 HNSW 사용 여부와 행 수를 남겼다. Custom Plan 방식은 EXPLAIN 결과 행 수와 서비스가 받은 SQL 행 수가 모든 행에서 같았다. +- **조합:** 2개 규모 × 허용 비율 4개 × topK 3개(5·10·20, 화면에서 고를 수 있는 값) × 방식 8개 × 질문 30개 = 5,760행이다. 반복 측정끼리 결과가 달라진 조합은 0개였다. + +## 4. 결과 + +### 4.1 현재 운영 계획은 HNSW를 쓰지 않는다 + +`planner_default`와 `planner_default_generic`은 두 규모·모든 비율·모든 topK의 720개 질문·조합에서 HNSW를 한 번도 쓰지 않았다. 결과는 Exact와 모두 같았다(SQL·최종 Recall 1.00). + +5만 청크·허용 100%·topK 10의 실행 계획 발췌다([plans-psql.txt](evidence/issue-545/plans-psql.txt)). + +```text +Limit (cost=2083.56..2083.59 rows=9) (actual rows=40) + -> Sort Sort Key: ((e.vector <=> '<질문 벡터 1024차원>')) Sort Method: top-N heapsort + -> Nested Loop (cost=254.45..2083.42 rows=9) (actual rows=50008) + -> Hash Join (rows=9) (actual rows=50008) + Hash Cond: ((e.document_id = d.id) AND (e.document_version_id = d.current_version_id)) + -> Index Scan using idx_embeddings_document_id on embeddings e (actual rows=50008) + -> Seq Scan on documents d (actual rows=3559) + -> Index Scan using document_chunks_pkey on document_chunks dc +Execution Time: 338.816 ms +``` + +조인 추정 9행과 실제 50,008행의 차이가 HNSW를 고르지 않는 직접 원인이다. 추정은 규모가 커져도 "임베딩 수 ÷ 문서 수" 수준에 머문다. 따라서 이 쿼리 구조에서는 청크가 더 늘어도 planner가 HNSW로 바꿀 가능성이 낮다. 다만 이번에 측정하지 않은 규모에 대한 추정이다. + +### 4.2 H2: 허용 100%에서도 HNSW `ef_search` 40은 40개에서 멈춘다 + +![허용 100%에서 SQL이 돌려준 후보 수](evidence/issue-545/h2-sql-candidates.svg) + +| 규모 | topK (SQL 요청) | Exact = 현재 계획 SQL 후보 | HNSW 강제 ef40 SQL 후보 (SQL Recall) | ef100 SQL 후보 | 최종 결과: Exact / ef40 (topK 미달 질문) | +|---|---|---:|---:|---:|---| +| 5만 청크 | 5 (20) | 20.0 | 20.0 (0.92) | 20.0 | 5.00 / 5.00 (0 / 0) | +| 5만 청크 | 10 (40) | 40.0 | 40.0 (0.92) | 40.0 | 9.93 / 9.83 (1 / 3) | +| 5만 청크 | **20 (80)** | **80.0** | **40.0 (0.48)** | 80.0 | **19.10 / 17.27 (5 / 15)** | +| 실제 3,572 | 5 (20) | 20.0 | 20.0 (0.85) | 20.0 | 4.93 / 4.93 (2 / 2) | +| 실제 3,572 | 10 (40) | 40.0 | 40.0 (0.86) | 40.0 | 10.00 / 10.00 (0 / 0) | +| 실제 3,572 | **20 (80)** | **80.0** | **40.0 (0.50)** | 80.0 | **20.00 / 18.57 (0 / 10)** | + +- topK 20에서 ef40은 30개 질문 모두 정확히 40개를 돌려줬다(최소·최대 40). +- topK 5·10(요청 20·40)에서는 상한에 걸리지 않지만, HNSW 근사 탐색이라 SQL Recall이 0.85~0.92다. ef200이면 0.97~0.98이다. + +### 4.3 H1: 허용 비율이 낮을수록 HNSW 결과가 사라진다 + +![허용 문서 비율별 벡터 검색 최종 결과 수](evidence/issue-545/h1-final-results.svg) + +5만 청크·topK 10(SQL 요청 40)이다. 칸 값은 "SQL 후보 → 최종 결과 (최종 Recall)"이며, 모두 질문 30개 평균이다. + +| 허용 비율 | Exact = 현재 계획 최종 | HNSW 강제 ef40 | HNSW 강제 ef200 | iterative relaxed | iterative strict | +|---|---:|---|---|---|---| +| 100% | 9.93 | 40.0 → 9.83 (0.91) | 40.0 → 9.90 (0.97) | 40.0 → 9.83 (0.91) | 40.0 → 9.83 (0.91) | +| 10% | 10.00 | **2.6 → 1.77 (0.18)** | 14.3 → 5.97 (0.57) | **40.0 → 10.00 (0.87)** | 40.0 → 10.00 (0.72) | +| 1% | 9.97 | **0.2 → 0.17 (0.02)** | 1.9 → 0.60 (0.06) | 40.0 → 9.97 (0.94) | 40.0 → 9.93 (0.76) | +| 0.1% | 7.90 | **0.0 → 0.00 (0.00)** | 0.0 → 0.03 (0.00) | 16.5 → 6.73 (0.68) | 15.8 → 6.73 (0.61) | + +같은 조건의 실제 데이터(합성 0%)에서도 같은 경향이다. + +| 허용 비율 | Exact = 현재 계획 최종 | HNSW 강제 ef40 | HNSW 강제 ef200 | iterative relaxed | iterative strict | +|---|---:|---|---|---|---| +| 100% | 10.00 | 40.0 → 10.00 (0.82) | 40.0 → 10.00 (0.96) | 40.0 → 10.00 (0.82) | 40.0 → 10.00 (0.82) | +| 10% | 10.00 | **4.4 → 2.47 (0.24)** | 17.8 → 8.57 (0.84) | 40.0 → 10.00 (0.94) | 40.0 → 10.00 (0.85) | +| 1% | 6.00 | 0.4 → 0.40 (0.07) | 1.4 → 0.97 (0.16) | 34.0 → 6.00 (1.00) | 33.8 → 5.97 (0.96) | +| 0.1% | 2.00 | 0.2 → 0.23 (0.12) | 0.5 → 0.47 (0.23) | 10.0 → 2.00 (1.00) | 9.9 → 2.00 (0.97) | + +- **ef40의 SQL 후보 수는 대략 "40 × 허용 청크 비율"이다.** 5만 청크·10%에서 2.6개였다. `ef_search`를 200으로 올려도 10%에서 14.3개에 그쳐 topK 10을 채우지 못했다(23/30 질문 미달). +- **iterative relaxed는 10%·1%에서 개수를 모두 되찾는다.** 다만 Recall이 0.87~0.94라 일부 청크는 Exact와 다르다. +- **5만 청크·0.1%에서는 iterative도 16.5개만 돌려줬다.** 허용 청크 40개가 전체의 0.08%뿐이라, 반복 스캔 상한 `hnsw.max_scan_tuples`(20,000)을 다 써도 약 16개(20,000 × 40 / 50,008)만 만난다. 측정값 범위는 10~27개였다. +- **iterative strict는 relaxed보다 Recall이 낮았다**(5만 청크·10%: 0.72 vs 0.87). 이번에는 관측만 하고 원인은 분석하지 않았다. + +### 4.4 원인 분리 + +벡터 단독 경로, topK 10이다. 단위는 질문당 평균 결과 수다. + +| 규모 | 허용 비율 | topK − Exact
(권한 범위·앱 필터) | Exact − 현재 계획
(DB 후보 부족) | Exact − HNSW 강제 ef40
(DB 후보 부족) | Exact − iterative relaxed
(DB 후보 부족) | +|---|---|---:|---:|---:|---:| +| 5만 청크 | 100% | 0.07 | 0 | 0.10 | 0.10 | +| 5만 청크 | 10% | 0.00 | 0 | 8.23 | 0.00 | +| 5만 청크 | 1% | 0.03 | 0 | 9.80 | 0.00 | +| 5만 청크 | 0.1% | 2.10 | 0 | 7.90 | 1.17 | +| 실제 3,572 | 100% | 0.00 | 0 | 0.00 | 0.00 | +| 실제 3,572 | 10% | 0.00 | 0 | 7.53 | 0.00 | +| 실제 3,572 | 1% | 4.00 | 0 | 5.60 | 0.00 | +| 실제 3,572 | 0.1% | 8.00 | 0 | 1.77 | 0.00 | + +- **"권한 범위·앱 필터" 부족은 문서당 2개 상한과 허용 문서 수에서 나온다.** 허용 문서가 1건(실제 0.1%)이면 최대 2개, 3건(실제 1%)이면 최대 6개, 4건(5만 청크 0.1%)이면 최대 8개다. 검색 결과가 한 문서에 몰리지 않게 하려는 의도된 동작이다. +- **현재 운영 계획의 DB 후보 부족은 모든 조합에서 0이다.** +- 개수가 같아도 Recall이 1보다 작으면 다른 청크가 섞였다는 뜻이다(예: iterative relaxed 5만 청크·10%, 10개 중 Recall 0.87). + +### 4.5 하이브리드 경로 (기본 검색 경로) + +5만 청크·topK 10의 최종 결과 수다. + +| 허용 비율 | 하이브리드: Exact = 현재 계획 | 하이브리드: HNSW 강제 ef40 | 벡터 단독: HNSW 강제 ef40 | +|---|---:|---:|---:| +| 100% | 10.00 | 10.00 | 9.83 | +| 10% | 10.00 | 5.10 | 1.77 | +| 1% | 9.37 | 3.17 | 0.17 | +| 0.1% | 5.07 | 1.47 | 0.00 | + +- **하이브리드도 같은 `findTopK`를 쓰므로, HNSW가 선택되면 같은 누락이 생긴다.** 단어 갈래 조회는 HNSW를 쓰지 않아 일부를 메운다(10%: 1.77 → 5.10). +- **하이브리드의 Exact 결과가 벡터 단독보다 적은 조건이 있다.** 예를 들어 실제 데이터·허용 10%·topK 10에서 하이브리드는 7.60개, 벡터 단독은 10.00개였다. 하이브리드가 벡터 유사도 기준을 0.45로 더 엄격하게 쓰기 때문이며, 앱 필터 쪽 원인이다. + +### 4.6 지연 + +5만 청크·topK 10의 서비스 호출 지연이다. 단위는 ms, 표기는 p50 / p95다. + +| 허용 비율 | Exact | 현재 계획 Custom | 현재 계획 Generic | HNSW 강제 ef40 | HNSW 강제 ef200 | iterative relaxed | +|---|---|---|---|---|---|---| +| 벡터 100% | 212.9 / 268.3 | 204.2 / 264.7 | **4,226.3 / 4,674.7** | 15.7 / 20.4 | 17.0 / 23.7 | 15.9 / 18.8 | +| 벡터 10% | 27.7 / 40.1 | 28.1 / 37.8 | **480.5 / 597.4** | 8.1 / 14.6 | 10.6 / 14.8 | 15.8 / 23.3 | +| 벡터 1% | 7.3 / 9.3 | 7.3 / 11.9 | 43.2 / 49.0 | 6.8 / 10.9 | 8.1 / 12.6 | 49.0 / 73.2 | +| 벡터 0.1% | 4.3 / 5.9 | 3.6 / 5.5 | 7.9 / 8.9 | 6.0 / 7.2 | 6.6 / 8.2 | 50.1 / 72.0 | +| 하이브리드 100% | 1,328.3 / 1,913.3 | 1,338.4 / 1,869.2 | **5,444.6 / 6,157.0** | 1,128.4 / 1,730.9 | 1,137.4 / 1,633.0 | 1,123.1 / 1,676.1 | +| 하이브리드 10% | 160.7 / 250.2 | 164.1 / 241.4 | 609.4 / 777.4 | 133.0 / 197.1 | 138.3 / 198.6 | 151.5 / 215.4 | + +- **현재 계획(Custom)의 벡터 지연은 허용 청크 수에 비례해 늘어난다.** 50,008개를 모두 거리 계산하면 약 204ms다. +- **HNSW + iterative는 허용 청크가 많을 때 빠르고 적을 때 느리다.** 허용 100%에서는 Exact의 약 1/13이지만, 허용 1% 이하에서는 반복 스캔 때문에 Exact보다 약 7~14배 느렸다. +- **Generic Plan은 허용 청크가 많을수록 손해가 크다.** 100%에서 20.7배, 10%에서 17.1배였다. 실제 데이터·100%·topK 10에서도 17.8ms → 300.1ms로 16.9배였다. +- **하이브리드는 5만 청크·허용 100%에서 p50 1.3초다.** 벡터 방식과 거의 무관한 단어 갈래 비용이 대부분이다. #456은 83개 청크에서만 측정했으므로, 수만 청크에서의 단어 갈래 비용은 별도로 봐야 한다(이번 범위 밖). + +### 4.7 Generic Plan 증빙 (psql) + +5만 청크 데이터에서 운영 `findTopK` SQL을 그대로 `PREPARE`하고 시험과 같은 질문(D01)·허용 문서로 `EXPLAIN (ANALYZE, BUFFERS) EXECUTE`했다. 전문은 [plans-psql.txt](evidence/issue-545/plans-psql.txt)에 있다. + +| 경우 | Execution Time | +|---|---:| +| 허용 100%·topK 10·Custom Plan (첫 실행, 디스크 읽기 포함) | 338.8 ms | +| 허용 100%·topK 10·Generic Plan | 2,129.4 ms | +| 허용 10%·topK 10·Custom Plan / Generic Plan | 16.7 ms / 219.8 ms | +| `auto` 모드에서 같은 Statement 반복 1~5회째 | 140.4~144.0 ms | +| `auto` 모드에서 같은 Statement 반복 **6~8회째** | **2,099.0~2,114.9 ms** | +| 권고안 검증: 질문 Vector를 `(SELECT CAST($1 AS vector))`로 바꾼 SQL, Custom / **Generic** | 142.5 ms / **162.8 ms** | +| 허용 100%·topK 20·HNSW 강제 ef40 / iterative relaxed | 결과 40행 / 80행 | +| 허용 10%·topK 10·HNSW 강제 ef40 / iterative relaxed | 결과 1행 / 40행 | + +- **Generic Plan의 정렬 키는 `(e.vector <=> ($1)::vector)`다.** 계획 모양은 Custom Plan과 같지만, 행마다 `$1`을 Vector로 변환한다. +- **운영에서도 일어날 수 있다.** JDBC 드라이버 기본값(`prepareThreshold=5`)은 같은 SQL을 5번 넘게 실행하면 Server Prepared Statement를 쓴다. 이후 PostgreSQL은 6번째 실행부터 Generic Plan을 고를 수 있다. 허용 문서 수가 같은 사용자의 검색은 SQL 문자열(IN 목록의 자리표시자 수)이 같으므로, Connection Pool에서 반복되면 이 조건에 들어간다. +- **측정 경로에서도 관측했다.** `plan_cache_mode`를 명시하지 않은 예비 실행에서, 반복될수록 5만 청크·100%의 벡터 지연이 약 4.1초로 뛰었다. + +### 4.8 Index 크기와 생성 시간 + +| 규모 | HNSW 생성 시간 | HNSW 크기 | `embeddings` 전체(TOAST·Index 포함) | +|---|---:|---:|---:| +| 실제 3,572 청크 | 0.93 s | 27.9 MiB | 48.2 MiB | +| 5만 청크 | 26.73 s | 390.7 MiB | 668.3 MiB | + +`maintenance_work_mem` 64MB 기본값에서 측정했다. 현재 검색 SQL은 이 Index를 쓰지 않으므로, 지금은 저장 공간과 쓰기 비용만 들고 검색 이득은 없다. + +### 4.9 전체 표와 원시 데이터 + +| 파일 | 내용 | +|---|---| +| [tables.md](evidence/issue-545/tables.md) | 규모 × topK별 모든 비율·방식 요약 표 | +| [summary.csv](evidence/issue-545/summary.csv) | 192개 조합 요약 | +| [rows.csv.gz](evidence/issue-545/rows.csv.gz) | 5,760개 측정 행. 반복 측정 지연 표본과 실행 계획 요약 포함 | +| [results.json.gz](evidence/issue-545/results.json.gz) | 하네스 원본 출력. 환경·설정·데이터 통계 포함 | +| [plans-psql.txt](evidence/issue-545/plans-psql.txt) | 4.7의 psql 실행 계획 전문(질문 Vector·허용 문서 ID 목록은 가림) | + +## 5. 권고안 + +운영 코드·설정은 이번에 바꾸지 않았다. 아래는 기능테스트(10/28) 이후 별도 [Fix]에서 다시 측정한 뒤 적용할 후보다. + +| 우선순위 | 권고 | 근거 | 확인할 것 | +|---|---|---|---| +| 1 | **질문 Vector를 한 번만 계산하도록 SQL을 바꾼다.** `CAST(:queryVector AS vector)` → `(SELECT CAST(:queryVector AS vector))`. 대안은 Vector 타입으로 Bind하거나, 이 쿼리만 Custom Plan을 강제(`prepareThreshold=0` 등)하는 것이다 | Generic Plan 2,129ms → 162.8ms(psql). 권한과 무관하게 지연을 15~21배 줄인다 | 서비스 경로 재측정. 세 Repository 쿼리에 같은 규칙을 적용할지 | +| 2 | **지금 규모에서는 현재 계획(사실상 Exact)을 유지한다** | 권한 필터로 결과를 잃지 않는다(DB 후보 부족 0) | 청크 수가 더 늘었을 때 허용 100% 지연 | +| 3 | **HNSW 경로를 도입한다면 `hnsw.iterative_scan = relaxed_order`를 필수로 둔다** | `ef_search` 200도 허용 10%에서 topK 10을 못 채움. iterative relaxed는 10%·1%에서 개수를 모두 회복 | relaxed는 거리 순서가 조금 어긋날 수 있음. 앱이 다시 정렬하지 않으므로 영향 확인 | +| 4 | **허용 비율에 따라 계획을 나눠야 한다.** 허용 청크가 적으면 Exact, 많으면 HNSW + iterative | 허용 1% 이하에서 Exact 4~7ms vs iterative 49~50ms. 0.1%에서는 `max_scan_tuples` 상한으로 iterative도 일부만 찾음 | 조인 오추정(9행) 때문에 planner가 스스로 나누지 못함. 쿼리 구조 변경이 필요 | +| 5 | **안전장치로 `hnsw.iterative_scan = relaxed_order`를 기본값으로 둘지 검토한다** | HNSW가 선택되지 않으면 영향이 없고, 선택되는 순간의 누락을 막는다 | 3과 같은 순서 문제 | + +## 6. 한계 + +- **5만 청크 데이터의 92.9%가 합성이다.** 합성 방법이 실제 Embedding 분포를 완전히 재현한다고 보장하지 않는다. 대신 합성 0%인 실제 데이터(3,572 청크)에서도 H1·H2와 원인 분리 결론이 같은지 함께 확인했다. +- **권한은 질문 주제와 무관하게 무작위로 골랐다.** 부서·컬렉션처럼 주제와 상관된 권한에서는 숫자가 달라질 수 있다. +- **`enable_sort = off`는 실험 장치다.** 쿼리를 바꿔 HNSW를 쓰게 했을 때의 실제 계획과 다를 수 있다. +- **한 대의 로컬 장비, PostgreSQL 기본 설정, 질문 30개만 썼다.** OpenSQL 서버 성능이나 운영 SLO가 아니다. +- **지연은 서비스 메서드 호출 기준이다.** HTTP·인증·질문 Embedding·live check·결과 저장은 포함하지 않았다. +- **조합별 EXPLAIN은 Custom Plan이다.** Generic Plan의 실행 계획은 4.7의 psql 증빙으로만 확인했다. +- **iterative strict가 relaxed보다 Recall이 낮은 원인은 분석하지 않았다.** + +## 7. 다음 단계: DiskANN(pgvectorscale) 비교 절차 + +이번에는 실행하지 않았다. 다음 단계에서 OpenSQL VM으로 같은 시험을 돌릴 때의 절차다. + +### 0단계: 확장 확인 + +#124 단일 모드 OpenSQL 환경에는 pgvectorscale 0.9.0이 포함돼 있었지만, 실제로 만든 확장은 `vector`뿐이었다. 2026-09-23 GCP 3노드 VM에는 선택 항목인 pgvectorscale을 설치하지 않았다([gimin-opensql-three-node-runtime-activation-20260923.md](gimin-opensql-three-node-runtime-activation-20260923.md)). 따라서 먼저 다음을 확인한다. + +```sql +SELECT name, default_version, installed_version +FROM pg_available_extensions WHERE name IN ('vector', 'vectorscale'); +-- 목록에 없으면 OpenSQL 배포본의 pgvectorscale 패키지를 시험 노드에 설치한 뒤 다시 확인한다. +CREATE EXTENSION IF NOT EXISTS vectorscale CASCADE; -- 격리 시험 DB에서만 +SELECT extversion FROM pg_extension WHERE extname = 'vectorscale'; +``` + +### 1~3단계: 같은 시험 반복 + +1. **데이터 적재:** 이번 하네스로 같은 Seed의 데이터를 격리 스키마에 적재한다. Index를 한 번에 하나만 두고 `CREATE INDEX … USING diskann (vector vector_cosine_ops)`(StreamingDiskANN)로 만든 뒤 생성 시간·크기를 기록한다. +2. **방식 추가:** `PermissionRecallBenchmark.Method`에 DiskANN 방식을 더한다(시험 코드 변경). `enable_sort = off`로 DiskANN을 쓰게 하고 `diskann.query_search_list_size`(기본 100)와 `diskann.query_rescore`(기본 50)를 바꾼다. StreamingDiskANN은 필터를 통과하는 행이 LIMIT을 채울 때까지 계속 스트리밍하므로, HNSW iterative relaxed가 주 비교 대상이다. +3. **측정:** 같은 비율 × topK × 질문으로 Exact 기준 Recall·지연·실행 계획을 잰다. 허용 0.1%처럼 iterative가 상한에 걸린 구간이 핵심이다. + +**범위 밖으로 두는 것** +- pgvectorscale의 라벨 기반 필터는 권한을 Index에 복제한다. 권한 회수 직후 갱신이 늦어지는 문제가 있어 이번 비교에서 뺀다. +- 채택은 대회 후에 한다. README 빠른 실행과 CI가 쓰는 `pgvector/pgvector` 이미지에 vectorscale이 없기 때문이다. + +## 8. 재현 방법 + +격리 DB를 새로 띄우고 접속 정보를 환경변수로 넘긴다. 지정하지 않으면 시험 기본 접속 정보로 다른 DB에 스키마를 만들 수 있다. + +```bash +docker run -d --name docgrid-bench545-pg -e POSTGRES_DB=bench545 -e POSTGRES_USER=bench545 \ + -e POSTGRES_PASSWORD=<로컬 비밀번호> -p 127.0.0.1:55471:5432 \ + -v "$PWD/docker/postgres/init/001-enable-vector.sql:/docker-entrypoint-initdb.d/001-enable-vector.sql:ro" \ + pgvector/pgvector:0.8.1-pg17@sha256:3e8b3adfd27b5707128f60956f62a793c3c9326ea8cfaf0eab7adccb5d700b21 + +export DB_HOST=127.0.0.1 DB_PORT=55471 DB_NAME=bench545 DB_USER=bench545 DB_PASSWORD=<로컬 비밀번호> DB_SSLMODE=disable + +# 1) 실제 문서·질문 Embedding 캐시 (Embedding 서버 BGE-M3 필요, 약 70분) +./backend/gradlew -p backend vectorSearchPerformanceTest --tests '*PermissionRecallBenchmark' \ + -Dvector.search.performance.permission.phase=prepare + +# 2) 측정 (약 95분). 결과: backend/build/reports/permission-recall/results.json, plans.json +./backend/gradlew -p backend vectorSearchPerformanceTest --tests '*PermissionRecallBenchmark' \ + -Dvector.search.performance.permission.phase=measure -Dvector.search.performance.permission.drop-schema=false + +# 3) 표·그래프·CSV +python3 docs/test-results/evidence/issue-545/make_report.py backend/build/reports/permission-recall/results.json docs/test-results/evidence/issue-545 + +# 4) psql 실행 계획 증빙 (2를 drop-schema=false로 실행해 5만 청크가 남아 있을 때) +python3 docs/test-results/evidence/issue-545/explain_plans.py backend backend/build/reports/permission-recall/plans.json > plans.sql +docker exec -i docgrid-bench545-pg psql -U bench545 -d bench545 -q < plans.sql > plans-raw.txt +python3 docs/test-results/evidence/issue-545/explain_plans.py --mask plans-raw.txt > docs/test-results/evidence/issue-545/plans-psql.txt +``` + +- 시험은 `vector.search.performance.permission.phase` 시스템 속성이 있을 때만 실행되므로, 기존 `vectorSearchPerformanceTest`(#131)와 일반 `test`에는 영향이 없다. +- 규모·비율·topK·방식·질문 수는 `scales`·`ratios`·`top-ks`·`methods`·`question-limit` 속성으로 바꿀 수 있다. + +시험 코드는 `backend/src/test/java/com/opensource/docgrid/domain/search/benchmark/`의 다음 클래스다. + +| 클래스 | 역할 | +|---|---| +| `PermissionRecallBenchmark` | 적재·방식 전환·측정·보고서 | +| `PermissionRecallCorpus` | 실제 문서를 운영 처리 경로로 Embedding하고 캐시 | +| `PermissionRecallDataset` | 결정적 합성 확장·허용 문서 선택 | +| `PermissionRecallMetrics` | Recall·백분위 | +| `PermissionRecallDatasetTest` | 위 계산의 단위 테스트 | + +## 9. 자동 테스트 결과 + +### 9.1 시험 하네스 단위 테스트 + +```text +./backend/gradlew -p backend test --tests '*PermissionRecallDatasetTest' +PermissionRecallDatasetTest: tests=7, skipped=0, failures=0, errors=0 +``` + +확인하는 것은 다음과 같다. +- 정상 케이스: 합성 Vector가 단위 길이이고 원본과의 유사도가 지정값과 같다. 같은 Seed면 같은 결과를 만든다. 허용 문서가 중첩되고 최소 1건이다. Recall·백분위가 정확하다. +- 경계·예외 케이스: 섞을 Vector가 원본과 같은 방향이면 무작위 직교 방향으로 대신한다. 목표가 실제 청크 수 이하면 실제 문서만 쓴다. 정답 집합이 비면 Recall을 계산하지 않는다. + +### 9.2 전체 테스트 + +격리 DB(위 컨테이너, 시험 스키마는 `docgrid_test` 등 별도)와 격리 Valkey 컨테이너에 연결해 CI와 같은 설정으로 실행했다. 설정은 `SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE=3`, CI 전용 `JWT_SECRET`이다. + +```text +./backend/gradlew -p backend test # PR 기준 develop 39dbfb9 + 이번 변경 +BUILD SUCCESSFUL in 1m 24s +테스트 클래스 252개, tests=1,508, skipped=5, failures=0, errors=0 +``` + +측정 기준 `ed5d601`에서 실행한 결과도 같았다(1,508개, 실패 0, 건너뜀 5). + +이번 변경은 시험 코드 5개 파일과 문서만 추가했다. 운영 코드·설정·마이그레이션 변경은 없다.