소식
모델
API
keyboard_arrow_down
리더
URL을 읽거나 검색하면 대규모 모델에 대한 지원이 더 향상됩니다.
벡터 모델
세계적 수준의 다중 모드 다중 언어 임베딩.
재배열자
검색 관련성을 극대화하는 세계적 수준의 신경 검색기입니다.
탄력적 추론 서비스
Jina 모델을 Elasticsearch에서 네이티브 방식으로 실행하세요.
MCP terminal명령줄articlellms.txtsmart_toy대리인data_object모델menu_book문서



로그인
login
Llama.cpp에서 이미지 입력 이해하기
이미지 토큰에 대한 어텐션
수정 사항
평가
남은 문제
결론
기술 블로그
9월 09, 2025

Llama.cpp 및 GGUF의 멀티모달 向量模型

저희는 llama.cpp 및 GGUF에 멀티모달 向量模型을 도입했으며, 그 과정에서 몇 가지 놀라운 문제점을 발견했습니다.
Andrei Ungureanu
Alex C-G
Andrei Ungureanu, Alex C-G • 11 독서의 분
llama.cpp/jina_embeddings at master · jina-ai/llama.cpp
LLM inference in C/C++. Contribute to jina-ai/llama.cpp development by creating an account on GitHub.
GitHubjina-ai

저희는 llama.cpp 포크에서 여러 버그를 수정하여 멀티모달 jina-embeddings-v4에서 작동하도록 했습니다.

jina-embeddings-v4는 벡터 검색을 위해 텍스트, 이미지, 복잡한 시각적 문서를 처리할 수 있는 최첨단 멀티모달 向量模型을 도입했습니다. 몇 주 전에 저희는 텍스트 전용 작업을 위해 v4의 GGUF 및 동적 양자화를 출시하여 더 작은 VRAM 공간과 향상된 성능을 제공했습니다. 하지만 GGUF에서 멀티모달 向量模型 지원은 여전히 부족했습니다. 이를 보완하기 위해 llama.cpp 및 GGUF를 사용하여 멀티모달 向量模型을 생성하는 방법을 알아냈습니다. 자세한 내용은 이 README 파일을 확인하세요.

공정하게 말하면 llama.cpp 업스트림은 멀티모달 입력을 어느 정도 지원하지만, 대부분의 llama.cpp 커뮤니티가 대규모 언어 모델(LLM) 및 텍스트 생성에 집중하고 있기 때문에 멀티모달 向量模型 출력에 대한 지원은 완전히 누락되었습니다. 이 글에서는 llama.cpp에서 멀티모달 向量模型을 구현한 방법과 jina-embeddings-v4의 PyTorch 버전과 비교하여 어떻게 작동하는지 (양자화된 두 가지 버전과 함께) 살펴봅니다. 이 글 전체에서 PyTorch 버전을 "기준 모델"이라고 합니다.

tagLlama.cpp에서 이미지 입력 이해하기

먼저 기준 모델로 멀티모달 向量模型을 수행하는 방법을 요약해 보겠습니다. 먼저 각 이미지 입력을 특수 提示词과 페어링합니다.

<|im_start|>user\n<|vision_start|><|image_pad|><|vision_end|>Describe the image.<|im_end|>\n

그런 다음 모델은 이미지를 전처리하고, (ViT를 통해) 인코딩한 다음, 단일 순방향 패스에서 전체 인터리브된 시퀀스를 처리합니다.

하지만 llama.cpp의 경우 상황이 더 복잡합니다. 채팅 완료를 위한 이미지 입력을 지원하지만 멀티모달 입력, 즉 텍스트와 이미지를 결합한 위의 입력과 같은 입력은 지원하지 않습니다. 이것이 바로 저희가 llama.cpp를 포크한 이유이며, 向量模型 처리기가 base64로 인코딩된 이미지를 수락하도록 변경하여 채팅 완료 처리기와 유사한 방식으로 멀티모달 콘텐츠를 처리할 수 있도록 했습니다.

이제 llama.cpp에서 멀티모달 입력을 사용하려면 기준 모델에서 사용된 것과 유사한 提示词으로 시작할 수 있습니다.

<|im_start|>user\n<__image__>Describe the image.<|im_end|>\n

프로세스는 다음과 같이 작동합니다.

  1. llama.cpp는 <__image__> 토큰을 시각 마커 <|vision_start|> 및 <|vision_end|>로 둘러싸서 다음과 같이 만듭니다. <|im_start|>user\n<|vision_start|><__image__><|vision_end|>Describe the image.<|im_end|>\n
  2. 토큰화기는 提示词을 토큰화할 때 특수 <__image__> 토큰을 -1로 대체하여 시퀀스에 인코딩해야 하는 이미지가 포함되어 있음을 내부적으로 알립니다 (나중에 처리됨).
  3. <__image__> 마커 앞의 텍스트 토큰 (즉, <|im_start|>user\n<|vision_start|>)은 LLM을 통해 디코딩되어 KVCache에 삽입됩니다.
  4. 이미지는 ViT 구성 요소를 통해 인코딩되어 LLM을 통해 디코딩되는 일련의 이미지 토큰을 출력합니다. 이러한 토큰은 1단계의 토큰과 별도로 처리되지만 어텐션 계층은 여전히 KVCache를 통해 해당 텍스트 토큰에 주의를 기울일 수 있습니다. 하지만 이 시점에서 어텐션 계층은 이후의 텍스트 토큰(<|vision_end|>Describe the image.<|im_end|>\n)에는 주의를 기울일 수 없습니다.
  5. LLM은 나머지 텍스트 토큰(<|end_vision|>Describe the image.<|im_end|>\n)을 디코딩합니다. 이제 어텐션 계층은 KVCache를 통해 이전의 모든 토큰 (텍스트 및 이미지 모두)에 주의를 기울일 수 있습니다.

向量模型 추론 프로세스 (이미지 인코딩 및 텍스트/이미지 토큰 디코딩)는 아래 그림에 나와 있습니다.

그림 1: jina-embeddings-v4 llama.cpp 버전의 向量模型 추론 프로세스입니다.

tag이미지 토큰에 대한 어텐션

어텐션 메커니즘 때문에 이 다단계 프로세스는 일부 모델에서 문제가 될 수 있습니다. 모델에서 사용되는 다양한 유형의 어텐션을 간단히 요약해 보겠습니다.

  • 인과적 어텐션 - 위치 k의 한 토큰에 대한 어텐션 메커니즘은 이전 토큰, 즉 위치 [0:k-1]의 토큰에만 주의를 기울입니다.
  • 비인과적 어텐션 - 위치 k의 한 토큰에 대한 어텐션 메커니즘은 시퀀스 [0:n]의 모든 토큰에 주의를 기울입니다.

아래 그림은 2단계에서 img_tok_n을 처리할 때 어텐션 메커니즘이 주의를 기울일 토큰을 보여줍니다.

그림 2: 인과적 어텐션과 비인과적 어텐션

img_tok_n을 처리할 때 모델의 상태는 다음과 같습니다.

  • 이전의 모든 텍스트 토큰(<|im_start|>, user, \n, <|vision_start|>)은 이미 처리되어 KVCache에 저장되었습니다.
  • 모든 이미지 토큰(img_tok_1에서 img_tok_n)은 동일한 시퀀스의 일부로 이 시점에서 처리됩니다.
  • 다음의 모든 텍스트 토큰(<|vision_end|> , Describe 등)은 나중에 처리됩니다.

인과적 어텐션의 경우 이전 토큰만 어텐션 점수를 계산할 때 고려되며 과거 토큰은 KVCache를 통해 검색됩니다.

비인과적 어텐션의 경우 모든 토큰을 고려해야 합니다. 하지만 미래의 텍스트 토큰(<|vision_end|> , Describe 등)은 아직 처리되지 않았습니다. 나중에 처리되므로 문제가 빠르게 발생합니다.

jina-embeddings-v4는 인과적 어텐션을 사용하므로 다단계 프로세스가 문제 없이 작동하지만 다른 모델의 경우 그렇지 않을 수 있습니다.

向量模型 측면에서 각 토큰의 숨겨진 상태는 처리될 때 정확하게 캡처되어 마지막에 단일 시퀀스로 결합됩니다. 현재 정규화 및 풀링은 Python에서 처리되지만 (추가 작업이 주어지면) llama.cpp 측에서도 수행할 수 있습니다.

tag수정 사항

llama.cpp 서버에서 向量模型 엔드포인트에 대한 이미지 입력을 활성화한 후 벤치마크로 구현을 테스트하기 시작했고 기준 모델에 비해 놀라울 정도로 큰 차이를 발견했습니다. 저희는 Qwen2.5-VL이 이미지를 Qwen2.5 LLM이 처리할 수 있는 이미지 패치 向量模型(이미지 사각형의 조밀한 벡터 표현)으로 인코딩하는 데 사용하는 ViT의 llama.cpp 구현에 문제가 있다고 의심했습니다.

다음은 기준 모델과 llama.cpp 구현 간의 ViT 출력 차이의 예입니다.

=== vit_out reference === Shape: [1008, 1280]
Logging patch 0, dimensions 0-9
Patch 0: -0.375000 -0.250000 -4.281250 -5.968750 2.953125 -8.125000 8.625000 -9.250000 8.937500 -0.332031 ... (dims 10-1279)
... (patches 1-1007 not shown)
=== vit_out llama.cpp === Shape: [1280, 1008, 1, 1]
Logging patch 0, dimensions 0-9
Patch 0: -2.998136 -2.226554 0.233671 -7.486460 0.596918 -12.889042 8.904849 -8.6
... (patches 1-1007 not shown)

보시다시피 차이가 상당히 눈에 띕니다. 이것이 유일한 문제인지 확인하기 위해 Python에서 이미지 토큰을 미리 계산한 다음 llama.cpp의 Qwen2.5 구현(LLM만 사용)을 사용하여 디코딩하여 결과 向量模型이 기준 모델의 값과 훨씬 더 가깝게 일치하기를 바랐지만 그렇지 않았습니다.

tag수정 #1: 어텐션 계층에 대한 인과적 어텐션 마스크

저희는 숫자 차이의 가장 큰 원인인 어텐션 계층을 살펴보면서 디버깅을 계속했습니다. 어텐션 계층에서 사용하는 어텐션 마스크가 이미지 토큰에 대해 올바르게 계산되지 않았음을 알았습니다. 이를 확인하기 위해 예제 시퀀스로 돌아갈 수 있습니다.

<|im_start|>user\n<|vision_start|><__image__><|vision_end|>Describe the image.<|im_end|>\n

이미지 토큰을 처리할 때 <__image__> 마커는 img_tok_1 img_tok_2 .... img_tok_last와 같은 것으로 압축 해제됩니다. 따라서 전체 시퀀스는 다음과 같습니다.

<|im_start|>user\n<|vision_start|> img_tok_1 img_tok_2 ... img_tok_last <|vision_end|>Describe the image.<|im_end|>\n
⚠️
참고: 파이프라인의 이 시점에서 텍스트 토큰(<|im_start|>, user 등)과 이미지 토큰(img_tok_1,img_tok_2 등)는 밀집 벡터이며 실제 텍스트 토큰이 아닙니다. 설명을 더 간단하게 만들기 위해 이러한 형태의 시퀀스를 사용합니다.

img_tok_2를 디코딩할 때 어텐션 메커니즘은 반드시 이전의 모든 토큰, 즉 다음 토큰에 어텐션해야 합니다.

<|im_start|>user\n<|vision_start|> img_tok_1

그러나 어텐션 마스크의 버그로 인해 메커니즘이 대신 다음과 같이 전체 이미지 시퀀스에 어텐션하게 되었습니다.

 <|im_start|>user\n<|vision_start|> img_tok_1 img_tok_2 ... img_tok_last

이 버그를 수정하자 llama.cpp 모델의 임베딩(Torch 모델의 ViT에서 사전 계산된 이미지 토큰 사용)이 최종적으로 참조 모델 임베딩과 (작은 오차 범위 내에서) 일치했습니다.

tag수정 #2: 이미지 처리 및 패치 임베딩

llama.cpp의 ViT 인코더 또한 참조 모델과 다른 이미지 임베딩을 생성했으며, 전처리 직후 숫자가 달라졌습니다. 이는 특히 초기 패치 생성 단계에서 두드러졌는데, 여기서 참조 모델과 llama.cpp 모두 원시 이미지(픽셀 값)를 컨볼루션 레이어를 통해 인코딩되는 패치로 분할합니다. (ViT 처리 전) 원시 패치 간의 차이는 아래에서 확인할 수 있습니다.

=== raw_patches reference === Shape: [1008, 1176]
Logging patches 0-4, dimensions 0-9
Patch 0: 0.484375 0.484375 0.500000 0.500000 0.470703 0.470703 0.470703 0.484375 0.470703 0.484375 ... (dims 10-1175)
... (patches 1-1007 not shown)
=== raw_patches llama.cpp === Shape: [1176, 1008, 1, 1]
Logging patches 0-4, dimensions 0-9
Patch 0: 0.455895 0.455895 0.455895 0.455895 0.455895 0.455895 0.470494 0.470494 0.470494 0.470494 ... (dims 10-1175)
... (patches 1-1007 not shown)

참조 모델과 llama.cpp는 이러한 패치를 서로 다른 방식으로 처리합니다.

  • 참조 모델은 재형상화 작업을 사용하여 픽셀 값을 그룹화한 다음 단일 conv3d 레이어를 사용하여 미리 그룹화된 픽셀 패치를 인코딩합니다.
  • llama.cpp 모델은 두 개의 conv2d 레이어를 사용하여 이러한 패치를 생성하고 인코딩합니다.

llama.cpp 모델의 임베딩을 참조 모델의 임베딩에 더 가깝게 만들기 위해 llama.cpp의 접근 방식을 디버깅하는 대신 참조 모델의 정확한 작업을 사용하는 것이 더 간단하다고 생각했습니다.

참조 모델은 9차원 텐서를 필요로 하는 복잡한 재형상화 및 전치 연산을 사용하여 픽셀 패치를 생성합니다. llama.cpp에서 사용되는 로우 레벨 텐서 처리 라이브러리인 ggml은 이를 지원할 수 없으므로 이를 해결하기 위해 HTTP를 통해 llama.cpp 서버를 호출하는 별도의 Python 서비스를 사용하여 패치를 생성했습니다.

ggml은 conv3d 레이어도 지원하지 않습니다. 참조 모델에서 conv3d 레이어 구성은 다음과 같습니다.

kernel_size = [
	2,  # temporal_patch_size, 
	14, # patch_size
	14  # patch_size
]

proj = nn.Conv3d(
	3, # in_channels
	1152, # embed_dim, 
	kernel_size=kernel_size, 
	stride=kernel_size, 
	bias=False
)

stride와 kernel_size가 동일하므로 conv3d 레이어의 입력과 가중치를 평면화하고 대신 간단한 행렬 곱셈 연산을 수행할 수 있습니다. 이를 위해 llama.cpp(convert_hf_to_gguf.py)의 변환 스크립트를 수정하여 패치 프로젝션 레이어에 대한 conv3d 가중치의 평면화된 버전을 내보냈습니다.

if 'patch_embed.proj.weight' in name:
	c1, c2, kt, kh, kw = data_torch.shape
	# Note: this part of the script also exports other versions of this layer
	# Only showing the relevant parts
	# Flat matmul weight: row-major [out, in*kT*kH*kW] = [embed_dim, 1176]
	W_flat = data_torch.contiguous().view(c1, -1)
	outputs.append(("v.patch_embd.weight_flat", W_flat))

llama.cpp에서 두 개의 conv2d 레이어 대신 matmul 연산을 적용하기 위해 Qwen2.5-VL ViT의 그래프를 빌드하는 코드를 수정했습니다.

ggml_tensor * build_inp_raw_precomputed() {
  ggml_tensor * inp_raw = ggml_new_tensor_2d(
		ctx0, 
		GGML_TYPE_F32, 
		img.p_dim, 
		img.npx * img.npy
	);
  ggml_set_name(inp_raw, "inp_raw");
  ggml_set_input(inp_raw);
  return inp_raw;
}

ggml_cgraph * build_qwen2vl() {
	// NOTE: only showing the code we've added for using pre-arranged image patches
  const bool uses_precomputed_image = img.is_precomputed;
  ggml_tensor * inp = nullptr;
	if (uses_precomputed_image) {
      ggml_tensor * inp_raw = build_inp_raw_precomputed();
      cb(inp_raw, "inp_raw", -1);
      inp = ggml_mul_mat(ctx0, model.patch_embeddings_flat, inp_raw);
	} else {
		// Usual 2x conv2d path 
	}
	// rest of the code 
}

이러한 최종 변경 사항을 통해 최종 이미지 임베딩은 참조 모델과 비교했을 때 2% 오차 범위 내에 있었습니다(다음 섹션의 평가 표에서 확인 가능).

tag평가

이러한 변경 사항을 적용한 후 ViDoRe 작업에서 MTEB 벤치마크를 사용하여 참조 모델에 대해 llama.cpp 모델을 평가했습니다. llama.cpp 포크에서 이러한 결과를 복제하기 위한 스크립트 및 지침과 두 개의 양자화된 버전을 확인할 수 있습니다.

작업 참조 모델 llama.cpp (F16) llama.cpp (Q4_K_M) llama.cpp (IQ4_XS)
VidoreArxivQARetrieval 83.55 85.00 84.38 84.34
VidoreDocVQARetrieval 50.53 52.02 51.93 51.57
VidoreInfoVQARetrieval 87.77 87.31 87.61 87.28
VidoreShiftProjectRetrieval 84.07 82.25 82.56 81.73
VidoreSyntheticDocQAAIRetrieval 97.52 96.71 97.28 97.15
VidoreSyntheticDocQAEnergyRetrieval 91.22 90.34 90.47 90.30
VidoreSyntheticDocQAGovernmentReportsRetrieval 91.61 93.84 93.47 94.47
VidoreSyntheticDocQAHealthcareIndustryRetrieval 95.42 96.08 95.67 96.05
VidoreTabfquadRetrieval 94.52 94.94 94.83 94.72
VidoreTatdqaRetrieval 65.52 64.85 64.63 64.76
평균 84.17 84.33 84.28 84.23

결과 테이블을 보면 평균적으로 llama.cpp 모델과 양자화된 변형은 참조 모델과 크게 다르지 않습니다.

모델을 더 자세히 비교하기 위해 서로 다른 도메인과 해상도를 가진 이미지를 사용하여 (풀링/정규화 전) 이미지 패치 임베딩 간의 거리를 플로팅했습니다. 패치가 더 붉을수록 해당 특정 패치에 대한 참조 모델과 llama.cpp 모델의 벡터 간의 코사인 거리가 더 큽니다.

그림 3: jina-embeddings-v4 기술 보고서 페이지, 372 × 526 해상도 (왼쪽), 2481 × 3508 해상도 (오른쪽)

그림 4: Jina AI 웹사이트 스크린샷, 594 × 428 해상도 (왼쪽), 1982 × 1428 해상도 (오른쪽)

그림 5: 도쿄, 시부야 (S K 제공), Unsplash, 383 × 255 해상도 (왼쪽), 5472 × 3649 해상도 (오른쪽)

수치 정밀도 차이 이상의 패턴, 즉 모델 간의 추가 버그 또는 차이점을 드러낼 수 있는 패턴을 발견하는 것을 목표로 했습니다. 그러나 이미지 해상도가 증가함에 따라 발산하는 패치의 수가 증가한다는 점을 제외하고는 특별한 패턴이 보이지 않았습니다. 이러한 차이는 jina-embeddings-v4의 백본 모델인 Qwen2.5-VL의 구현상의 특정 버그 때문이 아니라 백엔드 차이로 인해 나타날 가능성이 높습니다.

그럼에도 불구하고 이러한 차이는 미미하며 벤치마크 결과도 이러한 사실을 반영한다는 점을 다시 한번 강조해야 합니다. 전반적으로 llama.cpp 모델은 약간 다른 임베딩 벡터를 생성하지만 참조 모델만큼 성능이 좋습니다.

tag남은 문제

llama.cpp에서 멀티모달 임베딩을 개선할 수 있는 몇 가지 잠재적인 영역이 있습니다.

  • 비전 인코더 양자화. 현재 llama.cpp는 대규모 언어 모델(LLM)에 대한 양자화만 지원하지만 더 나은 확장을 위해 비전 인코더도 양자화하고 싶습니다.
💡
참고: llama.cpp는 llama-llava-quantize-cli를 사용하여 CLIP 모델 양자화를 지원했지만 mtmd 라이브러리가 도입된 이후 관련 리소스가 제거되었습니다.
  • 비전 인코더를 전용 서비스로 분리. 비전 인코더는 일반적으로 비인과적 마스크를 사용하므로 주어진 이미지를 단일 순방향 호출 내에서 인코딩해야 합니다. 따라서 지속적인 일괄 처리를 사용할 수 없습니다. 그러나 비전 인코더를 별도의 서비스로 분리하여 여러 이미지(서로 다른 소스의 이미지도 포함)를 일괄 처리하고 단일 순방향 패스에서 모두 인코딩하는 것을 고려할 수 있습니다. 이렇게 하면 더 높은 vRAM 요구 사항이 필요하지만 각 이미지를 하나씩 인코딩하는 것보다 훨씬 빠릅니다. 이러한 분리는 언어 모델과 독립적으로 비전 인코더를 자동 스케일링할 수 있음을 의미하기도 합니다.
  • 다중 벡터 임베딩 활성화. 이 문서에서는 단일 벡터 임베딩만 사용했습니다. 그러나 jina-embeddings-v4를 최대한 활용하려면 복잡한 이미지에서 더 높은 정확도를 얻기 위해 다중 벡터 임베딩도 활성화하고 싶습니다. 이러한 임베딩은 기본 모델 위에 단일 선형 레이어로 생성되므로 쉽게 추가할 수 있습니다.

tag결론

초기 버그와 차질에도 불구하고 멀티모달 임베딩을 llama.cpp에 통합하면 광범위한 벤치마크 작업을 포함하여 참조 PyTorch 모델과 거의 일치하는 결과가 나타납니다. 주의 마스크 및 이미지 패치 처리 수정으로 주요 차이 원인이 제거되었으며 양자화된 변형조차 훨씬 적은 리소스를 사용하면서 유사한 정확도를 달성합니다. 더 높은 이미지 해상도에서 남은 차이는 사소해 보이며 핵심 모델 구현보다는 백엔드 변형으로 인한 것일 가능성이 높습니다.

향후 비전 인코더로 양자화를 확장하고 별도 서비스를 통해 일괄 처리 활성화, 다중 벡터 임베딩 지원을 통해 효율성과 정확성을 더욱 향상시킬 수 있습니다. 이러한 추가 기능을 통해 llama.cpp의 멀티모달 임베딩은 더욱 확장 가능해지고 실제 사용 사례에 더 적합해집니다.

범주:
기술 블로그
rss_feed

더 많은 뉴스
3월 11, 2026 • 7 독서의 분
멀티모달 LLM 을 활용한 오디오 임베딩 부트스트래핑
Han Xiao
Abstract illustration of a sound wave or heartbeat, formed by blue, orange, and gray dots on a white background.
3월 06, 2026 • 6 독서의 분
원시 수치 값에서 임베딩 모델 식별하기
Han Xiao
Fingerprint illustration made from numbers, showcasing digital and high-tech design on a light background.
8월 29, 2025 • 9 독서의 분
Jina Remote MCP 서버를 이용한 에이전트 워크플로우
Alex C-G
Digital map of Europe formed with binary code in shades of blue, grey, and white, with red, yellow, and blue highlights in so
사무실
location_on
캘리포니아주 서니베일
710 Lakeway Dr, Ste 200, Sunnyvale, California 94085, United States
location_on
베를린, 독일
Prinzessinnenstraße 19-20, 10969 베를린, 독일
검색 기반
리더
벡터 모델
재배열자
탄력적 추론 서비스
Jina API 키 받기
비율 제한
API 상태
회사
회사 소개
영업팀에 문의
소식
인턴십 프로그램
지나 로고 다운로드
open_in_new
Elastic 로고 다운로드
open_in_new
자귀
안전
이용약관
은둔
쿠키 관리
email
탄력적인 지나 AI © 2020-2026.