대부분의 검색 상자는 한 가지 질문에 답하기 위해 존재합니다: 제가 입력한 단어와 일치하는 것은 무엇입니까? 이 질문은 도서관 목록에는 적합하지만, 영양 앱에는 잘못된 질문입니다.
사용자가 Appetec에서 레시피 페이지를 열 때, 그들이 입력하는 단어는 요청에서 가장 덜 흥미로운 부분입니다. 실제로 중요한 것은 보이지 않는 정보입니다: 오늘 남은 칼로리, 매주 아무 생각 없이 요리했던 것, 비건인지 여부, 그리고 저녁 식사로 이미 예산을 초과했는지 여부. 이 모든 것을 무시하는 검색 엔진은 일치하는 레시피를 기꺼이 반환할 것이고 — 사용자 앱을 연 이유를 기꺼이 망칠 것입니다. 이것은 그 격차를 해소하기 위해 구축된 패턴 뒤에 있는 아키텍처입니다: 이 사람에게 지금 바로 적합한 관련성 말입니다.
텍스트 매칭만으로는 충분하지 않은 이유
이 패턴은 동일한 쿼리가 다른 사용자에게 다른 결과를 반환해야 할 때마다 적용됩니다. 즉, "최고"가 사용자가 입력하지 않은 컨텍스트에 따라 달라지고, 소유한 카탈로그가 사용자가 기대하는 카탈로그보다 작을 때 적용됩니다. 개인 맞춤형 레시피 검색은 관련성을 단일 신호가 아닌 세 가지 신호의 조합으로 처리합니다:
| 신호 | 캡처하는 내용 |
|---|---|
| 텍스트 | 사용자가 입력한 내용 — 또는, 식사 스캔 모드에서, 접시에서 감지된 내용 |
| 목표 적합성 | 레시피가 사용자의 오늘 이 식사에 남은 칼로리에 얼마나 잘 맞는지 |
| 습관 | 이 특정 사용자가 실제로 이 레시피를 얼마나 자주 먹는지 |
단순한 엔진은 이 표의 첫 번째 행만 봅니다. 이 패턴은 세 가지를 단일 순위 목록으로 통합하고, 로컬 카탈로그가 부족할 때마다 외부 소스에서 보충합니다 — 그렇게 할 때마다 조용히 카탈로그를 성장시킵니다. 그 결과는 데이터베이스에 쿼리하는 것보다 당신의 주방과 식단을 이미 알고 있는 친구와 같습니다.
파이프라인 구성 방식
이 시스템은 전면에 개인화 레이어를, 후면에 자가 치유 카탈로그를 갖춘 검색 파이프라인으로 작동합니다.
두 개의 검색 엔진, 하나의 계약. Elasticsearch가 기본입니다: 퍼지 오타 허용, 영어 어간 추출("grill"이 "grilled"를 찾음), 가중치 관련성 점수 매기기. 만약 Elasticsearch를 사용할 수 없게 되면, 동일한 쿼리는 동일한 필터와 개인화 규칙을 따르는 MongoDB 검색으로 대체됩니다. 검색은 장애 발생 시 성능이 저하될 수는 있지만, 완전히 사라지지는 않습니다.
사용자가 느끼지만 보지 못하는 레이어. 결과가 반환되기 전에 세 가지 요소가 목록을 재구성합니다. 칼로리 예산은 일일 목표에서 기록된 내용을 제외하여 계산되며, 남은 범위에 맞는 레시피를 부스트합니다 — 그리고 사용자가 목표를 초과했다면, 목록은 조용히 최저 칼로리 순으로 재정렬되어 방종 대신 회복을 유도합니다. 식사 기록은 지난 60일간의 식사 로그를 "실제로 먹는 것" 지도로 요약하여, 즐겨찾기를 첫 페이지에서 한 번의 탭으로 볼 수 있게 합니다. 식단 선호도 — 채식, 비건, 비채식, 그리고 keto, gluten-free, high-protein과 같은 두 다스 이상의 세부 태그 — 는 모든 것을 필터링하고 재정렬합니다.
스스로 성장하는 카탈로그. 로컬 결과가 부족할 때, 파이프라인은 외부 소스인 FatSecret으로 페이지를 넘겨 결과를 동일한 무한 스크롤 피드에 눈에 띄는 이음새 없이 연결합니다. 그런 다음 LLM을 사용하여 각 레시피를 채식, 비채식 또는 비건으로 분류하여 로컬 카탈로그에 다시 작성하고, 다음 번에 올바르게 개인화될 수 있도록 합니다 — 이는 로컬 및 외부 결과가 하나의 시스템처럼 느껴져야 하는 하이브리드 검색 아키텍처 뒤에 있는 것과 동일한 원리입니다.
세 가지 캐시, 세 가지 작업. 결합된 결과 세트, 사용자별 습관 지도, 그리고 외부 API 응답은 각각 자체적인 독립적인 수명을 가지므로, 개인 맞춤형 다중 소스 쿼리도 여전히 단일 일반 조회와 거의 같은 속도로 반환됩니다.
언급할 만한 트레이드오프
이 모든 것이 거저 얻어진 것은 아니며, 비용에 대해 솔직한 것이 이 패턴을 단순히 영리한 것을 넘어 신뢰할 수 있게 만드는 요소의 일부입니다.
관련성은 의도적으로 비중립적으로 만들어졌습니다. 순수한 텍스트 관련성 순위는 여기서는 적극적으로 도움이 되지 않는 방식으로 "공정"합니다. 결과는 의도적으로 목표 적합성과 습관에 편향되어 있습니다. 왜냐하면, 텍스트적으로는 약간 덜 완벽하더라도 누군가의 예산과 입맛에 맞는 레시피가 더 나은 답이기 때문입니다. "왜 이것이 1위를 차지했는가?"는 검색 엔진의 기본값이 아닌 제품 결정 사항이 됩니다 — 그래서 이러한 가중치는 소유되지 않은 구성 파일이 아닌 애플리케이션 코드에 존재합니다.
전체 카탈로그를 소유하는 것이 목표가 아니었습니다 — 올바른 부분을 소유하는 것이 목표였습니다. 고려했던 극단적인 방식은 완전히 큐레이션된 카탈로그(고품질, 좁은 범위) 또는 외부 API에 대한 완전한 프록시(무제한 범위, 제어 불가능, 개인화 없음)였습니다. 이 시스템은 그 중간을 선택합니다: 소유하고 사용자가 작성한 레시피를 우선적으로 제공하고, 필요할 때 외부에서 보충하며, 보충된 내용을 흡수합니다 — 시간이 지남에 따라 사용자가 실제로 검색하는 것을 정확히 소유하는 방향으로 나아갑니다.
최고 효율성보다 복원력을 선택했습니다. 두 개의 전체 검색 엔진은 하나보다 운영 비용이 더 많이 듭니다 — 이는 대부분의 백엔드 결정 뒤에 있는 복원력 우선 사고방식과 동일합니다. 이러한 비용은 의도적으로 수용되었습니다: 레시피 검색은 핵심적인 일상 사용 기능이며, 검색 기능이 저하되는 것은 사소한 불편함이지만, 검색 기능이 없는 것은 앱이 고장 난 것이나 다름없기 때문입니다.
개인화는 예측 가능성을 희생합니다. 결과는 하루 중 시간, 섭취 칼로리, 이력에 따라 실제로 달라집니다 — 심지어 같은 사용자라도 아침 식사와 저녁 식사 시에 다릅니다. 이는 의도된 바지만, 테스트 및 지원의 기준을 높입니다: 정확성이 사용자별, 순간별로 정의될 때 "내 휴대폰에서는 작동해요"라는 말은 큰 의미를 가지지 못하게 됩니다.
이 패턴이 적합한 경우 — 그리고 적합하지 않은 경우
이 패턴이 사용되지 않아야 하는 경우를 명확히 할 가치가 있습니다. 이 패턴은 사용자가 입력하지 않은 컨텍스트를 기반으로 사용자마다 결과가 실제로 달라져야 할 때, 소유한 카탈로그가 사용자가 기대하는 것보다 작지만 외부적으로 풍부하게 만들 수 있을 때, 기능이 충분히 자주 사용되어 최소 인프라보다 우아한 성능 저하가 더 중요할 때, 그리고 "관련성"이 텍스트 이상의 신호를 합법적으로 포함할 때 그 복잡성을 정당화합니다.
이 패턴은 모든 사용자에게 결과가 동일한 경우 — 공공 문서 검색, SKU 조회 등 — 개인화가 아무런 이점 없이 비용만 추가할 때 잘못된 도구입니다. 카탈로그가 작고 안정적이어서 단순 필터링만으로도 충분한 단일 엔진으로도 충분할 때 불필요하며, 엄격하고 동일하며 완전히 설명 가능한 순위가 필수 요구 사항인 곳에서는 불리하게 작용합니다.
실제 역할
여기서 검색 품질은 단순히 매칭 문제가 아니라 개인화 문제로 다루어집니다. 사용자가 비건이고, 이미 예산을 초과했으며, 한 달 내내 똑같은 다섯 가지 저녁 식사를 요리했다는 사실을 무시한 채 텍스트적으로 완벽한 레시피 검색은 기술적으로는 작동했지만 실제로는 실패한 것입니다. 본래의 임무는 쿼리와 일치하는 레시피를 찾는 것이 아니라, 이 사람이 다음에 요리해야 할 레시피를 드러내는 것입니다. 듀얼 검색 엔진부터 누군가 예산을 초과했을 때 조용히 재정렬하는 기능까지, 이 아키텍처의 모든 레이어는 그 격차를 해소하기 위해 존재합니다.
자사 제품의 검색 또는 검색 기능에 대해 유사한 빌드 대 블렌드 결정을 고민하고 계시다면, 기꺼이 논의해 드리겠습니다.
다른 블로그
1. 마이크로서비스 아키텍처로 디지털 건강 플랫폼 확장하기

