들어가며

자유롭게 검색하더라도 원하는 결과가 나오는 검색 시스템을 꿈꿨습니다.

Picky Trip은 월별 기후, 예산, 치안, 여행 스타일을 보고 여행지를 추천해주는 사이드 프로젝트입니다. 지금까지는 월을 고르고 필터를 조절하는 방식이었는데, 첫 화면에서 이렇게 문장으로 검색하고 싶었습니다.

“오토바이 타고 캠핑할 수 있는 10월 여행지”

이 글은 이 검색을 만들면서 Decision Model을 검색에 써본 기록입니다. 로컬에서 돌리는 laya + e5 조합과 호스팅 API인 Jev를 비교했고, 결국 Jev를 골랐습니다. 그 이유를 숫자와 함께 정리했습니다.


1. 키워드 사전으로 시작했다

처음에는 가장 단순한 방법으로 시작했습니다. 5개 언어(한국어, 영어, 일본어, 중국어, 스페인어) 사전을 만들고, 문장에서 조건을 뽑아 기존 점수 엔진에 넘기는 방식입니다.

종류예시
월/계절12월, 겨울, 벚꽃, 단풍
예산저렴하게, 가성비, 럭셔리
날씨따뜻한, 바닷가, 눈
지역/나라유럽, 동남아, 일본
여행 스타일휴양, 맛집, 미술관, 온천

브라우저에서 1ms 안에 끝나고, “12월에 따뜻한 바닷가에서 저렴하게 쉬고 싶어”에는 끄라비, 세부, 콜롬보가 나왔습니다. 꽤 괜찮았습니다.

그런데 처음 하고 싶었던 문장을 넣으면 이렇게 됩니다.

오토바이 타고 다니면서 캠핑이 가능한 10월 여행지
  인식한 조건: 10월
  결과: 도쿄, 나고야, 교토, 오사카, 후쿠오카

“오토바이”와 “캠핑”은 사전에 없으니 무시되고, 10월에 좋은 일본 도시만 나옵니다. 오토바이, 캠핑, 서핑, 요가, 아이 동반… 이런 조건을 전부 사전과 라벨로 만들 수는 없습니다. 그래서 Decision Model을 써보기로 했습니다.


2. Decision Model이란

Decision Model은 텍스트를 생성하지 않습니다. 상태(state)와 타입이 정해진 질문을 주면 각 질문에 대한 답을 보정된 확률로 돌려줍니다.

  • choice: 보기 중 하나를 고르고, 보기별 확률을 줌
  • score: 순서가 있는 척도 위의 기대값
  • noul: 예/아니오 문장의 참일 확률 P(true)

이번에 써본 건 두 가지입니다.

  • laya: Convai Innovations가 공개한 모델(ModernBERT-large 기반, 421M 파라미터, Apache 2.0)입니다. @receptron/laya로 Node.js에서 ONNX Runtime으로 돌릴 수 있고, GPU 없이 CPU로 동작합니다. 필요한 자원은 이렇습니다.
    • 디스크: 약 1.7GB (첫 실행 때 내려받는 모델 파일)
    • 메모리(RAM): 약 2GB (실제 측정 최대 2.04GB)
    • 속도: 4코어 ARM CPU에서 도시 1곳 판정에 약 0.75초
  • Jev: TypeSafe의 호스팅 API입니다. laya가 “Jev 호환”을 표방할 만큼 요청/응답 형식이 같습니다.
const result = await laya.systemOne(
  { request: "12월에 따뜻한 바닷가에서 저렴하게 쉬고 싶어" },
  {
    month: { type: "choice", instructions: "Which month?", criteria: { any: "not mentioned", "12": "December" /* ... */ } },
    budget: { type: "score", instructions: "How much money?", criteria: ["cheap", "moderate", "comfortable", "luxury"] },
    relax: { type: "noul", instructions: "Does the traveler want to relax?" },
  },
);

3. 첫 시도: 검색어에서 조건 뽑기 — 실패

처음에는 laya를 검색어 해석기로 썼습니다. 월(choice), 예산(score), 날씨·지역(choice), 여행 스타일 10개(noul)까지 질문 16개를 던져서 조건을 뽑는 방식입니다.

검색어laya 결과
Europe trip for museums and history지역 Europe 1.00, 문화·역사·예술 ✅
유럽에서 미술관이랑 역사 유적 보고 싶어지역 “any”, 스타일 adventure ❌
가까운 일본에서 쇼핑이랑 밤문화지역 “any”, 스타일 history ❌
겨울에 눈 보면서 온천하고 싶어월 4월 ❌

영어는 어느 정도 되는데 한국어는 거의 알아듣지 못했습니다. 공개된 체크포인트가 영어 모델이기 때문입니다. 속도도 문제였습니다. 4코어 CPU에서 질문 16개에 검색 한 번당 12~27초가 걸렸습니다.


4. 질문을 바꾸다: “이 도시가 요청을 만족하나?”

laya의 강점은 주어진 글을 읽고 판정하는 것입니다. 그래서 질문의 방향을 바꿨습니다. 검색어를 해석시키는 대신, 요청문과 도시 소개글을 같이 주고 판정만 시켰습니다.

await laya.systemOne(
  { traveler_request: query, destination: profile },
  { fit: { type: "noul", instructions: "Does this destination satisfy the traveler's request?" } },
);

결과가 확 달라졌습니다.

검색어상위하위
a destination in October where I can ride a motorcycle and go camping울란바토르 0.91, 하노이 0.86, 제주 0.70나머지 0.18 이하
오토바이 타고 다니면서 캠핑이 가능한 10월 여행지제주 0.77, 울란바토르 0.74, 도쿄 0.74파리 0.23
사막에서 럭셔리하게두바이 0.73오클랜드 0.15

영어는 확실하게 갈랐지만, 한국어에서는 여전히 도쿄가 높았습니다. 그리고 도시 하나 판정에 약 0.75초가 걸려서 80개 도시를 다 보면 1분입니다.

판정은 입력의 품질을 넘지 못한다

판정 모델은 우리가 준 글만 봅니다. 그래서 80개 도시마다 영어 소개글(profile)을 만들었는데, 첫 버전은 “Okutama offers designated camping”처럼 가능하기만 한 활동까지 다 적어서 80곳 중 70곳에 캠핑이 들어가 있었습니다. 이러면 어떤 모델도 구분할 수 없습니다.

소개글을 이렇게 바꿨습니다.

Tokyo, Japan. Known for: Shibuya's neon streets, Asakusa's Senso-ji, sushi and ramen culture, ...
(경험, 이동 방식, 분위기, 누구에게 맞는지, 비용 수준)
Less suited for: camping or road trips, beach holidays, spacious budget accommodation.

“Known for”에는 그 도시를 고르는 대표 이유만 넣고, 오해하기 쉬운 것은 “Less suited for”로 뺐습니다. 캠핑은 70곳에서 5곳으로 줄었습니다.


5. laya + e5 설계

0.75초/도시라면 전부 판정할 수는 없으니, 후보를 먼저 추리는 단계가 필요했습니다. 여기에 e5(multilingual-e5-small)를 붙였습니다.

e5는 문장을 384차원 벡터로 바꾸는 다국어 임베딩 모델입니다. 뜻이 비슷하면 벡터도 가까워서, 단어가 하나도 안 겹쳐도 “오토바이 캠핑”과 “Ha Giang motorcycle loop, camping”을 가깝게 봅니다. 모델은 130MB이고 검색어 하나에 6~15ms였습니다.

1. 키워드 사전   → 즉시 결과
2. e5            → 뜻이 가까운 후보 8곳
3. laya          → 8곳만 정밀 판정 (약 6초)
검색어 (e5만)상위
오토바이 타고 다니면서 캠핑이 가능한 10월 여행지제주, 발리, 오클랜드, 하노이
서핑하고 요가발리
사막에서 럭셔리하게두바이 4위

e5는 한국어를 잘 받아줬지만 점수 차이가 작았습니다(0.78 대 0.80). 그리고 임베딩의 유사도만으로는 부정 조건을 확실하게 처리하기 어렵습니다. 예를 들어 “바다는 아닌 휴양지”를 검색하는 사람은 바닷가를 제외하고 산이나 온천에서 쉴 곳을 찾고 있습니다. 그런데 “바다에서 쉬기 좋은 휴양지”도 바다와 휴양이라는 같은 주제를 다루므로, 두 문장의 벡터가 가깝게 나올 수 있습니다. “아닌”이라는 말이 원하는 여행지를 바꿔도, 그 차이가 유사도 점수에 충분히 반영되지 않으면 제외하고 싶었던 바다 휴양지가 후보에 들어오는 겁니다. 임베딩 모델이 문장의 의미를 전혀 이해하지 못한다는 뜻은 아닙니다. 비슷한 주제의 문장을 찾는 것과, 요청의 모든 조건을 만족하는지 판정하는 것은 다른 일이라는 뜻입니다. 같은 이유로 소개글의 “Less suited for: camping”(캠핑에는 덜 적합함)도 캠핑 검색어와 가까워질 수 있어서, 임베딩 입력에서는 그 절을 빼고 처리하려 했습니다.

운영 서버가 작아서(메모리 512Mi) laya를 그대로는 올릴 수 없었습니다. laya를 별도 서비스로 띄우는 것도 가능했습니다(메모리 3Gi, CPU 2~3코어). 하지만 그래도 판정에 6~10초가 걸리고, 검색하는 동안 같은 노드의 다른 서비스가 느려지며, 한국어 약점은 그대로였습니다.


6. Jev: 80곳을 한 번에, 1초 안에

Jev는 요청 형식이 laya와 같은데, 한 요청에 최대 32k 토큰을 받고 계산은 Jev 서버에서 합니다. 그래서 후보를 추리지 않고 80개 도시 전부를 한 요청에 넣어봤습니다.

const res = await fetch("https://api.typesafe.ai/v1/systemone", {
  method: "POST",
  headers: { Authorization: `Bearer ${JEV_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    model: "jev-latest",
    state: { traveler_request: query, destinations }, // 80개 도시의 소개글
    questions: Object.fromEntries(
      cityIds.map(id => [id, { type: "noul", instructions: `Does destination "${id}" satisfy the traveler's request?` }]),
    ),
  }),
});
검색어상위 결과시간
오토바이 타고 다니면서 캠핑이 가능한 10월 여행지하노이 0.78, 오클랜드 0.67, 울란바토르 0.66, 제주 0.65 (뉴욕 0.11)0.52초
사막에서 럭셔리하게두바이 0.89, 마라케시 0.72, 카이로 0.540.39초
서핑하고 요가발리 0.92, 시드니 0.77, 케이프타운 0.740.40초
12월에 따뜻한 바닷가에서 저렴하게 쉬고 싶어끄라비 0.83, 보라카이 0.83, 카르타헤나 0.740.39초
아이 데리고 가기 좋은 안전한 곳후쿠오카·싱가포르 0.83, 도쿄 0.820.37초

처음에는 소개글에 12개월 날씨와 비행시간까지 붙여서 넘겼습니다. 한 번에 약 3만 토큰, 입력 100만 토큰당 $0.042 라 검색당 약 $0.0013 이었고, 위 표가 그때 결과입니다.

어떤 식으로 데이터를 넘겨야 정확도가 높게 나올까

Jev를 붙이고 나서 이런 질문이 생겼습니다.

“소개글을 같이 넘기는 게 맞나? 그냥 Jev에 온전히 맡기는게 나을까?”

Jev도 언어 모델이니 “하노이”가 어떤 곳인지는 이미 알 겁니다. 그렇다면 소개글을 공들여 만들고 3만 토큰씩 넘길 이유가 있는지 확인해봤습니다. 넘기는 내용만 바꾸고 같은 검색어 7개를 돌렸습니다. 비교 기준은 “구분력”, 즉 1위와 10위의 점수 차이입니다. 클수록 맞는 곳과 아닌 곳을 확실히 가른다는 뜻입니다.

1차: 이름만 / 이름 + 날씨 / 소개글 + 날씨

넘기는 내용토큰”오토바이 타고 캠핑할 수 있는 10월 여행지""12월에 따뜻한 바다에서 아무것도 안 하기”
이름만2.9k제주, 사파, 루앙프라방, 하노이, 발리발리, 보라카이, 괌, 끄라비
이름 + 12개월 날씨12.4k제주, LA, 후쿠오카, 교토, 마라케시끄라비, 괌, 카르타헤나
소개글 + 12개월 날씨 (당시 방식)29.9k하노이, 제주, 울란바토르, 오클랜드보라카이, 끄라비, 칸쿤, 괌

결과가 예상과 달랐습니다.

  • 이름만 넘겨도 쓸 만했습니다. 오히려 사파·루앙프라방처럼 실제로 바이크 여행으로 유명한 곳을 찾아냈습니다. 우리 소개글에는 없는 지식입니다. 대신 시기는 못 따져서, “12월에 따뜻한 바다에서 아무것도 안 하기”에 12월이 우기인 발리를 공동 1위(86점)로 올렸습니다.
  • 날씨를 붙이자 오히려 나빠졌습니다. 이름 + 날씨는 오토바이 캠핑에 LA·후쿠오카·교토를 올렸습니다. 80곳 × 12개월, 960줄의 기온·날씨가 판단을 흐린 것 같았습니다.

그렇다면 지금 방식(소개글 + 날씨)에서도 날씨가 발목을 잡고 있는 건 아닐까? 그래서 날씨를 뺀 조합으로 한 번 더 돌렸습니다.

2차: 날씨를 빼면?

넘기는 내용토큰검색당 비용구분력특징
이름만2.9k$0.000120.07~0.29지식은 넓지만 시기를 못 따짐
이름 + “Known for” 한 줄4.9k$0.00020.09~0.47이름만보다 조금 날카로움
소개글12.8k$0.00050.38~0.59가장 확실하게 가름. 오토바이 캠핑 → 울란바토르 0.87, 서핑·요가 → 발리 0.91
소개글 + 12개월 날씨29.9k$0.00130.14~0.48날씨가 도움된 건 “12월 바다”처럼 시기를 콕 집은 검색뿐

날씨를 빼자 구분력이 가장 좋아졌고, 토큰은 절반 이하로 줄었습니다. 남은 문제는 시기를 콕 집은 검색이었는데, 이건 이미 가지고 있던 데이터로 할 수 있는 일이었습니다. 도시마다 12개월 각각의 추천도(우기·성수기 반영)와 기온 데이터가 있고, 키워드 엔진이 “12월”, “겨울”, “벚꽃”을 알아들으면 그 달의 추천도·기온으로 점수를 매기니까요.

그래서 역할을 나눴습니다.

  • Jev: 소개글만 보고 “어떤 여행인가”를 판정 (오토바이, 서핑, 와인, 밤문화…)
  • 기존 월별 추천도·기온 데이터: “언제 가는가”를 판정

검색당 비용은 $0.0013 → $0.0005, 시간은 0.4초 → 0.3초가 됐습니다. 덤으로, 같은 요청을 다시 보내도 점수가 1~2점씩 달라진다는 것도 알게 돼서, 그 정도 차이의 순위 변화에는 의미를 두지 않기로 했습니다.


7. laya + e5 vs Jev

laya + e5 (자체 구동)Jev (호스팅)
한국어 검색어약함 (도쿄 0.74)잘 구분함
속도후보 8곳에 6~10초80곳 전부 약 0.3초
판정 범위후보 8곳 (정답이 8위 밖이면 놓침)80곳 전부
인프라별도 서비스, 메모리 3Gi, CPU 2~3코어, glibc 이미지없음 (HTTP 호출)
비용서버 자원검색당 약 $0.0005
외부 의존없음있음 (장애 시 대비 필요)

로컬 모델의 장점(외부 의존 없음, 데이터가 밖으로 안 나감)도 분명합니다. 하지만 이 서비스는 공개된 여행지 정보만 다루고, 작은 노드에서 돌아가며, 사용자 대부분이 한국어로 검색합니다. 이 조건에서는 Jev가 모든 면에서 나았습니다.


8. 최종 구조

입력 ─┬─ 키워드 사전 (브라우저, 1ms) ─────────────▶ 즉시 결과
      └─ Enter·버튼 → /api/search/judge ─▶ Jev (80곳 소개글, 0.3초) ─▶ 점수 합쳐 재정렬
  • 점수 합치기: Jev 적합도(0~1)를 키워드 조건 점수와 섞습니다(가중치 6). 키워드 조건 하나보다 크게 반영하고, “일본”처럼 나라를 적으면 그 나라 안에서만 보여주는 필터는 유지합니다.
  • 비용 관리: 같은 검색어는 1시간 캐시하고, 실제 Jev 호출은 하루 100회로 제한합니다.
  • 장애 대비: 키가 없거나, 한도를 넘거나, Jev가 실패하면 API가 503/429를 돌려주고 화면은 키워드 결과만 보여줍니다.
검색어키워드만최종 (키워드 + Jev)
오토바이 타고 캠핑할 수 있는 10월 여행지도쿄, 나고야, 교토하노이, 울란바토르, 발리, 제주, 오클랜드
서핑 배우고 요가도 하는 한 달 살기칼리아리, 팔마, 발리발리, 시드니, 케이프타운, 끄라비
12월에 따뜻한 바다에서 아무것도 안 하기끄라비, 살바도르, 방콕보라카이, 괌, 끄라비, 칸쿤, 발리

마지막 줄이 역할 분담을 잘 보여줍니다. Jev에 소개글만 넘기면 12월이 우기인 발리도 높게 나옵니다. 하지만 “12월”을 알아들은 뒤 기존 데이터의 12월 추천도를 섞으면 발리가 5위로 내려갑니다.

12월 데이터시기(추천도)날씨(기온)바다 태그AI최종(약)
발리24~30°C, 비, 추천도 6610106.37.6
보라카이24~29°C, 온화, 추천도 9910107.48.7

두 곳 모두 따뜻해서 기온 점수는 같고, 차이를 만든 건 12월 추천도입니다. 최종 점수는 AI(비중 6), 시기·날씨(각 3), 태그(2)를 비중대로 섞은 평균입니다.

예시 문구를 바꾸다가 생긴 규칙 두 개

처음 화면의 예시 문구는 키워드 검색 시절에 만든 것이었습니다. “12월에 따뜻한 바닷가에서 쉬고 싶어”, “유럽 미술관이랑 역사 유적” 같은 문장이라, 사전만으로도 되는 검색이었고 AI가 잘하는 걸 전혀 보여주지 못했습니다. 그래서 AI가 아니면 못 찾는 문장으로 바꾸려고 후보 8개를 “키워드만”과 “키워드 + Jev” 두 경우로 돌려봤습니다.

예시 후보키워드만키워드 + Jev
와인 마시면서 느긋하게 걷기 좋은 소도시시에나, 케이프타운, 산티아고시에나 83%, 피렌체 …
혼자 가도 심심하지 않은 밤이 재밌는 도시(결과 없음)도쿄 87%, 오사카, 서울, 베를린
사막에서 럭셔리하게 쉬고 싶어칼리아리, 두바이, 취리히두바이 89%, 마라케시 67%, 칼리아리 7%

여기서 두 가지가 보였습니다.

  1. AI가 아니라고 한 곳이 위에 남았습니다. “사막에서 럭셔리하게”에서 칼리아리가 AI 적합도 7%인데도 3위였습니다. “럭셔리”·“쉬고” 같은 키워드 점수가 끌어올린 겁니다. 그래서 AI 적합도 25% 미만은 키워드 점수가 높아도 결과에서 뺐습니다. (남는 곳이 3개 미만이면 키워드 결과를 유지합니다.)
  2. 예시 버튼이 한도를 다 쓸 수 있었습니다. 예시는 누구나 누르니 캐시가 잘 맞지만, 캐시가 1시간마다 비워지면 예시 4개만으로 하루 최대 96회를 씁니다. 그래서 예시 문장은 7일 동안 캐시합니다.

최종 예시는 이 네 문장입니다. 오토바이 캠핑, 서핑·요가 한 달 살기, 와인과 소도시, 혼자 즐기는 밤. 사전에는 없는 말들이고, Jev가 있어야 제대로 찾는 문장입니다.


9. 배운 점

  • Decision Model은 질문을 어떻게 던지느냐가 전부입니다. 같은 laya라도 “검색어에서 조건을 뽑아라”는 실패했고, “이 도시가 요청에 맞나”는 잘 됐습니다.
  • 좋은 판정은 좋은 입력에서 나왔고, 좋은 입력은 많은 입력이 아니라 핵심만 담은 입력이었습니다. AI는 우리가 준 소개글만 보고 판단합니다. 처음엔 “근교에 캠핑장도 있다”처럼 할 수 있는 활동을 다 적어서 80곳 중 70곳에 캠핑이 들어갔고, 그러면 캠핑 검색에 모든 도시가 맞는 곳처럼 보입니다. 그래서 대표 이유(Known for)와 기대하면 안 되는 것(Less suited for)만 나눠 적었습니다. 12개월 날씨를 붙였을 때도 토큰은 2.3배가 됐지만 판정은 오히려 흐려졌습니다.
  • 임베딩에서 가깝다고 요청에 맞는 것은 아닙니다. “바다는 아닌 휴양지”와 “바다에서 쉬기 좋은 휴양지”는 바다와 휴양이라는 주제를 공유해서 벡터가 가까울 수 있지만, 사용자가 원하는 결과는 다릅니다. 전자는 바닷가를 제외해야 합니다. 그래서 임베딩으로 비슷한 후보를 찾더라도, “아닌”, “제외하고” 같은 조건까지 만족하는지는 별도로 판정해야 합니다.
  • 키워드 검색은 버리지 않았습니다. 즉시 응답하고, AI가 실패했을 때 대비책이 되며, 나라 필터처럼 확실한 조건은 규칙이 더 정확합니다.

체험해보세요

👉 Picky Trip 첫 화면 검색창에 원하는 여행을 문장으로 적고 Enter나 ✨ 버튼을 눌러보세요. 아래 예시를 눌러도 바로 찾아줍니다.

  • 오토바이 타고 캠핑할 수 있는 10월 여행지
  • 서핑 배우고 요가도 하는 한 달 살기
  • 와인 마시면서 느긋하게 걷기 좋은 소도시
  • 혼자 가도 심심하지 않은 밤이 재밌는 도시

AI 판정은 하루 100회까지만 동작해서, 한도를 넘으면 키워드 검색 결과만 보입니다. 재미있는 결과나 이상한 결과가 나오면 알려주세요.

여행지라는 작은 범위지만, 꿈꿔봤던 검색 시스템을 직접 만들어볼 수 있어서 재밌었습니다.