AI 검색을 측정해봤습니다. 그 수치들이 겸손하게 만들었습니다
요약
본 글은 사용자가 자신이 좋아했던 콘텐츠와 그 이유를 입력하면, 시스템이 유사한 영화, 책, 게임 등을 추천하는 서비스의 개선 과정을 다룹니다. 특히 검색 결과만으로 판단하기보다 테스트 세트를 구축하고 'recall@12' 같은 객관적 지표로 성능을 측정하는 중요성을 강조합니다.
핵심 포인트
- 검색 결과를 보고 판단하지 말고, 체계적인 테스트 세트가 필요하다.
- 성능 측정 시 recall@12와 같은 정량적 지표를 사용해야 한다.
- 단순 유사도(similarity) 외에 청중의 평가(acclaim) 가중치를 적용하는 것이 효과적이다.
part 1에서는 제가 Because I liked를 만들었습니다: 사용자가 자신이 좋아했던 것과 그 이유를 설명하면, 시스템이 그 '이유'에 맞는 영화, 쇼, 책, 게임을 찾아내고 각각의 선택지를 설명해 줍니다.
Part 1은 "놓친 부분(misses)이야말로 당신이 나에게 보낼 수 있는 가장 유용한 것"이라는 내용으로 끝났습니다. 사람들은 여러 플랫폼을 통해 이런 '놓친 부분'들을 보내주었습니다. 이 글은 제가 그 데이터들로 무엇을 했는지에 대한 이야기입니다: 테스트 세트를 구축하고, 작동했던 수정 방법, 작동했어야 했는데 그렇지 못했던 수정 방법, 그리고 제가 전혀 예상하지 못한 실제 방문자들이 입력한 내용들에 관한 것입니다.
📏 교훈 1: 검색 결과를 보고 판단하는 것을 멈추세요
몇 주 동안 저의 작업 과정은 이랬습니다: 무언가를 변경하고, 마음에 드는 세 가지 검색어를 입력하고, 결과들을 유심히 살펴본 다음, 배포했습니다. 모든 변경 사항이 "더 좋아 보였습니다". 하지만 그중 일부는 그렇지 않았습니다.
그래서 저는 테스트 세트를 작성했습니다. 각 케이스는 하나의 검색어(일부는 실제 방문자의 것, 일부는 이를 반영하여 작성된 것)와 잘 아는 친구가 언급할 만한 3~5개의 타이틀로 구성되어 있습니다:
{
"id": "hard-but-fair",
"query": "a hard but fair game where dying teaches you something",
...
점수는 recall@12입니다. 이는 예상되는 타이틀 중 몇 퍼센트가 상위 12개 결과에 나타나는지를 측정합니다. 이 지표는 다소 투박한 도구입니다. 다섯 개의 타이틀만으로는 "좋은 취향"을 포착할 수 없으며, 목록에 없는 훌륭한 선택지는 점수가 0점 처리됩니다. 하지만 매번 동일하게 사용되는 투박한 도구라는 점이 핵심입니다.
첫 번째 솔직한 수치는 **28%**였습니다.
| 변경 사항 | 평균 recall@12 |
|---|---|
| 기준선 (Baseline) | 28% |
| ... |
🏆 교훈 2: 유사성은 명작과 그 복제품을 구별할 수 없다
기준선(baseline)에서 "a hard but fair game where dying teaches you something" 검색어는 다음과 같은 결과를 반환했습니다: White Knuckle, Causality, I Wanna Maker, Levelhead, NetHack, Celeste… 이들 모두 난이도가 높고, 충분히 공정합니다. 6번째에 나온 _Celeste_만이 사람이 실제로 언급할 만한 타이틀이었으며, Dark Souls는 상위 12개 목록에 아예 없었습니다.
임베딩(embedding)에, 난해한 정밀 플랫포머 게임(precision platformer), 그리고 Dark Souls는 가까운 이웃입니다. 그들은 같은 방식으로 묘사됩니다. 다만 그중 실제로 사람들이 의미하는 것은 오직 하나뿐입니다.
이 문제를 해결하기 위해 청중의 의견을 이용합니다. 각 후보작에 대해 다음 공식을 사용합니다:
score = similarity + ACCLAIM_WEIGHT * acclaim(medium, votes, rating) // acclaim은 0–1 사이 값
acclaim은 청중 평점과 얼마나 많은 사람이 평가했는지를 결합한 값이므로, 투표가 40개인 9/10 점수가 투표가 40,000개인 9/10 점수를 이길 수는 없습니다. 이는 단지 적합성(fit)에 따라 상위 50개를 재정렬하는 역할만 하므로, '적합하지 않은' 유명한 작품이 끼어들 수 없게 만듭니다.
흥미로운 부분은 가중치(weight)입니다. 제가 이 값을 변화시키며 테스트했습니다:
| Weight | Recall@12 | 어떤 일이 일어났는지 |
|---|---|---|
| 0 | 30% | |
| ... | ||
| 0.15의 점수가 더 높게 나왔지만, 저는 이 값을 채택하지 않았습니다. <span style="font-style: italic;">Her</span>는 훌륭한 영화이지만, 평범한 방에 대한 조용한 영화는 아닙니다. 그것은 '가까운' 유명한 영화입니다. 이것이 바로 전체 앱이 피하려는 실패 지점입니다: 적합성보다 명성(fame over fit). 테스트 세트는 이 구분을 볼 수 없었기 때문에, 저는 결과를 읽고 판단을 내렸습니다. |
변화가 생겼다고 해서 모든 경우에 개선된 것은 아니었습니다. "Time loop"는 40%에서 20%로 떨어졌고, "90s romcom"은 0이 되었습니다. 평균값이 올라갔다는 것이 하락한 사례를 숨길 수 있기 때문에, 평가(eval)는 각 사례별 변화량(delta)을 출력했고 저는 그 모든 것을 읽어보았습니다.
🎭 교훈 3: 오프라인에서 작동했지만 테스트에서는 실패한 해결책
Reddit의 방문자가 다음과 같이 검색했습니다:
"주인공이 냉소적인 금욕주의자이며 자신의 감정으로 고군분투하는 분석적 드라마."
그것은 <span style="font-style: italic;">하우스(House)</span>입니다. 또는 <span style="font-style: italic;">셜록(Sherlock)</span>일 수도 있습니다. 어느 것도 검색 결과에 나타나지 않았습니다. 155개 타이틀의 샘플에서, <span style="font-style: italic;">하우스(House)</span>는 153위였습니다.
그 이유는 파트 1 교훈의 변형입니다. 각 타이틀은 하나의 설명(description)을 가지고 있고, <span style="font-style: italic;">하우스(House)</span>에 대한 설명은 의학, 미스터리, 병원, 팀에 대해 이야기합니다. 주인공의 성격은 여러 문장 중 한 문장에 불과하고, 그 한 문장은 벡터를 거의 움직이지 못하게 합니다.
그래서 저는 모든 영화와 쇼에 오직 주인공에 대해서만 다루는 두 번째 지문(fingerprint)을 제공해 보기로 했습니다:
이 리드는 환자를 퍼즐처럼 여기고 동료들에게는 노골적인 경멸을 보이는, 그러나 사적으로 만성 통증과 진통제 중독과 싸우는 영리하고 입이 쓴 진단가이다.
비용 지출 전 오프라인 테스트:
| 설명만 | 리드 라인을 추가했을 때 | 리드 라인 자체를 벡터로 사용했을 때 | |
|---|---|---|---|
| Lie to Me | #145 | #136 | #3 |
| ... | |||
| 설명에 이 라인을 추가하는 것은 이전과 같은 이유로 아무런 효과가 없었습니다. 평균화되어 사라지기 때문입니다. 하지만 자체 벡터로 사용했을 때는 효과적이었습니다. Anthropic의 배치 API(Batch API)를 통해 7,971개의 리드 라인을 작성하는 데 $0.22가 들었습니다. |
실제 파이프라인에서는, 쿼리를 읽는 모델이 일치하는 "The lead is…" 문장을 작성하고, 이를 임베딩하여 리드 벡터를 검색한 다음, 이 순위(ranking)를 일반적인 순위와 상호 역순위 융합(reciprocal rank fusion)을 사용하여 결합합니다:
// 각 목록이 1/(k + rank)만큼 투표함; 두 목록의 제목은 합산됨
const add = 1 / (RRF_K + rank + 1)
그런 다음 테스트 세트를 실행했습니다. 케이스는 여전히 0%를 기록했습니다.
결과는 더 좋았습니다: Scorpion, Death Note, The Imitation Game, Good Will Hunting 등 그 요청에 대한 합리적인 답변이었습니다. 하지만 목록의 다섯 개 제목은 아니었습니다. 깊이 파고들어 보니, 리드 벡터가 단어 선택에 매우 민감하다는 것이 밝혀졌습니다. 모델이 작성한 문장은 _Sherlock_을 #35에 배치했지만, 제가 직접 손으로 쓴 문장은 이를 #2에 배치했습니다. 테스트 케이스 28개 중 하나가 통과할 때까지 프롬프트를 조정하는 것이 바로 과적합(overfit)의 방식입니다.
그래서 현재는 보류 상태입니다. 7,971개 라인과 그 벡터들은 데이터베이스에 저장되어 있으며, 더 나은 아이디어, 어쩌면 최적의 일치 항목을 유지하는 여러 구문별 쿼리에 대비하고 있습니다. 저는 테스트 케이스를 "Scorpion이 포함된다"고 수정하지 않을 것입니다. 놓친 것에 대해 목표 지점을 옮기는 것이 바로 테스트 세트가 막아야 하는 행동입니다.
📋 학습 내용 4: 사용자는 당신이 설계한 형태가 아닌 방식으로 검색한다
검색에 대한 저의 정신적 모델은 '왜(why)'라는 이유가 포함된 문장이었습니다. 이번 주 실제 검색 사례는 다음과 같습니다:
"Dark, Severance, Black Mirror, Love, Death & Robots, MR. Robot, Lost, 1899, From, Alice in Borderlands, Squid game,"
이유 같은 건 전혀 없고, 그냥 목록일 뿐입니다. 결과는 괜찮았지만 (그들이 클릭한 Silo가 가장 먼저 나왔음), #3은 1899였고 #7은 FROM이었습니다. 저는 그들 자신의 쇼 두 개를 돌려주었습니다.
제목을 필터링할 때 사용자가 지정한 제목을 건너뛰는 기능은 의도적으로 한 단어짜리 제목들을 제외합니다. 따라서 "dark thrillers"가 쇼 _Dark_를 제거하지 않도록 합니다. 대문자로 된 항목들의 쉼표로 구분된 목록은 다른 개념이므로, 이제 이것 또한 카운트됩니다:
// "Dark, Severance, 1899, From"는 목록입니다; "dark, moody, slow thrillers"는 아닙니다
const titled = entries.filter((e) => /^[\p{Lu}\p{N}]/u.test(e)).length
return entries.length >= 3 && titled >= entries.length * 0.66 ? entries : []
더 큰 문제는 코드가 아니라 제품 자체였습니다. 제목 목록은 오직 제목 자체로만 매칭될 수 있으며, 이는 이 앱이 피하고자 하는 장르 선반(genre-shelf) 매칭과 정확히 일치합니다. 하지만 검색을 거부하는 것이 더 나쁠 것입니다. 그래서 결과는 유지되며, 그 위에는 이제 페이지가 다음과 같이 묻습니다:
여것들은 쇼 자체와 일치합니다. 당신이 이 쇼들에서 좋아하는 점을 알려주시면, 저희가 대신 그것에 맞춰 매칭해 드리겠습니다.
입력창에는 문장 시작어(분위기(The mood), 캐릭터(A character),_ 순간(A moment),_ 느낌(How it felt)_ 등)가 포함되어 있어, 사용자가 완성할 수 있도록 상자에 "왜냐하면 분위기가..."와 같은 구문을 넣습니다. 그들이 무엇을 쓰든 그것이 다음 검색이 매칭되는 '이유'가 됩니다.
🎮 레슨 5: "GTA V 같아"는 GTA V였고, 여섯 번이나 그랬다
모든 타이틀 페이지는 가장 가까운 이웃들을 나열합니다. 게임의 경우, 카탈로그에는 모든 에디션(Special, Enhanced, Story Mode, DLC)이 있습니다. 이들은 거의 동일하게 설명되므로 서로가 서로의 가장 가까운 이웃이 됩니다. GTA V의 상위 추천 6개 항목 모두는 기본 게임인 GTA V가 두 번 나열된 후, Special, Enhanced, Premium Online, 그리고 Story Mode 에디션으로 구성되어 있었습니다.
해결책은 의도적으로 직설적인 프랜차이즈 키(franchise key)를 사용하는 것입니다. 부제목과 모든 끝에 붙는 숫자를 제거한 후 남은 제목의 처음 두 개의 의미 있는 단어입니다:
franchiseKey('Grand Theft Auto V: Special Edition') // "grand theft"
franchiseKey('Grand Theft Auto: San Andreas') // "grand theft"
franchiseKey('The Lord of the Rings') // "lord rings"
...
프랜차이즈당 목록에 제목은 최대 하나만 가능합니다. 저는 총 12,016개의 목록을 다시 계산했습니다. 이는 데이터베이스 작업일 뿐이라 비용이 들지 않았습니다. Skyrim의 페이지는 이제 자체 에디션 진열 대신 Enderal, The Witcher 3, Dragon Age를 추천합니다.
🔎 교훈 6: Google은 당신의 임베딩에 신경 쓰지 않습니다
저는 마침내 Google Search Console을 설정했습니다. 첫 번째 보고서에서 제 제목 페이지 중 여섯 개가 "크롤링됨 – 현재 색인되지 않음(Crawled – currently not indexed)" 상태였습니다. Google은 이 페이지들을 읽었지만 보관할 가치가 없다고 판단한 것입니다.
이것에 대해 반박하기는 어렵습니다. 제목 페이지의 시놉시스는 TMDB(또는 게임의 경우 IGDB)에서 가져오므로, IMDb와 수많은 다른 사이트의 것과 동일한 내용입니다. 제가 가진 유일한 부분은 왜인 이유인데, 페이지들은 그 부분을 실제로 명확히 언급하지 않았습니다.
그래서 가장 인기 있는 제목 페이지들은 이제
20페이지에 0.009달러가 들었습니다. 16페이지에서 하나의 사실적 오류가 발견되었는데, 주연 캐릭터는 어머니인데도 불구하고 _Fear the Walking Dead_에게 "보호적인 아버지"라는 설정을 부여한 것이었습니다. 그 오류와 이 자료가 도움이 된다는 증거를 원했기 때문에, 현재까지의 분량은 12,000페이지가 아닌 25페이지입니다. 거절된 여섯 페이지 덕분에 새로운 내용이 추가되었고, 저는 구글에게 다시 한번 확인해 달라고 요청했습니다. 이것이 구글의 마음을 바꿀지는 파트 3에서 다룰 예정입니다.
제가 그곳에 있는 동안 URL도 수정했습니다. 타이틀 페이지는 /browse/3bd40852-97cc-4767-8584-388236de35cc였습니다. 이제는 /browse/forza-horizon-5-3bd40852입니다. 뒤에 붙은 8개의 16진수 문자는 항목의 UUID(Universally Unique Identifier, 고유 식별자) 시작 부분이며, 조회 방식이 범위 쿼리(range query)이기 때문에 여전히 기본 키 인덱스(primary key index)를 사용합니다:
// ids는 uuid 타입이므로 "starts with"는 범위를 의미함
.gte('id', `${prefix}-0000-0000-0000-000000000000`)
.lte('id', `${prefix}-ffff-ffff-ffff-ffffffffffff`)
이전 링크 308개가 새로운 링크로 변경되었습니다. URL을 변경하기 가장 좋은 시기는 구글이 이를 인덱싱하기 전이며, 저에게는 이번 주가 그랬습니다.
💸 교훈 7: 호출당 비용(cheap per call)이 실수당 비용(cheap per mistake)을 의미하지 않는다
파트 1에서는 카탈로그를 재기술하는 데 약 2.30달러가 들 것이라고 했습니다. 사실입니다. 하지만 그것이 말해주지 않은 것은, 반복 작업을 할 때마다 다시 실행한다는 점입니다. 제 청구서에는 그 2.30달러가 이틀 동안 한 번 이상 발생했습니다. 큰돈은 아니지만 잘못된 습관이며, 카탈로그 규모에 따라 비용이 커집니다. 이제의 규칙은 다음과 같습니다:
- 먼저 샘플링(Sample first)하세요. 20개의 타이틀을 모두 읽어본 후 결정합니다. 이 게시물에서 수정된 내용 중 대부분이 이런 방식으로 시작되었습니다.
- 카탈로그 전체에 무언가를 실행하기 전에 비용을 명시하고, 스크립트에 확실한 비용 상한선을 설정하세요.
- 상호작용적이지 않은 모든 작업에는 **배치 API(Batch API)**를 사용하세요. 반값입니다.
- 데이터베이스 작업을 선호하세요. 프랜차이즈 수정은 12,016개의 행에 대해 0달러가 들었습니다.
이제 비용이 드는 항목들:
- 7,971개 영화 및 쇼의 리드 라인(Lead lines): 0.22달러
- 페이지 카피: 페이지당 약 0.0005달러
- 전체 테스트 세트 실행: 몇 센트
🙏 직접 해보고, 부숴보세요
검색하는 데는 무료이며, 가입할 필요도 없고, 광고도 없습니다: becauseiliked.com
왜(why)를 포함한 검색과 그렇지 않은 검색을 모두 시도해 보세요. 그리고 만약 검색 결과가 잘못되었다면, 특히 '주인공은...' 같은 경우라면 댓글로 남겨 주세요. 테스트 세트에 바로 반영됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기