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 @@ -42,6 +42,21 @@ ON CONFLICT (document_id, chunk_index)
//
// 호출부마다 필터를 거는 대신 쿼리에서 막는 이유: 호출자가 늘 때마다 같은 실수를 반복할 수
// 있고, 실제로 3개 호출부 중 어디도 삭제를 확인하지 않았다. 여기 한 곳이 마지막 관문이다.
/**
* 삭제된 문서의 청크를 거르는 방어선.
*
* <p><b>오늘 기준 아무것도 거르지 않는다.</b> 문서 삭제 시 임베딩을 함께 지우고
* (soft delete 된 문서 19개의 청크가 0개), {@code DocumentEmbeddingService} 가 이미
* 활성 소유 문서로 범위를 좁혀 넘긴다. 그래도 남겨 둔다 — 삭제 경로가 언젠가 새면
* 이게 마지막 방어선이고 비용은 0.3ms 안쪽이다.
*
* <p><b>대가는 알고 있어야 한다.</b> {@code ORDER BY <=>} 가 조인된 관계 위에 놓이면
* 플래너가 HNSW ANN 인덱스로 밀어 넣지 못한다 — {@code enable_seqscan=off} 로도
* 안 쓴다. 즉 인덱스가 있어도 **근사 최근접 탐색이 도는 게 아니라** 범위 안에서 정확
* 거리 계산 후 정렬한다. 지금 규모(청크 57행, 실행 0.338ms)에서는 그게 더 빠르다.
*
* <p>바꿔야 할 시점과 방법은 {@code docs/database.md} 의 pgvector 인덱스 절에 적었다.
*/
private static final String ACTIVE_DOC_JOIN =
"JOIN analyzed_documents d ON d.id = e.document_id AND d.is_deleted = FALSE ";

Expand Down
42 changes: 40 additions & 2 deletions docs/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,10 +339,48 @@ CREATE TABLE document_embeddings (
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (document_id, chunk_index)
);
CREATE INDEX idx_embeddings_ivfflat
ON document_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
-- 실제 인덱스는 V8 에서 ivfflat → HNSW 로 교체됐다(ivfflat 은 k-means 학습 기반이라
-- 빈 테이블에서 centroid 가 부정확하고 probes 튜닝이 필요하다).
CREATE INDEX idx_document_embeddings_hnsw
ON document_embeddings USING hnsw (embedding vector_cosine_ops);
-- 하이브리드 검색용 full-text 색인(V8). chunk_text 의 generated tsvector 컬럼.
CREATE INDEX idx_document_embeddings_tsv
ON document_embeddings USING GIN (chunk_text_tsv);
```

#### 이 두 인덱스는 **현재 한 번도 쓰이지 않는다** (2026-09-29 실측)

`pg_stat_user_indexes` 기준 둘 다 `idx_scan = 0` 이다. 이유는 고장이 아니라 **쿼리 모양**이다.

`DocumentEmbeddingService.search` 는 항상 요청자가 소유한 활성 문서 id 로 범위를 좁혀
(`findActiveIdsByOwner`) 넘기고, 비면 조회 자체를 건너뛴다. 그래서 리포지토리에 도달하는
쿼리는 **예외 없이** `WHERE document_id IN (...)` 를 달고 있다. 이 필터가 선택적이라
플래너는 `idx_document_embeddings_document_id`(btree) + 정렬을 고른다 — 그게 더 빠르기
때문이며, 옳은 선택이다.

`EXPLAIN` 으로 확인한 것:

| 쿼리 모양 | 계획 |
|---|---|
| JOIN 없음 · 필터 없음 | **`Index Scan using idx_document_embeddings_hnsw`** (ANN 사용) |
| `ACTIVE_DOC_JOIN` 있음 | `Sort` ← `Nested Loop` — `enable_seqscan=off` 로도 ANN 미사용 |
| `document_id IN (...)` 있음 | btree + `Sort` (선택적 필터라 이게 맞다) |

즉 **ANN 인덱스가 붙어 있다고 해서 근사 최근접 탐색이 도는 것이 아니다.** 실제로는 사용자
범위 안에서 정확(brute-force) 거리 계산 후 정렬한다. 지금 규모에서는 그게 더 빠르다 —
운영 쿼리 실측 **실행 0.338ms**(계획 1.69ms, 청크 57행).

`ORDER BY <=>` 가 조인된 관계 위에 놓이면 플래너가 ANN 인덱스로 밀어 넣지 못한다.
`ACTIVE_DOC_JOIN` 은 오늘 기준 **아무것도 거르지 않는다**(문서 삭제 시 임베딩을 지우므로
soft delete 된 문서 19개의 청크는 0개, 게다가 서비스가 이미 활성 문서로 범위를 좁힌다).
그래도 남겨 둔다 — 삭제 경로가 언젠가 새면 이게 마지막 방어선이고, 비용은 0.3ms 안쪽이다.

**언제 다시 볼 것인가**: 한 사용자의 세션에 걸린 청크가 수천 줄로 늘면 정렬 비용이
RAG 하드 타임아웃(`questions_rag_timeout_sec`/`followup_rag_timeout_sec` 각 1.5초)에
닿기 시작한다. 그때는 ANN 을 서브쿼리로 먼저 돌리고 JOIN 을 바깥으로 빼면
(`EXPLAIN` 으로 HNSW 사용 확인함) 인덱스가 살아난다. **그 전에는 손대지 않는다** —
지금 바꾸면 얻는 것 없이 방어선만 잃는다.

---

## 5. 필수 개선 사항 (Flyway V1 작성 전 적용)
Expand Down
Loading