전체 글 보기

무료 NVIDIA AI API로 블로그에 연관 글 붙이기

profile icon

NVIDIA의 무료 임베딩 API로 블로그 글끼리의 의미 유사도를 계산해 연관 글 기능을 만들고, 임베딩과 코사인 유사도 개념부터 결과가 이상하게 나왔던 이유와 고친 과정까지 정리했다.

#AI
조회수
마지막 수정일

NVIDIA가 여러 AI 모델을 API로 무료 공개했다는 소식을 듣고, "안쓰면 손해 아니야?"" 라는 생각이 들었다.

그런데 막상 쓰려고 하니 딱히 마땅한 곳이 떠오르지 않았다. 무료인 만큼 요청 수에 제한이 꽤 있어서, 실시간으로 사용자가 사용할 수 있는 기능에 붙이기에는 좀 애매했다.

사용량을 예측하기 어려운 동적인 기능에 쓸 수 없다면, 블로그 글처럼 정적으로 만들어지는 콘텐츠에는 써볼 만하겠다는 생각이 들었다. 그중에서도 많은 사이트에서 볼 수 있는 연관 글 기능이 잘 맞을 것 같았다.

build.nvidia.com은 NVIDIA가 운영하는 AI 모델 카탈로그다. 채팅, 임베딩, 이미지 생성 등 다양한 모델을 브라우저에서 바로 써볼 수 있고, 같은 모델을 API로도 호출할 수 있다. NVIDIA Developer Program에 가입하면 결제 수단 없이 무료로 쓸 수 있다.

API는 OpenAI와 같은 형식이라, OpenAI API를 써봤다면 주소와 키만 바꾸면 된다. SDK 없이 fetch만으로도 충분하다. 물론 무료인 만큼 요청 수에는 제한이 있다.

  1. build.nvidia.com에 접속해 NVIDIA 계정으로 로그인한다.
  2. 사용할 모델 페이지나 설정 화면에서 Get API Key를 눌러 키를 발급한다.
  3. nvapi-로 시작하는 키가 나온다. 발급 화면에서 한 번만 보여주니 바로 안전한 곳에 저장해 두자.

무료인 대신 몇 가지 제약이 있으니, 쓰기 전에 알아 두자.

  • 개발과 테스트 용도로만 쓸 수 있다.
    • NVIDIA는 Developer Program으로 제공하는 API를 프로토타이핑, 연구, 개발, 테스트 용도로 한정한다. 실제 서비스에 쓰려면 NVIDIA AI Enterprise 라이선스가 필요하다. (NVIDIA NIM FAQ)
  • 요청 수에 제한이 있다.
    • 허용되는 요청 수는 모델마다 다르고, 동시에 쓰는 사용자 수에 따라 바뀌기도 한다.
  • 모델이 예고 없이 내려갈 수 있다.

연관 글을 고르는 가장 쉬운 방법은 태그가 겹치는 글을 보여주는 것이다. 따로 계산할 것도 없고 구현도 간단하다. 대신 결과가 태그 품질에 그대로 좌우된다. 태그 몇 개로 글 전체의 맥락을 담기는 어렵고, 태그는 겹치는데 내용은 전혀 다른 글이 묶이기도 한다. 그렇다고 연관 글을 위해 태그를 늘리다 보면 태그가 덕지덕지 붙어 지저분해질 게 뻔하다.

그래서 태그 대신 글 내용을 직접 비교하기로 했다. AI 모델로 글 내용을 비교하는 방법은 크게 두 가지다.

  • LLM에게 비슷한 글을 직접 골라 달라고 요청하기
  • 임베딩으로 계산해서 비교하기

LLM(Large Language Model)은 ChatGPT처럼 질문이나 지시를 받으면 자연어로 답을 만들어 내는 모델이다. 글 목록을 넘기고 "이 글과 비슷한 글을 골라줘" 라고 하면 꽤 그럴듯한 결과를 받을 수 있다.

임베딩(Embedding)은 텍스트를 고정된 길이의 숫자 배열(벡터)로 바꾸는 기법이다. 이렇게 바꾼 벡터끼리 비교하면 두 텍스트의 의미가 얼마나 비슷한지 계산할 수 있다. 같은 입력을 같은 조건으로 넣으면 보통 같은 벡터가 나온다.

연관 글을 찾는 건 새 글을 만드는 일이 아니라, 이미 있는 글 사이의 유사도를 계산하는 일이다. LLM은 글의 맥락을 읽고 판단할 수 있지만, 요청할 때마다 글 내용을 보내야 하고 결과도 매번 조금씩 달라질 수 있다.

반면 임베딩은 글을 한 번만 벡터로 바꿔 저장해 두면, 이후에는 새 글이 추가될 때만 API를 호출하고 나머지는 벡터끼리 계산하면 된다. 호출 횟수가 적으니 요청 수 제한이 있는 무료 API로도 충분하겠다 싶어서, 이번에는 LLM 대신 임베딩 모델을 쓰기로 했다.

그럼 임베딩이 텍스트의 의미를 어떻게 숫자로 담는지, 그 숫자를 어떻게 비교하는지 살펴보자.

이번에 사용한 모델은 nvidia/nemotron-3.5-embed-1b다. 한국어를 지원하는 임베딩 모델로, 글 하나를 숫자 2,048개짜리 벡터로 바꿔 준다.

숫자 하나하나가 무슨 뜻인지는 사람이 알 수 없다. 대신 모델은 의미가 비슷한 글일수록 벡터의 방향도 비슷해지도록 학습되어 있다. 덕분에 "두 글이 얼마나 비슷한가?"라는 질문을 "두 벡터의 방향이 얼마나 비슷한가?"라는 계산 문제로 바꿀 수 있다.

키워드 검색과 다른 점은 단어가 겹치지 않아도 의미가 비슷하면 벡터가 가까워질 수 있다는 것이다. "React Suspense"와 "lazy initialization"은 겹치는 단어가 거의 없지만, 둘 다 지연 처리를 다룬다. 모델이 두 표현의 맥락을 비슷하게 읽으면 벡터도 가까운 곳에 놓인다.

글을 벡터로 바꿨다면, 이제 두 벡터가 얼마나 비슷한지 숫자 하나로 나타내야 한다. 이때 많이 쓰는 방법이 코사인 유사도다.

코사인 유사도는 두 벡터가 이루는 각도의 코사인 값이다. 벡터의 길이는 보지 않고 방향만 비교하는데, 임베딩에서는 의미가 방향에 담기니 방향을 비교하는 게 곧 의미를 비교하는 셈이다. 값은 -1부터 1 사이로 나온다.

  • 1: 같은 방향을 가리킨다. 의미가 매우 비슷하다.
  • 0: 서로 직각을 이룬다. 방향으로 보면 별 관련이 없다.
  • -1: 정반대 방향을 가리킨다.

실제 벡터는 숫자가 2,048개라 감이 잘 오지 않으니, 숫자 3개짜리 벡터로 예를 들어 보자.

A와 B는 약 9°, A와 C는 약 74° 벌어져 있다

둘 다 Three.js를 다루는 A와 B는 방향이 거의 같아서 1에 가깝다. 쿠키 보안을 다루는 C는 A와 방향이 달라서 값이 한참 낮다.

계산 방법도 조금 더 들여다보자. 각도를 직접 구하지 않아도, 내적을 이용하면 코사인 유사도를 구할 수 있다.

내적은 두 벡터에서 같은 위치의 숫자끼리 곱한 뒤 모두 더한 값이다. 예를 들어 [1,2,3][1, 2, 3]과 [4,5,6][4, 5, 6]의 내적은 이렇다.

1×4+2×5+3×6=321 \times 4 + 2 \times 5 + 3 \times 6 = 32

그런데 내적만으로는 방향이 얼마나 비슷한지 알기 어렵다. 벡터가 길수록 내적도 커지기 때문이다. 그래서 내적을 두 벡터의 길이로 나눠 크기의 영향을 없애는데, 이 값이 코사인 유사도다.

cos⁡θ=a⋅b∥a∥ ∥b∥\cos\theta = \frac{\mathbf{a} \cdot \mathbf{b}}{\lVert \mathbf{a} \rVert \, \lVert \mathbf{b} \rVert}

여기서 벡터의 길이를 미리 1로 맞춰 두면 계산이 더 간단해진다. 이 과정을 정규화(normalize)라고 한다. 정규화된 벡터끼리는 분모가 1×1=11 \times 1 = 1이 되니, 코사인 유사도는 내적과 같아진다.

ts
// a와 b가 모두 정규화된 벡터일 때 코사인 유사도를 구한다.
export const cosineSimilarity = (a: Vector, b: Vector): number => {
  return a.reduce((sum, value, i) => sum + value * b[i], 0);
};

정리하면, 연관 글 기능은 글마다 벡터를 만들고 코사인 유사도가 높은 글끼리 짝지어 주는 일이다.

블로그를 빌드할 때마다 연관 글을 계산하면, 배포할 때마다 API를 수십 번씩 호출하게 된다. 그러다 무료 API의 요청 한도에 걸릴 수도 있어서, 계산과 빌드를 나눴다.

  • 연관 글 계산은 로컬에서 pnpm related 명령으로 직접 실행한다.
  • 계산 결과는 JSON 파일로 저장해 커밋한다.
  • 블로그 빌드는 그 JSON을 읽기만 한다. 배포 환경에는 API 키도 필요 없다.

API를 호출하는 단계는 임베딩뿐이다. 그래서 글마다 만든 벡터를 캐시 파일에 저장해 두고, 다음 실행부터는 새로 추가된 글만 임베딩하게 했다. 글 49개를 처음 임베딩할 때는 1~2분 정도 걸렸고, 그 뒤로는 새 글 하나에 몇 초면 끝난다.

전체 흐름은 다음과 같다.

API를 호출하는 단계는 임베딩뿐이다

글 전체를 벡터 하나로 바꾸면 간단하지만, 글이 길수록 여러 주제가 한 벡터에 뒤섞인다. 조명과 성능 최적화를 비슷한 비중으로 다루는 글이라면, 두 주제 모두와 어중간하게 가까운 벡터가 되는 식이다. 게다가 임베딩 모델은 한 번에 받을 수 있는 입력 길이에 한도가 있어서, 너무 긴 글은 뒷부분이 잘리기도 한다.

그래서 글을 작은 조각(chunk)으로 나눠 각각 임베딩했다. 나누는 규칙은 이렇다.

  • 코드 블록은 뺀다. import, const 같은 단어는 거의 모든 기술 글에 나온다. 코드를 그대로 두면 주제와 상관없이 코드가 많다는 이유만으로 글끼리 가까워진다.
  • 헤딩을 기준으로 나눈다. 섹션 하나가 보통 한 주제를 다루기 때문이다. 섹션이 500자를 넘으면 문단 단위로 한 번 더 나눈다.
  • 조각 앞에 글 제목 / 섹션 제목을 붙인다. 조각만 떼어 놓으면 어느 글의 어떤 내용인지 알기 어려운데, 제목을 붙이면 맥락이 함께 전달된다.

이렇게 나눈 조각을 각각 임베딩하고, 다시 글 하나를 대표하는 벡터로 합친다.

조각을 32개씩 묶어 API에 보낸다. OpenAI와 같은 형식이라 요청 코드도 간단하다.

ts
const response = await fetch(
  "https://integrate.api.nvidia.com/v1/embeddings",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: "nvidia/nemotron-3.5-embed-1b",
      input: inputs, // ["메타 텍스트", "조각 1", "조각 2", ...]
      input_type: "passage", // 검색어가 아닌 문서를 임베딩한다
      truncate: "END", // 입력 한도를 넘으면 뒷부분을 자른다
    }),
  },
);

요청 제한에 걸리지 않도록 요청 사이에 1.6초씩 쉬게 했고, 429(요청 과다) 응답이 오면 잠시 기다렸다가 다시 시도한다.

조각마다 벡터가 하나씩 나오니, 글 하나에는 조각 수만큼 벡터가 생긴다. 글끼리 비교하려면 이걸 글을 대표하는 벡터 하나로 합쳐야 한다. 합치는 건 두 단계로 했다.

1단계: 조각 벡터를 합쳐 본문 벡터 만들기

조각 벡터를 그냥 평균 내면, 한두 줄짜리 "마치며" 섹션이 핵심 섹션과 같은 비중을 갖게 된다. 그래서 조각의 글자 수만큼 가중치를 줘서 더했다. 내용이 많은 조각일수록 본문 벡터에 더 크게 반영된다.

vbody=normalize⁡(∑iℓi ci)\mathbf{v}_{\text{body}} = \operatorname{normalize}\left( \sum_{i} \ell_i \, \mathbf{c}_i \right)

여기서 ci\mathbf{c}_i는 ii번째 조각의 벡터, ℓi\ell_i는 그 조각의 글자 수다.

2단계: 메타 벡터를 섞어 대표 벡터 만들기

본문만으로 대표 벡터를 만들면, 사설이 긴 글은 핵심 주제에서 조금씩 멀어진다. 그래서 제목 + 설명 + 태그를 따로 임베딩한 메타 벡터를 섞었다. 제목과 설명에는 글의 핵심 주제가 잘 압축되어 있기 때문이다.

다만 메타 벡터의 비중이 너무 크면 정작 본문 내용이 묻히니, 25%만 섞었다.

v=normalize⁡(0.75 vbody+0.25 vmeta)\mathbf{v} = \operatorname{normalize}\left( 0.75 \, \mathbf{v}_{\text{body}} + 0.25 \, \mathbf{v}_{\text{meta}} \right)

두 단계 모두 마지막에 정규화를 한다. 길이를 1로 맞춰 둬야 앞에서 본 것처럼 내적만으로 코사인 유사도를 구할 수 있다.

글마다 대표 벡터가 생겼으니, 이제 글끼리 비교해 연관 글을 고를 차례다. 과정은 세 단계다.

  1. 모든 글 쌍의 코사인 유사도를 계산한다.
    • 글이 49개면 글 쌍은 1,176개다. 벡터는 이미 저장되어 있으니 내적만 계산하면 된다. API를 호출할 필요 없이 로컬에서 금방 끝난다.
  2. 태그가 겹치면 점수를 조금 더한다.
    • 태그만으로 연관 글을 고르기엔 부족하지만, 태그가 겹친다는 건 참고할 만한 신호다. 다만 태그가 결과를 좌우하지 않도록 더하는 점수는 최대 0.05로 작게 잡았다.
  3. 글마다 점수가 높은 글을 최대 3개 고른다.
    • 점수가 기준값보다 낮은 글은 3개를 못 채우더라도 넣지 않는다. 관련 없는 글로 억지로 채우느니 비워 두는 게 낫다고 봤다. 처음 기준값은 0.3으로 잡았다.

처음 나온 결과는 꽤 그럴듯했다. Three.js 글 네 편은 서로를 추천했고, 함수형 프로그래밍 시리즈는 0.7~0.8의 높은 점수로 역였다. 그런데 목록을 하나씩 살펴보니 어색한 조합이 눈에 띄었다.

  • git-cherry-pick → practical-functional-programming
  • claude-code-extensions-overview → practical-functional-programming
  • cookie-consent-guide → jakobs-law-ux

Git 글을 읽은 독자에게 함수형 프로그래밍 글을 권하는 건 아무래도 어색하다. 더 살펴보니 practical-functional-programming은 관련 없는 글들에 무려 8번이나 추천되고 있었다.

이처럼 특정 글이 여러 글의 이웃으로 반복해서 뽑히는 현상을 허브(hub) 문제라고 한다. 차원이 높은 벡터 공간에서 자주 나타나는 현상이다.

원인을 찾으려고 전체 글 쌍의 유사도 분포를 살펴봤다. 그랬더니 서로 관련 없는 글끼리도 유사도의 중간값이 0.25나 됐다. 관련이 없다면 0 근처여야 하는데, 모든 글이 기본적으로 어느 정도 비슷하다고 계산되고 있었다.

내 블로그 글은 전부 한국어로 쓴 개발 글이다. 그래서 벡터마다 글의 주제와 별개로, 언어와 분야에서 오는 공통 방향의 성분이 조금씩 섞여 있었을 것이다. 이 공통 방향에 가장 가까운 글, 말하자면 가장 평균적인 글은 자연스럽게 누구와도 가까워진다. practical-functional-programming이 바로 그런 글이었다.

공통 성분은 어디서 왔을까?

그럼 이 공통 성분은 한국어라는 언어에서 왔을까, 개발이라는 분야에서 왔을까? 언어와 분야를 바꿔 가며 시험 문장 4개를 만들고, 각 문장이 공통 방향과 얼마나 가까운지 재 봤다.

개발비개발(여행 이야기)
한국어0.5320.357
영어0.3880.209

개발 문장과 여행 문장의 차이는 두 언어 모두 약 0.18, 한국어와 영어의 차이는 약 0.15였다. 분야와 언어 둘 다 영향을 줬고, 분야 쪽이 조금 더 컸다. 다국어 모델이라 언어는 별 상관없을 줄 알았는데, 생각보다 차이가 컸다.

물론 조건마다 문장이 하나뿐인 작은 실험이다. 원인을 확정하기보다는, 언어와 분야가 모두 영향을 줄 수 있다는 정도의 경향으로 보는 게 좋다.

원인이 모든 글이 공유하는 방향이라면, 그 방향을 빼 버리면 된다.

먼저 전체 글 벡터의 평균 벡터를 구한다. 글마다 다른 주제 성분은 평균을 내면서 서로 상쇄되고, 모든 글에 깔린 공통 방향만 남는다. 이 평균 벡터를 각 글의 벡터에서 빼면 공통 방향이 사라지고, 글마다 고유한 특징이 드러난다. 이런 처리를 센터링(centering)이라고 한다.

평균을 빼면 벡터의 길이가 달라지니, 다시 정규화해서 길이를 1로 맞춘다.

vi′=normalize⁡(vi−vˉ)\mathbf{v}_i' = \operatorname{normalize}\left( \mathbf{v}_i - \bar{\mathbf{v}} \right)

여기서 vˉ\bar{\mathbf{v}}는 전체 글 벡터의 평균이다.

ts
// 전체 글의 평균 벡터를 빼고 다시 정규화한다.
export const centerPosts = (
  posts: Array<EmbeddedPost>,
): Array<EmbeddedPost> => {
  const mean = meanVector(posts.map((post) => post.vector));

  return posts.map((post) => ({
    ...post,
    vector: normalize(subtract(post.vector, mean)),
  }));
};

센터링은 대표 벡터를 만든 뒤, 연관 글을 고르기 전에 한 번 적용하면 된다. 저장해 둔 벡터로 다시 계산만 하면 되니 API를 다시 호출할 필요도 없다.

센터링으로 공통 성분을 빼자 점수가 전체적으로 내려갔다. 관련 없는 글끼리의 유사도 중간값이 0.25에서 -0.04로, 원래 있어야 할 0 근처로 돌아왔다. 대신 처음 잡은 기준값 0.3은 더 이상 쓸 수 없게 됐다.

새 기준값은 숫자만 보고 정하지 않았다. 점수가 낮은 추천부터 40개를 직접 읽어 보면서, 어디부터 납득할 만한 추천이 나오는지 확인했다.

  • 0.1 미만: 대부분 관련이 없었다. (what-is-graphql → next-js-dynamic-og-image-generation 등)
  • 0.12 이상: 대부분 납득할 만했다. (zustand-closure-deep-dive → sync-with-effect 등)

그래서 기준값을 0.12로 정했다. 숫자만 보면 낮아 보이지만, 전체 글 쌍 중 상위 7%에 드는 점수다. 이 쌍들의 센터링 전 유사도는 평균 0.367이었다. 연관성이 낮아진 게 아니라, 공통 성분이 빠지면서 점수의 기준선이 아래로 옮겨진 것이다.

Note

코사인 유사도의 절대값은 모델, 글 모음, 센터링 여부에 따라 크게 달라진다. "0.5 이상이면 비슷하다" 같은 절대 기준보다는, 전체 분포에서 어디쯤인지와 실제 추천 결과를 함께 보고 정하는 편이 안전하다.

센터링 후 기존 추천 143개 중 113개 유지, 30개 제거, 12개가 추가로 생겼다. 빠진 추천은 대부분 빠지는 게 맞았고, 새로 생긴 추천은 이전보다 자연스러웠다. 예를 들어, 쿠키 글과 카카오 로그인 글이 서로 연결됐고, 서버 컴포넌트 글은 Server Action 글을 추천하게 됐다.

아쉬운 점도 있었다. Next.js 글끼리의 약한 연관 몇 개는 기준값에 걸려서 같이 빠졌다. 그래도 엉뚱한 추천이 사라진 걸 생각하면 감수할 만했다.

이번 작업으로 배운 건 크게 두 가지다.

  • 글끼리 비교할 때는 LLM보다 임베딩이 API 호출도 적고 결과도 일정하다.
  • 임베딩 결과는 그대로 믿기보다 점수 분포와 실제 추천을 같이 봐야 한다. 추천을 하나씩 확인하다 허브 문제를 발견했고, 센터링 하나로 결과가 꽤 좋아졌다.

이 글이 흥미로웠다면?

“pagefind를 이용한 블로그 검색 기능 구현하기”, 그리고 “나를 게으르게 만드는 AI”도 읽어보세요.

전체 글 보기