---
tags: [research, rag, companyx, hybrid, keyword, tsvector, grounding]
status: closed
closed_date: 2026-03-21
closed_reason: 원인 5개 실측 확정, 조치 5개(P0 prefix/threshold/RRF + P1 청크재분할/라우팅완화) 전부 구현·검증 완료. grounding 5/18→11/18.
---

# 260321 하이브리드 검색 keyword recall=0 및 grounding 실패 원인 확정 리서치

## 목적

- 3중 검색(벡터+키워드+그래프) 구현 후 1차 검증에서 드러난 두 가지 실패의 직접 원인을 확정한다.
- 실패 1: 샘플 질문 16개 keyword recall이 0
- 실패 2: 재오픈 20개 중 인덱싱 문서가 있는데도 grounding 실패하는 케이스

## 사실 (Facts)

### 1. keyword recall = 0의 직접 원인: `simple` 토크나이저의 한국어 교착어 미처리

tsvector 트리거가 `to_tsvector('simple', chunk_text)`로 생성한다. `simple` 설정은 공백 기준 분리 + 소문자 변환만 수행하고 형태소 분석을 하지 않는다.

한국어는 교착어로 어간에 조사/어미가 항상 붙기 때문에 다음이 발생한다:

| 원문 | tsv 토큰 | tsquery 검색어 | 결과 |
|------|----------|---------------|------|
| `투자를 하다` | `'투자를'` | `'투자'` | 불일치 |
| `개인투자조합을 운용` | `'개인투자조합을'` | `'개인투자조합'` | 불일치 |
| `투자 유치` | `'투자'` | `'투자'` | 일치 |

DB 실측 (키워드 '투자', Company X 청크 대상):

| 방식 | 건수 | recall |
|------|------|--------|
| `chunk_text LIKE '%투자%'` | 1,099 | 100% |
| `tsv @@ to_tsquery('simple', '투자')` (현재) | 299 | 27% |
| `tsv @@ to_tsquery('simple', '투자:*')` (prefix) | 986 | 90% |

- **주요 손실(~63%)**: 조사 미분리 — `'투자를'`, `'투자에'`, `'투자와'`가 `'투자'`와 exact match 실패
- **추가 손실(~10%)**: 복합어 미분리 — `'개인투자조합'`, `'총투자금액'`이 하나의 토큰으로 저장되어 prefix로도 불가

`_build_keyword_tsquery()`는 Python에서 정규식 `[0-9A-Za-z가-힣]+`로 깨끗한 어절 토큰을 추출하지만, DB의 tsv 토큰에는 조사가 붙어 있어 구조적으로 불일치.

### 1-1. keyword recall = 0의 추가 직접 원인: threshold 스케일 불일치 (23서버 실측 보완)

리서치 원문은 `simple` 토크나이저의 한국어 교착어 미처리를 주 원인으로 서술했으나, **threshold가 동일하게 심각한 원인**이다.

`keyword_search()`는 213줄에서 `if keyword_score >= threshold`로 결과를 필터링한다. 호출 경로별 threshold:

| 호출 경로 | threshold | 결과 |
|-----------|-----------|------|
| `search.py` 46줄 단독 keyword | `request.threshold` (기본 0.35) | **전부 필터링 → 0건** |
| `hybrid_search()` 248줄 내부 호출 | 하드코딩 `0.0` | 통과 가능 |

23서버 실측 (`아크로셀 | 개인투자조합` 쿼리):

| 조건 | 건수 |
|------|------|
| `ts_rank >= 0.35` | **0건** |
| `ts_rank >= 0.0` | **41건** |
| prefix 매칭 `아크로셀:* \| 개인투자조합:*` | **58건** |

즉 **두 원인이 중첩**되어 있다:
1. `simple` 토크나이저 → tsv에 조사 붙은 토큰 저장 → exact match 실패 (recall 손실)
2. threshold 0.35 → ts_rank 최대 0.089 → 매칭된 결과도 전부 필터링 (precision 필터가 recall을 완전 소멸)

교착어 문제만 해결해도 threshold 0.35에서는 여전히 0건이다. **threshold를 먼저 수정하지 않으면 어떤 토크나이저 개선도 keyword 결과에 반영되지 않는다.**

### 2. grounding 실패의 세 가지 계층

재오픈 20개 중 expect_evidence=True인 11건을 분석한 결과, 실패는 세 계층에서 발생한다:

#### A. 검색 계층 실패 (8건) — 근본 원인

검색이 관련 문서를 찾지 못함:
- keyword_score 전부 0.000 (위 원인)
- 벡터 검색도 의미적으로 엉뚱한 문서를 반환 (본점 소재지 변경 공문, 고유번호증 등)
- **옐로펀치 협약 문서를 원문 그대로 쿼리해도 top-5에 미등장** → 임베딩 공간에서 해당 청크가 쿼리와 먼 위치에 있거나 인덱싱 문제

해당 질문들:
- 오늘전통+옐로펀치 근거
- Company X 프로그램 운영 흐름
- 주요 프로그램 목록
- 옐로펀치 협력 범위
- 오늘전통 대상/조건
- Company X 조직도/팀 구성
- Company X 슬로건/미션

#### B. 라우팅 계층 실패 (3건) — 마커 미매칭

`should_handle_companyx_grounding()`의 intent_markers/domain_markers에 걸리지 않아 grounding 경로 자체에 미진입:

| 질문 | 이유 |
|------|------|
| IR 채널 투자사 리스트 | `IR 채널` 마커 없음 |
| 프로그램 참여 기업 선정 기준 | 일반 질문, 마커 없음 |
| 프로그램 운영 기간/일정 | 일반 질문, 마커 없음 |

이 케이스는 일반 챗봇 경로로 빠져서 "홍태주님, 확인해보니..." 톤으로 응답.

#### C. LLM 판단 계층 (정상 동작)

검색이 무관한 문서를 반환하면 LLM이 `failure_reason`을 설정하여 실패 응답 생성. 이는 엉뚱한 문서로 답변하는 것보다 나은 정상 동작.

### 3. RRF 점수와 threshold 불일치 — hybrid 모드의 숨은 문제

hybrid 모드에서 `relevance_score`가 RRF 점수로 대체된다. RRF 점수는 구조적으로 낮다:

| 모드 | top-1 relevance_score | threshold | 통과 여부 |
|------|----------------------|-----------|-----------|
| vector | 0.775 (cosine) | 0.35 | ✅ |
| hybrid | **0.032** (RRF: 1/(60+1)) | 0.35 | ❌ (vector_score OR 조건으로 우회) |

실측 (질문: "아크로셀 개인투자조합 제2호 정기주총 서류 있어?"):
- vector: score=0.775, total=5
- hybrid: rrf=0.032, vector_score=0.775, total=5

현재 `hybrid_search()`의 필터가 `vector_score >= threshold OR keyword_score >= threshold OR relevance_score >= threshold`로 OR 조건이라 결과가 반환되긴 한다. 하지만:
- **`relevance_score`(=rrf)가 0.03 수준**이므로, 이 값을 신뢰하는 다른 컴포넌트가 있으면 잘못된 판단을 할 수 있다
- **grounding_service의 `_select_top_results()`가 `relevance_score` 기준으로 정렬**하므로, hybrid 모드에서 RRF 점수 기준 정렬이 벡터 유사도 정렬과 다를 수 있다
- threshold 0.35는 cosine similarity 기준으로 설계된 값이고, RRF 점수 범위(0.01~0.03)와 스케일이 맞지 않는다

이것은 keyword recall=0과 별개의 문제다. keyword가 정상 작동해도 RRF 점수는 최대 `1/(60+1) + 1/(60+1)` ≈ 0.033이므로 threshold 0.35를 절대 넘지 못한다.

## 원인 요약

| 실패 | 직접 원인 | 계층 |
|------|----------|------|
| keyword recall = 0 (원인1) | `simple` 토크나이저가 한국어 조사 분리 불가 → tsv 토큰과 tsquery 불일치 | DB/인덱스 |
| keyword recall = 0 (원인2) | `keyword_search()` 단독 호출 시 threshold 0.35 적용 → ts_rank 최대 0.089로 전부 필터링 | 검색/점수 |
| RRF score ≪ threshold | RRF 점수(0.01~0.03)와 threshold(0.35)의 스케일 불일치 | 검색/점수 |
| 인덱싱 문서 있는데 검색 실패 | 쿼리 임베딩과 문서 청크 임베딩 사이의 cosine 거리가 멀어 top-50에도 미등장 (아래 §4 참조) + keyword 보완 불가 | 검색/임베딩 |
| 3건 grounding 미진입 | intent/domain 마커에 일반 질문 패턴 미포함 | 라우팅 |

## 해결 방향 (구현은 별도 계획)

### keyword recall 개선 옵션

**우선순위 1 (즉시 적용 — 두 원인 동시 해결):**

| 조치 | 변경 위치 | 효과 |
|------|-----------|------|
| threshold 분리 | `keyword_search()` 단독 호출 시 threshold를 0.01 이하로 | ts_rank 필터링 해소 → 0건→41건+ |
| prefix 매칭 | `_build_keyword_tsquery()`에서 각 토큰에 `:*` 접미 | 조사 붙은 토큰도 매칭 → recall 27%→90% |

두 조치를 함께 적용하면 keyword 단독 검색에서 즉시 결과가 나온다.

**우선순위 2 (근본 해결):**

| 옵션 | 방식 | 장점 | 단점 |
|------|------|------|------|
| trigram (pg_trgm) | `chunk_text % :query` | 부분 문자열 매칭 가능 | GIN 인덱스 추가 필요, 정밀도 낮음 |
| 한국어 형태소 분석 | mecab + textsearch_ko | 근본 해결 | 확장 설치 필요 (51123 서버), PG14 EOL 고려 |
| 하이브리드 보완 | ILIKE fallback | 간단 | 인덱스 미사용, 느림 |

### 4. 벡터 검색 품질 — 인덱싱 문서 있는데 검색 실패의 직접 원인 확정

옐로펀치 MOU(`TalkFile_MOU_옐로펀치X컴퍼니엑스- 25.01.23.pdf.pdf`)를 지표로 확인:

- **인덱싱 여부**: completed, doc_id `b7116f6a`, 청크 1개 (2,703자), 임베딩 존재
- **자기 유사도**: self-cosine = 1.000 (임베딩 정상)
- **다른 MOU와 유사도**: 0.81~0.91 (같은 문서군끼리는 근접)
- **쿼리 "옐로펀치 컴퍼니엑스 업무협력협약서"로 검색**: threshold=0.0, limit=50에서도 **미등장**

직접 원인: **쿼리 임베딩(짧은 질문)과 문서 청크 임베딩(2,703자 긴 본문)의 cosine 거리가 멀다.**

이는 Gemini Embedding 2가 task_type `RETRIEVAL_QUERY` vs `RETRIEVAL_DOCUMENT`로 비대칭 임베딩을 생성하는데, 짧은 한국어 고유명사 쿼리와 긴 법률/계약 문서 사이의 의미 거리가 크기 때문이다. 문서끼리(document-document)는 유사하지만 쿼리-문서 간은 다른 공간에 놓임.

keyword 검색이 이를 보완해야 하지만, 위 §1의 이유로 keyword도 작동하지 않아 이중 실패.

### 벡터 검색 품질 개선 방향

### 라우팅 마커 확장

- Company X team_id 사용자의 질문은 마커 없이도 grounding 시도하는 옵션 검토
- 또는 일반 질문 패턴 마커 추가 (단, 하드코딩 확장은 금지 원칙에 저촉)

## 검증에 사용한 데이터

- 테스트 결과: `/home/admin/robeing/tests/results/hybrid_vs_vector_comparison.json`
- 검증 스크립트: `/home/admin/robeing/tests/test_companyx_rag_hybrid.py`
- DB 실측: 51123 서버 `main_db.team_document_chunk` (team_id: `79441171-...`)

## 관련 문서

- [260320 로빙 다형식문서 RAG 적용1 계획](../../plans/260320_로빙_다형식문서_RAG_적용1_계획.md)
- [260315 companyx rag 답변합성 시나리오동시종결 리서치](../260315_companyx_rag_답변합성_시나리오동시종결_리서치.md)
