From 58faea63d4e990336ca8bce17bc9505eb819959a Mon Sep 17 00:00:00 2001 From: jmj Date: Tue, 29 Sep 2026 20:26:42 +0900 Subject: [PATCH] =?UTF-8?q?fix(core):=20=ED=95=98=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=A6=AC=EB=93=9C=20=EA=B2=80=EC=83=89=EC=9D=98=20=ED=82=A4?= =?UTF-8?q?=EC=9B=8C=EB=93=9C=20=EC=A0=88=EB=B0=98=EC=9D=B4=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EC=A7=88=EC=9D=98=EC=97=90=EC=84=9C=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=88=EB=8F=84=20=EB=A7=A4=EC=B9=AD=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plainto_tsquery 는 토큰을 전부 AND 로 묶는다. 그런데 이 검색의 질의는 꼬리질문이면 "직전질문 + 답변", 질문 풀이면 "모드 + 직군 + 자기소개 600자"라 토큰이 10~90개다. 그 전부를 담은 청크는 존재하지 않으므로 full-text 브랜치가 항상 0건이었고, RRF 가 조용히 벡터 단독 검색으로 퇴화했다. 운영 실측: 꼬리질문 질의 47/47, 질문 풀 질의 30/30 이 0건. 구현돼 있고 테스트도 통과하지만 실제 질의 형태에서는 무효인 상태였다. tsquery 를 OR 로 결합한다. 운영과 같은 헤딩 청킹 인덱스에서 질의 47개, top_k=5 기준: MRR 0.791 → 0.885 (paired bootstrap 95% CI +0.029~+0.162, 12승/3패) Top-1 0.681 → 0.830 (CI +0.043~+0.277, 8승/1패) Recall 0.936 → 0.979 후보 풀 :cand 은 손대지 않았다 — 측정한 구성이 cand = top_k 였고, 바꾸면 측정하지 않은 것을 배포하는 셈이 된다. 회귀 테스트는 벡터만으로는 top_k 밖인 청크를 키워드로만 끌어올리게 만들어 두 절반을 구분한다. 미끼를 top_k 만큼 채우지 않으면 벡터가 어차피 전부 반환해 AND 에서도 통과한다(처음에 그렇게 써서 변별력이 없었다). 기존 헬퍼 vector(head) 는 0번 차원의 크기만 바꿔 모든 벡터가 평행이라 코사인 거리가 언제나 0이었다 — 직교 단위벡터 헬퍼 axis(dim) 를 추가했다. AND 로 되돌리면 이 테스트가 실패하는 것을 확인했다. --- .../JdbcDocumentEmbeddingRepository.java | 27 +++++++-- .../DocumentEmbeddingSearchTest.java | 58 +++++++++++++++++++ docs/api-conventions.md | 2 + docs/database.md | 2 + 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java b/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java index 9cd7cbb0..8e1e8c6a 100644 --- a/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java +++ b/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java @@ -140,6 +140,19 @@ private List searchVectorOnly( // 벡터(코사인) 랭킹과 full-text(ts_rank_cd) 랭킹을 각각 구한 뒤 // RRF(Reciprocal Rank Fusion, k=60): score = 1/(k+rank) 합으로 융합한다. // 점수 스케일이 다른 두 랭킹을 "순위"만으로 합치므로 가중치 튜닝이 불필요. + // + // **tsquery 는 OR 로 묶는다.** `plainto_tsquery` 는 토큰을 전부 `&`(AND)로 묶는데, 이쪽 + // 호출자의 질의는 "직전질문 + 답변"(꼬리질문)이나 "모드 + 직군 + 자기소개 600자" + // (질문 풀)라 토큰이 10~90개다. 그 전부를 담은 청크는 존재하지 않으므로 full-text + // 브랜치가 **항상 0건**이었고, RRF 가 조용히 벡터 단독으로 퇴화했다 — 운영 실측으로 + // 꼬리질문 47/47, 질문 풀 30/30 이 0건이었다. 구현돼 있고 테스트도 통과하지만 실제 + // 질의 형태에서는 무효인 상태였다. + // + // OR 로 바꾼 효과(운영과 같은 헤딩 청킹 인덱스, 질의 47개, top_k=5): + // MRR 0.791 → 0.885, Top-1 0.681 → 0.830, Recall 0.936 → 0.979 + // (paired bootstrap 95% CI: MRR +0.029~+0.162, Top-1 +0.043~+0.277). + // 후보 풀 `:cand` 은 손대지 않았다 — 측정한 구성이 `cand = top_k` 였고, 바꾸면 + // 측정하지 않은 것을 배포하는 셈이 된다. private List searchHybrid( float[] queryEmbedding, String queryText, @@ -153,7 +166,13 @@ private List searchHybrid( // 쪼개면 `.formatted` 가 마지막 조각에만 걸려 placeholder 가 밀린다(실제로 그렇게 깨졌다). // 조각을 넣어야 하면 여기처럼 %%s 인자로 주입한다 — 순서는 등장 순. String sql = """ - WITH v AS ( + WITH tq AS ( + -- plainto_tsquery 의 AND 결합을 OR 로 바꾼다. 어휘소 사이의 구분자만 + -- 치환하므로 어휘소 안의 문자는 건드리지 않는다(`&` 는 구분자라 어휘소가 + -- 되지 못한다). 빈 질의는 빈 tsquery 가 되어 0건 — 현행과 같다. + SELECT replace(plainto_tsquery('simple', :qtext)::text, ' & ', ' | ')::tsquery AS tsq + ), + v AS ( SELECT e.document_id, e.chunk_index, e.chunk_text, (e.embedding <=> CAST(:qvec AS vector)) AS distance, ROW_NUMBER() OVER (ORDER BY e.embedding <=> CAST(:qvec AS vector)) AS rnk @@ -166,13 +185,13 @@ WITH v AS ( t AS ( SELECT e.document_id, e.chunk_index, e.chunk_text, ROW_NUMBER() OVER ( - ORDER BY ts_rank_cd(e.chunk_text_tsv, plainto_tsquery('simple', :qtext)) DESC + ORDER BY ts_rank_cd(e.chunk_text_tsv, (SELECT tsq FROM tq)) DESC ) AS rnk FROM document_embeddings e %s - WHERE e.chunk_text_tsv @@ plainto_tsquery('simple', :qtext) + WHERE e.chunk_text_tsv @@ (SELECT tsq FROM tq) %s - ORDER BY ts_rank_cd(e.chunk_text_tsv, plainto_tsquery('simple', :qtext)) DESC + ORDER BY ts_rank_cd(e.chunk_text_tsv, (SELECT tsq FROM tq)) DESC LIMIT :cand ) SELECT COALESCE(v.document_id, t.document_id) AS document_id, diff --git a/backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java b/backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java index 4a43524f..451c0f06 100644 --- a/backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java +++ b/backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java @@ -100,6 +100,56 @@ void hybridSearchExcludesDeletedDocuments() { assertThat(hits).isEmpty(); } + /** + * 하이브리드의 full-text 절반이 실제 질의 형태에서 죽어 있지 않아야 한다. + * + *

`plainto_tsquery` 는 토큰을 전부 AND 로 묶는다. 그런데 이 검색의 질의는 + * 꼬리질문이면 "직전질문 + 답변", 질문 풀이면 "모드 + 직군 + 자기소개 600자"라 + * 토큰이 수십 개다 — 그 전부를 담은 청크는 없으므로 full-text 브랜치가 **항상 0건**이었고 + * RRF 가 조용히 벡터 단독으로 퇴화했다(운영 실측 47/47, 30/30 이 0건). + * + *

고장이 "결과가 없다"가 아니라 "벡터 결과와 같다"로 나타나 눈에 띄지 않는다. 그래서 + * **벡터만으로는 top_k 밖인 청크를 키워드로만 끌어올리게** 만들어 두 절반을 구분한다 — + * 미끼를 top_k 만큼 채우지 않으면 벡터가 어차피 전부 반환해 AND 에서도 통과한다. + */ + @Test + void hybridMatchesOnAnySignificantTokenNotAllOfThem() { + AnalyzedDocument doc = document(97010L, "or-tsquery"); + List chunks = new java.util.ArrayList<>(); + // 질의와 같은 방향(=가까운) 미끼를 top_k 개. 질의 토큰은 하나도 담지 않는다. + for (int i = 0; i < 5; i++) { + chunks.add(new EmbeddingChunk(i, "미끼 문단 " + i, axis(0))); + } + // 질의 토큰 중 "카프카" 하나만 담은 청크. 방향이 직교라 벡터로는 꼴찌다. + chunks.add(new EmbeddingChunk(5, "카프카 컨슈머 랙을 줄인 경험", axis(1))); + embeddingRepository.upsertAll(doc.getId(), "test-model", chunks); + + // 실제 호출자를 흉내 낸 여러 토큰 질의 — 이 전부를 담은 청크는 없다. + String query = "앞서 말씀하신 카프카 이야기를 이어가면 파티션 재배치와 리밸런싱을 " + + "어떻게 처리하셨는지 궁금합니다"; + + List hits = embeddingRepository.search(axis(0), query, List.of(doc.getId()), 5); + + assertThat(hits) + .as("키워드 절반이 죽어 있으면 벡터가 고른 미끼 5개만 남는다") + .extracting(SearchHit::chunkIndex) + .contains(5); + } + + // 질의에 어휘소가 없으면(구두점만 등) tsquery 가 비는데, 그때도 깨지지 않고 + // 벡터 단독으로 동작해야 한다 — 수정 전과 같은 동작. + @Test + void hybridFallsBackToVectorWhenQueryHasNoLexemes() { + AnalyzedDocument doc = document(97011L, "empty-tsquery"); + embeddingRepository.upsertAll(doc.getId(), "test-model", + List.of(new EmbeddingChunk(0, "레디스 캐시 도입", axis(0)))); + + List hits = embeddingRepository.search( + axis(0), "!!! ??? ...", List.of(doc.getId()), 5); + + assertThat(hits).extracting(SearchHit::chunkIndex).containsExactly(0); + } + // 검색 범위는 호출자가 뭘 보내든 요청자 소유 문서를 벗어나면 안 된다. // 이전에는 documentIds 를 그대로 믿었고, 비면 전체 사용자 청크가 대상이었다. @Test @@ -168,6 +218,14 @@ private List search(List documentIds) { return embeddingRepository.search(vector(0.9f), null, documentIds, 10); } + // 서로 직교하는 단위 벡터. 기존 vector(head) 는 0번 차원만 크기를 달리해서 + // **모든 벡터가 평행**이다 — 코사인 거리가 언제나 0이라 순위가 갈리지 않는다. + private static float[] axis(int dim) { + float[] v = new float[1536]; + v[dim] = 1f; + return v; + } + private static float[] vector(float head) { float[] v = new float[1536]; v[0] = head; diff --git a/docs/api-conventions.md b/docs/api-conventions.md index 2fdef25a..7a8eefd4 100644 --- a/docs/api-conventions.md +++ b/docs/api-conventions.md @@ -425,6 +425,8 @@ HTTP/1.1 202 Accepted RAG 검색. AI가 질문 풀·꼬리질문·피드백 생성 시 컨텍스트 청크를 가져온다. `document_embeddings`에서 pgvector cosine topK — `queryText`가 주어지면 벡터 + full-text(BM25) RRF 하이브리드로 검색한다. +> full-text 쪽 tsquery 는 **OR 결합**이다(2026-09-29). `plainto_tsquery` 기본값인 AND 로는 이 API 의 실제 질의(꼬리질문 = 직전질문+답변, 질문 풀 = 모드+직군+자기소개 600자, 토큰 수십 개)를 전부 담은 청크가 없어 full-text 브랜치가 항상 0건이었고 RRF 가 벡터 단독으로 퇴화했다 — 운영 실측 47/47·30/30 이 0건. OR 로 바꾼 뒤 운영과 같은 헤딩 청킹 인덱스에서 MRR 0.791→0.885, Top-1 0.681→0.830, Recall 0.936→0.979. + **Request body** ```json { diff --git a/docs/database.md b/docs/database.md index 38375cef..80c8f8c8 100644 --- a/docs/database.md +++ b/docs/database.md @@ -344,6 +344,8 @@ CREATE TABLE document_embeddings ( CREATE INDEX idx_document_embeddings_hnsw ON document_embeddings USING hnsw (embedding vector_cosine_ops); -- 하이브리드 검색용 full-text 색인(V8). chunk_text 의 generated tsvector 컬럼. +-- 질의 쪽 tsquery 는 OR 결합으로 만든다(JdbcDocumentEmbeddingRepository) — AND 로는 +-- 실제 질의가 길어 이 색인이 한 번도 매칭되지 않았다. CREATE INDEX idx_document_embeddings_tsv ON document_embeddings USING GIN (chunk_text_tsv); ```