당신의 RAG 코드 어딘가에는 const vec = await embed(text)와 같은 줄이 있습니다. 384개의 float 목록이 반환되고, 당신은 그것을 저장한 후 다시는 내부를 들여다보지 않습니다.
그 함수는 하나의 선택을 숨기고 있습니다. [Transformers.js]에서 피처 추출(feature-extraction) 파이프라인에는 해당 float들이 무엇을 의미하는지 결정하는 두 가지 옵션이 있으며, 둘 다 기본값은 "아무것도 하지 않음"입니다. [파이프라인 API 문서]에 따르면 pooling은 "none"으로 기본 설정되며, normalize는 false로 기본 설정됩니다. 아무 옵션 없이 "This is a simple test."를 호출하면 문서는 모양이 [1, 8, 384]인 텐서(tensor)를 얻게 된다고 보여줍니다. 토큰당 하나씩 총 여덟 개의 벡터가 반환되는 것입니다.
모든 임베딩 라이브러리는 어딘가에서 이 선택을 당신 대신 해줍니다. 인덱싱 작업(indexing job)과 쿼리 경로(query path)가 이를 다르게 처리할 때, 아무것도 에러를 발생시키지 않습니다. 검색 성능이 그저 조용히 나빠질 뿐입니다. 노드(Node) 환경에서 상자를 한 단계씩 열어보고, 의도적으로 고장 내보세요.
모델이 실제로 반환하는 것
라이브러리를 설치합니다 (버전 4.3.1이 현재 npm에서 출시된 버전이며, Node에서는 [ONNX Runtime]을 통해 ONNX 모델을 실행합니다). [tsx]로 스크립트를 실행합니다. 모델은 첫 실행 시 [Hugging Face Hub]에서 다운로드됩니다. API 키는 필요 없습니다.
npm i @huggingface/transformers
npx tsx embed.mts
모델은 all-MiniLM-L6-v2의 ONNX 내보내기 버전이며, Transformers.js 문서에서 사용하는 것과 동일합니다. 모델 카드에 따르면 이 모델은 텍스트를 384차원 공간으로 매핑하고, 평균 풀링(mean pooling)을 사용하며, 256 단어 조각(word pieces)보다 긴 입력은 잘라냅니다.
// embed.mts
import {
AutoModel,
...
이 출력에서 읽어야 할 세 가지가 있습니다.
tokenize는 WordPiece 분할을 보여줍니다. 어휘 사전에 없는 단어들은 하위 단어 조각(sub-word pieces)으로 분해되므로, 토큰 개수는 단어 개수와 다릅니다.
input_ids는 tokenize가 출력한 것보다 더 많은 항목을 가집니다. 토크나이저는 기본적으로 양 끝에 특수 토큰을 추가합니다(add_special_tokens가 기본값으로 true). 위치 0의 것이 [CLS]입니다. 이 위치를 기억하세요.
attention_mask는 배치 내에서 가장 긴 문장보다 짧은 모든 문장에 대해 1로 시작하고 0으로 끝나는 행입니다. padding: true를 사용하면 모든 행이 동일한 길이로 늘어나고, 마스크가 어느 위치가 실제 텍스트인지를 표시합니다. 토크나이저 문서에서는 이를
const H = hidden.tolist() as number[][][];
const M = (enc.attention_mask.tolist() as bigint[][])
.map((row) => row.map(Number));
...
if (mask[t] === 0) return 라인은 마스크가 존재하는 전체 이유입니다. 패딩 위치는 여전히 모델에서 은닉 상태(hidden states)를 얻습니다. 이 값들을 평균 내면, 짧은 문장의 벡터가 배치(batch) 내 인접한 요소들이 얼마나 길었는지에 따라 달라지게 됩니다. sentence-transformers model card는 이 이유로 자체 mean_pooling 헬퍼에 "정확한 평균을 위해 어텐션 마스크를 고려하십시오(Take attention mask into account for correct averaging)."라는 주석을 포함하고 있습니다.
사용 중인 버전을 라이브러리의 버전과 비교해 보세요. 파이프라인은 내부적으로 동일한 세 단계를 수행합니다: padding: true, truncation: true로 토큰화하고, "mean"의 경우 어텐션 마스크를 mean_pooling에 전달하며, 위치 0을 슬라이스하여 "cls"를 사용하고, 정규화를 요청할 때 normalize(2, -1)을 호출합니다 (source).
import { pipeline } from "@huggingface/transformers";
const extractor = await pipeline("feature-extraction", ID, {
...
당신은 부동 소수점 반올림(float-rounding scale) 단위의 숫자를 찾고 있습니다. 만약 이 값이 크다면, 당신의 풀링 방식이 라이브러리의 방식과 일치하지 않을 수 있으며, 이는 어떤 인덱싱을 하기 전에 알아내야 할 가치가 있습니다. 마스크 확인 코드를 삭제하고 다시 실행해 보세요: 짧은 문장에서 발생하는 드리프트(drift)는 패딩이 새어 들어오는 현상입니다.
정규화는 코사인 유사도가 아닌 내적을 변경한다
코사인 유사도(Cosine Similarity)는 벡터 길이 모두로 나누기 때문에, 먼저 정규화한다고 해서 코사인 점수 자체가 변하지 않습니다. 임의의 두 풀링된 벡터에 대해 cosine(a, b) 옆에 cosine(l2(a), l2(b))를 출력해 보면 반올림 오차를 제외하고 일치합니다.
정규화를 통해 변경되는 것은 내적(dot product)입니다. 단위 길이 벡터(unit-length vectors)에서는 내적과 코사인이 같은 숫자가 됩니다. 하지만 원본 벡터(raw vectors)의 경우, 내적 역시 길이에 따라 보상을 주기 때문에, 풀링된 벡터가 우연히 더 긴 문서가 순위에서 올라올 수는 있지만, 그것이 더 관련성이 높다는 것을 의미하지는 않습니다.
이것이 중요한 이유는 벡터 스토어에서 종종 어떤 연산자(operator)를 선택하라고 요청하기 때문입니다. pgvector README에는 음의 내적을 위한 <#>와 코사인 거리를 위한 <=>가 나열되어 있으며, 다음과 같이 권장합니다: "벡터가 길이 1로 정규화되었다면(OpenAI 임베딩과 같이), 최고의 성능을 위해 내적(inner product)을 사용하세요." 이 조언에 따라 normalize: false 벡터를 사용하면 검색 코드 한 줄도 변경하지 않고 순위 지정 함수를 바꿀 수 있습니다.
동일한 쿼리를 네 가지 방식으로 실행하기
이 실험은 동일한 여섯 개의 문자열에 대한 단일 순전파(forward pass)를 재사용하고, 은닉 상태(hidden states)를 네 가지 다른 방식으로 순위 지정합니다.
function rank(name: string, q: number[], d: number[][]) {
console.log(`
${name}`);
d.map((v, i) => ({ i, s: dot(q, v) }))
...
Run A는 모델 카드에서 설명하는 구성이므로 기준선(baseline)으로 간주하세요. 다른 실행 결과들과 비교해 보세요.
Run B는 양쪽 모두에 다른 풀링(pooling)을 사용합니다. 문장 벡터가 서로 다른 벡터이기 때문에 점수들이 이동합니다. 상위 결과가 움직일지 여부는 여러분의 문장에 달려 있습니다. model card는 평균 풀링(mean pooling)을 사용하므로, 위치 0은 파인튜닝(fine-tuning) 중에 풀링 대상이 아니었습니다. 이는 약한 신호일 것으로 예상하십시오.
Run C는 평균 풀링을 유지하지만 정규화를 건너뛰고 원시 내적(raw dot product)으로 순위 지정합니다. A와 순서가 어떻게 다른지 비교해 보세요. 보이는 모든 재배열은 벡터 길이 자체에서 비롯됩니다.
Run D는 실제 운영 환경에서 문제가 되는 경우입니다: 인덱스는 평균 풀링으로 구축되었고, 쿼리 경로는 CLS를 사용했습니다. 두 벡터 모두 단위 길이의 384개의 float로 구성되어 있습니다. 데이터베이스는 이를 수용하고, 쿼리는 다섯 개의 행을 반환하며, 점수는 마치 점수처럼 보입니다. 파이프라인의 어느 것도 양쪽이 서로 다른 함수에 의해 생성되었음을 알려주지 않습니다.
여러분의 자체 쿼리와 수십 개의 문서를 넣어보고 각 단계가 출력물을 어떻게 변화시키는지 지켜보세요.
불일치는 복사-붙여넣기 거리만큼 떨어져 있습니다
BGE 모델은 다른 방식으로 훈련되었습니다. BAAI/bge-small-en-v1.5에 포함된 sentence-transformers 풀링 설정은 "pooling_mode_cls_token": true 및 "pooling_mode_mean_tokens": false로 설정되어 있으며, 모델 카드(model card)의 Transformers 예제는 model_output[0][:, 0]을 사용하여 풀링합니다. BGE 튜토리얼에 따르면 BGE는 "문장 임베딩으로 [CLS]의 마지막 은닉 상태(last hidden state)를 사용하도록 훈련되었다"고 명시하며, "평균 풀링(mean pooling)을 사용하면 성능이 크게 저하될 것"이라고 경고합니다.
동일한 모델인 Xenova/bge-small-en-v1.5의 Transformers.js 지원 포트에서는 사용 예제에 { pooling: 'mean', normalize: true }를 보여줍니다. 이 예제는 인덱싱과 쿼리 모두 평균(mean)을 사용하기 때문에 자체적으로 일관성이 있습니다. 하지만 이를 Node indexer에 복사하여, sentence-transformers를 통해 BAAI/bge-small-en-v1.5를 로드하는 Python 서비스에서 동일한 컬렉션을 쿼리하면, 프로덕션 환경에서 Run D와 같은 문제가 발생할 수 있습니다.
Cloudflare Workers AI는 호스팅된 bge-base-en-v1.5에 대해 동일한 분기(split)를 문서화하고 있습니다. 해당 문서는 "이것이 깨지는 변경(breaking change)이 되지 않도록" 풀링 매개변수(pooling parameter)의 기본값이 mean이라고 설명하며, 정확도를 위해 cls 사용을 권장하고, "cls 풀링으로 생성된 임베딩은 mean 풀링으로 생성된 임베딩과 호환되지 않는다"고 명확히 밝힙니다.
이 마지막 문장이 규칙입니다. 풀링(Pooling)은 인덱스가 관여하는 한 모델의 일부입니다.
벡터 옆에 임베딩 설정 고정하기 (Pin the embedding config next to the vectors)
벡터를 생성하는 설정을 하나의 값으로 취급하고, 그 값을 인덱스와 함께 저장하십시오.
export const EMBED = {
model: "onnx-community/all-MiniLM-L6-v2-ONNX",
dtype: "fp32",
...
인덱스를 구축할 때 EMBED_KEY를 메타데이터 행에 작성하세요. 쿼리 서비스가 시작될 때 assertSameSpace를 호출하세요. 만약 다른 언어의 두 번째 서비스가 동일한 인덱스를 읽는다면, 동일한 키와 동일한 검사를 제공해야 합니다.
dtype이 여기에 있는 데에는 이유가 있습니다: Transformers.js는 양자화된 변형(q8, q4, fp16 등, dtypes 가이드 참조)을 로드할 수 있게 해주며, 양자화된 모델은 다른 가중치 세트이므로 나머지 부분처럼 고정해야 합니다. queryPrefix가 여기에 있는 이유는 BGE 모델 카드에서 짧은 검색 쿼리에는 지침 접두사(instruction prefix)를 사용하고 패시지(passage)에는 사용하지 않는다고 제안하기 때문이며, 이는 한쪽에서 쉽게 잊어버릴 수 있는 또 다른 비대칭적 설정입니다.
last_token 또는 eos 풀링을 사용하는 디코더 스타일 임베딩 모델로 이동할 때의 또 다른 함정이 있습니다: 파이프라인은 어텐션 마스크(attention mask)를 확인하지 않고 시퀀스 축을 따라 최종 위치를 가져갑니다 (출처). 배치하기 전에 토크나이저가 어느 쪽에 패딩하는지 확인하세요. 그렇지 않으면 짧은 문장의