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); ```