[Model] ColQwen3.5: fix retrieval correctness (bias + bidirectional) (#46108)
Signed-off-by: Athrael Soju <athrael.soju@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Athrael Soju committed
Jun 22, 2026 at 10:25 UTC
3c8e49596c3fd34ae82c8c5d881e91a38663639b
7 files changed
+79
-5
docs/models/pooling_models/token_embed.md
+1
-1
@@ -61,7 +61,7 @@ Models of any architecture can be converted into embedding models using `--conve
61
| `ColModernVBertForRetrieval` | ColModernVBERT | T / I | `ModernVBERT/colmodernvbert-merged` | | |
62
| `ColPaliForRetrieval` | ColPali | T / I | `vidore/colpali-v1.3-hf` | | |
63
| `ColQwen3` | Qwen3-VL | T / I | `TomoroAI/tomoro-colqwen3-embed-4b`, `TomoroAI/tomoro-colqwen3-embed-8b` | | |
64
-| `ColQwen3_5` | ColQwen3.5 | T + I + V | `athrael-soju/colqwen3.5-4.5B-v3` | | |
64
+| `ColQwen3_5` | ColQwen3.5 | T + I + V | `athrael-soju/colqwen3.5-4.5B-v3`, `vultr/VultronRetrieverPrime-Qwen3.5-8B` | | |
65
| `OpsColQwen3Model` | Qwen3-VL | T / I | `OpenSearch-AI/Ops-Colqwen3-4B`, `OpenSearch-AI/Ops-Colqwen3-8B` | | |
66
| `Qwen3VLNemotronEmbedModel` | Qwen3-VL | T / I | `nvidia/nemotron-colembed-vl-4b-v2`, `nvidia/nemotron-colembed-vl-8b-v2` | ✅︎ | ✅︎ |
67
| `*ForConditionalGeneration`<sup>C</sup>, `*ForCausalLM`<sup>C</sup>, etc. | Generative models | \* | N/A | \* | \* |
examples/pooling/score/colqwen3_5_rerank_online.py
+17
-1
@@ -7,11 +7,27 @@ ColQwen3.5 is a multi-modal ColBERT-style model based on Qwen3.5.
7
It produces per-token embeddings and uses MaxSim scoring for retrieval
8
and reranking. Supports both text and image inputs.
9
10
+Works for any ColQwen3.5 checkpoint, e.g. `athrael-soju/colqwen3.5-4.5B-v3`
11
+or `vultr/VultronRetrieverPrime-Qwen3.5-8B`.
12
+
13
Start the server with:
11
- vllm serve athrael-soju/colqwen3.5-4.5B --max-model-len 4096
14
+ vllm serve athrael-soju/colqwen3.5-4.5B-v3 --max-model-len 4096 \
15
+ --mm-processor-kwargs '{"min_pixels": 65536, "max_pixels": 1835008}'
16
17
Then run this script:
18
python colqwen3_5_rerank_online.py
19
+
20
+Parity note (matching the native colpali ColQwen3_5Processor pipeline):
21
+ - Visual-token budget: ColQwen3_5Processor uses max_num_visual_tokens=1792,
22
+ i.e. max_pixels = 1792 * (patch_size*merge_size)^2 = 1792 * 32^2 = 1835008
23
+ (with min_pixels = shortest_edge = 65536). Pass these via --mm-processor-kwargs
24
+ as above; the default budget gives fewer visual tokens and lower retrieval ndcg.
25
+ - When you build prompts yourself (token_embed), reproduce the processor exactly:
26
+ image (document): wrap in the instruction template
27
+ "<|im_start|>user\n<|vision_start|><|image_pad|><|vision_end|>"
28
+ "Describe the image.<|im_end|><|endoftext|>"
29
+ query: append the augmentation suffix <text> + "<|endoftext|>" * 10
30
+ Omitting these reproduces a silent ~2.5 ndcg@10 drop vs the native pipeline.
31
"""
32
33
import requests
tests/models/multimodal/pooling/test_colqwen3_5.py
+18
@@ -152,3 +152,21 @@ def test_colqwen3_5_relevance_ordering(
152
dtype: str,
153
) -> None:
154
_run_relevance_test(vllm_runner, model, dtype=dtype)
155
+
156
+
157
+def test_colqwen3_5_config_enables_bidirectional_attention() -> None:
158
+ """ColQwen3.5 retrieval must be served BIDIRECTIONAL (is_causal=False) so the
159
+ full_attention layers build with AttentionType.ENCODER_ONLY. This guards the
160
+ silent-causal regression (no GPU / model load needed)."""
161
+ from types import SimpleNamespace
162
+
163
+ from vllm.model_executor.models.config import (
164
+ MODELS_CONFIG_MAP,
165
+ ColQwen3_5Config,
166
+ )
167
+
168
+ assert MODELS_CONFIG_MAP["ColQwen3_5"] is ColQwen3_5Config
169
+
170
+ model_config = SimpleNamespace(hf_config=SimpleNamespace())
171
+ ColQwen3_5Config.verify_and_update_model_config(model_config)
172
+ assert model_config.hf_config.is_causal is False
vllm/model_executor/layers/attention/attention.py
+8
-1
@@ -581,7 +581,14 @@ class Attention(nn.Module, AttentionLayerBase):
581
def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec | None:
582
# Block size may get updated after model loading, refresh it
583
block_size = vllm_config.cache_config.block_size
584
- # Should not be called for enc-dec or encoder-only attention.
584
+ # Encoder-only attention is prefill-only and keeps no autoregressive KV
585
+ # cache. In hybrid models (e.g. Qwen3.5 / ColQwen3.5: GatedDeltaNet
586
+ # linear_attention interleaved with full_attention) the runner iterates
587
+ # every attention module to build the KV-cache spec, so an ENCODER_ONLY
588
+ # full_attention layer reaches here; it contributes no KV cache group.
589
+ if self.attn_type in (AttentionType.ENCODER_ONLY, AttentionType.ENCODER):
590
+ return None
591
+ # Should not be called for enc-dec attention.
592
assert self.attn_type == AttentionType.DECODER
593
quant_mode = get_kv_quant_mode(self.kv_cache_dtype)
594
if self.sliding_window is not None:
vllm/model_executor/models/colqwen3_5.py
+9
-1
@@ -15,6 +15,7 @@ Based on: Qwen3.5 backbone with custom text projection
15
16
Target models:
17
- athrael-soju/colqwen3.5-4.5B-v3
18
+- vultr/VultronRetrieverPrime-Qwen3.5-8B
19
"""
20
21
from collections.abc import Iterable, Mapping
@@ -166,12 +167,19 @@ class ColQwen3_5Model(
167
or 128 # default from reference implementation
168
)
169
170
+ # ColPali defines `custom_text_proj = nn.Linear(hidden, dim)`, i.e.
171
+ # bias=True by default, and the trained ColQwen3.5 checkpoints ship a
172
+ # `custom_text_proj.bias`. Construct with a bias and zero-initialize it:
173
+ # a (legacy) bias-less checkpoint then behaves identically to bias=False,
174
+ # while load_weights() below picks up a trained bias instead of silently
175
+ # dropping it (which shifts every per-token vector and the MaxSim ranking).
176
self.custom_text_proj = nn.Linear(
177
hidden_size,
178
self.embed_dim,
172
- bias=False,
179
+ bias=True,
180
dtype=head_dtype,
181
)
182
+ nn.init.zeros_(self.custom_text_proj.bias)
183
184
pooler_config = vllm_config.model_config.pooler_config
185
assert pooler_config is not None
vllm/model_executor/models/config.py
+15
-1
@@ -627,6 +627,20 @@ class Qwen3_5ForConditionalGenerationConfig(VerifyAndUpdateConfig):
627
)
628
629
630
+class ColQwen3_5Config(Qwen3_5ForConditionalGenerationConfig):
631
+ """ColQwen3.5 (late-interaction retrieval) inherits Qwen3.5's mamba cache
632
+ handling and additionally serves BIDIRECTIONAL attention: ColPali-style
633
+ document/query encoding attends over the whole sequence, not causally. Set
634
+ is_causal=False so Qwen3NextAttention builds its full_attention layers with
635
+ AttentionType.ENCODER_ONLY (the linear_attention GatedDeltaNet layers are
636
+ unaffected). Generation arches keep the parent (causal) and are untouched.
637
+ """
638
+
639
+ @staticmethod
640
+ def verify_and_update_model_config(model_config: "ModelConfig") -> None:
641
+ model_config.hf_config.is_causal = False
642
+
643
+
644
class SnowflakeGteNewModelConfig(VerifyAndUpdateConfig):
645
@staticmethod
646
def verify_and_update_model_config(model_config: "ModelConfig") -> None:
@@ -656,7 +670,7 @@ class VoyageQwen3BidirectionalEmbedModelConfig(VerifyAndUpdateConfig):
670
671
MODELS_CONFIG_MAP: dict[str, type[VerifyAndUpdateConfig]] = {
672
"ColBERTJinaRobertaModel": JinaRobertaModelConfig,
659
- "ColQwen3_5": Qwen3_5ForConditionalGenerationConfig,
673
+ "ColQwen3_5": ColQwen3_5Config,
674
"DeepseekV4ForCausalLM": DeepseekV4ForCausalLMConfig,
675
"DeepseekV32ForCausalLM": DeepseekV32ForCausalLM,
676
"DiffusionGemmaForBlockDiffusion": DiffusionGemmaModelForBlockDiffusionConfig, # noqa: E501
vllm/model_executor/models/qwen3_next.py
+11
@@ -62,6 +62,7 @@ from vllm.model_executor.models.utils import sequence_parallel_chunk
62
from vllm.platforms import current_platform
63
from vllm.sequence import IntermediateTensors
64
from vllm.transformers_utils.configs.qwen3_next import Qwen3NextConfig
65
+from vllm.v1.attention.backend import AttentionType
66
67
from .interfaces import (
68
EagleModelMixin,
@@ -267,6 +268,15 @@ class Qwen3NextAttention(nn.Module):
268
dual_chunk_attention_config=self.dual_chunk_attention_config,
269
)
270
271
+ # Late-interaction retrieval models (e.g. ColQwen3.5) run BIDIRECTIONAL
272
+ # attention on the full_attention layers; they set config.is_causal=False
273
+ # via a VerifyAndUpdateConfig handler. Generation models leave is_causal
274
+ # unset (-> causal/DECODER), so this is a no-op for them. Mirrors qwen3.py.
275
+ attn_type = (
276
+ AttentionType.DECODER
277
+ if getattr(config, "is_causal", True)
278
+ else AttentionType.ENCODER_ONLY
279
+ )
280
self.attn = Attention(
281
self.num_heads,
282
self.head_dim,
@@ -275,6 +285,7 @@ class Qwen3NextAttention(nn.Module):
285
cache_config=cache_config,
286
quant_config=quant_config,
287
prefix=f"{prefix}.attn",
288
+ attn_type=attn_type,
289
**{
290
"layer_idx": extract_layer_index(prefix),
291
"dual_chunk_attention_config": self.dual_chunk_attention_config,