Skip to content

Commit bc20026

Browse files
committed
fix(embedding): bound vectorizer text input
1 parent 4b9d17d commit bc20026

2 files changed

Lines changed: 281 additions & 3 deletions

File tree

openviking/storage/vectordb/vectorize/vectorizer.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
22
# SPDX-License-Identifier: AGPL-3.0
3+
import logging
34
from typing import Any, Dict, List, Optional, Tuple, TypedDict
45

6+
logger = logging.getLogger(__name__)
7+
58

69
class DenseMeta(TypedDict, total=False):
710
ModelName: str
@@ -22,6 +25,45 @@ class VectorizeMeta(TypedDict, total=False):
2225
Sparse: SparseMeta
2326

2427

28+
def _safe_vectorizer_config(vectorizer: Any) -> Optional[Dict[str, Any]]:
29+
try:
30+
config = getattr(vectorizer, "config", None)
31+
except AttributeError:
32+
return None
33+
except Exception as exc:
34+
raise RuntimeError("failed to read vectorizer config") from exc
35+
if config is None:
36+
return None
37+
return config if isinstance(config, dict) else None
38+
39+
40+
def _resolve_runtime_max_input_tokens(config: Optional[Dict[str, Any]]) -> Optional[int]:
41+
if config is None:
42+
return None
43+
raw_value = config.get("max_input_tokens")
44+
if raw_value is None or isinstance(raw_value, bool):
45+
if isinstance(raw_value, bool):
46+
logger.warning("Invalid vectorizer max_input_tokens=%r; limit disabled", raw_value)
47+
return None
48+
if isinstance(raw_value, int):
49+
max_input_tokens = raw_value
50+
elif isinstance(raw_value, str) and raw_value.strip().isdigit():
51+
max_input_tokens = int(raw_value)
52+
else:
53+
logger.warning("Invalid vectorizer max_input_tokens=%r; limit disabled", raw_value)
54+
return None
55+
return max_input_tokens if max_input_tokens > 0 else None
56+
57+
58+
def _truncate_provider_text(text: str, max_input_tokens: int) -> str:
59+
try:
60+
from openviking.utils.embedding_input import truncate_embedding_input
61+
except ImportError as exc:
62+
raise RuntimeError("embedding input truncation is unavailable") from exc
63+
64+
return truncate_embedding_input(text, max_input_tokens)
65+
66+
2567
class VectorizerAdapter:
2668
"""Adapter for vectorizer to handle data vectorization.
2769
@@ -42,6 +84,9 @@ def __init__(self, vectorizer: Any, vectorize_meta: VectorizeMeta):
4284
self.image_field = dense_meta.get("ImageField", "")
4385
self.video_field = dense_meta.get("VideoField", "")
4486
self.vectorizer = vectorizer
87+
self.max_input_tokens = _resolve_runtime_max_input_tokens(
88+
_safe_vectorizer_config(vectorizer)
89+
)
4590
sparse_meta = vectorize_meta.get("Sparse", {})
4691
self.dense_model = {
4792
"name": dense_meta.get("ModelName", ""),
@@ -59,6 +104,14 @@ def __init__(self, vectorizer: Any, vectorize_meta: VectorizeMeta):
59104
)
60105
self.dim = self.vectorizer.get_dense_vector_dim(self.dense_model, self.sparse_model)
61106

107+
def _prepare_text(self, text: Any) -> Any:
108+
if self.max_input_tokens is None or not isinstance(text, str):
109+
return text
110+
# The vectorizer provider request has one text slot shared by dense and
111+
# sparse generation. Keep stored raw fields untouched, but bound the
112+
# provider-facing text so one oversized file cannot fail the whole write.
113+
return _truncate_provider_text(text, self.max_input_tokens)
114+
62115
def get_dim(self) -> int:
63116
"""Get the dimension of the dense vector.
64117
@@ -84,7 +137,7 @@ def vectorize_raw_data(
84137
for raw_data in raw_data_list:
85138
data = {}
86139
if self.text_field in raw_data:
87-
data["text"] = raw_data[self.text_field]
140+
data["text"] = self._prepare_text(raw_data[self.text_field])
88141
if self.image_field in raw_data:
89142
data["image"] = raw_data[self.image_field]
90143
if self.video_field in raw_data:
@@ -110,7 +163,7 @@ def vectorize_one(
110163
"""
111164
data = {}
112165
if text:
113-
data["text"] = text
166+
data["text"] = self._prepare_text(text)
114167
if image:
115168
data["image"] = image
116169
if video:

tests/unit/test_vectorize_file_strategy.py

Lines changed: 226 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
import builtins
2+
import logging
13
import types
24

35
import pytest
46

57
from openviking.core.context import Context
6-
from openviking.utils import embedding_utils
8+
from openviking.storage.vectordb.vectorize.base import VectorizeResult
9+
from openviking.storage.vectordb.vectorize.vectorizer import VectorizerAdapter
10+
from openviking.utils import embedding_input, embedding_utils
11+
from openviking.utils.embedding_input import EMBEDDING_TRUNCATION_SUFFIX
712

813

914
class DummyQueue:
@@ -64,6 +69,226 @@ def __init__(self):
6469
self.account_id = "default"
6570

6671

72+
class DummyVectorizer:
73+
config = {"max_input_tokens": 20}
74+
75+
def __init__(self):
76+
self.config = type(self).config
77+
self.data = None
78+
79+
def get_dense_vector_dim(self, _dense_model, _sparse_model):
80+
return 1
81+
82+
def vectorize_document(self, data, _dense_model, _sparse_model):
83+
self.data = data
84+
return VectorizeResult(dense_vectors=[[1.0] for _ in data])
85+
86+
87+
class DummyVectorizerWithoutLimit(DummyVectorizer):
88+
config = {}
89+
90+
91+
class DummyVectorizerDisabledLimit(DummyVectorizer):
92+
config = {"max_input_tokens": 0}
93+
94+
95+
class DummyVectorizerMalformedConfig(DummyVectorizer):
96+
config = []
97+
98+
99+
class DummyVectorizerMalformedLimit(DummyVectorizer):
100+
config = {"max_input_tokens": object()}
101+
102+
103+
class DummyVectorizerStringLimit(DummyVectorizer):
104+
config = {"max_input_tokens": "20"}
105+
106+
107+
class DummyVectorizerBoolLimit(DummyVectorizer):
108+
config = {"max_input_tokens": True}
109+
110+
111+
class DummyVectorizerFloatLimit(DummyVectorizer):
112+
config = {"max_input_tokens": 20.5}
113+
114+
115+
class DummyVectorizerRaisingConfig(DummyVectorizer):
116+
@property
117+
def config(self):
118+
raise RuntimeError("config unavailable")
119+
120+
def __init__(self):
121+
self.data = None
122+
123+
124+
def test_vectorizer_adapter_truncates_provider_text_without_mutating_raw_data():
125+
vectorizer = DummyVectorizer()
126+
adapter = VectorizerAdapter(
127+
vectorizer,
128+
{"Dense": {"TextField": "content", "ModelName": "dummy"}},
129+
)
130+
raw_text = "oversized memory content " * 80
131+
raw_data = [{"content": raw_text, "uri": "viking://user/default/resources/big.md"}]
132+
133+
dense, sparse = adapter.vectorize_raw_data(raw_data)
134+
135+
assert dense == [[1.0]]
136+
assert sparse == []
137+
provider_text = vectorizer.data[0]["text"]
138+
assert provider_text.endswith(EMBEDDING_TRUNCATION_SUFFIX)
139+
assert len(provider_text) < len(raw_text)
140+
assert raw_data[0]["content"] == raw_text
141+
142+
143+
def test_vectorizer_adapter_accepts_integer_string_limit():
144+
vectorizer = DummyVectorizerStringLimit()
145+
adapter = VectorizerAdapter(
146+
vectorizer,
147+
{"Dense": {"TextField": "content", "ModelName": "dummy"}},
148+
)
149+
raw_text = "oversized memory content " * 80
150+
151+
adapter.vectorize_raw_data([{"content": raw_text}])
152+
153+
assert vectorizer.data[0]["text"].endswith(EMBEDDING_TRUNCATION_SUFFIX)
154+
155+
156+
def test_vectorizer_adapter_import_failure_fails_closed(monkeypatch):
157+
real_import = builtins.__import__
158+
159+
def fail_embedding_input_import(name, *args, **kwargs):
160+
if name == "openviking.utils.embedding_input":
161+
raise ImportError("import failed")
162+
return real_import(name, *args, **kwargs)
163+
164+
monkeypatch.setattr(builtins, "__import__", fail_embedding_input_import)
165+
vectorizer = DummyVectorizer()
166+
adapter = VectorizerAdapter(
167+
vectorizer,
168+
{"Dense": {"TextField": "content", "ModelName": "dummy"}},
169+
)
170+
raw_text = "oversized memory content " * 80
171+
172+
with pytest.raises(RuntimeError, match="truncation is unavailable"):
173+
adapter.vectorize_raw_data([{"content": raw_text}])
174+
175+
176+
def test_vectorizer_adapter_truncation_failure_fails_closed(monkeypatch):
177+
def fail_truncation(_text, _max_input_tokens):
178+
raise RuntimeError("truncate failed")
179+
180+
monkeypatch.setattr(embedding_input, "truncate_embedding_input", fail_truncation)
181+
vectorizer = DummyVectorizer()
182+
adapter = VectorizerAdapter(
183+
vectorizer,
184+
{"Dense": {"TextField": "content", "ModelName": "dummy"}},
185+
)
186+
raw_text = "oversized memory content " * 80
187+
188+
with pytest.raises(RuntimeError, match="truncate failed"):
189+
adapter.vectorize_raw_data([{"content": raw_text}])
190+
191+
192+
def test_vectorizer_adapter_raising_config_fails_closed():
193+
with pytest.raises(RuntimeError, match="failed to read vectorizer config"):
194+
VectorizerAdapter(
195+
DummyVectorizerRaisingConfig(),
196+
{"Dense": {"TextField": "content", "ModelName": "dummy"}},
197+
)
198+
199+
200+
def test_vectorizer_adapter_preserves_media_fields_when_truncating_text():
201+
vectorizer = DummyVectorizer()
202+
adapter = VectorizerAdapter(
203+
vectorizer,
204+
{
205+
"Dense": {
206+
"TextField": "content",
207+
"ImageField": "image",
208+
"VideoField": "video",
209+
"ModelName": "dummy",
210+
}
211+
},
212+
)
213+
raw_text = "oversized memory content " * 80
214+
image = {"uri": "data:image/png;base64,aaa"}
215+
video = {"uri": "data:video/mp4;base64,bbb"}
216+
217+
adapter.vectorize_raw_data([{"content": raw_text, "image": image, "video": video}])
218+
219+
provider_data = vectorizer.data[0]
220+
assert provider_data["text"].endswith(EMBEDDING_TRUNCATION_SUFFIX)
221+
assert provider_data["image"] is image
222+
assert provider_data["video"] is video
223+
224+
225+
def test_vectorizer_adapter_vectorize_one_truncates_text_and_preserves_media():
226+
vectorizer = DummyVectorizer()
227+
adapter = VectorizerAdapter(
228+
vectorizer,
229+
{"Dense": {"TextField": "content", "ModelName": "dummy"}},
230+
)
231+
raw_text = "oversized memory content " * 80
232+
image = {"uri": "data:image/png;base64,aaa"}
233+
video = {"uri": "data:video/mp4;base64,bbb"}
234+
235+
adapter.vectorize_one(text=raw_text, image=image, video=video)
236+
237+
provider_data = vectorizer.data[0]
238+
assert provider_data["text"].endswith(EMBEDDING_TRUNCATION_SUFFIX)
239+
assert provider_data["image"] is image
240+
assert provider_data["video"] is video
241+
242+
243+
@pytest.mark.parametrize(
244+
"vectorizer_cls",
245+
[
246+
DummyVectorizerWithoutLimit,
247+
DummyVectorizerDisabledLimit,
248+
DummyVectorizerMalformedConfig,
249+
DummyVectorizerMalformedLimit,
250+
DummyVectorizerBoolLimit,
251+
DummyVectorizerFloatLimit,
252+
],
253+
)
254+
def test_vectorizer_adapter_leaves_text_unchanged_without_explicit_limit(vectorizer_cls, caplog):
255+
if vectorizer_cls in {
256+
DummyVectorizerMalformedLimit,
257+
DummyVectorizerBoolLimit,
258+
DummyVectorizerFloatLimit,
259+
}:
260+
caplog.set_level(logging.WARNING)
261+
vectorizer = vectorizer_cls()
262+
adapter = VectorizerAdapter(
263+
vectorizer,
264+
{"Dense": {"TextField": "content", "ModelName": "dummy"}},
265+
)
266+
raw_text = "oversized memory content " * 80
267+
268+
adapter.vectorize_raw_data([{"content": raw_text}])
269+
270+
assert vectorizer.data[0]["text"] == raw_text
271+
if vectorizer_cls in {
272+
DummyVectorizerMalformedLimit,
273+
DummyVectorizerBoolLimit,
274+
DummyVectorizerFloatLimit,
275+
}:
276+
assert "max_input_tokens disabled" in caplog.text
277+
278+
279+
def test_vectorizer_adapter_vectorize_one_leaves_text_unchanged_without_limit():
280+
vectorizer = DummyVectorizerWithoutLimit()
281+
adapter = VectorizerAdapter(
282+
vectorizer,
283+
{"Dense": {"TextField": "content", "ModelName": "dummy"}},
284+
)
285+
raw_text = "oversized memory content " * 80
286+
287+
adapter.vectorize_one(text=raw_text)
288+
289+
assert vectorizer.data[0]["text"] == raw_text
290+
291+
67292
@pytest.mark.asyncio
68293
async def test_vectorize_file_uses_summary_first(monkeypatch):
69294
queue = DummyQueue()

0 commit comments

Comments
 (0)