hongst commited on
Commit
b28b620
·
verified ·
1 Parent(s): b364ded
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ Roboto-Regular.ttf filter=lfs diff=lfs merge=lfs -text
37
+ tokenizer.json filter=lfs diff=lfs merge=lfs -text
1_LogitScore/config.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "true_token_id": 9693,
3
+ "false_token_id": 2152,
4
+ "module_input_name": "causal_logits"
5
+ }
README.md ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ library_name: transformers
4
+ pipeline_tag: text-ranking
5
+ language:
6
+ - en
7
+ base_model:
8
+ - Qwen/Qwen3-VL-2B-Instruct
9
+ tags:
10
+ - sentence-transformers
11
+ - transformers
12
+ - multimodal rerank
13
+ - text rerank
14
+ - conversational
15
+ ---
16
+ # RenderRank
17
+
18
+ **Learning to Rerank Text through Visual Compression**
19
+
20
+ RenderRank converts text into compact visual token sequences by rendering it as images. This change in input modality reduces the number of tokens used to represent documents, allowing more content to fit within the same context budget. Queries remain text, and relevance is scored against the visual document representations.
21
+
22
+ [![RenderRank architecture: text queries and visually encoded documents for relevance scoring](assets/struc.png)](assets/struc.pdf)
23
+
24
+ ## Highlights
25
+
26
+ - **Text as visual tokens:** represent document text through rendered images instead of conventional text token sequences.
27
+ - **Shorter input sequences:** compact visual representations reduce input length and allow more document content within a fixed context budget.
28
+ - **Three input modes:** score document text directly with `text`, render it automatically with `render`, or provide existing document pages with `image`.
29
+
30
+ | Property | Value |
31
+ | :--- | :--- |
32
+ | Model type | Pairwise document reranker |
33
+ | Architecture | Qwen3-VL |
34
+ | Parameters | Approximately 2.1B, including the vision encoder |
35
+ | Language evaluated | English |
36
+ | Maximum context length | 32,768 tokens, including query, template, document text, and visual tokens |
37
+ | Scoring | Yes/no relevance scoring |
38
+
39
+ ## BEIR Performance
40
+
41
+ All rerankers score the top 100 BM25 candidates for each query. Baselines use text queries and documents; RenderRank uses text queries and documents rendered with the default 12pt configuration below. Scores are NDCG@10; Avg. is the mean across 11 datasets. Tokens is the mean input length per query–document pair before truncation, averaged across the same datasets. For RenderRank, this includes both textual and visual tokens.
42
+
43
+ | Model | AA | CFV | DBP | FQA | FVR | HQA | NFC | SD | SF | TC | TCH | Avg. | Tokens |
44
+ | :--- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
45
+ | gte-reranker-modernbert-base | 66.14 | 25.80 | 42.10 | 42.54 | 89.84 | 76.15 | 34.81 | 18.90 | 75.81 | 79.66 | 33.44 | 53.20 | 359.25 |
46
+ | mxbai-rerank-large-v1 | 16.16 | 25.61 | 45.37 | 40.29 | 82.19 | 71.58 | 37.32 | 18.96 | 75.26 | 85.84 | 37.44 | 48.73 | 347.45 |
47
+ | bge-reranker-large | 30.94 | 34.11 | 44.21 | 37.47 | 89.30 | 80.09 | 33.91 | 16.68 | 73.98 | 73.21 | 34.69 | 49.87 | 409.32 |
48
+ | LAMAR-600m | 66.21 | 36.21 | 46.23 | 41.14 | 89.49 | 79.14 | 35.04 | 20.22 | 76.97 | 80.65 | 35.76 | 55.19 | 409.32 |
49
+ | Qwen3-Reranker-0.6B | 68.04 | 34.34 | 43.76 | 39.74 | 87.13 | 77.25 | 36.60 | 20.44 | 77.00 | 85.54 | 30.32 | 54.56 | 438.70 |
50
+ | llama-nemotron-rerank-1b-v2 | 54.29 | 27.37 | 45.13 | 47.11 | 87.66 | 80.34 | 38.14 | 21.53 | 79.82 | 83.14 | 32.47 | 54.27 | 363.99 |
51
+ | mxbai-rerank-large-v2 | 42.23 | 23.03 | 38.01 | 28.85 | 70.84 | 69.50 | 35.17 | 17.49 | 78.81 | 67.78 | 46.94 | 47.15 | 449.76 |
52
+ | LightOn-rerank-PW-2B | 45.52 | 23.21 | 42.60 | 35.66 | 86.51 | 73.05 | 35.61 | 16.64 | 77.70 | 81.24 | 36.91 | 50.42 | 416.22 |
53
+ | bge-reranker-v2-gemma | 74.98 | 32.03 | 45.01 | 43.18 | 87.45 | 80.47 | 36.25 | 19.58 | 77.43 | 80.08 | 37.56 | 55.82 | 399.11 |
54
+ | Qwen3-Reranker-4B | 75.40 | 37.84 | 47.02 | 44.63 | 89.14 | 79.06 | 37.48 | 23.59 | 79.47 | 85.47 | 38.77 | 57.99 | 438.70 |
55
+ | zerank-2-reranker | 44.87 | 23.65 | 44.95 | 42.75 | 83.22 | 70.97 | 38.50 | 20.23 | 79.35 | 85.24 | 38.10 | 51.98 | 380.69 |
56
+ | LightOn-rerank-PW-4B | 52.50 | 28.24 | 44.80 | 40.27 | 87.70 | 72.79 | 37.33 | 17.29 | 76.72 | 79.54 | 36.70 | 52.17 | 416.22 |
57
+ | **RenderRank (Ours)** | 69.11 | 35.91 | 46.19 | 42.66 | 88.92 | 78.60 | 37.31 | 20.75 | 77.00 | 84.82 | 34.27 | **55.96** | **290.07** |
58
+
59
+ AA: ArguAna; CFV: Climate-FEVER; DBP: DBPedia; FQA: FiQA; FVR: FEVER; HQA: HotpotQA; NFC: NFCorpus; SD: SCIDOCS; SF: SciFact; TC: TREC-COVID; TCH: Touché-2020.
60
+
61
+ ## Long-Document Performance
62
+
63
+ Results on the English subset of MLDR and three LongEmbed datasets: 2WikiMQA, QMSum, and SummScreenFD. The comparison includes models supporting at least 16K input tokens. MLDR follows the MMTEB reranking protocol; for LongEmbed, each query reranks eight candidates retrieved with Qwen3-Embedding-0.6B.
64
+
65
+ Each dataset cell shows NDCG@10 followed by the average input token count in brackets: **score [tokens]**. Token counts are measured per query–document pair before truncation and include both textual and visual tokens for RenderRank. Avg. reports the mean score across the four datasets.
66
+
67
+ | Model | MLDR | 2WikiMQA | QMSum | SummScreenFD | Avg. |
68
+ | :--- | ---: | ---: | ---: | ---: | ---: |
69
+ | Qwen3-Reranker-0.6B | 99.63 [8863.4] | 94.54 [9230.2] | 56.43 [13543.6] | 98.25 [8606.3] | 87.21 |
70
+ | LightOn-rerank-PW-2B | 98.91 [8878.0] | 71.27 [9230.1] | 54.30 [13876.6] | 93.05 [8977.2] | 79.38 |
71
+ | Qwen3-Reranker-4B | 99.85 [8863.4] | 94.54 [9230.2] | 59.11 [13543.6] | 99.11 [8606.3] | 88.15 |
72
+ | zerank-2-reranker | 99.57 [8805.6] | 94.42 [9173.1] | 60.10 [13485.9] | 98.97 [8548.5] | 88.26 |
73
+ | LightOn-rerank-PW-4B | 99.68 [8878.0] | 93.84 [9230.1] | 58.87 [13876.6] | 98.91 [8977.2] | 87.82 |
74
+ | **RenderRank (Ours)** | 99.74 [4198.0] | 94.30 [4262.2] | 59.94 [6388.8] | 99.10 [3701.9] | 88.27 |
75
+
76
+ ## Usage
77
+
78
+ The examples load the model from `nlpai-lab/RenderRank-2B` on Hugging Face.
79
+
80
+ The model applies its reranking chat template internally. Pass the instruction as shown below; do not manually prepend a chat template to the document.
81
+
82
+ ### Rendering
83
+
84
+ With Transformers, `{"render": document_text}` automatically renders the document before scoring. The font is included in this repository.
85
+
86
+ For Sentence Transformers, use the bundled `rendering.py` to create a list of PIL page images first:
87
+
88
+ ```python
89
+ import sys
90
+ from pathlib import Path
91
+
92
+ from huggingface_hub import snapshot_download
93
+
94
+ repo_dir = Path(snapshot_download(
95
+ "nlpai-lab/RenderRank-2B",
96
+ allow_patterns=["rendering.py", "Roboto-Regular.ttf"],
97
+ ))
98
+ sys.path.insert(0, str(repo_dir))
99
+ from rendering import DocumentImageConfig, DocumentImageRenderer
100
+
101
+ renderer = DocumentImageRenderer(
102
+ DocumentImageConfig(font_path=repo_dir / "Roboto-Regular.ttf")
103
+ )
104
+ pages = renderer.render_document("Paris is the capital of France.")
105
+ # pages is a list of PIL images, in document order.
106
+ ```
107
+
108
+ The returned PIL images can be passed directly as image content or saved as numbered page files. The Transformers `render` mode calls this renderer internally, so no separate rendering step is needed there.
109
+
110
+ | Setting | Value |
111
+ | :--- | :--- |
112
+ | Font | Roboto Regular |
113
+ | Font size | 12pt at 96 DPI (16px) |
114
+ | Line spacing | 1.0 (16px line height) |
115
+ | Page width | 896px |
116
+ | Page height | 32–896px, rounded up to a multiple of 32px |
117
+ | Lines per page | Up to 56 |
118
+ | Margins | 0px on all sides |
119
+ | Colors | Black text on a white background |
120
+ | Rendering | Pillow default text antialiasing |
121
+
122
+ Whitespace, including paragraph breaks and tabs, is normalized to a single space. Lines wrap according to the font's actual pixel width; words wider than the page are split by character. Overflow continues onto subsequent pages without overlap. The final page is sized to its content, and empty documents produce one 896 × 32px page.
123
+
124
+ All generated pages are supplied in order as one document, producing one relevance score. Rendering is not limited to the first page.
125
+
126
+ ### Sentence Transformers
127
+
128
+ Use a Sentence Transformers version supporting multimodal `CrossEncoder` models.
129
+
130
+ ```bash
131
+ pip install -U sentence-transformers
132
+ ```
133
+
134
+ The same call can score text, a single document image, and multiple page images. In the example below, `1.png`, `2.png`, and `3.png` are ordered pages of one document.
135
+
136
+ ```python
137
+ import torch
138
+ from PIL import Image
139
+ from sentence_transformers import CrossEncoder
140
+
141
+ model = CrossEncoder(
142
+ "nlpai-lab/RenderRank-2B",
143
+ device="cuda",
144
+ max_length=32768,
145
+ model_kwargs={"dtype": torch.bfloat16},
146
+ )
147
+
148
+ query = "What is the capital of France?"
149
+ documents = [
150
+ [{"type": "text", "text": "Paris is the capital of France."}],
151
+ [{"type": "image", "image": Image.open("/path/to/document.png")}],
152
+ [
153
+ {"type": "image", "image": Image.open("/path/to/1.png")},
154
+ {"type": "image", "image": Image.open("/path/to/2.png")},
155
+ {"type": "image", "image": Image.open("/path/to/3.png")},
156
+ # Add further pages here in document order.
157
+ ],
158
+ ]
159
+ messages = [
160
+ [
161
+ {"role": "query", "content": [{"type": "text", "text": query}]},
162
+ {"role": "document", "content": document},
163
+ ]
164
+ for document in documents
165
+ ]
166
+
167
+ scores = model.predict(
168
+ messages,
169
+ prompt="Retrieve text relevant to the user's query.",
170
+ batch_size=1,
171
+ )
172
+ print(scores) # One score per document, not per page.
173
+ ```
174
+
175
+ Larger scores indicate greater relevance. Scores are the difference between the yes and no logits, as used in our evaluation. Sentence Transformers requires one image content entry per page; `render` and `{"image": [page1, page2]}` are supported by the Transformers interface below, not this interface.
176
+
177
+ ### Transformers
178
+
179
+ This repository provides a custom `AutoModel` entry point with a `process()` method. It supports all three document input modes.
180
+
181
+ **Requirements**
182
+
183
+ The Transformers interface was tested with the following versions:
184
+
185
+ ```text
186
+ transformers==5.9.0
187
+ qwen-vl-utils==0.0.14
188
+ torch==2.11.0
189
+ torchvision==0.26.0
190
+ scipy
191
+ ```
192
+
193
+ `qwen-vl-utils` handles image preparation in this interface; `scipy` is used for score normalization. NumPy and Pillow are installed through the dependencies above. Install PyTorch and torchvision builds matching your CUDA environment.
194
+
195
+ ```python
196
+ import torch
197
+ from transformers import AutoModel
198
+
199
+ model = AutoModel.from_pretrained(
200
+ "nlpai-lab/RenderRank-2B",
201
+ trust_remote_code=True,
202
+ dtype=torch.bfloat16,
203
+ ).to("cuda").eval()
204
+
205
+ inputs = {
206
+ "instruction": "Retrieve text relevant to the user's query.",
207
+ "query": {"text": "What is the capital of France?"},
208
+ "documents": [
209
+ # Score text directly.
210
+ {"text": "Paris is the capital of France."},
211
+ # Render text into document pages, then score the images.
212
+ {"render": "Paris is the capital of France."},
213
+ # Score an existing document image.
214
+ {"image": "/path/to/document.png"},
215
+ # Score multiple ordered pages as one document.
216
+ {"image": ["/path/to/page1.png", "/path/to/page2.png"]},
217
+ ],
218
+ }
219
+
220
+ with torch.inference_mode():
221
+ scores = model.process(inputs)
222
+
223
+ ranked_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
224
+ print(scores) # One score in [0, 1] per document, in input order.
225
+ print(ranked_indices)
226
+ ```
Roboto-Regular.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:797e35f7f5d6020a5c6ea13b42ecd668bcfb3bbc4baa0e74773527e5b6cb3174
3
+ size 305608
additional_chat_templates/reranker.jinja ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {%- set default_instruction = "Given a search query, retrieve relevant candidates that answer the query." -%}
2
+ {%- set ns = namespace(instruction="", found_instruction=false) -%}
3
+ {%- for message in messages -%}
4
+ {%- if message.role == "system" -%}
5
+ {%- if message.content is string -%}
6
+ {%- set ns.instruction = message.content -%}
7
+ {%- else -%}
8
+ {%- for content in message.content -%}
9
+ {%- if 'text' in content -%}
10
+ {%- set ns.instruction = ns.instruction + content.text -%}
11
+ {%- endif -%}
12
+ {%- endfor -%}
13
+ {%- endif -%}
14
+ {%- set ns.found_instruction = true -%}
15
+ {%- endif -%}
16
+ {%- endfor -%}
17
+ {%- if not ns.found_instruction -%}
18
+ {%- set ns.instruction = default_instruction -%}
19
+ {%- endif -%}
20
+ {%- set image_count = namespace(value=0) -%}
21
+ {%- set video_count = namespace(value=0) -%}
22
+ {%- macro render_multimodal(message) -%}
23
+ {%- if message.content is string -%}
24
+ {{- message.content -}}
25
+ {%- else -%}
26
+ {%- for content in message.content -%}
27
+ {%- if content.type == 'image' or 'image' in content or 'image_url' in content -%}
28
+ {%- set image_count.value = image_count.value + 1 -%}
29
+ {%- if add_vision_id %}Picture {{ image_count.value }}: {% endif -%}
30
+ <|vision_start|><|image_pad|><|vision_end|>
31
+ {%- elif content.type == 'video' or 'video' in content -%}
32
+ {%- set video_count.value = video_count.value + 1 -%}
33
+ {%- if add_vision_id %}Video {{ video_count.value }}: {% endif -%}
34
+ <|vision_start|><|video_pad|><|vision_end|>
35
+ {%- elif 'text' in content -%}
36
+ {{- content.text -}}
37
+ {%- endif -%}
38
+ {%- endfor -%}
39
+ {%- endif -%}
40
+ {%- endmacro -%}
41
+ {{- '<|im_start|>system\nJudge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be "yes" or "no".<|im_end|>\n<|im_start|>user\n<Instruct>: ' + ns.instruction + '<Query>:' -}}
42
+ {%- for message in messages if message.role == "query" -%}
43
+ {{- render_multimodal(message) -}}
44
+ {%- endfor -%}
45
+ {{- '\n<Document>:' -}}
46
+ {%- for message in messages if message.role == "document" -%}
47
+ {{- render_multimodal(message) -}}
48
+ {%- endfor -%}
49
+ {{- '<|im_end|>\n' -}}
50
+ {%- if add_generation_prompt -%}
51
+ {{- '<|im_start|>assistant\n' -}}
52
+ {%- endif -%}
assets/struc.pdf ADDED
Binary file (99.3 kB). View file
 
assets/struc.png ADDED
chat_template.jinja ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {%- if tools %}
2
+ {{- '<|im_start|>system\n' }}
3
+ {%- if messages[0].role == 'system' %}
4
+ {%- if messages[0].content is string %}
5
+ {{- messages[0].content }}
6
+ {%- else %}
7
+ {%- for content in messages[0].content %}
8
+ {%- if 'text' in content %}
9
+ {{- content.text }}
10
+ {%- endif %}
11
+ {%- endfor %}
12
+ {%- endif %}
13
+ {{- '\n\n' }}
14
+ {%- endif %}
15
+ {{- "# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>" }}
16
+ {%- for tool in tools %}
17
+ {{- "\n" }}
18
+ {{- tool | tojson }}
19
+ {%- endfor %}
20
+ {{- "\n</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call><|im_end|>\n" }}
21
+ {%- else %}
22
+ {%- if messages[0].role == 'system' %}
23
+ {{- '<|im_start|>system\n' }}
24
+ {%- if messages[0].content is string %}
25
+ {{- messages[0].content }}
26
+ {%- else %}
27
+ {%- for content in messages[0].content %}
28
+ {%- if 'text' in content %}
29
+ {{- content.text }}
30
+ {%- endif %}
31
+ {%- endfor %}
32
+ {%- endif %}
33
+ {{- '<|im_end|>\n' }}
34
+ {%- endif %}
35
+ {%- endif %}
36
+ {%- set image_count = namespace(value=0) %}
37
+ {%- set video_count = namespace(value=0) %}
38
+ {%- for message in messages %}
39
+ {%- if message.role == "user" %}
40
+ {{- '<|im_start|>' + message.role + '\n' }}
41
+ {%- if message.content is string %}
42
+ {{- message.content }}
43
+ {%- else %}
44
+ {%- for content in message.content %}
45
+ {%- if content.type == 'image' or 'image' in content or 'image_url' in content %}
46
+ {%- set image_count.value = image_count.value + 1 %}
47
+ {%- if add_vision_id %}Picture {{ image_count.value }}: {% endif -%}
48
+ <|vision_start|><|image_pad|><|vision_end|>
49
+ {%- elif content.type == 'video' or 'video' in content %}
50
+ {%- set video_count.value = video_count.value + 1 %}
51
+ {%- if add_vision_id %}Video {{ video_count.value }}: {% endif -%}
52
+ <|vision_start|><|video_pad|><|vision_end|>
53
+ {%- elif 'text' in content %}
54
+ {{- content.text }}
55
+ {%- endif %}
56
+ {%- endfor %}
57
+ {%- endif %}
58
+ {{- '<|im_end|>\n' }}
59
+ {%- elif message.role == "assistant" %}
60
+ {{- '<|im_start|>' + message.role + '\n' }}
61
+ {%- if message.content is string %}
62
+ {{- message.content }}
63
+ {%- else %}
64
+ {%- for content_item in message.content %}
65
+ {%- if 'text' in content_item %}
66
+ {{- content_item.text }}
67
+ {%- endif %}
68
+ {%- endfor %}
69
+ {%- endif %}
70
+ {%- if message.tool_calls %}
71
+ {%- for tool_call in message.tool_calls %}
72
+ {%- if (loop.first and message.content) or (not loop.first) %}
73
+ {{- '\n' }}
74
+ {%- endif %}
75
+ {%- if tool_call.function %}
76
+ {%- set tool_call = tool_call.function %}
77
+ {%- endif %}
78
+ {{- '<tool_call>\n{"name": "' }}
79
+ {{- tool_call.name }}
80
+ {{- '", "arguments": ' }}
81
+ {%- if tool_call.arguments is string %}
82
+ {{- tool_call.arguments }}
83
+ {%- else %}
84
+ {{- tool_call.arguments | tojson }}
85
+ {%- endif %}
86
+ {{- '}\n</tool_call>' }}
87
+ {%- endfor %}
88
+ {%- endif %}
89
+ {{- '<|im_end|>\n' }}
90
+ {%- elif message.role == "tool" %}
91
+ {%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}
92
+ {{- '<|im_start|>user' }}
93
+ {%- endif %}
94
+ {{- '\n<tool_response>\n' }}
95
+ {%- if message.content is string %}
96
+ {{- message.content }}
97
+ {%- else %}
98
+ {%- for content in message.content %}
99
+ {%- if content.type == 'image' or 'image' in content or 'image_url' in content %}
100
+ {%- set image_count.value = image_count.value + 1 %}
101
+ {%- if add_vision_id %}Picture {{ image_count.value }}: {% endif -%}
102
+ <|vision_start|><|image_pad|><|vision_end|>
103
+ {%- elif content.type == 'video' or 'video' in content %}
104
+ {%- set video_count.value = video_count.value + 1 %}
105
+ {%- if add_vision_id %}Video {{ video_count.value }}: {% endif -%}
106
+ <|vision_start|><|video_pad|><|vision_end|>
107
+ {%- elif 'text' in content %}
108
+ {{- content.text }}
109
+ {%- endif %}
110
+ {%- endfor %}
111
+ {%- endif %}
112
+ {{- '\n</tool_response>' }}
113
+ {%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
114
+ {{- '<|im_end|>\n' }}
115
+ {%- endif %}
116
+ {%- endif %}
117
+ {%- endfor %}
118
+ {%- if add_generation_prompt %}
119
+ {{- '<|im_start|>assistant\n' }}
120
+ {%- endif %}
config.json ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "auto_map": {
3
+ "AutoModel": "modeling_renderrank.RenderRank"
4
+ },
5
+ "architectures": [
6
+ "Qwen3VLForConditionalGeneration"
7
+ ],
8
+ "dtype": "bfloat16",
9
+ "hidden_size": 2048,
10
+ "image_token_id": 151655,
11
+ "model_type": "qwen3_vl",
12
+ "pad_token_id": 151643,
13
+ "text_config": {
14
+ "attention_bias": false,
15
+ "attention_dropout": 0.0,
16
+ "bos_token_id": 151643,
17
+ "dtype": "bfloat16",
18
+ "eos_token_id": 151645,
19
+ "head_dim": 128,
20
+ "hidden_act": "silu",
21
+ "hidden_size": 2048,
22
+ "initializer_range": 0.02,
23
+ "intermediate_size": 6144,
24
+ "max_position_embeddings": 262144,
25
+ "model_type": "qwen3_vl_text",
26
+ "num_attention_heads": 16,
27
+ "num_hidden_layers": 28,
28
+ "num_key_value_heads": 8,
29
+ "pad_token_id": 151643,
30
+ "rms_norm_eps": 1e-06,
31
+ "rope_parameters": {
32
+ "mrope_interleaved": true,
33
+ "mrope_section": [
34
+ 24,
35
+ 20,
36
+ 20
37
+ ],
38
+ "rope_theta": 5000000,
39
+ "rope_type": "default"
40
+ },
41
+ "tie_word_embeddings": true,
42
+ "use_cache": false,
43
+ "vocab_size": 151936
44
+ },
45
+ "tie_word_embeddings": true,
46
+ "transformers_version": "5.14.1",
47
+ "video_token_id": 151656,
48
+ "vision_config": {
49
+ "deepstack_visual_indexes": [
50
+ 5,
51
+ 11,
52
+ 17
53
+ ],
54
+ "depth": 24,
55
+ "dtype": "bfloat16",
56
+ "hidden_act": "gelu_pytorch_tanh",
57
+ "hidden_size": 1024,
58
+ "in_channels": 3,
59
+ "initializer_range": 0.02,
60
+ "intermediate_size": 4096,
61
+ "model_type": "qwen3_vl_vision",
62
+ "num_heads": 16,
63
+ "num_position_embeddings": 2304,
64
+ "out_hidden_size": 2048,
65
+ "pad_token_id": 151643,
66
+ "patch_size": 16,
67
+ "spatial_merge_size": 2,
68
+ "temporal_patch_size": 2
69
+ },
70
+ "vision_end_token_id": 151653,
71
+ "vision_start_token_id": 151652
72
+ }
config_sentence_transformers.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "__version__": {
3
+ "pytorch": "2.11.0+cu129",
4
+ "sentence_transformers": "5.7.0.dev0",
5
+ "transformers": "5.14.1"
6
+ },
7
+ "activation_fn": "torch.nn.modules.linear.Identity",
8
+ "default_prompt_name": "query",
9
+ "model_type": "CrossEncoder",
10
+ "prompts": {
11
+ "query": "Retrieve text relevant to the user's query."
12
+ }
13
+ }
generation_config.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "bos_token_id": 151643,
3
+ "do_sample": true,
4
+ "eos_token_id": [
5
+ 151645,
6
+ 151643
7
+ ],
8
+ "pad_token_id": 151643,
9
+ "temperature": 0.7,
10
+ "top_k": 20,
11
+ "top_p": 0.8,
12
+ "transformers_version": "5.14.1"
13
+ }
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0add172e901d1f99bd2cd42a1a3e38b2d2016788862386961456019dd4346fff
3
+ size 4255140312
modeling_renderrank.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AutoModel entry point: render preprocessing plus Qwen's existing scorer."""
2
+ from pathlib import Path
3
+
4
+ from transformers import AutoProcessor, Qwen3VLForConditionalGeneration
5
+ from transformers.utils.hub import cached_file
6
+
7
+ from .qwen3_vl_reranker import Qwen3VLReranker
8
+ from .rendering import DocumentImageConfig, DocumentImageRenderer, prepare_render_inputs
9
+
10
+
11
+ class _QwenScorer(Qwen3VLReranker):
12
+ def tokenize(self, pairs, **kwargs):
13
+ inputs = super().tokenize(pairs, **kwargs)
14
+ # Transformers 5 adds modality IDs. Align them with the upstream's
15
+ # finalized input IDs, then convert every model input to tensors.
16
+ if "mm_token_type_ids" in inputs:
17
+ inputs["mm_token_type_ids"] = self.processor.create_mm_token_type_ids(
18
+ inputs["input_ids"].tolist()
19
+ )
20
+ return inputs.convert_to_tensors("pt")
21
+
22
+
23
+ class RenderRank(Qwen3VLForConditionalGeneration):
24
+ """Keep the checkpoint architecture; expose the official process API."""
25
+
26
+ @classmethod
27
+ def from_pretrained(cls, pretrained_model_name_or_path, *args, **kwargs):
28
+ loaded = super().from_pretrained(pretrained_model_name_or_path, *args, **kwargs)
29
+ model = loaded[0] if isinstance(loaded, tuple) else loaded
30
+ asset_kwargs = {
31
+ key: kwargs[key] for key in
32
+ ("cache_dir", "revision", "token", "local_files_only", "subfolder", "force_download")
33
+ if key in kwargs
34
+ }
35
+ model.processor = AutoProcessor.from_pretrained(
36
+ pretrained_model_name_or_path, padding_side="left", **asset_kwargs
37
+ )
38
+ model._render_font_path = cached_file(
39
+ pretrained_model_name_or_path, "Roboto-Regular.ttf", **asset_kwargs
40
+ )
41
+ return loaded
42
+
43
+ def process(self, inputs):
44
+ """Return scores in document order; render is only an input conversion."""
45
+ if not isinstance(inputs, dict):
46
+ raise TypeError("inputs must be a dictionary")
47
+ renderer = DocumentImageRenderer(DocumentImageConfig(Path(self._render_font_path)))
48
+ prepared = prepare_render_inputs(inputs, renderer)
49
+
50
+ # Bind the official wrapper to the already-loaded model (no second load).
51
+ scorer = _QwenScorer.__new__(_QwenScorer)
52
+ scorer.model = self.model
53
+ scorer.processor = self.processor
54
+ scorer.device = self.device
55
+ scorer.max_length = 32768
56
+ scorer.min_pixels = self.processor.image_processor.size["shortest_edge"]
57
+ scorer.max_pixels = self.processor.image_processor.size["longest_edge"]
58
+ scorer.total_pixels = 10 * 768 * 32 * 32
59
+ scorer.fps = 1
60
+ scorer.num_frames = 64
61
+ scorer.max_frames = 64
62
+ scorer.default_instruction = "Retrieve text relevant to the user's query."
63
+ vocab = self.processor.tokenizer.get_vocab()
64
+ scorer.score_linear = scorer.get_binary_linear(self, vocab["yes"], vocab["no"])
65
+ scorer.score_linear = scorer.score_linear.to(device=self.device, dtype=self.dtype).eval()
66
+ return scorer.process(prepared)
modules.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "idx": 0,
4
+ "name": "0",
5
+ "path": "",
6
+ "type": "sentence_transformers.base.modules.transformer.Transformer"
7
+ },
8
+ {
9
+ "idx": 1,
10
+ "name": "1",
11
+ "path": "1_LogitScore",
12
+ "type": "sentence_transformers.cross_encoder.modules.logit_score.LogitScore"
13
+ }
14
+ ]
processor_config.json ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "image_processor": {
3
+ "do_convert_rgb": true,
4
+ "do_normalize": true,
5
+ "do_rescale": true,
6
+ "do_resize": true,
7
+ "image_mean": [
8
+ 0.5,
9
+ 0.5,
10
+ 0.5
11
+ ],
12
+ "image_processor_type": "Qwen2VLImageProcessor",
13
+ "image_std": [
14
+ 0.5,
15
+ 0.5,
16
+ 0.5
17
+ ],
18
+ "merge_size": 2,
19
+ "patch_size": 16,
20
+ "resample": 3,
21
+ "rescale_factor": 0.00392156862745098,
22
+ "size": {
23
+ "longest_edge": 802816,
24
+ "shortest_edge": 4096
25
+ },
26
+ "temporal_patch_size": 2
27
+ },
28
+ "processor_class": "Qwen3VLProcessor",
29
+ "video_processor": {
30
+ "data_format": "channels_first",
31
+ "default_to_square": true,
32
+ "do_convert_rgb": true,
33
+ "do_normalize": true,
34
+ "do_rescale": true,
35
+ "do_resize": true,
36
+ "do_sample_frames": true,
37
+ "fps": 2,
38
+ "image_mean": [
39
+ 0.5,
40
+ 0.5,
41
+ 0.5
42
+ ],
43
+ "image_std": [
44
+ 0.5,
45
+ 0.5,
46
+ 0.5
47
+ ],
48
+ "max_frames": 768,
49
+ "merge_size": 2,
50
+ "min_frames": 4,
51
+ "patch_size": 16,
52
+ "resample": 3,
53
+ "rescale_factor": 0.00392156862745098,
54
+ "return_metadata": false,
55
+ "size": {
56
+ "longest_edge": 25165824,
57
+ "shortest_edge": 4096
58
+ },
59
+ "temporal_patch_size": 2,
60
+ "video_processor_type": "Qwen3VLVideoProcessor"
61
+ }
62
+ }
qwen3_vl_reranker.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Based on Qwen/Qwen3-VL-Reranker-2B, Hugging Face revision 4bd860a.
2
+ # Apache-2.0. Changes: ordered multi-image input and frame-helper call repair.
3
+ import torch
4
+ import numpy as np
5
+ import logging
6
+
7
+ from PIL import Image
8
+ from scipy import special
9
+ from typing import List
10
+ from qwen_vl_utils import process_vision_info
11
+ from transformers import Qwen3VLForConditionalGeneration, AutoProcessor
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ MAX_LENGTH = 8192
16
+ IMAGE_BASE_FACTOR = 16
17
+ IMAGE_FACTOR = IMAGE_BASE_FACTOR * 2
18
+ MIN_PIXELS = 4 * IMAGE_FACTOR * IMAGE_FACTOR # 4 tokens
19
+ MAX_PIXELS = 1280 * IMAGE_FACTOR * IMAGE_FACTOR # 1280 tokens
20
+ MAX_RATIO = 200
21
+
22
+ FRAME_FACTOR = 2
23
+ FPS = 1
24
+ MIN_FRAMES = 2
25
+ MAX_FRAMES = 64
26
+ MIN_TOTAL_PIXELS = 1 * FRAME_FACTOR * MIN_PIXELS # 1 frames
27
+ MAX_TOTAL_PIXELS = 4 * FRAME_FACTOR * MAX_PIXELS # 4 frames
28
+
29
+
30
+ def sample_frames(frames, num_segments, max_segments):
31
+ duration = len(frames)
32
+ frame_id_array = np.linspace(0, duration - 1, num_segments, dtype=int)
33
+ frame_id_list = frame_id_array.tolist()
34
+ last_frame_id = frame_id_list[-1]
35
+
36
+ sampled_frames = []
37
+ for frame_idx in frame_id_list:
38
+ try:
39
+ single_frame_path = frames[frame_idx]
40
+ except:
41
+ break
42
+ sampled_frames.append(single_frame_path)
43
+ # Pad with last frame if total frames less than num_segments
44
+ while len(sampled_frames) < num_segments:
45
+ sampled_frames.append(frames[last_frame_id])
46
+ return sampled_frames[:max_segments]
47
+
48
+ class Qwen3VLReranker():
49
+ def __init__(
50
+ self,
51
+ model_name_or_path: str,
52
+ max_length: int = MAX_LENGTH,
53
+ min_pixels: int = MIN_PIXELS,
54
+ max_pixels: int = MAX_PIXELS,
55
+ total_pixels: int = MAX_TOTAL_PIXELS,
56
+ fps: float = FPS,
57
+ num_frames: int = MAX_FRAMES,
58
+ max_frames: int = MAX_FRAMES,
59
+ default_instruction: str = "Given a search query, retrieve relevant candidates that answer the query.",
60
+ **kwargs,
61
+ ):
62
+
63
+ self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
64
+
65
+ self.max_length = max_length
66
+ self.min_pixels = min_pixels
67
+ self.max_pixels = max_pixels
68
+ self.total_pixels = total_pixels
69
+ self.fps = fps
70
+ self.num_frames = num_frames
71
+ self.max_frames = max_frames
72
+
73
+ self.default_instruction = default_instruction
74
+
75
+ lm = Qwen3VLForConditionalGeneration.from_pretrained(
76
+ model_name_or_path,
77
+ trust_remote_code=True, **kwargs
78
+ ).to(self.device)
79
+
80
+ self.model = lm.model
81
+ self.processor = AutoProcessor.from_pretrained(
82
+ model_name_or_path, trust_remote_code=True,
83
+ padding_side='left'
84
+ )
85
+ self.model.eval()
86
+
87
+ token_true_id = self.processor.tokenizer.get_vocab()["yes"]
88
+ token_false_id = self.processor.tokenizer.get_vocab()["no"]
89
+ self.score_linear = self.get_binary_linear(lm, token_true_id, token_false_id)
90
+ self.score_linear.eval()
91
+ self.score_linear.to(self.device).to(self.model.dtype)
92
+
93
+ def get_binary_linear(self, model, token_yes, token_no):
94
+
95
+ lm_head_weights = model.lm_head.weight.data
96
+
97
+ weight_yes = lm_head_weights[token_yes]
98
+ weight_no = lm_head_weights[token_no]
99
+
100
+ D = weight_yes.size()[0]
101
+ linear_layer = torch.nn.Linear(D, 1, bias=False)
102
+ with torch.no_grad():
103
+ linear_layer.weight[0] = weight_yes - weight_no
104
+ return linear_layer
105
+
106
+ @torch.no_grad()
107
+ def compute_scores(self, inputs):
108
+ batch_scores = self.model(**inputs).last_hidden_state[:, -1]
109
+ scores = self.score_linear(batch_scores)
110
+ scores = torch.sigmoid(scores).squeeze(-1).cpu().detach().tolist()
111
+ return scores
112
+
113
+ def truncate_tokens_optimized(
114
+ self,
115
+ tokens: List[str],
116
+ max_length: int,
117
+ special_tokens: List[str]
118
+ ) -> List[str]:
119
+ if len(tokens) <= max_length:
120
+ return tokens
121
+
122
+ special_tokens_set = set(special_tokens)
123
+
124
+ # Calculate budget: how many non-special tokens we can keep
125
+ num_special = sum(1 for token in tokens if token in special_tokens_set)
126
+ num_non_special_to_keep = max_length - num_special
127
+
128
+ # Build final list according to budget
129
+ final_tokens = []
130
+ non_special_kept_count = 0
131
+ for token in tokens:
132
+ if token in special_tokens_set:
133
+ final_tokens.append(token)
134
+ elif non_special_kept_count < num_non_special_to_keep:
135
+ final_tokens.append(token)
136
+ non_special_kept_count += 1
137
+
138
+ return final_tokens
139
+
140
+ def tokenize(self, pairs: list, **kwargs):
141
+ max_length = self.max_length
142
+ text = self.processor.apply_chat_template(pairs, tokenize=False, add_generation_prompt=True)
143
+ try:
144
+ images, videos, video_kwargs = process_vision_info(
145
+ pairs, image_patch_size=16,
146
+ return_video_kwargs=True,
147
+ return_video_metadata=True
148
+ )
149
+ except Exception as e:
150
+ logger.error(f"Error in processing vision info: {e}")
151
+ images = None
152
+ videos = None
153
+ video_kwargs = {'do_sample_frames': False}
154
+ text = self.processor.apply_chat_template(
155
+ [{'role': 'user', 'content': [{'type': 'text', 'text': 'NULL'}]}],
156
+ add_generation_prompt=True, tokenize=False
157
+ )
158
+
159
+ if videos is not None:
160
+ videos, video_metadatas = zip(*videos)
161
+ videos, video_metadatas = list(videos), list(video_metadatas)
162
+ else:
163
+ video_metadatas = None
164
+ inputs = self.processor(
165
+ text=text,
166
+ images=images,
167
+ videos=videos,
168
+ video_metadata=video_metadatas,
169
+ truncation=False,
170
+ padding=False,
171
+ do_resize=False,
172
+ **video_kwargs
173
+ )
174
+ for i, ele in enumerate(inputs['input_ids']):
175
+ inputs['input_ids'][i] = self.truncate_tokens_optimized(
176
+ inputs['input_ids'][i][:-5], max_length,
177
+ self.processor.tokenizer.all_special_ids
178
+ ) + inputs['input_ids'][i][-5:]
179
+ temp_inputs = self.processor.tokenizer.pad(
180
+ {'input_ids': inputs['input_ids']}, padding=True,
181
+ return_tensors="pt", max_length=self.max_length
182
+ )
183
+ for key in temp_inputs:
184
+ inputs[key] = temp_inputs[key]
185
+ return inputs
186
+
187
+ def format_mm_content(
188
+ self,
189
+ text, image, video,
190
+ prefix='Query:',
191
+ fps=None, max_frames=None,
192
+ ):
193
+ content = []
194
+
195
+ content.append({'type': 'text', 'text': prefix})
196
+ if not text and not image and not video:
197
+ content.append({'type': 'text', 'text': "NULL"})
198
+ return content
199
+
200
+ if video:
201
+ video_content = None
202
+ video_kwargs = { 'total_pixels': self.total_pixels }
203
+ if isinstance(video, list):
204
+ video_content = video
205
+ if self.num_frames is not None or self.max_frames is not None:
206
+ video_content = sample_frames(video_content, self.num_frames or len(video_content), self.max_frames or len(video_content))
207
+ video_content = [
208
+ ('file://' + ele if isinstance(ele, str) else ele)
209
+ for ele in video_content
210
+ ]
211
+ elif isinstance(video, str):
212
+ video_content = video if video.startswith(('http://', 'https://')) else 'file://' + video
213
+ video_kwargs = {'fps': fps or self.fps, 'max_frames': max_frames or self.max_frames,}
214
+ else:
215
+ raise TypeError(f"Unrecognized video type: {type(video)}")
216
+
217
+ if video_content:
218
+ content.append({
219
+ 'type': 'video', 'video': video_content,
220
+ **video_kwargs
221
+ })
222
+
223
+ # RenderRank: one document may contain multiple ordered page images.
224
+ for image in (image if isinstance(image, list) else [image] if image is not None else []):
225
+ image_content = None
226
+ if isinstance(image, Image.Image):
227
+ image_content = image
228
+ elif isinstance(image, str):
229
+ image_content = image if image.startswith(('http', 'oss')) else 'file://' + image
230
+ else:
231
+ raise TypeError(f"Unrecognized image type: {type(image)}")
232
+
233
+ if image_content:
234
+ content.append({
235
+ 'type': 'image', 'image': image_content,
236
+ "min_pixels": self.min_pixels,
237
+ "max_pixels": self.max_pixels
238
+ })
239
+
240
+ if text:
241
+ content.append({'type': 'text', 'text': text})
242
+ return content
243
+
244
+ def format_mm_instruction(
245
+ self,
246
+ query_text, query_image, query_video,
247
+ doc_text, doc_image, doc_video,
248
+ instruction=None,
249
+ fps=None, max_frames=None
250
+ ):
251
+ inputs = []
252
+ inputs.append({
253
+ "role": "system",
254
+ "content": [{
255
+ "type": "text",
256
+ "text": "Judge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be \"yes\" or \"no\"."
257
+ }
258
+ ]
259
+ })
260
+ if isinstance(query_text, tuple):
261
+ instruct, query_text = query_text
262
+ else:
263
+ instruct = instruction
264
+ contents = []
265
+ contents.append({
266
+ "type": "text",
267
+ "text": '<Instruct>: ' + instruct
268
+ })
269
+ query_content = self.format_mm_content(
270
+ query_text, query_image, query_video, prefix='<Query>:',
271
+ fps=fps, max_frames=max_frames
272
+ )
273
+ contents.extend(query_content)
274
+ doc_content = self.format_mm_content(
275
+ doc_text, doc_image, doc_video, prefix='\n<Document>:',
276
+ fps=fps, max_frames=max_frames
277
+ )
278
+ contents.extend(doc_content)
279
+ inputs.append({
280
+ "role": "user",
281
+ "content": contents
282
+ })
283
+ return inputs
284
+
285
+ def process(
286
+ self,
287
+ inputs,
288
+ ) -> list[torch.Tensor]:
289
+ instruction = inputs.get('instruction', self.default_instruction)
290
+
291
+ query = inputs.get("query", {})
292
+ documents = inputs.get("documents", [])
293
+ if not query or not documents:
294
+ return []
295
+
296
+ pairs = [self.format_mm_instruction(
297
+ query.get('text', None),
298
+ query.get('image', None),
299
+ query.get('video', None),
300
+ document.get('text', None),
301
+ document.get('image', None),
302
+ document.get('video', None),
303
+ instruction=instruction,
304
+ fps=inputs.get('fps', self.fps),
305
+ max_frames=inputs.get('max_frames', self.max_frames)
306
+ ) for document in documents]
307
+
308
+ final_scores = []
309
+ for pair in pairs:
310
+ inputs = self.tokenize([pair])
311
+ inputs = inputs.to(self.model.device)
312
+ scores = self.compute_scores(inputs)
313
+ final_scores.extend(scores)
314
+ return final_scores
rendering.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Existing evaluation renderer, packaged without evaluation/cache dependencies."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, Sequence
8
+
9
+ from PIL import Image, ImageDraw, ImageFont
10
+
11
+ @dataclass(frozen=True)
12
+ class DocumentImageConfig:
13
+ """Exact document rendering parameters used by the vision evaluation."""
14
+
15
+ font_path: Path
16
+ font_index: int = 0
17
+ font_family: str = "Roboto"
18
+ font_style: str = "Regular"
19
+ font_size_px: int = 16
20
+ dpi: int = 96
21
+ line_height_px: int | None = None
22
+ width_px: int = 896
23
+ min_height_px: int = 32
24
+ height_step_px: int = 32
25
+ max_height_px: int = 896
26
+ max_lines_per_page: int | None = None
27
+
28
+ def __post_init__(self) -> None:
29
+ object.__setattr__(self, "font_path", Path(self.font_path))
30
+ line_height_px = self.font_size_px if self.line_height_px is None else self.line_height_px
31
+ object.__setattr__(self, "line_height_px", line_height_px)
32
+ max_lines_per_page = (
33
+ min(56, self.max_height_px // line_height_px)
34
+ if self.max_lines_per_page is None
35
+ else self.max_lines_per_page
36
+ )
37
+ object.__setattr__(self, "max_lines_per_page", max_lines_per_page)
38
+ numeric_values = {
39
+ "font_index": self.font_index,
40
+ "font_size_px": self.font_size_px,
41
+ "dpi": self.dpi,
42
+ "line_height_px": line_height_px,
43
+ "width_px": self.width_px,
44
+ "min_height_px": self.min_height_px,
45
+ "height_step_px": self.height_step_px,
46
+ "max_height_px": self.max_height_px,
47
+ "max_lines_per_page": max_lines_per_page,
48
+ }
49
+ for name, value in numeric_values.items():
50
+ if name == "font_index" and value < 0:
51
+ raise ValueError("font_index cannot be negative")
52
+ if name != "font_index" and value < 1:
53
+ raise ValueError(f"{name} must be positive")
54
+ if self.min_height_px > self.max_height_px:
55
+ raise ValueError("min_height_px cannot exceed max_height_px")
56
+ if self.min_height_px % self.height_step_px or self.max_height_px % self.height_step_px:
57
+ raise ValueError("page heights must be exact multiples of height_step_px")
58
+ if line_height_px * max_lines_per_page > self.max_height_px:
59
+ raise ValueError("max_lines_per_page does not fit in max_height_px")
60
+
61
+ def to_dict(self) -> dict[str, Any]:
62
+ rendering = {
63
+ "font": f"{self.font_family} {self.font_style}",
64
+ "font_path": str(self.font_path),
65
+ "font_size_px": self.font_size_px,
66
+ "dpi": self.dpi,
67
+ "line_height_px": self.line_height_px,
68
+ "width_px": self.width_px,
69
+ "min_height_px": self.min_height_px,
70
+ "height_step_px": self.height_step_px,
71
+ "max_height_px": self.max_height_px,
72
+ "max_lines_per_page": self.max_lines_per_page,
73
+ "margins_px": 0,
74
+ "background": "white",
75
+ "foreground": "black",
76
+ "whitespace_normalization": "all_runs_to_single_space",
77
+ "wrap_measurement": (
78
+ "roboto_pixel_width"
79
+ if self.font_family.casefold() == "roboto"
80
+ else "font_pixel_width"
81
+ ),
82
+ "overwide_word_split": "character",
83
+ "page_overlap": 0,
84
+ }
85
+ if self.font_index:
86
+ rendering["font_index"] = self.font_index
87
+ return rendering
88
+
89
+
90
+ class DocumentImageRenderer:
91
+ """Normalize, pixel-wrap, paginate, and render document text."""
92
+
93
+ def __init__(self, config: DocumentImageConfig) -> None:
94
+ self.config = config
95
+ if not config.font_path.is_file():
96
+ raise FileNotFoundError(f"font not found: {config.font_path}")
97
+ self.font = ImageFont.truetype(
98
+ str(config.font_path),
99
+ config.font_size_px,
100
+ index=config.font_index,
101
+ )
102
+ family, style = self.font.getname()
103
+ if (
104
+ family.casefold() != config.font_family.casefold()
105
+ or style.casefold() != config.font_style.casefold()
106
+ ):
107
+ raise ValueError(
108
+ f"{config.font_family} {config.font_style} is required, "
109
+ f"but {config.font_path} reports {family} {style}"
110
+ )
111
+
112
+ @staticmethod
113
+ def normalize_text(text: str) -> str:
114
+ return " ".join(str(text).split())
115
+
116
+ def text_length(self, text: str) -> float:
117
+ return self.font.getlength(text)
118
+
119
+ def draw_line(self, draw: ImageDraw.ImageDraw, line: str, y: int) -> None:
120
+ draw.text(
121
+ (0, y),
122
+ line,
123
+ font=self.font,
124
+ fill="black",
125
+ anchor="lt",
126
+ )
127
+
128
+ def rendering_metadata(self) -> dict[str, Any]:
129
+ return self.config.to_dict()
130
+
131
+ def _split_overwide_word(self, word: str, max_width: int) -> list[str]:
132
+ chunks: list[str] = []
133
+ offset = 0
134
+ while offset < len(word):
135
+ low, high = offset + 1, len(word)
136
+ best = offset
137
+ while low <= high:
138
+ middle = (low + high) // 2
139
+ if self.text_length(word[offset:middle]) <= max_width:
140
+ best = middle
141
+ low = middle + 1
142
+ else:
143
+ high = middle - 1
144
+ if best == offset:
145
+ raise ValueError(
146
+ f"width {max_width}px is narrower than one rendered character"
147
+ )
148
+ chunks.append(word[offset:best])
149
+ offset = best
150
+ return chunks
151
+
152
+ def wrap_text(self, text: str, *, max_width: int | None = None) -> list[str]:
153
+ max_width = self.config.width_px if max_width is None else max_width
154
+ if max_width < 1:
155
+ raise ValueError("max_width must be positive")
156
+ normalized = self.normalize_text(text)
157
+ if not normalized:
158
+ return []
159
+
160
+ lines: list[str] = []
161
+ current = ""
162
+ for word in normalized.split(" "):
163
+ if self.text_length(word) > max_width:
164
+ if current:
165
+ lines.append(current)
166
+ current = ""
167
+ chunks = self._split_overwide_word(word, max_width)
168
+ lines.extend(chunks[:-1])
169
+ current = chunks[-1]
170
+ continue
171
+
172
+ candidate = word if not current else f"{current} {word}"
173
+ if self.text_length(candidate) <= max_width:
174
+ current = candidate
175
+ else:
176
+ lines.append(current)
177
+ current = word
178
+ if current:
179
+ lines.append(current)
180
+ return lines
181
+
182
+ def paginate_lines(self, lines: Sequence[str]) -> list[list[str]]:
183
+ if not lines:
184
+ return [[]]
185
+ page_size = self.config.max_lines_per_page
186
+ return [list(lines[start : start + page_size]) for start in range(0, len(lines), page_size)]
187
+
188
+ def page_height(self, line_count: int) -> int:
189
+ if line_count < 0 or line_count > self.config.max_lines_per_page:
190
+ raise ValueError(
191
+ f"line_count must be between 0 and {self.config.max_lines_per_page}"
192
+ )
193
+ content_height = max(1, line_count) * self.config.line_height_px
194
+ stepped_height = math.ceil(content_height / self.config.height_step_px) * self.config.height_step_px
195
+ return min(
196
+ self.config.max_height_px,
197
+ max(self.config.min_height_px, stepped_height),
198
+ )
199
+
200
+ def render_document(self, text: str) -> list[Image.Image]:
201
+ lines = self.wrap_text(text)
202
+ rendered: list[Image.Image] = []
203
+ for page_lines in self.paginate_lines(lines):
204
+ image = Image.new(
205
+ "RGB",
206
+ (self.config.width_px, self.page_height(len(page_lines))),
207
+ "white",
208
+ )
209
+ draw = ImageDraw.Draw(image)
210
+ for line_index, line in enumerate(page_lines):
211
+ self.draw_line(draw, line, line_index * self.config.line_height_px)
212
+ rendered.append(image)
213
+ return rendered
214
+
215
+
216
+
217
+ def prepare_render_inputs(inputs: dict, renderer: DocumentImageRenderer) -> dict:
218
+ """Convert render documents to images; leave query/instruction/scoring untouched."""
219
+ if not isinstance(inputs, dict):
220
+ raise TypeError("inputs must be a dictionary")
221
+ documents = inputs.get("documents", [])
222
+ if not isinstance(documents, list):
223
+ raise TypeError("documents must be a list")
224
+ converted = []
225
+ for document in documents:
226
+ if not isinstance(document, dict):
227
+ raise TypeError("each document must be a dictionary")
228
+ if "render" not in document:
229
+ converted.append(document)
230
+ continue
231
+ if any(key in document for key in ("text", "image", "video")):
232
+ raise ValueError("render cannot be combined with text, image, or video")
233
+ if not isinstance(document["render"], str):
234
+ raise TypeError("render must contain document text as a string")
235
+ converted.append({
236
+ **{key: value for key, value in document.items() if key != "render"},
237
+ "image": renderer.render_document(document["render"]),
238
+ })
239
+ return {**inputs, "documents": converted}
sentence_bert_config.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "transformer_task": "any-to-any",
3
+ "modality_config": {
4
+ "text": {
5
+ "method": "forward",
6
+ "method_output_name": "logits"
7
+ },
8
+ "image": {
9
+ "method": "forward",
10
+ "method_output_name": "logits"
11
+ },
12
+ "video": {
13
+ "method": "forward",
14
+ "method_output_name": "logits"
15
+ },
16
+ "message": {
17
+ "method": "forward",
18
+ "method_output_name": "logits",
19
+ "format": "structured"
20
+ }
21
+ },
22
+ "module_output_name": "causal_logits",
23
+ "processing_kwargs": {
24
+ "chat_template": {
25
+ "chat_template": "reranker",
26
+ "add_generation_prompt": true
27
+ }
28
+ },
29
+ "unpad_inputs": false
30
+ }
tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:be75606093db2094d7cd20f3c2f385c212750648bd6ea4fb2bf507a6a4c55506
3
+ size 11422650
tokenizer_config.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_prefix_space": false,
3
+ "backend": "tokenizers",
4
+ "bos_token": null,
5
+ "clean_up_tokenization_spaces": false,
6
+ "eos_token": "<|im_end|>",
7
+ "errors": "replace",
8
+ "is_local": true,
9
+ "local_files_only": false,
10
+ "max_pixels": 802816,
11
+ "min_pixels": 4096,
12
+ "model_max_length": 262144,
13
+ "pad_token": "<|endoftext|>",
14
+ "processor_class": "Qwen3VLProcessor",
15
+ "split_special_tokens": false,
16
+ "tokenizer_class": "Qwen2Tokenizer",
17
+ "unk_token": null
18
+ }