학습 자료

임베딩 — 의미를 벡터로


Article

16강에서 문서 3개를 청크 9개로 잘랐다. 그런데 그 청크는 아직 글자 뭉치일 뿐이다. 사용자 질문에 청크와 똑같은 글자가 들어 있어야만 찾을 수 있다. 오늘은 그 한계를 실제로 확인하고, 넘어서는 방법을 만든다.

문자열 검색이 실패하는 순간

lesson-16docs 폴더와 청킹 함수를 그대로 가져와 lesson-17 을 만든다. 이번엔 실제 사용자가 물어볼 법한 질문을 던져본다.

const query = "연차를 못 쓰면 다음 해로 이월할 수 있나요?";

const naiveHits = chunks.filter((c) => c.text.includes(query));
console.log(`일치하는 청크: ${naiveHits.length}개`);
터미널
  • 질문: "연차를 못 쓰면 다음 해로 이월할 수 있나요?"
  • [방법 1] 문자열 그대로 포함 검색 (naive)
  • 일치하는 청크: 0개

일치하는 청크는 0개였다. 답은 분명히 연차 정책 문서 안에 있는데(5일까지 이월 가능하다고 적혀 있다), 사용자가 쓴 문장 그대로가 어느 청크에도 들어 있지 않으니 하나도 못 찾는다. 사람은 질문과 문서가 "같은 얘기"라는 걸 바로 알지만, 컴퓨터에게 텍스트는 그냥 문자들의 나열이다.

임베딩이 하는 일

임베딩(embedding)은 텍스트를 숫자로 이루어진 벡터로 바꾸는 것이다. 잘 만든 임베딩이라면 뜻이 비슷한 텍스트는 벡터도 비슷한 방향을 가리킨다. 그러면 "같은 문장인지"가 아니라 "같은 방향을 가리키는지"로 비교할 수 있는데, 이 방향의 유사도를 재는 도구가 코사인 유사도(cosine similarity) 다.

텍스트 → 벡터 → 유사도
  1. 청크 텍스트"미사용 연차는... 5일까지 이월할 수 있다"
  2. 임베딩 함수텍스트를 고정 길이 숫자 배열로 변환
  3. 벡터[0.12, -0.04, 0.31, ...] — 예: 256개 숫자
  4. 코사인 유사도질문 벡터와 청크 벡터가 얼마나 같은 방향인지 계산 (−1~1)

간이 임베딩을 직접 만든다

한글은 조사(은/는/이/가 등)가 단어 끝에 자유롭게 붙어서, 띄어쓰기 기준 단어 매칭이 잘 안 맞는다("연차는", "연차가", "연차를" 이 전부 다른 단어로 취급된다). 그래서 글자를 2개씩 겹쳐 묶은 조각(bigram) 을 단위로 쓴다. "연차는" 은 "연차", "차는" 두 조각이 된다. "연차가" 도 "연차" 조각을 공유하므로, 조사가 달라도 겹치는 부분이 남는다.

const DIMENSIONS = 256;

function bigrams(text: string): string[] {
  const clean = text.replace(/\s+/g, "");
  const grams: string[] = [];
  for (let i = 0; i < clean.length - 1; i++) {
    grams.push(clean.slice(i, i + 2));
  }
  return grams;
}

function hashToIndex(token: string): number {
  let hash = 0;
  for (let i = 0; i < token.length; i++) {
    hash = (hash * 31 + token.charCodeAt(i)) >>> 0;
  }
  return hash % DIMENSIONS;
}

function embed(text: string): number[] {
  const vector = new Array(DIMENSIONS).fill(0);
  for (const gram of bigrams(text)) {
    vector[hashToIndex(gram)] += 1;
  }
  const norm = Math.sqrt(vector.reduce((sum, v) => sum + v * v, 0));
  return norm === 0 ? vector : vector.map((v) => v / norm);
}

function cosineSimilarity(a: number[], b: number[]): number {
  let dot = 0;
  for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
  return dot;
}

hashToIndex 는 글자 조각을 0~255 사이의 숫자 하나로 눌러 담는다(해싱). embed 는 텍스트에 나온 모든 2글자 조각을 세어 256칸짜리 배열에 누적한 뒤, 벡터의 길이가 1이 되도록 나눠준다(정규화). 벡터 길이를 1로 맞춰두면 두 벡터의 내적(dot product)이 곧 코사인 유사도가 된다. cosineSimilarity 가 단순히 곱해서 더하기만 하는 이유다.

성공 사례 — 방금 실패한 질문을 다시 던진다

같은 질문 "연차를 못 쓰면 다음 해로 이월할 수 있나요?" 를 이번엔 임베딩으로 검색한다.

const queryVector = embed(query);
const scored = chunks
  .map((c) => ({ chunk: c, score: cosineSimilarity(queryVector, embed(c.text)) }))
  .sort((a, b) => b.score - a.score);
npx tsx embed.ts
터미널 — "연차를 못 쓰면 다음 해로 이월할 수 있나요?"
  • [방법 1] 문자열 그대로 포함 검색 (naive)
  • 일치하는 청크: 0개
  • [방법 2] 임베딩 코사인 유사도 검색
  • 0.3828 [연차-휴가-정책.md #2] 미사용 연차는 원칙적으로 소멸하지만, 회사 사정으로 사...
  • 0.3473 [연차-휴가-정책.md #1] 연차 사용을 원하는 직원은 사용 예정일 최소 3일 전까...
  • 0.2541 [연차-휴가-정책.md #0] # 연차 휴가 정책 입사 1년 차부터 연 15일의 연...
  • 0.2535 [원격근무-정책.md #0] # 원격근무 정책 전 직원은 주 3일까지 원격 근무를...
  • 0.2435 [경비-처리-가이드.md #0] # 경비 처리 가이드 업무상 지출은 원칙적으로 법인카...
  • 0.2378 [원격근무-정책.md #1] 코어타임은 10시부터 16시까지이며, 이 시간에는 원격...
  • 0.2294 [경비-처리-가이드.md #1] 개인카드 정산을 받으려면 결제일로부터 30일 이내에 경...
  • 0.1730 [경비-처리-가이드.md #2] 식대는 1인당 1만 5천 원, 야근 식대는 2만 원까지...
  • 0.1410 [원격근무-정책.md #2] 해외에서의 원격 근무(워케이션)는 연 최대 14일까지 ...

정답 청크(연차-휴가-정책.md #2, 5일 이월 규정이 있는 바로 그 청크)가 0.3828 로 1위다. 문자열 검색은 0개였는데, 벡터 검색은 정확히 관련 청크를 찾아 순위까지 매겼다. 2·3위도 같은 문서(연차 정책)의 다른 청크이고, 관련 없는 경비·원격근무 문서는 뒤로 밀렸다. 질문과 청크가 글자 하나도 똑같지 않은데 이런 순위가 나온 건, "연차", "이월", "다음 해로" 같은 조각들이 겹치기 때문이다.

실패 사례 — 정직하게 한계를 확인한다

이번엔 진짜 동의어로 바꿔 물어본다. "이월" 이라는 단어를 아예 쓰지 않고 "미루다" 로 바꿨다.

const query = "연차를 미룰 수 있나요?"; // "이월"이라는 글자를 전혀 쓰지 않음
터미널 — "연차를 미룰 수 있나요?"
  • [방법 1] 문자열 그대로 포함 검색 (naive)
  • 일치하는 청크: 0개
  • [방법 2] 임베딩 코사인 유사도 검색
  • 0.2477 [경비-처리-가이드.md #0] # 경비 처리 가이드 업무상 지출은 원칙적으로 법인카...
  • 0.1895 [연차-휴가-정책.md #0] # 연차 휴가 정책 입사 1년 차부터 연 15일의 연...
  • 0.1478 [원격근무-정책.md #1] 코어타임은 10시부터 16시까지이며, 이 시간에는 원격...
  • 0.1324 [경비-처리-가이드.md #2] 식대는 1인당 1만 5천 원, 야근 식대는 2만 원까지...
  • 0.1260 [원격근무-정책.md #0] # 원격근무 정책 전 직원은 주 3일까지 원격 근무를...
  • 0.1260 [연차-휴가-정책.md #2] 미사용 연차는 원칙적으로 소멸하지만, 회사 사정으로 사...
  • 0.1199 [연차-휴가-정책.md #1] 연차 사용을 원하는 직원은 사용 예정일 최소 3일 전까...
  • 0.1188 [경비-처리-가이드.md #1] 개인카드 정산을 받으려면 결제일로부터 30일 이내에 경...
  • 0.1002 [원격근무-정책.md #2] 해외에서의 원격 근무(워케이션)는 연 최대 14일까지 ...

완전히 틀렸다. 진짜 정답 청크(연차-휴가-정책.md #2)는 9개 중 6위(0.1260)로 밀려나고, 아무 관련 없는 경비 처리 문서가 1위에 올라왔다. "미루다" 와 "이월하다" 는 글자 하나 안 겹치니, 글자 조각(bigram)을 세는 우리 임베딩은 둘을 전혀 다른 텍스트로 본다. 글자가 겹치는지만 볼 뿐, 뜻을 이해하지 못하는 셈이다.

같은 질문, 다른 표현 — 결과가 갈린다

지금까지 만든 것

my-ai-assistant/ (ai-lab-b, lesson-17)
  • my-ai-assistant
    • docs/16강과 동일한 사내 규정 문서 3개
    • embed.ts청킹 + 간이 임베딩(bigram 해싱) + 코사인 유사도
    • .env.example
    • package.json

정리하면

텍스트를 벡터로 바꾸고 코사인 유사도로 비교하면, 글자가 겹치는 한도 안에서는 문자열 검색보다 훨씬 유연하게 찾아낸다. 하지만 진짜 동의어처럼 글자 자체가 다르면 우리가 만든 간이 임베딩은 못 찾고, 그래서 실무는 학습된 임베딩 모델을 쓴다.

다시 짚어보기
  1. 01

    문자열 그대로 포함 검색이 0건이 되는 걸 확인했다

    질문과 문서가 같은 얘기를 해도, 글자가 완전히 겹치지 않으면 못 찾는다.

  2. 02

    글자 조각(bigram) + 해싱으로 텍스트를 256차원 벡터로 바꿨다

    정규화한 벡터끼리는 내적이 곧 코사인 유사도다.

    npx tsx embed.ts
  3. 03

    성공 사례와 실패 사례를 둘 다 실제로 돌려 확인했다

    글자가 겹치는 질문은 정답을 1위로 찾았고, 진짜 동의어로 바꾼 질문은 정답이 6위로 밀렸다.

오늘 만든 embed 함수는 아직 매번 모든 청크를 다시 벡터로 계산하고, 배열을 매번 새로 정렬한다. 문서가 9개면 상관없지만, 900개라면 이야기가 다르다. 다음 18강에서는 이 벡터들을 미리 계산해 저장하고, 저장소에서 빠르게 검색하는 벡터 저장소를 만든다.

임베딩 — 의미를 벡터로 — 디코드랩(DCODELAB)