AI로 장소 검색 서비스 만드는 중입니다 (기술 5) — 문서 청크 임베딩: 리뷰 한 문장에 묻힌 정답을 살리다

기술 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)에서는 이 모든 하드게이트와 청크가 딛고 선 라벨링 데이터 공장 — 수천 곳을 사람 없이 자동으로 분류하는 파이프라인 — 을 봅니다.