Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,19 @@ private List<SearchHit> 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<SearchHit> searchHybrid(
float[] queryEmbedding,
String queryText,
Expand All @@ -153,7 +166,13 @@ private List<SearchHit> 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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,56 @@ void hybridSearchExcludesDeletedDocuments() {
assertThat(hits).isEmpty();
}

/**
* 하이브리드의 full-text 절반이 실제 질의 형태에서 죽어 있지 않아야 한다.
*
* <p>`plainto_tsquery` 는 토큰을 전부 AND 로 묶는다. 그런데 이 검색의 질의는
* 꼬리질문이면 "직전질문 + 답변", 질문 풀이면 "모드 + 직군 + 자기소개 600자"라
* 토큰이 수십 개다 — 그 전부를 담은 청크는 없으므로 full-text 브랜치가 **항상 0건**이었고
* RRF 가 조용히 벡터 단독으로 퇴화했다(운영 실측 47/47, 30/30 이 0건).
*
* <p>고장이 "결과가 없다"가 아니라 "벡터 결과와 같다"로 나타나 눈에 띄지 않는다. 그래서
* **벡터만으로는 top_k 밖인 청크를 키워드로만 끌어올리게** 만들어 두 절반을 구분한다 —
* 미끼를 top_k 만큼 채우지 않으면 벡터가 어차피 전부 반환해 AND 에서도 통과한다.
*/
@Test
void hybridMatchesOnAnySignificantTokenNotAllOfThem() {
AnalyzedDocument doc = document(97010L, "or-tsquery");
List<EmbeddingChunk> 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<SearchHit> 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<SearchHit> hits = embeddingRepository.search(
axis(0), "!!! ??? ...", List.of(doc.getId()), 5);

assertThat(hits).extracting(SearchHit::chunkIndex).containsExactly(0);
}

// 검색 범위는 호출자가 뭘 보내든 요청자 소유 문서를 벗어나면 안 된다.
// 이전에는 documentIds 를 그대로 믿었고, 비면 전체 사용자 청크가 대상이었다.
@Test
Expand Down Expand Up @@ -168,6 +218,14 @@ private List<SearchHit> search(List<Long> 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;
Expand Down
2 changes: 2 additions & 0 deletions docs/api-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
2 changes: 2 additions & 0 deletions docs/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
```
Expand Down
Loading