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개에서 멈춘다
+
+
+
+| 규모 | 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 결과가 사라진다
+
+
+
+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개 파일과 문서만 추가했다. 운영 코드·설정·마이그레이션 변경은 없다.