> ## Content Index
> Fetch the complete content index at: https://www.popit.kr/llms.txt
> Use this file to discover other available public pages before exploring further.

# AI로 장소 검색 서비스 만드는 중입니다 (기술 5) — 문서 청크 임베딩: 리뷰 한 문장에 묻힌 정답을 살리다
- URL: https://www.popit.kr/ai-place-search-tech-5-document-chunk-embedding/
- Published: 2026-08-02T22:42:58.000Z
- Updated: 2026-08-02T22:42:58.000Z
- Author: 김형준
- Tags: ai, 검색, #서비스소개

![t5-int8-memory](https://www.popit.kr/content/images/wp/2026/07/t5-int8-memory-600x204.png)

> 기술 4편은 "하드게이트는 정밀도엔 강하지만, 라벨과 후보가 적으면 재현율이 희생된다"로 끝났습니다. 이번 글은 그 **후보를 더 많이·정확히 불러오는** 이야기입니다. 그리고 중간에 **"개선이 아니라 개악"이라는 측정 결과가 나와 폐기 직전까지 갔던** 반전이 있습니다.

수치는 유추카 자체 오프라인/종단 평가 실측입니다.

## 리뷰 30개를 벡터 하나로 뭉개면 생기는 일

유추카는 장소 하나를 **벡터(좌표) 하나**로 바꿔 검색합니다. 그 벡터를 만드는 원본 텍스트에는 장소 소개, 네이버 리뷰, 블로그 후기가 전부 들어갑니다. 장소당 평균 **리뷰 24건 + 블로그 9.7건**이니 꽤 두툼합니다.

여기서 함정이 생깁니다. 기술 3편에서 임베딩이 "문장의 단어들을 평균 내 섞는다"고 했는데, 문서 임베딩은 그걸 **문서 전체 규모로** 합니다. 리뷰 30개의 온갖 이야기가 좌표 하나로 뭉개지는 거죠.

구체적인 장면으로 봅시다. **"아이들과 가기 좋은 곳"** 의 정답이 되는 카페가 있습니다. 그 근거는 30개 리뷰 중 **딱 한 문장**입니다.

> *"아이 의자도 있고 안쪽에 놀이방이 있어서 애기랑 편하게 있다 왔어요"*

완벽한 근거입니다. 그런데 나머지 29개 리뷰는 커피 맛, 주차, 웨이팅, 디저트 이야기입니다. 이 30개가 한 벡터로 평균되면 **"놀이방" 신호는 30분의 1로 희석됩니다.** 좌표는 결국 "평범한 카페 동네"에 찍히고, 코사인 유사도는 0.420.51쯤에 깔립니다. 실제 정답 장소들의 실측치가 그랬습니다 — 비루개 0.509, 새소리물소리 0.426, 얼트 0.515\. 후보 목록에서 **2151위 근처**, 즉 상위권 근처에도 못 갑니다.

여기에 두 번째 문제가 겹칩니다. **텍스트가 4,000자에서 잘립니다.** 실측 평균이 3,766자에 최댓값이 4,000자(= 잘림)이니, 상당수 문서가 뒷부분을 통째로 버리고 있었습니다. **뒤쪽 리뷰에 있던 근거는 희석되기도 전에 사라진** 겁니다.

이건 임계값을 만져서 고칠 수 있는 문제가 아닙니다. **신호 자체가 이미 뭉개진 뒤**니까요. 컷오프를 0.42까지 낮추면 그 정답 하나를 건지는 대신 관계없는 장소 수백 곳이 함께 들어옵니다.

## 그럼 문장마다 벡터를 만들면?

정공법은 명확합니다. **문서를 통째로 임베딩하지 말고, 문장 단위로 쪼개 각각 임베딩하자.** 그러면 "놀이방" 문장은 자기만의 좌표를 갖게 되고, 다른 29개 리뷰에 희석되지 않습니다.

이건 저희가 발명한 게 아니라 검색 분야에서 **늦은 상호작용(late interaction)** 계열로 알려진 접근입니다.

> **늦은 상호작용(late interaction)**: 문서를 벡터 하나로 압축해 미리 비교하는 대신, 문서를 여러 조각의 벡터로 두고 **검색 시점에 조각별로 비교**하는 방식. ColBERT가 대표적입니다. 정확하지만 저장 공간과 계산이 훨씬 많이 듭니다.

바로 그 "훨씬 많이 든다"가 문제였습니다. 문서 6,380개에 문장이 각각 수백 개면 벡터가 **수백만 개**가 됩니다. 유추카 운영 서버는 JVM 힙을 **500MB**로 설정된 작은 박스입니다. 그대로는 절대 못 올립니다.

그래서 **개수, 차원 정밀도** 세 군데를 줄였습니다.

## 1\. 개수 줄이기 — 문장을 다 쓰지 않고 14개만 고른다

문서당 **최대 14개**의 대표 문장만 뽑아 쓰기로 했습니다. 각 조각을 **청크(chunk)** 라고 부릅니다.

> **청크(chunk)**: 문서를 검색 단위로 쪼갠 조각. 유추카는 **문장 12개, 42000자**를 한 청크로 봅니다. 인사말·방문 인증 같은 보일러플레이트는 걸러냅니다.

문제는 **어떤 14개를 고르냐**입니다. 그냥 "가장 대표적인" 문장을 고르면 망합니다. 대부분의 카페 리뷰에서 가장 대표적인 문장은 "커피가 맛있어요"류이기 때문입니다. 그렇게 뽑으면 **비슷한 문장 14개**를 갖게 되고, 정작 놀이방 문장은 탈락합니다.

그래서 **MMR** 방식으로 골랐습니다.

> **MMR (Maximal Marginal Relevance)**: "대표성은 높으면서, **이미 뽑은 것과는 안 겹치게**" 고르는 선별 방법. 한 번 뽑을 때마다 "이미 뽑힌 것들과 얼마나 다른가"에 점수를 주기 때문에, 중복이 아니라 **다양성**이 쌓입니다.

결과적으로 뽑히는 14개는 이런 식이 됩니다 — 놀이방 하나, 주차 하나, 뷰 하나, 디저트 하나, 웨이팅 하나… **서로 다른 측면을 하나씩 담은 14개**입니다. AI 리뷰 요약은 정보 밀도가 가장 높으니 항상 1청크로 포함시킵니다.

그리고 중요한 부수 효과 — 청크는 **4,000자로 자르기 전의 원문**에서 만듭니다. 앞에서 말한 "잘려 나간 뒷부분 리뷰" 문제가 여기서 함께 풀렸습니다.

## 2\. 차원 줄이기 — 2048차원을 512로 자른다

임베딩 모델(`text-embedding-v4`)은 기본 **2048차원** 벡터를 뱉습니다. 청크는 문장 한두 개짜리라 그렇게까지 세밀한 표현이 필요 없습니다. 그래서 **512차원**으로 줄였습니다. 저장 공간이 곧바로 4분의 1이 됩니다.

그냥 뒤를 잘라도 되냐고요? **이 모델에선 됩니다.**

> **MRL (Matryoshka Representation Learning)**: 러시아 인형(마트료시카)처럼, **앞쪽 차원만 잘라내도 의미가 보존되도록** 학습시키는 기법. 중요한 정보를 앞쪽 차원에 몰아넣기 때문에, 2048차원 벡터의 앞 512개만 써도 성능이 거의 유지됩니다. 뒷부분을 버리는 게 아니라 **해상도를 낮추는** 것에 가깝습니다.

여기서 예상 못 한 이득이 하나 나왔습니다. **검색어를 두 번 임베딩할 필요가 없어졌습니다.**

원래는 이래야 할 것 같았습니다 — 문서 벡터(2048d)와 비교하려면 2048차원 질의 벡터, 청크 벡터(512d)와 비교하려면 512차원 질의 벡터. 즉 검색 한 번에 임베딩 API를 **두 번** 호출. 느리고 비쌉니다.

그런데 MRL 성질 덕에 **2048차원 질의 벡터의 앞 512개를 그냥 잘라 쓰면** 됩니다. 진짜 그런지 실측해 봤더니 **코사인 유사도 1.0000, 상위 50개 결과 100% 일치** — 별도로 512차원을 요청해 받은 것과 사실상 같은 벡터였습니다. 추가 API 호출 0회로 끝났습니다.

## 3\. 정밀도 줄이기 — 실수를 정수로 바꾼다

이제 청크가 **81,783개**(6,380 문서 × 최대 14) 생겼습니다. 이걸 통상적인 방식인 float32(숫자 하나에 4바이트)로 메모리에 올리면:  

```
81,783 청크 × 512차원 × 4바이트 ≈ 167MB
```

힙이 500MB인 서버에서 167MB는 감당이 안 됩니다. 그래서 마지막으로 **정밀도**를 줄였습니다.

> **int8 양자화(quantization)**: 벡터를 길이 1로 정규화한 뒤 127을 곱해 **1바이트 정수**로 반올림해 저장합니다(4바이트 → 1바이트). 검색할 때는 "실수 질의 벡터 · 정수 청크 벡터의 내적 ÷ 127"로 코사인 유사도를 복원합니다. 사진을 JPEG로 저장하는 것과 비슷한 발상 — 눈에 안 띄는 정밀도를 버려 용량을 얻습니다.

```
81,783 청크 × 512차원 × 1바이트 ≈ 42MB   (운영 실측 39MB)
```

당연히 걱정되는 건 **"정밀도를 버렸는데 검색이 나빠지지 않나"** 입니다. 두 단계로 확인했습니다. 단위 테스트에서 **코사인 오차 약 0.4%**, 그리고 종단 평가에서 **float32와 완전히 동일한 성적**(R@10 29.6%, 후보 재현율 93.8% — 소수점까지 같음)이 나왔습니다. **무손실로 판정**하고 채택했습니다.

세 번의 줄이기를 합치면 이렇습니다.

| 항목     | 방법               | 효과                   |
| ------ | ---------------- | -------------------- |
| 개수     | 문서당 대표 14청크(MMR) | 수백만 → 8.2만           |
| 차원     | 2048 → 512 (MRL) | ÷ 4                  |
| 정밀도    | float32 → int8   | ÷ 4                  |
| **합계** |                  | **167MB → 39MB(실측)** |

## 검색에는 어떻게 끼워 넣나

문서 벡터는 **그대로 둡니다.** 청크는 대체가 아니라 보강입니다.  

```
청크 코사인 = max( 이 문서의 청크들 각각과 검색어의 유사도 )
최종 코사인 = max( 문서 코사인, 0.9 × 청크 코사인 )
```

`max`를 쓰는 이유가 이 설계의 핵심입니다. 청크 신호는 **"이 장소 어딘가에 강한 근거가 하나 있다"** 는 증거입니다. 문서 평균이 낮다고 그 증거를 평균으로 줄이면 애초에 청크를 만든 의미가 없습니다. **문서 평균과 국소 증거가 충돌하면 증거 쪽을 믿는다** — 그게 max입니다.

물론 반대 위험이 있습니다. 우연히 한 문장이 걸린 엉뚱한 장소가 올라올 수 있죠. 그건 기술 4편의 **리랭커 근거 검증**이 뒤에서 막습니다. 앞단은 넓게 불러오고 뒷단이 좁게 판정하는 역할 분담입니다.

## 그런데 첫 측정에서 "개선이 아니라 개악"이 나왔다

여기까지 만들고 자신 있게 평가를 돌렸습니다. 결과는 이랬습니다.

> **capped Recall@10: 25.0% → 18.8%**

**떨어졌습니다.** 그것도 6%p 넘게.

> **capped Recall@10**: 정답셋에 등록된 정답 중 **상위 10개 결과 안에 들어온 비율**. "빠짐없이 보여줬는가"를 재는 재현율 지표입니다.

원인을 후보 풀 확대로 의심했습니다. 청크로 후보를 300개나 더 불러오니 리랭크 창 배치가 흐트러졌나 싶어 **300 → 100으로 줄여 다시 쟀습니다. 18.0%.** 안 돌아왔습니다. 점수 결합을 끄고 후보 소환에만 써봐도 같았습니다.

**폐기 직전이었습니다.** 오프라인 평가에선 분명히 좋았거든요(정답 순위 중앙값 140.5 → 116.5, 리랭크 창 도달률 44.6% → 54.3%). "오프라인은 좋은데 실제로는 나쁘다"는 패턴은 이미 겪어봤습니다 — 기술 1편의 임베딩 어댑터가 정확히 그랬고, 그래서 버렸습니다. 같은 결말인 줄 알았습니다.

### 이상한 점 하나

그런데 표에서 한 칸이 계속 걸렸습니다.

|                      | 청크 OFF | 청크 ON    |
| -------------------- | ------ | -------- |
| capped R@10          | 25.0%  | 18.8% ↓  |
| **오탐 위반(명백히 틀린 결과)** | **2건** | **2건 →** |

**품질이 나빠졌다면 틀린 결과가 늘어야 정상입니다.** 그런데 안 늘었습니다. 상위 10개가 오염됐다면서 정작 "이건 명백히 오답"이라고 라벨된 것은 하나도 안 늘어난 겁니다.

그럼 상위 10개에 새로 들어온 그 장소들은 대체 뭔가? 정답도 아니고 오답도 아닌 것?

**답은 "우리가 라벨을 안 매긴 장소"였습니다.**

정답셋은 사람이 미리 "이 검색어의 정답은 이 장소들"이라고 적어둔 목록입니다. 그런데 그 목록은 **청크 임베딩이 없던 시절에** 만들어졌습니다. 청크가 새로 발굴해 올린 장소들은 애초에 목록에 없었고, 목록에 없으면 평가 스크립트는 **자동으로 오답 취급**합니다.

즉 이런 일이 벌어지고 있었습니다.

- 청크가 놀이방 카페를 상위 10개로 끌어올렸다 → **성공**
- 평가셋에 그 카페가 없다 → **"정답 아님"으로 계산**
- 원래 있던 정답이 그만큼 밀려났다 → **R@10 하락**

**개선했더니 점수가 떨어지는** 구조였던 겁니다. 자가 문제였지 검색이 문제가 아니었습니다.

### 자를 고쳐서 다시 재다

그래서 검색을 고치는 대신 **평가셋을 고쳤습니다.** 청크를 켠 상태에서 상위권에 새로 올라온 장소들을 사람이 하나씩 검수해 라벨을 붙였습니다. **138건을 추가해 정답 총 330건**으로 늘렸습니다. 그리고 같은 실험을 다시 돌렸습니다.

| 지표                           | 청크 OFF | 청크 ON              |
| ---------------------------- | ------ | ------------------ |
| **capped Recall@10**         | 16.8%  | **29.6%** (+12.8p) |
| Recall@20                    | 23.6%  | **43.1%**          |
| **후보 재현율**(정답을 후보로라도 불러온 비율) | 44.6%  | **93.8%**          |
| MRR(정답이 얼마나 위에 있나)           | 0.270  | **0.378**          |
| 오탐 위반                        | 3      | 5 (소폭)             |

**정반대 결과였습니다.** 특히 후보 재현율 **44.6% → 93.8%** 는 "정답의 절반 이상을 아예 못 불러오던" 상태에서 "거의 다 불러오는" 상태로 간 것입니다. 라이브에서도 "아이들과 가기 좋은 곳"의 후보가 900 → 985로 늘고 최종 결과가 26 → 32건이 됐습니다.

이 사흘이 저에게 남긴 교훈은 검색 기술이 아니라 측정에 관한 것이었습니다. **"측정값이 나빠졌다"와 "품질이 나빠졌다"는 다릅니다.** 자가 낡으면 좋아진 것도 나빠 보입니다. 그리고 그걸 구분해 준 건 직감이 아니라 **"오탐이 안 늘었다"는 한 칸의 숫자**였습니다. 지표를 하나만 봤다면 이 기능은 폐기됐을 겁니다. (평가 인프라 이야기는 기술 7편에서 따로 다룹니다.)

## 정직한 실패 — 청크를 '정렬'에 쓰면 무너진다

성공에 취해서 곧바로 다음 욕심을 냈습니다.

"청크가 후보를 이렇게 잘 불러오는데, **순위를 매기는 데도** 쓰면 더 좋지 않을까?" 마침 그럴듯한 근거도 있었습니다. **"썸 타는 사람과 갈 만한 곳"** 같은 검색은 근거가 리뷰 한 문장에 국소적으로 있기 마련이니, 문서 벡터보다 청크가 잘 잡을 것 같았거든요.

그래서 청크 max-cos를 순위의 보조 기준으로 넣고 측정했습니다.

> **R@10 78.8% → 71.0% 붕괴**

쿼리별로 보면 참혹했습니다. **디저트 100% → 20%. 수다 떨기 좋은 곳 100% → 30%. 혼밥 100% → 50%.** 13개 쿼리가 급락했습니다.

이유는 나중에 보니 뻔했습니다. **청크 max-cos는 일반적인 검색어에서 거의 모든 문서가 고만고만하게 높습니다.** 리뷰가 24개나 있으면 어떤 검색어를 넣든 그중 한 문장쯤은 어느 정도 비슷하거든요. 그러니 청크 점수는 장소를 **구분하지 못하는 노이즈**가 되고, 그 노이즈가 그나마 변별력 있던 문서 코사인을 덮어버립니다.

정리하면 이렇습니다.

|            | 후보 소환                 | 정렬                     |
| ---------- | --------------------- | ---------------------- |
| 물어보는 것     | "이 장소에 이 근거가 **있는가**" | "이 장소가 **얼마나 더** 적합한가" |
| 청크 max-cos | **잘함** (있으면 튄다)       | **못함** (다들 비슷하게 높다)    |

**"근거가 있느냐"와 "얼마나 좋으냐"는 다른 질문이었습니다.** 청크는 앞의 질문엔 훌륭한 답이고 뒤의 질문엔 형편없는 답이었습니다. 그래서 **청크는 후보 소환에만 쓰고, 정렬은 문서 벡터와 기존 신호에 맡깁니다.**

A/B 측정이 없었으면 이 "좋아 보이는" 확장을 그대로 넣고 품질을 8%p 깎았을 겁니다. 그것도 스스로는 개선했다고 믿으면서요. (비슷한 기각 사례는 기술 8편에 모았습니다.)

## 비용은 얼마였나

전체 청크를 한 번 임베딩하는 백필은 **약 540만 토큰, 수천 원 수준**이었습니다. 기술 2편의 "비싼 건 실시간 호출이지 일회성 배치가 아니다"라는 원칙 그대로입니다. 이후로는 리뷰가 갱신될 때 색인 파이프라인에서 증분으로 다시 만듭니다.

운영 메모리는 int8 덕에 **500MB JVM 안에 들어갑니다**(Full GC 후 live 277MB). 컬렉션이 비어 있으면 인덱스가 자동으로 꺼지고 기존 경로로 도니, 백필 전에 배포해도 아무 일도 일어나지 않습니다.

## 이 글의 결론

- 장소를 **벡터 하나**로만 보면, 리뷰 한 문장에 묻힌 정답이 30분의 1로 희석돼 사라집니다. 임계값으로는 못 고칩니다 — **신호가 이미 뭉개진 뒤**니까요.
- 문장 단위 검색은 비쌉니다. **개수(MMR 14청크)·차원(MRL 512d)·정밀도(int8)** 를 순서대로 줄여서 167MB를 39MB로 만들었습니다. 셋 다 "품질을 안 잃는 선에서" 줄였는지 각각 측정했습니다.
- MRL 덕에 **질의 임베딩을 두 번 부를 필요가 없었습니다** — 2048차원 벡터 앞 512개를 자르면 끝(실측 코사인 1.0000).
- 후보 재현율 **44.6% → 93.8%**, capped R@10 **+12.8p**.
- 하지만 **첫 측정은 −6.2p로 나왔고, 원인은 검색이 아니라 낡은 정답셋이었습니다.** "오탐이 안 늘었다"는 한 칸이 아니었으면 멀쩡한 기능을 버렸을 겁니다.
- 그리고 **청크는 "소환"엔 좋아도 "정렬"엔 나빴습니다**(78.8 → 71.0). 좋아 보이는 확장일수록 재봐야 합니다.

다음 글(기술 6)에서는 이 모든 하드게이트와 청크가 딛고 선 **라벨링 데이터 공장** — 수천 곳을 사람 없이 자동으로 분류하는 파이프라인 — 을 봅니다.

- [유튜버추천카페로 이동하기](https://youchuca.popit.kr/?ref=popit.kr)