← Ascendy

ml

정직하게 만들었더니 진짜 버그가 보였다 — 검색이 클러스터의 1%만 훑고 있었다

· Ascendy Engineering


TL;DR

소스 노트. 백엔드 팀 인테이크 2건(정직성 fallback / ANN 파라미터 기아)을 병합해 쓴 글이다. 두 원본은 같은 사건의 앞뒤다. 내부 코드 식별자·인프라 및 서빙 구성·규모를 특정할 수 있는 절대 수치는 일반화했고, 개인 사진의 내용은 묘사하지 않는다. 같은 검색 스택의 선행 결정은 벡터검색에서 리랭커를 뺐다에 있다.

”30장 찾았습니다” — 하나도 그 카테고리가 아니었다

증상은 단순했다. 사진 플랫폼의 AI 에이전트에게 “올해 찍은 사진 중에서 특정 주제의 사진만 찾아줄래?” 라고 물었다. 에이전트는 사진 30장을 돌려주며 답했다 — “30장 찾았습니다.”

한 장도 그 주제가 아니었다. 라이브러리에서 비중이 큰, 요청과 무관한 사진들이었다.

여기서 짚어야 할 건 틀렸다는 사실이 아니라 틀린 방식이다. 에러가 아니었다. 빈 화면도 아니었다. 시스템은 자신 있게 틀렸다.

top-k는 “없다”고 말할 줄 모른다

원인의 첫 겹은 벡터 검색의 구조 자체에 있다.

top-k 최근접 이웃 검색은 후보가 있는 한 관련성과 무관하게 k개를 채워서 돌려준다. 개수가 줄어드는 건 탐색 가능한 모집단이 k보다 작을 때지, 관련성이 낮다는 이유로는 아니다. 라이브러리에 해당 주제의 사진이 없으면, 그 질의에 “가장 덜 먼” 것은 그 라이브러리를 지배하는 사진들이 된다. 거리는 멀지만 순위는 1등이다. top-k에게 “적당한 게 없다”는 출력은 애초에 존재하지 않는다.

물론 이걸 거르는 방어선이 두 겹 있었다.

  1. 유사도 elbow cutoff + 절대 임계치
  2. 캡션 텍스트 기반 재정렬 + threshold

(2번은 텍스트 매칭 단계다. 이전 글에서 제거한 cross-encoder 리랭커와는 다른 물건이니 헷갈리지 말자.)

문제는 방어선이 전부 제대로 작동해서 후보가 0개가 됐을 때의 경로였다. 거기엔 이런 fallback이 있었다 — “빈 결과보다는 뭐라도 보여주자.” 임계치도 재정렬도 없는 순수 최근접 top-N을 반환한다.

이 결정 자체는 논쟁의 여지가 있지만, 치명적이었던 건 그 다음이다. 반환 타입이 그냥 리스트였다. 정상 랭킹 결과도 리스트, 임계치 없는 fallback도 리스트. 호출자는 자기가 받은 게 어느 쪽인지 알 방법이 없었다.

그리고 이 파이프라인의 최종 소비자는 LLM 에이전트였다. 도구가 “Found 30 photos”라고 하니, 에이전트는 그대로 사용자에게 “30장 찾았습니다”라고 전달했다.

LLM은 도구 출력의 톤을 그대로 증폭한다. 도구가 확신에 차 있으면 답변도 확신에 찬다. 도구가 자기 불확실성을 전달하지 않으면, LLM이 대신 의심해 주지 않는다.

고친 것은 검색 품질이 아니라 반환 형태였다

수정은 검색을 더 똑똑하게 만드는 게 아니었다. 정직해지게 만드는 것이었다.

반환 형태를 {결과 목록, fallback 여부}로 바꾸고, fallback 경로에서만 플래그를 세워 파이프라인 전체(하이브리드 검색 → 에이전트 도구 → LLM 요약)로 전파했다. 이제 에이전트는 이렇게 말한다 — “일치하는 사진은 없고, 가장 유사한 N장을 대신 보여드립니다.”

여기서 중요한 대목: 검색 결과 ID는 바이트 단위로 동일하다. 같은 사진 30장이 나온다. 바뀐 건 오직 말하는 방식이다.

이게 이 수정의 핵심 성질이다. 정직성은 검색 품질과 별개의 축이고, 대체로 훨씬 싸게 고칠 수 있다. 사용자 신뢰는 “틀린 결과”보다 “없다고 말할 줄 아는 결과”에서 지켜진다.

그리고 일반화하면 이렇다 — fallback을 만들 때는 그 사실을 반드시 호출자에게 전파하라. 방어선이 전부 발동해 결과가 0이 되는 경로는 반드시 존재한다. 그 경로의 UX를 설계하지 않으면, 코드가 임의로 정한다.

그 플래그가 관측 장치가 됐다

여기까지가 원래 고치려던 것이었다. 그런데 배포하고 보니 예상 밖의 일이 벌어졌다.

거의 모든 검색이 fallback으로 흐르고 있었다.

정직성 플래그는 UX 장치로 만든 것인데, 세우고 나니 그대로 관측 장치였다. 전에는 “가끔 이상한 결과가 나온다”는 체감이었던 것이, 이제 “이 질의는 fallback이었다”는 셀 수 있는 신호가 됐다.

이건 우연이 아니라 구조다. 조용한 실패는 신호를 삼킨다 — 이 블로그가 로그 레벨이중 쓰기에서 반복해 만난 그 구조다. 틀린 답을 조용히 내보내던 경로에 이름을 붙이는 순간, 그 경로의 빈도가 측정 가능해진다.

관측성 로그를 한 줄 심고 실제 분포를 들여다보니 두 번째 겹이 드러났다. 절대 유사도 임계치가 이 임베딩 모델의 실제 매치 대역보다 두 배 가까이 높게 잡혀 있었다. 진짜로 맞는 결과들이 몰려 있는 구간보다 컷이 훨씬 위에 있으니, 정상적인 매치까지 전부 잘려 나가 모든 검색이 fallback으로 떨어졌다.

코사인 유사도의 스케일은 모델마다 다르다. 어디선가 본 임계치를 그대로 가져오면, 그 숫자는 이 모델의 분포에 대해 아무 의미가 없다.

그런데 후보가 왜 이것뿐인가

임계치를 보정하고 나서도 이상한 게 남았다. 후보 수 자체가 한 자릿수였다.

인덱스 커버리지를 의심했다 — 임베딩이 안 만들어진 사진이 많은 게 아닐까? 재보니 벡터는 거의 전부 멀쩡히 들어가 있었다. 데이터는 있었다. 검색이 못 보고 있었을 뿐이다.

진범은 IVF 인덱스 파라미터였다. IVF 계열 ANN 인덱스는 벡터를 여러 클러스터로 나눠 두고(nlist), 질의할 때 그중 일부만 골라 탐색한다(nprobe). 속도를 위해 정확도를 조금 내주는 표준적인 트레이드오프다.

그런데 이 인덱스는 클러스터를 잔뜩 만들어 놓고, 탐색은 그중 극히 일부만 하도록 설정돼 있었다. 비율로 따지면 전체 클러스터의 약 1%. 나머지 클러스터에 들어 있는 벡터는 매 질의마다 존재하지 않는 것과 같았다. (리스트 길이는 균등하지 않으므로 이게 곧 “벡터의 99%“라는 뜻은 아니다 — 그건 따로 재봐야 아는 값이다.)

이 설정이 어디서 왔는지는 명확했다. 수백만 벡터를 다루는 환경의 레시피를, 훨씬 작은 컬렉션에 그대로 복사한 것이다. 그 규모에서는 합리적인 값이다. 하지만 이 규모에서는 모든 클러스터를 훑어도 비용이 충분히 작다는 게 팀의 판단이었다. 그리고 모든 클러스터를 훑으면 리스트를 안 봐서 놓치는 손실은 사라진다. (인덱스가 벡터를 압축해 저장하는 종류라면 양자화에서 오는 손실은 그와 별개로 남는다 — 여기서 사라지는 건 리스트 선택 축이다.)

즉 이 근사로 아낄 수 있는 시간은 애초에 크지 않았는데, recall은 확실히 깎이고 있었다.

기대값 계산이 가설의 순위를 바꾼다

“탐색 비율이 낮아 보인다”는 아직 의심이다. 이걸 맨 앞자리로 끌어올린 건 산수 한 줄이었다.

탐색하는 클러스터의 비율을 알고 후보군의 대략적인 크기를 알면, 검색이 실제로 만져볼 벡터 수의 어림값이 나온다.

기대 후보 수 ≈ 후보군 크기 × (탐색 클러스터 수 / 전체 클러스터 수)

이 어림값이 관측된 후보 수와 같은 자릿수로 맞아떨어졌다.

다만 여기서 한 걸음 물러설 필요가 있다. 이건 증명이 아니라 정합성 확인이다. 위 식은 벡터가 클러스터에 고르게 퍼져 있다고 가정하는데, IVF의 리스트 길이는 실제로 균등하지 않고 nprobe는 무작위 표본이 아니라 질의에 가장 가까운 중심들을 고른다. 자릿수가 맞았다는 사실이 말해주는 건 “인덱스 파라미터 가설과 모순되지 않는다”까지지, “원인이 이것이다”까지가 아니다.

그럼에도 이 계산은 값을 했다. 가설의 순위를 바꿨기 때문이다. 커버리지 가설은 실측으로 이미 지워졌고, 파라미터 가설은 관측치와 정합적이었다. 그래서 다음 수를 전수 탐색으로 잡았다 — 모든 클러스터를 훑으면 리스트 선택이라는 변수가 빠지므로, 그래도 후보가 안 늘면 가설이 틀렸다는 게 바로 드러난다.

이게 디버깅에서 계속 쓰게 되는 도구다. 관찰과 가설이 있을 때, 가설이 예측하는 숫자를 계산해서 관찰과 맞춰보라. 안 맞으면 가설이 틀렸거나 겹이 하나 더 있다. 맞으면 — 진단이 된 게 아니라, 그 가설을 먼저 검증할 이유가 생긴 것이다. 이 구분을 흐리면 정합성 확인이 증명으로 둔갑한다.

한 가지 창피한 곁가지도 있었다. 진단 중에 돌린 집계 쿼리에서 소유자 필터를 빼먹어, 전체 사용자 합계를 한 사용자의 값으로 오독할 뻔했다. 하마터면 한 라운드를 통째로 날릴 뻔한 실수다. 진단에 쓰는 측정 쿼리도 리뷰 대상이다 — 그게 틀리면 그 위에 세운 추론 전부가 틀린다.

진단 순서가 배포 횟수를 줄였다

돌아보면 이 사건에서 가장 실용적인 교훈은 순서였다.

  1. 관측성 로그 심기 — 배포 1회
  2. 커버리지 측정 — 운영자가 돌리는 read-only 스크립트, 배포 0회
  3. 파라미터 수정 — 배포 1회

각 단계가 다음 단계에서 확인할 것을 눈에 띄게 좁혔다. 특히 2번은 배포 없이 가설 하나를 통째로 지웠다(“데이터가 없는 게 아니다”). 프로덕션 디버깅에서 배포는 비싸고 느리다. 배포 없이 지울 수 있는 가설을 먼저 지우면, 남은 배포 예산을 진짜 수정에 쓸 수 있다.

마지막으로 작은 규약 하나를 도입했다. 임계치 상수 옆에는 그 값의 근거가 된 실측 분포를 주석으로 남긴다. 근거 없는 상수는 다음 사람이 못 건드리고, 모델을 바꾸면 조용히 틀린 값이 된다.

가져갈 것

세 겹을 다 벗기고 나서 남은 인상은 이렇다. 우리가 처음 고친 것은 버그가 아니라 태도였다. 그런데 그 태도 수정이 없었다면, 아래 두 겹은 여전히 “가끔 검색이 이상하다”는 체감으로만 남아 있었을 것이다.


저작·인용: 이 글은 Ascendy Engineering이 작성했으며 출처 표기 시 재인용 가능합니다. 잘못된 정보를 발견하면 GitHub 이슈로 알려주세요.


Tags: vector-search, rag, ann, retrieval, observability, postmortem, llm-agents