You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@doris.apache.org by zc...@apache.org on 2026/04/14 09:22:33 UTC
(doris) branch master updated: [Feature](multimodal_embed) Support multimodal(file) embed (#62147)
This is an automated email from the ASF dual-hosted git repository.
zclll pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 443e1acf438 [Feature](multimodal_embed) Support multimodal(file) embed (#62147)
443e1acf438 is described below
commit 443e1acf438fd95400314b8d2cad8edb95a8373a
Author: linrrarity <li...@selectdb.com>
AuthorDate: Tue Apr 14 17:22:26 2026 +0800
[Feature](multimodal_embed) Support multimodal(file) embed (#62147)
Support image/video/audio embedding from json.
Supported multimodal_embed AI provider : Gemini(image, video, audio),
Qwen(image, video), Voyage(image, video), Jina(image)
Supported authentication methods include IAM + EXTERNAL_ID / AK + SK
```text
-- ima role, video
mysql> SELECT ARRAY_SIZE(
-> EMBED(
-> 'qwen_mul_embed',
-> CAST('{
'> "uri": "s3://selectdb-qa-test-3/lzq-multimodal-test/video/45944deac7d96c872f559f2ef94ea0a9.mp4",
'> "content_type": "video/mp4",
'> "provider": "AWS",
'> "endpoint": "s3.us-east-1.amazonaws.com",
'> "region": "us-east-1",
'> "role_arn": "arn:aws:iam::447051187841:role/lzq-role-test",
'> "external_id": "1001"
'> }' AS JSON)
-> )
-> ) AS video_embed_size;
+------------------+
| video_embed_size |
+------------------+
| 2560 |
+------------------+
-- ima role, video
mysql> SELECT ARRAY_SIZE(
-> EMBED(
-> 'qwen_mul_embed',
-> CAST('{
'> "uri": "s3://selectdb-qa-test-3/lzq-multimodal-test/image/ai-agg.png",
'> "content_type": "image/png",
'> "provider": "AWS",
'> "endpoint": "s3.us-east-1.amazonaws.com",
'> "region": "us-east-1",
'> "role_arn": "arn:aws:iam::447051187841:role/lzq-role-test",
'> "external_id": "1001"
'> }' AS JSON)
-> )
-> ) AS img_embed_size;
+----------------+
| img_embed_size |
+----------------+
| 2560 |
+----------------+
-- gemini multimodal embed
mysql> SELECT ARRAY_SIZE(
-> EMBED(
-> 'gemini_mul_embed',
-> CAST('{
'> "uri": "s3://selectdb-qa-test-3/lzq-multimodal-test/image/ai-agg.png",
'> "content_type": "image/png",
'> "provider": "AWS",
'> "endpoint": "s3.us-east-1.amazonaws.com",
'> "region": "us-east-1",
'> "role_arn": "arn:aws:iam::447051187841:role/lzq-role-test",
'> "external_id": "1001"
'> }' AS JSON)
-> )
-> ) AS img_embed_size;
+----------------+
| img_embed_size |
+----------------+
| 3072 |
+----------------+
-- ak, sk
Doris> SELECT ARRAY_SIZE(
-> EMBED(
-> 'qwen_mul_embed',
-> CAST('{
'> "uri": "s3://<bucket>/<key>/test_img.png",
'> "content_type": "image/png",
'> "endpoint": "cos.ap-hongkong.myqcloud.com",
'> "region": "ap-hongkong",
'> "ak": "<ak>",
'> "sk": "<sk>"
'> }' AS JSON)
-> )
-> ) AS img_embed_size_ak_sk;
+----------------------+
| img_embed_size_ak_sk |
+----------------------+
| 2560 |
+----------------------+
```
---
be/src/exprs/function/ai/ai_adapter.h | 292 ++++++++++++++-
be/src/exprs/function/ai/ai_filter.h | 39 ++
be/src/exprs/function/ai/ai_functions.h | 155 ++++----
be/src/exprs/function/ai/ai_similarity.h | 41 ++
be/src/exprs/function/ai/embed.h | 223 +++++++++++
be/test/ai/ai_adapter_test.cpp | 202 +++++++++-
be/test/ai/ai_function_test.cpp | 323 +++++++++++++++-
be/test/ai/embed_test.cpp | 416 ++++++++++++++++++++-
.../java/org/apache/doris/catalog/Resource.java | 8 +
.../property/constants/AIProperties.java | 2 +-
.../org/apache/doris/nereids/StatementContext.java | 15 +
.../trees/expressions/functions/agg/AIAgg.java | 1 +
.../trees/expressions/functions/ai/AIFunction.java | 1 +
.../trees/expressions/functions/ai/Embed.java | 48 ++-
.../main/java/org/apache/doris/qe/Coordinator.java | 26 +-
.../java/org/apache/doris/qe/SessionVariable.java | 9 +
.../doris/qe/runtime/ThriftPlansBuilder.java | 30 +-
gensrc/thrift/PaloInternalService.thrift | 4 +
.../suites/ai_p0/test_ai_functions.groovy | 5 +-
19 files changed, 1721 insertions(+), 119 deletions(-)
diff --git a/be/src/exprs/function/ai/ai_adapter.h b/be/src/exprs/function/ai/ai_adapter.h
index b2de31377fc..0244261a3ed 100644
--- a/be/src/exprs/function/ai/ai_adapter.h
+++ b/be/src/exprs/function/ai/ai_adapter.h
@@ -88,6 +88,20 @@ struct AIResource {
}
};
+enum class MultimodalType { IMAGE, VIDEO, AUDIO };
+
+inline const char* multimodal_type_to_string(MultimodalType type) {
+ switch (type) {
+ case MultimodalType::IMAGE:
+ return "image";
+ case MultimodalType::VIDEO:
+ return "video";
+ case MultimodalType::AUDIO:
+ return "audio";
+ }
+ return "unknown";
+}
+
class AIAdapter {
public:
virtual ~AIAdapter() = default;
@@ -127,6 +141,13 @@ public:
_config.provider_type);
}
+ virtual Status build_multimodal_embedding_request(MultimodalType /*media_type*/,
+ const std::string& /*media_url*/,
+ std::string& /*request_body*/) const {
+ return Status::NotSupported("{} does not support multimodal Embed feature.",
+ _config.provider_type);
+ }
+
virtual Status parse_embedding_response(const std::string& response_body,
std::vector<std::vector<float>>& results) const {
return Status::NotSupported("{} does not support the Embed feature.",
@@ -195,6 +216,63 @@ public:
return Status::OK();
}
+ Status build_multimodal_embedding_request(MultimodalType media_type,
+ const std::string& media_url,
+ std::string& request_body) const override {
+ if (media_type != MultimodalType::IMAGE && media_type != MultimodalType::VIDEO)
+ [[unlikely]] {
+ return Status::InvalidArgument(
+ "VoyageAI only supports image/video multimodal embed, got {}",
+ multimodal_type_to_string(media_type));
+ }
+ if (_config.dimensions != -1) {
+ LOG(WARNING) << "VoyageAI multimodal embedding currently ignores dimensions parameter, "
+ << "model=" << _config.model_name << ", dimensions=" << _config.dimensions;
+ }
+
+ rapidjson::Document doc;
+ doc.SetObject();
+ auto& allocator = doc.GetAllocator();
+
+ /*{
+ "inputs": [
+ {
+ "content": [
+ {"type": "image_url", "image_url": "<url>"}
+ ]
+ }
+ ],
+ "model": "voyage-multimodal-3.5"
+ }*/
+ doc.AddMember("model", rapidjson::Value(_config.model_name.c_str(), allocator), allocator);
+
+ rapidjson::Value inputs(rapidjson::kArrayType);
+ rapidjson::Value input(rapidjson::kObjectType);
+ rapidjson::Value content(rapidjson::kArrayType);
+
+ rapidjson::Value media_item(rapidjson::kObjectType);
+ if (media_type == MultimodalType::IMAGE) {
+ media_item.AddMember("type", "image_url", allocator);
+ media_item.AddMember("image_url", rapidjson::Value(media_url.c_str(), allocator),
+ allocator);
+ } else {
+ media_item.AddMember("type", "video_url", allocator);
+ media_item.AddMember("video_url", rapidjson::Value(media_url.c_str(), allocator),
+ allocator);
+ }
+ content.PushBack(media_item, allocator);
+
+ input.AddMember("content", content, allocator);
+ inputs.PushBack(input, allocator);
+ doc.AddMember("inputs", inputs, allocator);
+
+ rapidjson::StringBuffer buffer;
+ rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
+ doc.Accept(writer);
+ request_body = buffer.GetString();
+ return Status::OK();
+ }
+
Status parse_embedding_response(const std::string& response_body,
std::vector<std::vector<float>>& results) const override {
rapidjson::Document doc;
@@ -355,6 +433,13 @@ public:
return Status::OK();
}
+ Status build_multimodal_embedding_request(MultimodalType /*media_type*/,
+ const std::string& /*media_url*/,
+ std::string& /*request_body*/) const override {
+ return Status::NotSupported("{} does not support multimodal Embed feature.",
+ _config.provider_type);
+ }
+
Status parse_embedding_response(const std::string& response_body,
std::vector<std::vector<float>>& results) const override {
rapidjson::Document doc;
@@ -703,6 +788,13 @@ public:
return Status::OK();
}
+ Status build_multimodal_embedding_request(MultimodalType /*media_type*/,
+ const std::string& /*media_url*/,
+ std::string& /*request_body*/) const override {
+ return Status::NotSupported("{} does not support multimodal Embed feature.",
+ _config.provider_type);
+ }
+
protected:
bool supports_dimension_param(const std::string& model_name) const override {
return !(model_name == "text-embedding-ada-002");
@@ -779,6 +871,103 @@ protected:
};
class QwenAdapter : public OpenAIAdapter {
+public:
+ Status build_multimodal_embedding_request(MultimodalType media_type,
+ const std::string& media_url,
+ std::string& request_body) const override {
+ if (media_type != MultimodalType::IMAGE && media_type != MultimodalType::VIDEO) {
+ return Status::InvalidArgument(
+ "QWEN only supports image/video multimodal embed, got {}",
+ multimodal_type_to_string(media_type));
+ }
+
+ rapidjson::Document doc;
+ doc.SetObject();
+ auto& allocator = doc.GetAllocator();
+
+ /*{
+ "model": "tongyi-embedding-vision-plus",
+ "input": {
+ "contents": [
+ {"image": "<url>"}
+ ]
+ }
+ "parameters": {
+ "dimension": 512
+ }
+ }*/
+ doc.AddMember("model", rapidjson::Value(_config.model_name.c_str(), allocator), allocator);
+ rapidjson::Value input(rapidjson::kObjectType);
+ rapidjson::Value contents(rapidjson::kArrayType);
+
+ rapidjson::Value media_item(rapidjson::kObjectType);
+ if (media_type == MultimodalType::IMAGE) {
+ media_item.AddMember("image", rapidjson::Value(media_url.c_str(), allocator),
+ allocator);
+ } else {
+ media_item.AddMember("video", rapidjson::Value(media_url.c_str(), allocator),
+ allocator);
+ }
+ contents.PushBack(media_item, allocator);
+
+ input.AddMember("contents", contents, allocator);
+ doc.AddMember("input", input, allocator);
+ if (_config.dimensions != -1 && supports_dimension_param(_config.model_name)) {
+ rapidjson::Value parameters(rapidjson::kObjectType);
+ std::string param_name = get_dimension_param_name();
+ rapidjson::Value dimension_name(param_name.c_str(), allocator);
+ parameters.AddMember(dimension_name, _config.dimensions, allocator);
+ doc.AddMember("parameters", parameters, allocator);
+ }
+
+ rapidjson::StringBuffer buffer;
+ rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
+ doc.Accept(writer);
+ request_body = buffer.GetString();
+ return Status::OK();
+ }
+
+ Status parse_embedding_response(const std::string& response_body,
+ std::vector<std::vector<float>>& results) const override {
+ rapidjson::Document doc;
+ doc.Parse(response_body.c_str());
+
+ if (doc.HasParseError() || !doc.IsObject()) [[unlikely]] {
+ return Status::InternalError("Failed to parse {} response: {}", _config.provider_type,
+ response_body);
+ }
+ // Qwen multimodal embedding usually returns:
+ // {
+ // "output": {
+ // "embeddings": [
+ // {"index":0, "embedding":[...], "type":"image|video|text"},
+ // ...
+ // ]
+ // }
+ // }
+ //
+ // In text-only or compatibility endpoints, Qwen may also return OpenAI-style
+ // "data":[{"embedding":[...]}]. For compatibility we first parse native
+ // output.embeddings and then fallback to OpenAIAdapter parser.
+ if (doc.HasMember("output") && doc["output"].IsObject() &&
+ doc["output"].HasMember("embeddings") && doc["output"]["embeddings"].IsArray()) {
+ const auto& embeddings = doc["output"]["embeddings"];
+ results.reserve(embeddings.Size());
+ for (rapidjson::SizeType i = 0; i < embeddings.Size(); i++) {
+ if (!embeddings[i].HasMember("embedding") ||
+ !embeddings[i]["embedding"].IsArray()) {
+ return Status::InternalError("Invalid {} response format: {}",
+ _config.provider_type, response_body);
+ }
+ std::transform(embeddings[i]["embedding"].Begin(), embeddings[i]["embedding"].End(),
+ std::back_inserter(results.emplace_back()),
+ [](const auto& val) { return val.GetFloat(); });
+ }
+ return Status::OK();
+ }
+ return OpenAIAdapter::parse_embedding_response(response_body, results);
+ }
+
protected:
bool supports_dimension_param(const std::string& model_name) const override {
static const std::unordered_set<std::string> no_dimension_models = {
@@ -789,6 +978,55 @@ protected:
std::string get_dimension_param_name() const override { return "dimension"; }
};
+class JinaAdapter : public VoyageAIAdapter {
+public:
+ Status build_multimodal_embedding_request(MultimodalType media_type,
+ const std::string& media_url,
+ std::string& request_body) const override {
+ if (media_type != MultimodalType::IMAGE && media_type != MultimodalType::VIDEO)
+ [[unlikely]] {
+ return Status::InvalidArgument(
+ "JINA only supports image/video multimodal embed, got {}",
+ multimodal_type_to_string(media_type));
+ }
+
+ rapidjson::Document doc;
+ doc.SetObject();
+ auto& allocator = doc.GetAllocator();
+
+ /*{
+ "model": "jina-embeddings-v4",
+ "task": "text-matching",
+ "input": [
+ {"image": "<url>"}
+ ]
+ }*/
+ doc.AddMember("model", rapidjson::Value(_config.model_name.c_str(), allocator), allocator);
+ doc.AddMember("task", "text-matching", allocator);
+
+ rapidjson::Value input(rapidjson::kArrayType);
+ rapidjson::Value media_item(rapidjson::kObjectType);
+ if (media_type == MultimodalType::IMAGE) {
+ media_item.AddMember("image", rapidjson::Value(media_url.c_str(), allocator),
+ allocator);
+ } else {
+ media_item.AddMember("video", rapidjson::Value(media_url.c_str(), allocator),
+ allocator);
+ }
+ input.PushBack(media_item, allocator);
+ if (_config.dimensions != -1 && supports_dimension_param(_config.model_name)) {
+ doc.AddMember("dimensions", _config.dimensions, allocator);
+ }
+ doc.AddMember("input", input, allocator);
+
+ rapidjson::StringBuffer buffer;
+ rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
+ doc.Accept(writer);
+ request_body = buffer.GetString();
+ return Status::OK();
+ }
+};
+
class BaichuanAdapter : public OpenAIAdapter {
protected:
bool supports_dimension_param(const std::string& model_name) const override { return false; }
@@ -969,6 +1207,51 @@ public:
return Status::OK();
}
+ Status build_multimodal_embedding_request(MultimodalType media_type,
+ const std::string& media_url,
+ std::string& request_body) const override {
+ const char* mime_type = nullptr;
+ switch (media_type) {
+ case MultimodalType::IMAGE:
+ mime_type = "image/png";
+ break;
+ case MultimodalType::AUDIO:
+ mime_type = "audio/mpeg";
+ break;
+ case MultimodalType::VIDEO:
+ mime_type = "video/mp4";
+ break;
+ }
+
+ rapidjson::Document doc;
+ doc.SetObject();
+ auto& allocator = doc.GetAllocator();
+
+ std::string model_name = _config.model_name;
+ if (!model_name.starts_with("models/")) {
+ model_name = "models/" + model_name;
+ }
+ doc.AddMember("model", rapidjson::Value(model_name.c_str(), allocator), allocator);
+ add_dimension_params(doc, allocator);
+
+ rapidjson::Value content(rapidjson::kObjectType);
+ rapidjson::Value parts(rapidjson::kArrayType);
+ rapidjson::Value part(rapidjson::kObjectType);
+ rapidjson::Value file_data(rapidjson::kObjectType);
+ file_data.AddMember("mime_type", rapidjson::Value(mime_type, allocator), allocator);
+ file_data.AddMember("file_uri", rapidjson::Value(media_url.c_str(), allocator), allocator);
+ part.AddMember("file_data", file_data, allocator);
+ parts.PushBack(part, allocator);
+ content.AddMember("parts", parts, allocator);
+ doc.AddMember("content", content, allocator);
+
+ rapidjson::StringBuffer buffer;
+ rapidjson::Writer<rapidjson::StringBuffer> writer(buffer);
+ doc.Accept(writer);
+ request_body = buffer.GetString();
+ return Status::OK();
+ }
+
Status parse_embedding_response(const std::string& response_body,
std::vector<std::vector<float>>& results) const override {
rapidjson::Document doc;
@@ -1135,6 +1418,12 @@ public:
return Status::OK();
}
+ Status build_multimodal_embedding_request(MultimodalType /*media_type*/,
+ const std::string& /*media_url*/,
+ std::string& /*request_body*/) const override {
+ return Status::OK();
+ }
+
Status parse_embedding_response(const std::string& response_body,
std::vector<std::vector<float>>& results) const override {
rapidjson::Document doc;
@@ -1166,6 +1455,7 @@ public:
{"MINIMAX", []() { return std::make_shared<MinimaxAdapter>(); }},
{"ZHIPU", []() { return std::make_shared<ZhipuAdapter>(); }},
{"QWEN", []() { return std::make_shared<QwenAdapter>(); }},
+ {"JINA", []() { return std::make_shared<JinaAdapter>(); }},
{"BAICHUAN", []() { return std::make_shared<BaichuanAdapter>(); }},
{"ANTHROPIC", []() { return std::make_shared<AnthropicAdapter>(); }},
{"GEMINI", []() { return std::make_shared<GeminiAdapter>(); }},
@@ -1177,4 +1467,4 @@ public:
}
};
-} // namespace doris
\ No newline at end of file
+} // namespace doris
diff --git a/be/src/exprs/function/ai/ai_filter.h b/be/src/exprs/function/ai/ai_filter.h
index ac34dba3bbc..f00c42337a7 100644
--- a/be/src/exprs/function/ai/ai_filter.h
+++ b/be/src/exprs/function/ai/ai_filter.h
@@ -17,6 +17,10 @@
#pragma once
+#include <algorithm>
+#include <cctype>
+#include <cstdlib>
+
#include "exprs/function/ai/ai_functions.h"
namespace doris {
@@ -38,6 +42,41 @@ public:
return std::make_shared<DataTypeBool>();
}
+ Status execute_with_adapter(FunctionContext* context, Block& block,
+ const ColumnNumbers& arguments, uint32_t result,
+ size_t input_rows_count, const TAIResource& config,
+ std::shared_ptr<AIAdapter>& adapter) const {
+ auto col_result = ColumnUInt8::create();
+
+ for (size_t i = 0; i < input_rows_count; ++i) {
+ std::string prompt;
+ RETURN_IF_ERROR(build_prompt(block, arguments, i, prompt));
+
+ std::string string_result;
+ RETURN_IF_ERROR(
+ execute_single_request(prompt, string_result, config, adapter, context));
+
+#ifdef BE_TEST
+ const char* test_result = std::getenv("AI_TEST_RESULT");
+ if (test_result != nullptr) {
+ string_result = test_result;
+ } else {
+ string_result = "0";
+ }
+#endif
+
+ std::string_view trimmed = doris::trim(string_result);
+ if (trimmed != "1" && trimmed != "0") {
+ return Status::RuntimeError("Failed to parse boolean value: " + string_result);
+ }
+
+ col_result->insert_value(static_cast<UInt8>(trimmed == "1"));
+ }
+
+ block.replace_by_position(result, std::move(col_result));
+ return Status::OK();
+ }
+
static FunctionPtr create() { return std::make_shared<FunctionAIFilter>(); }
};
} // namespace doris
\ No newline at end of file
diff --git a/be/src/exprs/function/ai/ai_functions.h b/be/src/exprs/function/ai/ai_functions.h
index d8c3659dcc9..0386689091c 100644
--- a/be/src/exprs/function/ai/ai_functions.h
+++ b/be/src/exprs/function/ai/ai_functions.h
@@ -19,6 +19,7 @@
#include <gen_cpp/FrontendService.h>
#include <gen_cpp/PaloInternalService_types.h>
+#include <glog/logging.h>
#include <algorithm>
#include <cctype>
@@ -43,6 +44,7 @@
#include "runtime/query_context.h"
#include "runtime/runtime_state.h"
#include "service/http/http_client.h"
+#include "util/security.h"
#include "util/threadpool.h"
namespace doris {
@@ -73,10 +75,6 @@ public:
Status execute_impl(FunctionContext* context, Block& block, const ColumnNumbers& arguments,
uint32_t result, size_t input_rows_count) const override {
- DataTypePtr return_type_impl =
- assert_cast<const Derived&>(*this).get_return_type_impl(DataTypes());
- MutableColumnPtr col_result = return_type_impl->create_column();
-
TAIResource config;
std::shared_ptr<AIAdapter> adapter;
if (Status status = assert_cast<const Derived*>(this)->_init_from_resource(
@@ -85,91 +83,42 @@ public:
return status;
}
+ return assert_cast<const Derived&>(*this).execute_with_adapter(
+ context, block, arguments, result, input_rows_count, config, adapter);
+ }
+
+protected:
+ // Derived classes can override this method for non-text/default behavior.
+ // The base implementation keeps previous text-oriented processing unchanged.
+ Status execute_with_adapter(FunctionContext* context, Block& block,
+ const ColumnNumbers& arguments, uint32_t result,
+ size_t input_rows_count, const TAIResource& config,
+ std::shared_ptr<AIAdapter>& adapter) const {
+ DataTypePtr return_type_impl =
+ assert_cast<const Derived&>(*this).get_return_type_impl(DataTypes());
+ if (return_type_impl->get_primitive_type() != PrimitiveType::TYPE_STRING) {
+ return Status::InternalError("{} must override execute for non-string return type",
+ get_name());
+ }
+ MutableColumnPtr col_result = ColumnString::create();
+
for (size_t i = 0; i < input_rows_count; ++i) {
// Build AI prompt text
std::string prompt;
RETURN_IF_ERROR(
assert_cast<const Derived&>(*this).build_prompt(block, arguments, i, prompt));
- // Execute a single AI request and get the result
- if (return_type_impl->get_primitive_type() == PrimitiveType::TYPE_ARRAY) {
- // Array(Float) for AI_EMBED
- std::vector<float> float_result;
- RETURN_IF_ERROR(
- execute_single_request(prompt, float_result, config, adapter, context));
-
- auto& col_array = assert_cast<ColumnArray&>(*col_result);
- auto& offsets = col_array.get_offsets();
- auto& nested_nullable_col = assert_cast<ColumnNullable&>(col_array.get_data());
- auto& nested_col =
- assert_cast<ColumnFloat32&>(*(nested_nullable_col.get_nested_column_ptr()));
- nested_col.reserve(nested_col.size() + float_result.size());
-
- size_t current_offset = nested_col.size();
- nested_col.insert_many_raw_data(reinterpret_cast<const char*>(float_result.data()),
- float_result.size());
- offsets.push_back(current_offset + float_result.size());
- auto& null_map = nested_nullable_col.get_null_map_column();
- null_map.insert_many_vals(0, float_result.size());
- } else {
- std::string string_result;
- RETURN_IF_ERROR(
- execute_single_request(prompt, string_result, config, adapter, context));
-
- switch (return_type_impl->get_primitive_type()) {
- case PrimitiveType::TYPE_STRING: { // string
- assert_cast<ColumnString&>(*col_result)
- .insert_data(string_result.data(), string_result.size());
- break;
- }
- case PrimitiveType::TYPE_BOOLEAN: { // boolean for AI_FILTER
-#ifdef BE_TEST
- const char* test_result = std::getenv("AI_TEST_RESULT");
- if (test_result != nullptr) {
- string_result = test_result;
- } else {
- string_result = "0";
- }
-#endif
- trim_string(string_result);
- if (string_result != "1" && string_result != "0") {
- return Status::RuntimeError("Failed to parse boolean value: " +
- string_result);
- }
- assert_cast<ColumnUInt8&>(*col_result)
- .insert_value(static_cast<UInt8>(string_result == "1"));
- break;
- }
- case PrimitiveType::TYPE_FLOAT: { // float for AI_SIMILARITY
-#ifdef BE_TEST
- const char* test_result = std::getenv("AI_TEST_RESULT");
- if (test_result != nullptr) {
- string_result = test_result;
- } else {
- string_result = "0.0";
- }
-#endif
- trim_string(string_result);
- try {
- float float_value = std::stof(string_result);
- assert_cast<ColumnFloat32&>(*col_result).insert_value(float_value);
- } catch (...) {
- return Status::RuntimeError("Failed to parse float value: " +
- string_result);
- }
- break;
- }
- default:
- return Status::InternalError("Unsupported ReturnType for AIFunction");
- }
- }
+ std::string string_result;
+ RETURN_IF_ERROR(
+ execute_single_request(prompt, string_result, config, adapter, context));
+ assert_cast<ColumnString&>(*col_result)
+ .insert_data(string_result.data(), string_result.size());
}
block.replace_by_position(result, std::move(col_result));
return Status::OK();
}
-protected:
// The endpoint `v1/completions` does not support `system_prompt`.
// To ensure a clear structure and stable AI results.
// Convert from `v1/completions` to `v1/chat/completions`
@@ -181,17 +130,6 @@ protected:
}
}
-private:
- // Trim whitespace and newlines from string
- static void trim_string(std::string& str) {
- str.erase(str.begin(), std::find_if(str.begin(), str.end(),
- [](unsigned char ch) { return !std::isspace(ch); }));
- str.erase(std::find_if(str.rbegin(), str.rend(),
- [](unsigned char ch) { return !std::isspace(ch); })
- .base(),
- str.end());
- }
-
// The ai resource must be literal
Status _init_from_resource(FunctionContext* context, const Block& block,
const ColumnNumbers& arguments, TAIResource& config,
@@ -228,7 +166,7 @@ private:
Status do_send_request(HttpClient* client, const std::string& request_body,
std::string& response, const TAIResource& config,
std::shared_ptr<AIAdapter>& adapter, FunctionContext* context) const {
- RETURN_IF_ERROR(client->init(config.endpoint));
+ RETURN_IF_ERROR(client->init(config.endpoint, false));
QueryContext* query_ctx = context->state()->get_query_ctx();
int64_t remaining_query_time = query_ctx->get_remaining_query_time_seconds();
@@ -242,7 +180,22 @@ private:
RETURN_IF_ERROR(adapter->set_authentication(client));
}
- return client->execute_post_request(request_body, &response);
+ Status st = client->execute_post_request(request_body, &response);
+ long http_status = client->get_http_status();
+
+ if (!st.ok()) {
+ LOG(INFO) << "AI HTTP request failed before status validation, provider="
+ << config.provider_type << ", model=" << config.model_name
+ << ", endpoint=" << mask_token(config.endpoint)
+ << ", exec_status=" << st.to_string() << ", response_body=" << response;
+ return st;
+ }
+ if (http_status != 200) {
+ return Status::HttpError(
+ "http status code is not 200, code={}, url={}, response_body={}", http_status,
+ mask_token(config.endpoint), response);
+ }
+ return Status::OK();
}
// Sends the request with retry mechanism for handling transient failures
@@ -311,6 +264,26 @@ private:
result = std::move(results[0]);
return Status::OK();
}
+
+ // Sends a pre-built embedding request body and parses the float vector result.
+ // Used when the request body has already been constructed (e.g., multimodal embedding).
+ Status execute_embedding_request(const std::string& request_body, std::vector<float>& result,
+ const TAIResource& config, std::shared_ptr<AIAdapter>& adapter,
+ FunctionContext* context) const {
+ std::vector<std::vector<float>> results;
+ std::string response;
+ if (config.provider_type == "MOCK") {
+ response = "{\"embedding\": [0, 1, 2, 3, 4]}";
+ } else {
+ RETURN_IF_ERROR(send_request_to_llm(request_body, response, config, adapter, context));
+ }
+ RETURN_IF_ERROR(adapter->parse_embedding_response(response, results));
+ if (results.empty()) {
+ return Status::InternalError("AI returned empty result");
+ }
+ result = std::move(results[0]);
+ return Status::OK();
+ }
};
-} // namespace doris
\ No newline at end of file
+} // namespace doris
diff --git a/be/src/exprs/function/ai/ai_similarity.h b/be/src/exprs/function/ai/ai_similarity.h
index bb6aed79a4b..21c96781341 100644
--- a/be/src/exprs/function/ai/ai_similarity.h
+++ b/be/src/exprs/function/ai/ai_similarity.h
@@ -17,6 +17,10 @@
#pragma once
+#include <algorithm>
+#include <cctype>
+#include <cstdlib>
+
#include "exprs/function/ai/ai_functions.h"
namespace doris {
@@ -41,6 +45,43 @@ public:
return std::make_shared<DataTypeFloat32>();
}
+ Status execute_with_adapter(FunctionContext* context, Block& block,
+ const ColumnNumbers& arguments, uint32_t result,
+ size_t input_rows_count, const TAIResource& config,
+ std::shared_ptr<AIAdapter>& adapter) const {
+ auto col_result = ColumnFloat32::create();
+
+ for (size_t i = 0; i < input_rows_count; ++i) {
+ std::string prompt;
+ RETURN_IF_ERROR(build_prompt(block, arguments, i, prompt));
+
+ std::string string_result;
+ RETURN_IF_ERROR(
+ execute_single_request(prompt, string_result, config, adapter, context));
+
+#ifdef BE_TEST
+ const char* test_result = std::getenv("AI_TEST_RESULT");
+ if (test_result != nullptr) {
+ string_result = test_result;
+ } else {
+ string_result = "0.0";
+ }
+#endif
+
+ std::string_view trimmed = doris::trim(string_result);
+ float float_value = 0;
+ auto [ptr, ec] = fast_float::from_chars(trimmed.data(), trimmed.data() + trimmed.size(),
+ float_value);
+ if (ec != std::errc() || ptr != trimmed.data() + trimmed.size()) [[unlikely]] {
+ return Status::RuntimeError("Failed to parse float value: " + string_result);
+ }
+ assert_cast<ColumnFloat32&>(*col_result).insert_value(float_value);
+ }
+
+ block.replace_by_position(result, std::move(col_result));
+ return Status::OK();
+ }
+
static FunctionPtr create() { return std::make_shared<FunctionAISimilarity>(); }
Status build_prompt(const Block& block, const ColumnNumbers& arguments, size_t row_num,
diff --git a/be/src/exprs/function/ai/embed.h b/be/src/exprs/function/ai/embed.h
index 8c5988dc4cd..f5f2176381b 100644
--- a/be/src/exprs/function/ai/embed.h
+++ b/be/src/exprs/function/ai/embed.h
@@ -17,8 +17,17 @@
#pragma once
+#include <glog/logging.h>
+#include <rapidjson/document.h>
+
+#include <string_view>
+
+#include "core/data_type/data_type_nullable.h"
#include "core/data_type/primitive_type.h"
#include "exprs/function/ai/ai_functions.h"
+#include "util/jsonb_utils.h"
+#include "util/s3_uri.h"
+#include "util/s3_util.h"
namespace doris {
class FunctionEmbed : public AIFunction<FunctionEmbed> {
@@ -33,7 +42,221 @@ public:
return std::make_shared<DataTypeArray>(make_nullable(std::make_shared<DataTypeFloat32>()));
}
+ Status execute_with_adapter(FunctionContext* context, Block& block,
+ const ColumnNumbers& arguments, uint32_t result,
+ size_t input_rows_count, const TAIResource& config,
+ std::shared_ptr<AIAdapter>& adapter) const {
+ if (arguments.size() != 2) {
+ return Status::InvalidArgument("Function EMBED expects 2 arguments, but got {}",
+ arguments.size());
+ }
+
+ PrimitiveType input_type =
+ remove_nullable(block.get_by_position(arguments[1]).type)->get_primitive_type();
+ if (input_type == PrimitiveType::TYPE_JSONB) {
+ return _execute_multimodal_embed(context, block, arguments, result, input_rows_count,
+ config, adapter);
+ }
+ if (input_type == PrimitiveType::TYPE_STRING || input_type == PrimitiveType::TYPE_VARCHAR ||
+ input_type == PrimitiveType::TYPE_CHAR) {
+ return _execute_text_embed(context, block, arguments, result, input_rows_count, config,
+ adapter);
+ }
+ return Status::InvalidArgument(
+ "Function EMBED expects the second argument to be STRING or JSON, but got type {}",
+ block.get_by_position(arguments[1]).type->get_name());
+ }
+
static FunctionPtr create() { return std::make_shared<FunctionEmbed>(); }
+
+private:
+ Status _execute_text_embed(FunctionContext* context, Block& block,
+ const ColumnNumbers& arguments, uint32_t result,
+ size_t input_rows_count, const TAIResource& config,
+ std::shared_ptr<AIAdapter>& adapter) const {
+ auto col_result = ColumnArray::create(
+ ColumnNullable::create(ColumnFloat32::create(), ColumnUInt8::create()));
+
+ for (size_t i = 0; i < input_rows_count; ++i) {
+ std::string prompt;
+ RETURN_IF_ERROR(build_prompt(block, arguments, i, prompt));
+
+ std::vector<float> float_result;
+ RETURN_IF_ERROR(execute_single_request(prompt, float_result, config, adapter, context));
+ _insert_embedding_result(*col_result, float_result);
+ }
+
+ block.replace_by_position(result, std::move(col_result));
+ return Status::OK();
+ }
+
+ Status _execute_multimodal_embed(FunctionContext* context, Block& block,
+ const ColumnNumbers& arguments, uint32_t result,
+ size_t input_rows_count, const TAIResource& config,
+ std::shared_ptr<AIAdapter>& adapter) const {
+ auto col_result = ColumnArray::create(
+ ColumnNullable::create(ColumnFloat32::create(), ColumnUInt8::create()));
+
+ int64_t ttl_seconds = 3600;
+ QueryContext* query_ctx = context->state()->get_query_ctx();
+ if (query_ctx && query_ctx->query_options().__isset.file_presigned_url_ttl_seconds) {
+ ttl_seconds = query_ctx->query_options().file_presigned_url_ttl_seconds;
+ if (ttl_seconds <= 0) {
+ ttl_seconds = 3600;
+ }
+ }
+
+ const ColumnWithTypeAndName& file_column = block.get_by_position(arguments[1]);
+ for (size_t i = 0; i < input_rows_count; ++i) {
+ rapidjson::Document file_input;
+ RETURN_IF_ERROR(_parse_file_input(file_column, i, file_input));
+
+ MultimodalType media_type;
+ RETURN_IF_ERROR(_infer_media_type(file_input, media_type));
+
+ std::string media_url;
+ RETURN_IF_ERROR(_resolve_media_url(file_input, ttl_seconds, media_url));
+
+ std::string request_body;
+ RETURN_IF_ERROR(adapter->build_multimodal_embedding_request(media_type, media_url,
+ request_body));
+
+ std::vector<float> float_result;
+ RETURN_IF_ERROR(execute_embedding_request(request_body, float_result, config, adapter,
+ context));
+ _insert_embedding_result(*col_result, float_result);
+ }
+
+ block.replace_by_position(result, std::move(col_result));
+ return Status::OK();
+ }
+
+ static void _insert_embedding_result(ColumnArray& col_array,
+ const std::vector<float>& float_result) {
+ auto& offsets = col_array.get_offsets();
+ auto& nested_nullable_col = assert_cast<ColumnNullable&>(col_array.get_data());
+ auto& nested_col =
+ assert_cast<ColumnFloat32&>(*(nested_nullable_col.get_nested_column_ptr()));
+ nested_col.reserve(nested_col.size() + float_result.size());
+
+ size_t current_offset = nested_col.size();
+ nested_col.insert_many_raw_data(reinterpret_cast<const char*>(float_result.data()),
+ float_result.size());
+ offsets.push_back(current_offset + float_result.size());
+ auto& null_map = nested_nullable_col.get_null_map_column();
+ null_map.insert_many_vals(0, float_result.size());
+ }
+
+ static bool _starts_with_ignore_case(std::string_view s, std::string_view prefix) {
+ if (s.size() < prefix.size()) {
+ return false;
+ }
+ return std::equal(prefix.begin(), prefix.end(), s.begin(), [](char a, char b) {
+ return std::tolower(static_cast<unsigned char>(a)) ==
+ std::tolower(static_cast<unsigned char>(b));
+ });
+ }
+
+ static Status _infer_media_type(const rapidjson::Value& file_input,
+ MultimodalType& media_type) {
+ std::string content_type;
+ RETURN_IF_ERROR(_get_required_string_field(file_input, "content_type", content_type));
+
+ if (_starts_with_ignore_case(content_type, "image/")) {
+ media_type = MultimodalType::IMAGE;
+ return Status::OK();
+ } else if (_starts_with_ignore_case(content_type, "video/")) {
+ media_type = MultimodalType::VIDEO;
+ return Status::OK();
+ } else if (_starts_with_ignore_case(content_type, "audio/")) {
+ media_type = MultimodalType::AUDIO;
+ return Status::OK();
+ }
+
+ return Status::InvalidArgument("Unsupported content_type for EMBED: {}", content_type);
+ }
+
+ // Parse the FILE-like JSONB argument into a JSON object for downstream field reads.
+ static Status _parse_file_input(const ColumnWithTypeAndName& file_column, size_t row_num,
+ rapidjson::Document& file_input) {
+ std::string file_json =
+ JsonbToJson::jsonb_to_json_string(file_column.column->get_data_at(row_num).data,
+ file_column.column->get_data_at(row_num).size);
+ file_input.Parse(file_json.c_str());
+ DORIS_CHECK(!file_input.HasParseError() && file_input.IsObject());
+ return Status::OK();
+ }
+
+ // TODO(lzq): After support FILE type, We should use the interface provided by FILE to get the fields
+ // replacing this function
+ static Status _get_required_string_field(const rapidjson::Value& obj, const char* field_name,
+ std::string& value) {
+ auto iter = obj.FindMember(field_name);
+ if (iter == obj.MemberEnd() || !iter->value.IsString()) {
+ return Status::InvalidArgument(
+ "EMBED file json field '{}' is required and must be a string", field_name);
+ }
+ value = iter->value.GetString();
+ if (value.empty()) {
+ return Status::InvalidArgument("EMBED file json field '{}' can not be empty",
+ field_name);
+ }
+ return Status::OK();
+ }
+
+ static Status init_s3_client_conf_from_json(const rapidjson::Value& file_input,
+ S3ClientConf& s3_client_conf) {
+ std::string endpoint;
+ RETURN_IF_ERROR(_get_required_string_field(file_input, "endpoint", endpoint));
+ std::string region;
+ RETURN_IF_ERROR(_get_required_string_field(file_input, "region", region));
+
+ auto get_optional_string_field = [&](const char* field_name, std::string& value) {
+ auto iter = file_input.FindMember(field_name);
+ if (iter == file_input.MemberEnd() || iter->value.IsNull()) {
+ return;
+ }
+ DORIS_CHECK(iter->value.IsString());
+ value = iter->value.GetString();
+ };
+
+ get_optional_string_field("ak", s3_client_conf.ak);
+ get_optional_string_field("sk", s3_client_conf.sk);
+ get_optional_string_field("role_arn", s3_client_conf.role_arn);
+ get_optional_string_field("external_id", s3_client_conf.external_id);
+ s3_client_conf.endpoint = endpoint;
+ s3_client_conf.region = region;
+
+ return Status::OK();
+ }
+
+ Status _resolve_media_url(const rapidjson::Value& file_input, int64_t ttl_seconds,
+ std::string& media_url) const {
+ std::string uri;
+ RETURN_IF_ERROR(_get_required_string_field(file_input, "uri", uri));
+
+ // If it's a direct http/https URL, use it as-is
+ if (_starts_with_ignore_case(uri, "http://") || _starts_with_ignore_case(uri, "https://")) {
+ media_url = uri;
+ return Status::OK();
+ }
+
+ S3ClientConf s3_client_conf;
+ RETURN_IF_ERROR(init_s3_client_conf_from_json(file_input, s3_client_conf));
+ auto s3_client = S3ClientFactory::instance().create(s3_client_conf);
+ if (s3_client == nullptr) {
+ return Status::InternalError("Failed to create S3 client for EMBED file input");
+ }
+
+ S3URI s3_uri(uri);
+ RETURN_IF_ERROR(s3_uri.parse());
+ std::string bucket = s3_uri.get_bucket();
+ std::string key = s3_uri.get_key();
+ DORIS_CHECK(!bucket.empty() && !key.empty());
+ media_url = s3_client->generate_presigned_url({.bucket = bucket, .key = key}, ttl_seconds,
+ s3_client_conf);
+ return Status::OK();
+ }
};
}; // namespace doris
diff --git a/be/test/ai/ai_adapter_test.cpp b/be/test/ai/ai_adapter_test.cpp
index 6956af5e3fd..aaaf64e7eaa 100644
--- a/be/test/ai/ai_adapter_test.cpp
+++ b/be/test/ai/ai_adapter_test.cpp
@@ -561,9 +561,9 @@ TEST(AI_ADAPTER_TEST, unsupported_provider_type) {
}
TEST(AI_ADAPTER_TEST, adapter_factory_all_types) {
- std::vector<std::string> types = {"LOCAL", "OPENAI", "MOONSHOT", "DEEPSEEK",
- "MINIMAX", "ZHIPU", "QWEN", "BAICHUAN",
- "ANTHROPIC", "GEMINI", "VOYAGEAI", "MOCK"};
+ std::vector<std::string> types = {"LOCAL", "OPENAI", "MOONSHOT", "DEEPSEEK", "MINIMAX",
+ "ZHIPU", "QWEN", "JINA", "BAICHUAN", "ANTHROPIC",
+ "GEMINI", "VOYAGEAI", "MOCK"};
for (const auto& type : types) {
auto adapter = doris::AIAdapterFactory::create_adapter(type);
ASSERT_TRUE(adapter != nullptr) << "Adapter not found for type: " << type;
@@ -688,4 +688,200 @@ TEST(AI_ADAPTER_TEST, voyage_adapter_chat_test) {
"[NOT_IMPLEMENTED_ERROR]VoyageAI don't support text generation");
}
+TEST(AI_ADAPTER_TEST, qwen_multimodal_embedding_request_image) {
+ QwenAdapter adapter;
+ TAIResource config;
+ config.model_name = "tongyi-embedding-vision-plus";
+ config.dimensions = 1024;
+ adapter.init(config);
+
+ std::string request_body;
+ Status st = adapter.build_multimodal_embedding_request(MultimodalType::IMAGE,
+ "https://a/b/c.png", request_body);
+ ASSERT_TRUE(st.ok()) << st.to_string();
+
+ rapidjson::Document doc;
+ doc.Parse(request_body.c_str());
+ ASSERT_FALSE(doc.HasParseError()) << request_body;
+ ASSERT_TRUE(doc.IsObject());
+ ASSERT_TRUE(doc.HasMember("model"));
+ ASSERT_STREQ(doc["model"].GetString(), "tongyi-embedding-vision-plus");
+ ASSERT_TRUE(doc.HasMember("input"));
+ ASSERT_TRUE(doc["input"].HasMember("contents"));
+ const auto& contents = doc["input"]["contents"];
+ ASSERT_TRUE(contents.IsArray());
+ ASSERT_EQ(contents.Size(), 1);
+ ASSERT_TRUE(contents[0].HasMember("image"));
+ ASSERT_STREQ(contents[0]["image"].GetString(), "https://a/b/c.png");
+ ASSERT_TRUE(doc.HasMember("parameters"));
+ ASSERT_TRUE(doc["parameters"].IsObject());
+ ASSERT_TRUE(doc["parameters"].HasMember("dimension"));
+ ASSERT_EQ(doc["parameters"]["dimension"].GetInt(), 1024);
+}
+
+TEST(AI_ADAPTER_TEST, qwen_multimodal_embedding_request_video) {
+ QwenAdapter adapter;
+ TAIResource config;
+ config.model_name = "tongyi-embedding-vision-plus";
+ config.dimensions = 1024;
+ adapter.init(config);
+
+ std::string request_body;
+ Status st = adapter.build_multimodal_embedding_request(MultimodalType::VIDEO,
+ "https://a/b/c.mp4", request_body);
+ ASSERT_TRUE(st.ok()) << st.to_string();
+
+ rapidjson::Document doc;
+ doc.Parse(request_body.c_str());
+ ASSERT_FALSE(doc.HasParseError()) << request_body;
+ ASSERT_TRUE(doc.IsObject());
+ ASSERT_TRUE(doc.HasMember("model"));
+ ASSERT_STREQ(doc["model"].GetString(), "tongyi-embedding-vision-plus");
+ ASSERT_TRUE(doc.HasMember("input"));
+ ASSERT_TRUE(doc["input"].HasMember("contents"));
+ const auto& contents = doc["input"]["contents"];
+ ASSERT_TRUE(contents.IsArray());
+ ASSERT_EQ(contents.Size(), 1);
+ ASSERT_TRUE(contents[0].HasMember("video"));
+ ASSERT_STREQ(contents[0]["video"].GetString(), "https://a/b/c.mp4");
+ ASSERT_TRUE(doc.HasMember("parameters"));
+ ASSERT_TRUE(doc["parameters"].IsObject());
+ ASSERT_TRUE(doc["parameters"].HasMember("dimension"));
+ ASSERT_EQ(doc["parameters"]["dimension"].GetInt(), 1024);
+}
+
+TEST(AI_ADAPTER_TEST, qwen_multimodal_embedding_request_audio_not_supported) {
+ QwenAdapter adapter;
+ TAIResource config;
+ config.model_name = "tongyi-embedding-vision-plus";
+ adapter.init(config);
+
+ std::string request_body;
+ Status st = adapter.build_multimodal_embedding_request(MultimodalType::AUDIO,
+ "https://a/b/c.mp3", request_body);
+ ASSERT_FALSE(st.ok());
+ ASSERT_THAT(st.to_string(),
+ ::testing::HasSubstr("QWEN only supports image/video multimodal embed"));
+ ASSERT_THAT(st.to_string(), ::testing::HasSubstr("audio"));
+}
+
+TEST(AI_ADAPTER_TEST, voyage_multimodal_embedding_request) {
+ VoyageAIAdapter adapter;
+ TAIResource config;
+ config.model_name = "voyage-multimodal-3.5";
+ config.dimensions = 2048;
+ adapter.init(config);
+
+ std::string request_body;
+ Status st = adapter.build_multimodal_embedding_request(MultimodalType::VIDEO,
+ "https://a/b/c.mp4", request_body);
+ ASSERT_TRUE(st.ok()) << st.to_string();
+
+ rapidjson::Document doc;
+ doc.Parse(request_body.c_str());
+ ASSERT_FALSE(doc.HasParseError()) << request_body;
+ ASSERT_TRUE(doc.IsObject());
+ ASSERT_TRUE(doc.HasMember("inputs"));
+ const auto& inputs = doc["inputs"];
+ ASSERT_TRUE(inputs.IsArray());
+ ASSERT_EQ(inputs.Size(), 1);
+ ASSERT_TRUE(inputs[0].HasMember("content"));
+ const auto& content = inputs[0]["content"];
+ ASSERT_TRUE(content.IsArray());
+ ASSERT_EQ(content.Size(), 1);
+ ASSERT_TRUE(content[0].HasMember("type"));
+ ASSERT_STREQ(content[0]["type"].GetString(), "video_url");
+ ASSERT_TRUE(content[0].HasMember("video_url"));
+ ASSERT_STREQ(content[0]["video_url"].GetString(), "https://a/b/c.mp4");
+ ASSERT_FALSE(doc.HasMember("dimensions"));
+ ASSERT_FALSE(doc.HasMember("output_dimension"));
+}
+
+TEST(AI_ADAPTER_TEST, jina_multimodal_embedding_request) {
+ JinaAdapter adapter;
+ TAIResource config;
+ config.model_name = "jina-embeddings-v4";
+ config.dimensions = 512;
+ adapter.init(config);
+
+ std::string request_body;
+ Status st = adapter.build_multimodal_embedding_request(MultimodalType::IMAGE,
+ "https://a/b/c.jpg", request_body);
+ ASSERT_TRUE(st.ok()) << st.to_string();
+
+ rapidjson::Document doc;
+ doc.Parse(request_body.c_str());
+ ASSERT_FALSE(doc.HasParseError()) << request_body;
+ ASSERT_TRUE(doc.IsObject());
+ ASSERT_TRUE(doc.HasMember("task"));
+ ASSERT_STREQ(doc["task"].GetString(), "text-matching");
+ ASSERT_TRUE(doc.HasMember("input"));
+ const auto& input = doc["input"];
+ ASSERT_TRUE(input.IsArray());
+ ASSERT_EQ(input.Size(), 1);
+ ASSERT_TRUE(input[0].HasMember("image"));
+ ASSERT_STREQ(input[0]["image"].GetString(), "https://a/b/c.jpg");
+ ASSERT_TRUE(doc.HasMember("dimensions"));
+ ASSERT_EQ(doc["dimensions"].GetInt(), 512);
+}
+
+TEST(AI_ADAPTER_TEST, multimodal_provider_support) {
+ OpenAIAdapter openai_adapter;
+ TAIResource openai_config;
+ openai_config.provider_type = "OPENAI";
+ openai_adapter.init(openai_config);
+
+ std::string request_body;
+ Status st = openai_adapter.build_multimodal_embedding_request(
+ MultimodalType::IMAGE, "https://a/b/c.png", request_body);
+ ASSERT_FALSE(st.ok());
+ ASSERT_THAT(st.to_string(), ::testing::HasSubstr("does not support multimodal Embed"));
+}
+
+TEST(AI_ADAPTER_TEST, gemini_multimodal_embedding_request) {
+ GeminiAdapter gemini_adapter;
+ TAIResource gemini_config;
+ gemini_config.provider_type = "GEMINI";
+ gemini_config.model_name = "gemini-embedding-2-preview";
+ gemini_config.dimensions = 768;
+ gemini_adapter.init(gemini_config);
+
+ struct GeminiMultimodalCase {
+ MultimodalType media_type;
+ const char* media_url;
+ const char* mime_type;
+ };
+ const std::vector<GeminiMultimodalCase> test_cases = {
+ {MultimodalType::IMAGE, "https://a/b/c.png", "image/png"},
+ {MultimodalType::AUDIO, "https://a/b/c.mp3", "audio/mpeg"},
+ {MultimodalType::VIDEO, "https://a/b/c.mp4", "video/mp4"},
+ };
+
+ for (const auto& test_case : test_cases) {
+ std::string request_body;
+ Status st = gemini_adapter.build_multimodal_embedding_request(
+ test_case.media_type, test_case.media_url, request_body);
+ ASSERT_TRUE(st.ok()) << st.to_string();
+
+ rapidjson::Document doc;
+ doc.Parse(request_body.c_str());
+ ASSERT_FALSE(doc.HasParseError()) << request_body;
+ ASSERT_TRUE(doc.IsObject());
+ ASSERT_TRUE(doc.HasMember("model"));
+ ASSERT_STREQ(doc["model"].GetString(), "models/gemini-embedding-2-preview");
+ ASSERT_TRUE(doc.HasMember("outputDimensionality"));
+ ASSERT_EQ(doc["outputDimensionality"].GetInt(), 768);
+ ASSERT_TRUE(doc.HasMember("content"));
+ ASSERT_TRUE(doc["content"].HasMember("parts"));
+ ASSERT_TRUE(doc["content"]["parts"].IsArray());
+ ASSERT_EQ(doc["content"]["parts"].Size(), 1);
+ ASSERT_TRUE(doc["content"]["parts"][0].HasMember("file_data"));
+ ASSERT_TRUE(doc["content"]["parts"][0]["file_data"].IsObject());
+ ASSERT_STREQ(doc["content"]["parts"][0]["file_data"]["mime_type"].GetString(),
+ test_case.mime_type);
+ ASSERT_STREQ(doc["content"]["parts"][0]["file_data"]["file_uri"].GetString(),
+ test_case.media_url);
+ }
+}
+
} // namespace doris
diff --git a/be/test/ai/ai_function_test.cpp b/be/test/ai/ai_function_test.cpp
index 415e9d2d222..8643c3d5c54 100644
--- a/be/test/ai/ai_function_test.cpp
+++ b/be/test/ai/ai_function_test.cpp
@@ -15,16 +15,22 @@
// specific language governing permissions and limitations
// under the License.
+#include <arpa/inet.h>
#include <gen_cpp/PaloInternalService_types.h>
#include <gtest/gtest.h>
+#include <netinet/in.h>
+#include <sys/socket.h>
+#include <unistd.h>
#include <string>
+#include <thread>
#include "core/block/block.h"
#include "core/column/column_array.h"
#include "core/column/column_string.h"
#include "core/column/column_vector.h"
#include "core/data_type/data_type_array.h"
+#include "exprs/function/ai/ai_adapter.h"
#include "exprs/function/ai/ai_classify.h"
#include "exprs/function/ai/ai_extract.h"
#include "exprs/function/ai/ai_filter.h"
@@ -41,6 +47,118 @@
namespace doris {
+class FunctionAITransportTestHelper : public FunctionAISentiment {
+public:
+ using FunctionAISentiment::do_send_request;
+ using FunctionAISentiment::execute_embedding_request;
+};
+
+class EmptyEmbeddingResultAdapter : public MockAdapter {
+public:
+ Status parse_embedding_response(const std::string& /*response_body*/,
+ std::vector<std::vector<float>>& /*results*/) const override {
+ return Status::OK();
+ }
+};
+
+class OneShotHttpServer {
+public:
+ OneShotHttpServer(int status_code, std::string response_body)
+ : _status_code(status_code), _response_body(std::move(response_body)) {
+ _listen_fd = socket(AF_INET, SOCK_STREAM, 0);
+ DCHECK_GE(_listen_fd, 0);
+
+ int reuse_addr = 1;
+ int ret = setsockopt(_listen_fd, SOL_SOCKET, SO_REUSEADDR, &reuse_addr, sizeof(reuse_addr));
+ DCHECK_EQ(ret, 0);
+
+ sockaddr_in addr {};
+ addr.sin_family = AF_INET;
+ addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+ addr.sin_port = 0;
+ ret = bind(_listen_fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
+ DCHECK_EQ(ret, 0);
+ ret = listen(_listen_fd, 1);
+ DCHECK_EQ(ret, 0);
+
+ socklen_t addr_len = sizeof(addr);
+ ret = getsockname(_listen_fd, reinterpret_cast<sockaddr*>(&addr), &addr_len);
+ DCHECK_EQ(ret, 0);
+ _port = ntohs(addr.sin_port);
+
+ _thread = std::thread([this] { _serve_once(); });
+ }
+
+ ~OneShotHttpServer() {
+ if (_thread.joinable()) {
+ _thread.join();
+ }
+ if (_listen_fd >= 0) {
+ close(_listen_fd);
+ }
+ }
+
+ std::string endpoint() const { return "http://127.0.0.1:" + std::to_string(_port); }
+
+ std::string join_and_get_request() {
+ if (_thread.joinable()) {
+ _thread.join();
+ }
+ return _request;
+ }
+
+private:
+ void _serve_once() {
+ int client_fd = accept(_listen_fd, nullptr, nullptr);
+ DCHECK_GE(client_fd, 0);
+
+ char buffer[4096];
+ while (true) {
+ ssize_t read_bytes = recv(client_fd, buffer, sizeof(buffer), 0);
+ if (read_bytes <= 0) {
+ break;
+ }
+ _request.append(buffer, read_bytes);
+ if (_request.find("\r\n\r\n") != std::string::npos) {
+ auto header_end = _request.find("\r\n\r\n");
+ size_t body_length = 0;
+ size_t length_pos = _request.find("Content-Length:");
+ if (length_pos != std::string::npos) {
+ size_t value_begin = length_pos + sizeof("Content-Length:") - 1;
+ size_t value_end = _request.find("\r\n", value_begin);
+ std::string length_str = _request.substr(value_begin, value_end - value_begin);
+ body_length = std::stoul(length_str);
+ }
+ if (_request.size() >= header_end + 4 + body_length) {
+ break;
+ }
+ }
+ }
+
+ std::string status_text = _status_code == 200 ? "OK" : "Internal Server Error";
+ std::string response = "HTTP/1.1 " + std::to_string(_status_code) + " " + status_text +
+ "\r\nContent-Type: application/json\r\nContent-Length: " +
+ std::to_string(_response_body.size()) +
+ "\r\nConnection: close\r\n\r\n" + _response_body;
+ size_t sent_bytes = 0;
+ while (sent_bytes < response.size()) {
+ ssize_t sent =
+ send(client_fd, response.data() + sent_bytes, response.size() - sent_bytes, 0);
+ DCHECK_GT(sent, 0);
+ sent_bytes += sent;
+ }
+ shutdown(client_fd, SHUT_RDWR);
+ close(client_fd);
+ }
+
+ int _listen_fd = -1;
+ uint16_t _port = 0;
+ int _status_code;
+ std::string _response_body;
+ std::string _request;
+ std::thread _thread;
+};
+
TEST(AIFunctionTest, AISummarizeTest) {
FunctionAISummarize function;
@@ -369,6 +487,43 @@ TEST(AIFunctionTest, AISimilarityTrimWhitespace) {
unsetenv("AI_TEST_RESULT");
}
+TEST(AIFunctionTest, AISimilarityInvalidValue) {
+ auto runtime_state = std::make_unique<MockRuntimeState>();
+ auto ctx = FunctionContext::create_context(runtime_state.get(), {}, {});
+
+ std::vector<std::string> invalid_cases = {"abc", "1.2x", "", " ", "\n\t", "1,2"};
+
+ for (const auto& invalid_value : invalid_cases) {
+ setenv("AI_TEST_RESULT", invalid_value.c_str(), 1);
+
+ std::vector<std::string> resources = {"mock_resource"};
+ std::vector<std::string> text1 = {"Test text 1"};
+ std::vector<std::string> text2 = {"Test text 2"};
+ auto col_resource = ColumnHelper::create_column<DataTypeString>(resources);
+ auto col_text1 = ColumnHelper::create_column<DataTypeString>(text1);
+ auto col_text2 = ColumnHelper::create_column<DataTypeString>(text2);
+
+ Block block;
+ block.insert({std::move(col_resource), std::make_shared<DataTypeString>(), "resource"});
+ block.insert({std::move(col_text1), std::make_shared<DataTypeString>(), "text1"});
+ block.insert({std::move(col_text2), std::make_shared<DataTypeString>(), "text2"});
+ block.insert({nullptr, std::make_shared<DataTypeFloat32>(), "result"});
+
+ ColumnNumbers arguments = {0, 1, 2};
+ size_t result_idx = 3;
+
+ auto similarity_func = FunctionAISimilarity::create();
+ Status exec_status = similarity_func->execute_impl(ctx.get(), block, arguments, result_idx,
+ text1.size());
+
+ ASSERT_FALSE(exec_status.ok())
+ << "Should have failed for invalid value: '" << invalid_value << "'";
+ ASSERT_NE(exec_status.to_string().find("Failed to parse float value"), std::string::npos);
+ }
+
+ unsetenv("AI_TEST_RESULT");
+}
+
TEST(AIFunctionTest, AIFilterTest) {
FunctionAIFilter function;
@@ -417,6 +572,39 @@ TEST(AIFunctionTest, AIFilterExecuteTest) {
ASSERT_TRUE(val == 0);
}
+TEST(AIFunctionTest, AIFilterExecuteMultipleRows) {
+ auto runtime_state = std::make_unique<MockRuntimeState>();
+ auto ctx = FunctionContext::create_context(runtime_state.get(), {}, {});
+
+ setenv("AI_TEST_RESULT", " 1 ", 1);
+
+ std::vector<std::string> resources = {"mock_resource", "mock_resource"};
+ std::vector<std::string> texts = {"This is valid.", "This is also valid."};
+ auto col_resource = ColumnHelper::create_column<DataTypeString>(resources);
+ auto col_text = ColumnHelper::create_column<DataTypeString>(texts);
+
+ Block block;
+ block.insert({std::move(col_resource), std::make_shared<DataTypeString>(), "resource"});
+ block.insert({std::move(col_text), std::make_shared<DataTypeString>(), "text"});
+ block.insert({nullptr, std::make_shared<DataTypeBool>(), "result"});
+
+ ColumnNumbers arguments = {0, 1};
+ size_t result_idx = 2;
+
+ auto filter_func = FunctionAIFilter::create();
+ Status exec_status =
+ filter_func->execute_impl(ctx.get(), block, arguments, result_idx, texts.size());
+
+ unsetenv("AI_TEST_RESULT");
+
+ ASSERT_TRUE(exec_status.ok()) << exec_status.to_string();
+ const auto& res_col =
+ assert_cast<const ColumnUInt8&>(*block.get_by_position(result_idx).column);
+ ASSERT_EQ(res_col.size(), 2);
+ ASSERT_EQ(res_col.get_data()[0], 1);
+ ASSERT_EQ(res_col.get_data()[1], 1);
+}
+
TEST(AIFunctionTest, AIFilterTrimWhitespace) {
auto runtime_state = std::make_unique<MockRuntimeState>();
auto ctx = FunctionContext::create_context(runtime_state.get(), {}, {});
@@ -662,4 +850,137 @@ TEST(AIFunctionTest, NormalizeEndpointNoopForOtherPaths) {
ASSERT_EQ(resource.endpoint, "https://localhost/v1/responses");
}
-} // namespace doris
\ No newline at end of file
+TEST(AIFunctionTest, DoSendRequestTransportError) {
+ TQueryOptions query_options = create_fake_query_options();
+ query_options.__set_query_timeout(5);
+ auto query_ctx = MockQueryContext::create(TUniqueId(), ExecEnv::GetInstance(), query_options);
+ TQueryGlobals query_globals;
+ RuntimeState runtime_state(TUniqueId(), 0, query_options, query_globals, nullptr,
+ query_ctx.get());
+ auto ctx = FunctionContext::create_context(&runtime_state, {}, {});
+
+ TAIResource config;
+ config.endpoint = "http://127.0.0.1:1";
+ config.provider_type = "OPENAI";
+ config.model_name = "test-model";
+ config.api_key = "secret";
+
+ std::shared_ptr<AIAdapter> adapter = std::make_shared<OpenAIAdapter>();
+ adapter->init(config);
+
+ HttpClient client;
+ std::string response;
+ FunctionAITransportTestHelper helper;
+ Status st = helper.do_send_request(&client, "{}", response, config, adapter, ctx.get());
+
+ ASSERT_FALSE(st.ok());
+ ASSERT_EQ(response, "");
+}
+
+TEST(AIFunctionTest, DoSendRequestNon200) {
+ TQueryOptions query_options = create_fake_query_options();
+ query_options.__set_query_timeout(5);
+ auto query_ctx = MockQueryContext::create(TUniqueId(), ExecEnv::GetInstance(), query_options);
+ TQueryGlobals query_globals;
+ RuntimeState runtime_state(TUniqueId(), 0, query_options, query_globals, nullptr,
+ query_ctx.get());
+ auto ctx = FunctionContext::create_context(&runtime_state, {}, {});
+
+ OneShotHttpServer server(500, R"({"error":"bad request"})");
+
+ TAIResource config;
+ config.endpoint = server.endpoint();
+ config.provider_type = "OPENAI";
+ config.model_name = "test-model";
+ config.api_key = "secret";
+
+ std::shared_ptr<AIAdapter> adapter = std::make_shared<OpenAIAdapter>();
+ adapter->init(config);
+
+ HttpClient client;
+ std::string response;
+ FunctionAITransportTestHelper helper;
+ Status st = helper.do_send_request(&client, R"({"message":"hello"})", response, config, adapter,
+ ctx.get());
+
+ ASSERT_FALSE(st.ok());
+ ASSERT_NE(st.to_string().find("http status code is not 200"), std::string::npos);
+ ASSERT_EQ(response, R"({"error":"bad request"})");
+
+ std::string request = server.join_and_get_request();
+ ASSERT_NE(request.find("Authorization: Bearer secret"), std::string::npos);
+ ASSERT_NE(request.find("Content-Type: application/json"), std::string::npos);
+}
+
+TEST(AIFunctionTest, DoSendRequestSuccess) {
+ TQueryOptions query_options = create_fake_query_options();
+ query_options.__set_query_timeout(5);
+ auto query_ctx = MockQueryContext::create(TUniqueId(), ExecEnv::GetInstance(), query_options);
+ TQueryGlobals query_globals;
+ RuntimeState runtime_state(TUniqueId(), 0, query_options, query_globals, nullptr,
+ query_ctx.get());
+ auto ctx = FunctionContext::create_context(&runtime_state, {}, {});
+
+ OneShotHttpServer server(200, R"({"ok":true})");
+
+ TAIResource config;
+ config.endpoint = server.endpoint();
+ config.provider_type = "OPENAI";
+ config.model_name = "test-model";
+ config.api_key = "secret";
+
+ std::shared_ptr<AIAdapter> adapter = std::make_shared<OpenAIAdapter>();
+ adapter->init(config);
+
+ HttpClient client;
+ std::string response;
+ FunctionAITransportTestHelper helper;
+ Status st = helper.do_send_request(&client, R"({"message":"hello"})", response, config, adapter,
+ ctx.get());
+
+ ASSERT_TRUE(st.ok()) << st.to_string();
+ ASSERT_EQ(response, R"({"ok":true})");
+
+ std::string request = server.join_and_get_request();
+ ASSERT_NE(request.find("POST "), std::string::npos);
+ ASSERT_NE(request.find(R"({"message":"hello"})"), std::string::npos);
+}
+
+TEST(AIFunctionTest, ExecuteEmbeddingRequestMockSuccess) {
+ auto runtime_state = std::make_unique<MockRuntimeState>();
+ auto ctx = FunctionContext::create_context(runtime_state.get(), {}, {});
+
+ TAIResource config;
+ config.provider_type = "MOCK";
+ std::shared_ptr<AIAdapter> adapter = std::make_shared<MockAdapter>();
+ adapter->init(config);
+
+ FunctionAITransportTestHelper helper;
+ std::vector<float> result;
+ Status st = helper.execute_embedding_request("{}", result, config, adapter, ctx.get());
+
+ ASSERT_TRUE(st.ok()) << st.to_string();
+ ASSERT_EQ(result.size(), 5);
+ for (size_t i = 0; i < result.size(); ++i) {
+ ASSERT_FLOAT_EQ(result[i], static_cast<float>(i));
+ }
+}
+
+TEST(AIFunctionTest, ExecuteEmbeddingRequestEmptyResult) {
+ auto runtime_state = std::make_unique<MockRuntimeState>();
+ auto ctx = FunctionContext::create_context(runtime_state.get(), {}, {});
+
+ TAIResource config;
+ config.provider_type = "MOCK";
+ std::shared_ptr<AIAdapter> adapter = std::make_shared<EmptyEmbeddingResultAdapter>();
+ adapter->init(config);
+
+ FunctionAITransportTestHelper helper;
+ std::vector<float> result;
+ Status st = helper.execute_embedding_request("{}", result, config, adapter, ctx.get());
+
+ ASSERT_FALSE(st.ok());
+ ASSERT_NE(st.to_string().find("AI returned empty result"), std::string::npos);
+}
+
+} // namespace doris
diff --git a/be/test/ai/embed_test.cpp b/be/test/ai/embed_test.cpp
index 0f3ef7fcd92..a296d21daea 100644
--- a/be/test/ai/embed_test.cpp
+++ b/be/test/ai/embed_test.cpp
@@ -25,7 +25,11 @@
#include <string>
#include <vector>
+#include "core/data_type/data_type_jsonb.h"
+#include "core/data_type/data_type_number.h"
+#include "core/value/jsonb_value.h"
#include "exprs/function/ai/ai_adapter.h"
+#include "io/fs/obj_storage_client.h"
#include "testutil/column_helper.h"
#include "testutil/mock/mock_runtime_state.h"
@@ -40,6 +44,101 @@ private:
std::string _content_type;
};
+class MockEmbedObjStorageClient : public io::ObjStorageClient {
+public:
+ io::ObjectStorageUploadResponse create_multipart_upload(
+ const io::ObjectStoragePathOptions& /*opts*/) override {
+ return {};
+ }
+
+ io::ObjectStorageResponse put_object(const io::ObjectStoragePathOptions& /*opts*/,
+ std::string_view /*stream*/) override {
+ return io::ObjectStorageResponse::OK();
+ }
+
+ io::ObjectStorageUploadResponse upload_part(const io::ObjectStoragePathOptions& /*opts*/,
+ std::string_view /*stream*/,
+ int /*part_num*/) override {
+ return {};
+ }
+
+ io::ObjectStorageResponse complete_multipart_upload(
+ const io::ObjectStoragePathOptions& /*opts*/,
+ const std::vector<io::ObjectCompleteMultiPart>& /*completed_parts*/) override {
+ return io::ObjectStorageResponse::OK();
+ }
+
+ io::ObjectStorageHeadResponse head_object(
+ const io::ObjectStoragePathOptions& /*opts*/) override {
+ return {};
+ }
+
+ io::ObjectStorageResponse get_object(const io::ObjectStoragePathOptions& /*opts*/,
+ void* /*buffer*/, size_t /*offset*/, size_t /*bytes_read*/,
+ size_t* /*size_return*/) override {
+ return io::ObjectStorageResponse::OK();
+ }
+
+ io::ObjectStorageResponse list_objects(const io::ObjectStoragePathOptions& /*opts*/,
+ std::vector<io::FileInfo>* /*files*/) override {
+ return io::ObjectStorageResponse::OK();
+ }
+
+ io::ObjectStorageResponse delete_objects(const io::ObjectStoragePathOptions& /*opts*/,
+ std::vector<std::string> /*objs*/) override {
+ return io::ObjectStorageResponse::OK();
+ }
+
+ io::ObjectStorageResponse delete_object(const io::ObjectStoragePathOptions& /*opts*/) override {
+ return io::ObjectStorageResponse::OK();
+ }
+
+ io::ObjectStorageResponse delete_objects_recursively(
+ const io::ObjectStoragePathOptions& /*opts*/) override {
+ return io::ObjectStorageResponse::OK();
+ }
+
+ std::string generate_presigned_url(const io::ObjectStoragePathOptions& opts,
+ int64_t expiration_secs, const S3ClientConf& conf) override {
+ last_opts = opts;
+ last_expiration_secs = expiration_secs;
+ last_conf = conf;
+ return fmt::format("mock-s3://{}/{}?ttl={}", opts.bucket, opts.key, expiration_secs);
+ }
+
+ io::ObjectStoragePathOptions last_opts;
+ int64_t last_expiration_secs = 0;
+ S3ClientConf last_conf;
+};
+
+static ColumnString::MutablePtr create_jsonb_column(const std::vector<std::string>& json_rows) {
+ auto column = ColumnString::create();
+ for (const auto& json_row : json_rows) {
+ JsonBinaryValue jsonb_value;
+ Status st = jsonb_value.from_json_string(json_row);
+ EXPECT_TRUE(st.ok()) << st.to_string();
+ column->insert_data(jsonb_value.value(), jsonb_value.size());
+ }
+ return column;
+}
+
+static void assert_mock_embedding_column(const ColumnArray& col_array, size_t row_count) {
+ const auto& offsets = col_array.get_offsets();
+ ASSERT_EQ(offsets.size(), row_count);
+
+ const auto& nested_nullable_col = assert_cast<const ColumnNullable&>(col_array.get_data());
+ const auto& nested_col =
+ assert_cast<const ColumnFloat32&>(*nested_nullable_col.get_nested_column_ptr());
+ ASSERT_EQ(nested_col.size(), row_count * 5);
+
+ for (size_t row = 0; row < row_count; ++row) {
+ ASSERT_EQ(offsets[row], (row + 1) * 5);
+ for (size_t i = 0; i < 5; ++i) {
+ ASSERT_FLOAT_EQ(nested_col.get_element(row * 5 + i), static_cast<float>(i));
+ }
+ }
+}
+
TEST(EMBED_TEST, embed_function_build_test) {
FunctionEmbed function;
@@ -73,7 +172,10 @@ TEST(EMBED_TEST, embed_function_test) {
Block block;
block.insert({std::move(col_resource), std::make_shared<DataTypeString>(), "resource"});
block.insert({std::move(col_text), std::make_shared<DataTypeString>(), "text"});
- block.insert({nullptr, std::make_shared<DataTypeString>(), "result"});
+ block.insert(
+ {nullptr,
+ std::make_shared<DataTypeArray>(make_nullable(std::make_shared<DataTypeFloat32>())),
+ "result"});
ColumnNumbers arguments = {0, 1};
size_t result_idx = 2;
@@ -96,6 +198,316 @@ TEST(EMBED_TEST, embed_function_test) {
}
}
+TEST(EMBED_TEST, embed_function_text_multi_rows) {
+ auto runtime_state = std::make_unique<MockRuntimeState>();
+ auto ctx = FunctionContext::create_context(runtime_state.get(), {}, {});
+
+ std::vector<std::string> resources = {"mock_resource", "mock_resource"};
+ std::vector<std::string> texts = {"test input 1", "test input 2"};
+ auto col_resource = ColumnHelper::create_column<DataTypeString>(resources);
+ auto col_text = ColumnHelper::create_column<DataTypeString>(texts);
+
+ Block block;
+ block.insert({std::move(col_resource), std::make_shared<DataTypeString>(), "resource"});
+ block.insert({std::move(col_text), std::make_shared<DataTypeString>(), "text"});
+ block.insert(
+ {nullptr,
+ std::make_shared<DataTypeArray>(make_nullable(std::make_shared<DataTypeFloat32>())),
+ "result"});
+
+ ColumnNumbers arguments = {0, 1};
+ size_t result_idx = 2;
+
+ auto embed_func = FunctionEmbed::create();
+ Status exec_status =
+ embed_func->execute_impl(ctx.get(), block, arguments, result_idx, texts.size());
+
+ ASSERT_TRUE(exec_status.ok()) << exec_status.to_string();
+ const auto& col_array =
+ assert_cast<const ColumnArray&>(*block.get_by_position(result_idx).column);
+ assert_mock_embedding_column(col_array, texts.size());
+}
+
+TEST(EMBED_TEST, embed_function_multimodal_direct_url) {
+ TQueryOptions query_options = create_fake_query_options();
+ query_options.__set_file_presigned_url_ttl_seconds(0);
+ auto query_ctx = MockQueryContext::create(TUniqueId(), ExecEnv::GetInstance(), query_options);
+ query_ctx->set_mock_ai_resource();
+ TQueryGlobals query_globals;
+ RuntimeState runtime_state(TUniqueId(), 0, query_options, query_globals, nullptr,
+ query_ctx.get());
+ auto ctx = FunctionContext::create_context(&runtime_state, {}, {});
+
+ std::vector<std::string> resources = {"mock_resource", "mock_resource", "mock_resource"};
+ std::vector<std::string> file_json_rows = {
+ R"({"content_type":"image/png","uri":"https://example.com/a.png"})",
+ R"({"content_type":"video/mp4","uri":"https://example.com/b.mp4"})",
+ R"({"content_type":"audio/mpeg","uri":"https://example.com/c.mp3"})"};
+
+ auto col_resource = ColumnHelper::create_column<DataTypeString>(resources);
+ auto col_file = create_jsonb_column(file_json_rows);
+
+ Block block;
+ block.insert({std::move(col_resource), std::make_shared<DataTypeString>(), "resource"});
+ block.insert({std::move(col_file), std::make_shared<DataTypeJsonb>(), "file"});
+ block.insert(
+ {nullptr,
+ std::make_shared<DataTypeArray>(make_nullable(std::make_shared<DataTypeFloat32>())),
+ "result"});
+
+ ColumnNumbers arguments = {0, 1};
+ size_t result_idx = 2;
+
+ auto embed_func = FunctionEmbed::create();
+ Status exec_status = embed_func->execute_impl(ctx.get(), block, arguments, result_idx,
+ file_json_rows.size());
+
+ ASSERT_TRUE(exec_status.ok()) << exec_status.to_string();
+ const auto& col_array =
+ assert_cast<const ColumnArray&>(*block.get_by_position(result_idx).column);
+ assert_mock_embedding_column(col_array, file_json_rows.size());
+}
+
+TEST(EMBED_TEST, embed_function_multimodal_s3_presigned_url) {
+ TQueryOptions query_options = create_fake_query_options();
+ query_options.__set_file_presigned_url_ttl_seconds(123);
+ auto query_ctx = MockQueryContext::create(TUniqueId(), ExecEnv::GetInstance(), query_options);
+ query_ctx->set_mock_ai_resource();
+ TQueryGlobals query_globals;
+ RuntimeState runtime_state(TUniqueId(), 0, query_options, query_globals, nullptr,
+ query_ctx.get());
+ auto ctx = FunctionContext::create_context(&runtime_state, {}, {});
+
+ auto mock_client = std::make_shared<MockEmbedObjStorageClient>();
+ S3ClientFactory::instance().set_client_creator_for_test(
+ [mock_client](const S3ClientConf&) { return mock_client; });
+
+ std::vector<std::string> resources = {"mock_resource"};
+ std::vector<std::string> file_json_rows = {R"({
+ "content_type":"image/png",
+ "uri":"s3://test-bucket/path/to/image.png",
+ "endpoint":"cos.ap-beijing.myqcloud.com",
+ "region":"ap-beijing",
+ "ak":"test-ak",
+ "sk":"test-sk",
+ "role_arn":"test-role",
+ "external_id":"test-external-id"
+ })"};
+
+ auto col_resource = ColumnHelper::create_column<DataTypeString>(resources);
+ auto col_file = create_jsonb_column(file_json_rows);
+
+ Block block;
+ block.insert({std::move(col_resource), std::make_shared<DataTypeString>(), "resource"});
+ block.insert({std::move(col_file), std::make_shared<DataTypeJsonb>(), "file"});
+ block.insert(
+ {nullptr,
+ std::make_shared<DataTypeArray>(make_nullable(std::make_shared<DataTypeFloat32>())),
+ "result"});
+
+ ColumnNumbers arguments = {0, 1};
+ size_t result_idx = 2;
+
+ auto embed_func = FunctionEmbed::create();
+ Status exec_status = embed_func->execute_impl(ctx.get(), block, arguments, result_idx,
+ file_json_rows.size());
+
+ S3ClientFactory::instance().clear_client_creator_for_test();
+
+ ASSERT_TRUE(exec_status.ok()) << exec_status.to_string();
+ const auto& col_array =
+ assert_cast<const ColumnArray&>(*block.get_by_position(result_idx).column);
+ assert_mock_embedding_column(col_array, file_json_rows.size());
+
+ ASSERT_EQ(mock_client->last_opts.bucket, "test-bucket");
+ ASSERT_EQ(mock_client->last_opts.key, "path/to/image.png");
+ ASSERT_EQ(mock_client->last_expiration_secs, 123);
+ ASSERT_EQ(mock_client->last_conf.endpoint, "cos.ap-beijing.myqcloud.com");
+ ASSERT_EQ(mock_client->last_conf.region, "ap-beijing");
+ ASSERT_EQ(mock_client->last_conf.ak, "test-ak");
+ ASSERT_EQ(mock_client->last_conf.sk, "test-sk");
+ ASSERT_EQ(mock_client->last_conf.role_arn, "test-role");
+ ASSERT_EQ(mock_client->last_conf.external_id, "test-external-id");
+}
+
+TEST(EMBED_TEST, embed_function_multimodal_s3_missing_endpoint) {
+ auto runtime_state = std::make_unique<MockRuntimeState>();
+ auto ctx = FunctionContext::create_context(runtime_state.get(), {}, {});
+
+ std::vector<std::string> resources = {"mock_resource"};
+ std::vector<std::string> file_json_rows = {R"({
+ "content_type":"image/png",
+ "uri":"s3://test-bucket/path/to/image.png",
+ "region":"ap-beijing"
+ })"};
+ auto col_resource = ColumnHelper::create_column<DataTypeString>(resources);
+ auto col_file = create_jsonb_column(file_json_rows);
+
+ Block block;
+ block.insert({std::move(col_resource), std::make_shared<DataTypeString>(), "resource"});
+ block.insert({std::move(col_file), std::make_shared<DataTypeJsonb>(), "file"});
+ block.insert(
+ {nullptr,
+ std::make_shared<DataTypeArray>(make_nullable(std::make_shared<DataTypeFloat32>())),
+ "result"});
+
+ ColumnNumbers arguments = {0, 1};
+ size_t result_idx = 2;
+
+ auto embed_func = FunctionEmbed::create();
+ Status exec_status = embed_func->execute_impl(ctx.get(), block, arguments, result_idx,
+ file_json_rows.size());
+
+ ASSERT_FALSE(exec_status.ok());
+ ASSERT_NE(exec_status.to_string().find("field 'endpoint' is required"), std::string::npos);
+}
+
+TEST(EMBED_TEST, embed_function_multimodal_s3_missing_region) {
+ auto runtime_state = std::make_unique<MockRuntimeState>();
+ auto ctx = FunctionContext::create_context(runtime_state.get(), {}, {});
+
+ std::vector<std::string> resources = {"mock_resource"};
+ std::vector<std::string> file_json_rows = {R"({
+ "content_type":"image/png",
+ "uri":"s3://test-bucket/path/to/image.png",
+ "endpoint":"cos.ap-beijing.myqcloud.com"
+ })"};
+ auto col_resource = ColumnHelper::create_column<DataTypeString>(resources);
+ auto col_file = create_jsonb_column(file_json_rows);
+
+ Block block;
+ block.insert({std::move(col_resource), std::make_shared<DataTypeString>(), "resource"});
+ block.insert({std::move(col_file), std::make_shared<DataTypeJsonb>(), "file"});
+ block.insert(
+ {nullptr,
+ std::make_shared<DataTypeArray>(make_nullable(std::make_shared<DataTypeFloat32>())),
+ "result"});
+
+ ColumnNumbers arguments = {0, 1};
+ size_t result_idx = 2;
+
+ auto embed_func = FunctionEmbed::create();
+ Status exec_status = embed_func->execute_impl(ctx.get(), block, arguments, result_idx,
+ file_json_rows.size());
+
+ ASSERT_FALSE(exec_status.ok());
+ ASSERT_NE(exec_status.to_string().find("field 'region' is required"), std::string::npos);
+}
+
+TEST(EMBED_TEST, embed_function_wrong_argument_count) {
+ auto runtime_state = std::make_unique<MockRuntimeState>();
+ auto ctx = FunctionContext::create_context(runtime_state.get(), {}, {});
+
+ std::vector<std::string> resources = {"mock_resource"};
+ auto col_resource = ColumnHelper::create_column<DataTypeString>(resources);
+
+ Block block;
+ block.insert({std::move(col_resource), std::make_shared<DataTypeString>(), "resource"});
+ block.insert(
+ {nullptr,
+ std::make_shared<DataTypeArray>(make_nullable(std::make_shared<DataTypeFloat32>())),
+ "result"});
+
+ ColumnNumbers arguments = {0};
+ size_t result_idx = 1;
+
+ auto embed_func = FunctionEmbed::create();
+ Status exec_status =
+ embed_func->execute_impl(ctx.get(), block, arguments, result_idx, resources.size());
+
+ ASSERT_FALSE(exec_status.ok());
+ ASSERT_NE(exec_status.to_string().find("Function EMBED expects 2 arguments"),
+ std::string::npos);
+}
+
+TEST(EMBED_TEST, embed_function_invalid_input_type) {
+ auto runtime_state = std::make_unique<MockRuntimeState>();
+ auto ctx = FunctionContext::create_context(runtime_state.get(), {}, {});
+
+ std::vector<std::string> resources = {"mock_resource"};
+ std::vector<Int32> ids = {1};
+ auto col_resource = ColumnHelper::create_column<DataTypeString>(resources);
+ auto col_ids = ColumnHelper::create_column<DataTypeInt32>(ids);
+
+ Block block;
+ block.insert({std::move(col_resource), std::make_shared<DataTypeString>(), "resource"});
+ block.insert({std::move(col_ids), std::make_shared<DataTypeInt32>(), "id"});
+ block.insert(
+ {nullptr,
+ std::make_shared<DataTypeArray>(make_nullable(std::make_shared<DataTypeFloat32>())),
+ "result"});
+
+ ColumnNumbers arguments = {0, 1};
+ size_t result_idx = 2;
+
+ auto embed_func = FunctionEmbed::create();
+ Status exec_status =
+ embed_func->execute_impl(ctx.get(), block, arguments, result_idx, resources.size());
+
+ ASSERT_FALSE(exec_status.ok());
+ ASSERT_NE(exec_status.to_string().find(
+ "Function EMBED expects the second argument to be STRING or JSON"),
+ std::string::npos);
+}
+
+TEST(EMBED_TEST, embed_function_missing_required_json_field) {
+ auto runtime_state = std::make_unique<MockRuntimeState>();
+ auto ctx = FunctionContext::create_context(runtime_state.get(), {}, {});
+
+ std::vector<std::string> resources = {"mock_resource"};
+ std::vector<std::string> file_json_rows = {R"({"uri":"https://example.com/a.png"})"};
+ auto col_resource = ColumnHelper::create_column<DataTypeString>(resources);
+ auto col_file = create_jsonb_column(file_json_rows);
+
+ Block block;
+ block.insert({std::move(col_resource), std::make_shared<DataTypeString>(), "resource"});
+ block.insert({std::move(col_file), std::make_shared<DataTypeJsonb>(), "file"});
+ block.insert(
+ {nullptr,
+ std::make_shared<DataTypeArray>(make_nullable(std::make_shared<DataTypeFloat32>())),
+ "result"});
+
+ ColumnNumbers arguments = {0, 1};
+ size_t result_idx = 2;
+
+ auto embed_func = FunctionEmbed::create();
+ Status exec_status = embed_func->execute_impl(ctx.get(), block, arguments, result_idx,
+ file_json_rows.size());
+
+ ASSERT_FALSE(exec_status.ok());
+ ASSERT_NE(exec_status.to_string().find("field 'content_type' is required"), std::string::npos);
+}
+
+TEST(EMBED_TEST, embed_function_unsupported_content_type) {
+ auto runtime_state = std::make_unique<MockRuntimeState>();
+ auto ctx = FunctionContext::create_context(runtime_state.get(), {}, {});
+
+ std::vector<std::string> resources = {"mock_resource"};
+ std::vector<std::string> file_json_rows = {
+ R"({"content_type":"text/plain","uri":"https://example.com/a.txt"})"};
+ auto col_resource = ColumnHelper::create_column<DataTypeString>(resources);
+ auto col_file = create_jsonb_column(file_json_rows);
+
+ Block block;
+ block.insert({std::move(col_resource), std::make_shared<DataTypeString>(), "resource"});
+ block.insert({std::move(col_file), std::make_shared<DataTypeJsonb>(), "file"});
+ block.insert(
+ {nullptr,
+ std::make_shared<DataTypeArray>(make_nullable(std::make_shared<DataTypeFloat32>())),
+ "result"});
+
+ ColumnNumbers arguments = {0, 1};
+ size_t result_idx = 2;
+
+ auto embed_func = FunctionEmbed::create();
+ Status exec_status = embed_func->execute_impl(ctx.get(), block, arguments, result_idx,
+ file_json_rows.size());
+
+ ASSERT_FALSE(exec_status.ok());
+ ASSERT_NE(exec_status.to_string().find("Unsupported content_type for EMBED"),
+ std::string::npos);
+}
+
TEST(EMBED_TEST, local_adapter_embedding_request) {
LocalAdapter adapter;
TAIResource config;
@@ -727,4 +1139,4 @@ TEST(EMBED_TEST, minimax_adapter_embedding_request) {
ASSERT_STREQ(doc["texts"][0].GetString(), "embed with minimax");
}
-} // namespace doris
\ No newline at end of file
+} // namespace doris
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/Resource.java b/fe/fe-core/src/main/java/org/apache/doris/catalog/Resource.java
index 274d9af23fa..96e510c1a83 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/Resource.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/Resource.java
@@ -28,6 +28,7 @@ import org.apache.doris.nereids.trees.plans.commands.CreateResourceCommand;
import org.apache.doris.nereids.trees.plans.commands.info.CreateResourceInfo;
import org.apache.doris.persist.gson.GsonPostProcessable;
import org.apache.doris.persist.gson.GsonUtils;
+import org.apache.doris.qe.ConnectContext;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
@@ -295,4 +296,11 @@ public abstract class Resource implements Writable, GsonPostProcessable {
}
public void applyDefaultProperties() {}
+
+ public static void registerUsedAIResourceName(String resourceName) {
+ ConnectContext ctx = ConnectContext.get();
+ if (ctx != null && ctx.getStatementContext() != null) {
+ ctx.getStatementContext().registerUsedAIResourceName(resourceName);
+ }
+ }
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/constants/AIProperties.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/constants/AIProperties.java
index 8bddd094954..6019aaef303 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/property/constants/AIProperties.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/property/constants/AIProperties.java
@@ -55,7 +55,7 @@ public class AIProperties extends BaseProperties {
public static final List<String> REQUIRED_FIELDS = Arrays.asList(ENDPOINT, PROVIDER_TYPE, MODEL_NAME);
public static final List<String> PROVIDERS
= Arrays.asList("OPENAI", "LOCAL", "GEMINI", "DEEPSEEK", "ANTHROPIC",
- "MOONSHOT", "QWEN", "MINIMAX", "ZHIPU", "BAICHUAN", "VOYAGEAI");
+ "MOONSHOT", "QWEN", "MINIMAX", "ZHIPU", "BAICHUAN", "VOYAGEAI", "JINA");
public static void requiredAIProperties(Map<String, String> properties) throws DdlException {
// Check required field
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
index a65da6ef8c3..520ef17fb0b 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
@@ -69,6 +69,7 @@ import org.apache.doris.system.Backend;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Stopwatch;
+import com.google.common.base.Strings;
import com.google.common.base.Supplier;
import com.google.common.base.Suppliers;
import com.google.common.base.Throwables;
@@ -84,10 +85,12 @@ import java.io.Closeable;
import java.util.ArrayList;
import java.util.BitSet;
import java.util.Collection;
+import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -316,6 +319,7 @@ public class StatementContext implements Closeable {
private boolean hasNestedColumns;
private final Set<CTEId> mustInlineCTE = new HashSet<>();
+ private final Set<String> usedAIResourceNames = new LinkedHashSet<>();
private final Map<String, Integer> lowerCaseTableNamesCache = Maps.newHashMap();
private final Map<String, Integer> lowerCaseDatabaseNamesCache = Maps.newHashMap();
@@ -465,6 +469,17 @@ public class StatementContext implements Closeable {
return connectContext;
}
+ public Set<String> getUsedAIResourceNames() {
+ return Collections.unmodifiableSet(usedAIResourceNames);
+ }
+
+ public void registerUsedAIResourceName(String resourceName) {
+ if (Strings.isNullOrEmpty(resourceName)) {
+ throw new AnalysisException("AI resource name can not be empty");
+ }
+ usedAIResourceNames.add(resourceName);
+ }
+
public void setOriginStatement(OriginStatement originStatement) {
this.originStatement = originStatement;
if (originStatement != null && sqlCacheContext != null) {
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/agg/AIAgg.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/agg/AIAgg.java
index a609019ab00..833f1b7db4d 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/agg/AIAgg.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/agg/AIAgg.java
@@ -90,6 +90,7 @@ public class AIAgg extends NullableAggregateFunction
if (!(resource instanceof AIResource)) {
throw new AnalysisException("AI resource '" + resourceName + "' does not exist");
}
+ Resource.registerUsedAIResourceName(resourceName);
}
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/ai/AIFunction.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/ai/AIFunction.java
index 2826991208c..7399f2a348f 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/ai/AIFunction.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/ai/AIFunction.java
@@ -61,6 +61,7 @@ public abstract class AIFunction extends ScalarFunction
if (!(resource instanceof AIResource)) {
throw new AnalysisException("AI resource '" + resourceName + "' does not exist");
}
+ Resource.registerUsedAIResourceName(resourceName);
}
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/ai/Embed.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/ai/Embed.java
index 3ce12e72043..e3e8725856f 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/ai/Embed.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/expressions/functions/ai/Embed.java
@@ -17,12 +17,18 @@
package org.apache.doris.nereids.trees.expressions.functions.ai;
+import org.apache.doris.catalog.AIResource;
+import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.FunctionSignature;
+import org.apache.doris.catalog.Resource;
+import org.apache.doris.nereids.exceptions.AnalysisException;
import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.literal.StringLikeLiteral;
import org.apache.doris.nereids.trees.expressions.literal.StringLiteral;
import org.apache.doris.nereids.trees.expressions.visitor.ExpressionVisitor;
import org.apache.doris.nereids.types.ArrayType;
import org.apache.doris.nereids.types.FloatType;
+import org.apache.doris.nereids.types.JsonType;
import org.apache.doris.nereids.types.StringType;
import org.apache.doris.nereids.types.VarcharType;
@@ -41,7 +47,11 @@ public class Embed extends AIFunction {
FunctionSignature.ret(ArrayType.of(FloatType.INSTANCE))
.args(StringType.INSTANCE, StringType.INSTANCE),
FunctionSignature.ret(ArrayType.of(FloatType.INSTANCE))
- .args(VarcharType.SYSTEM_DEFAULT, VarcharType.SYSTEM_DEFAULT)
+ .args(VarcharType.SYSTEM_DEFAULT, VarcharType.SYSTEM_DEFAULT),
+ FunctionSignature.ret(ArrayType.of(FloatType.INSTANCE))
+ .args(StringType.INSTANCE, JsonType.INSTANCE),
+ FunctionSignature.ret(ArrayType.of(FloatType.INSTANCE))
+ .args(VarcharType.SYSTEM_DEFAULT, JsonType.INSTANCE)
);
/**
@@ -60,7 +70,7 @@ public class Embed extends AIFunction {
@Override
public Embed withChildren(List<Expression> children) {
- Preconditions.checkArgument(children.size() == 1 || children.size() == 2,
+ Preconditions.checkArgument(children.size() >= 1 && children.size() <= 2,
"Function EMBED only accepts 1 or 2 arguments");
if (children.size() == 1) {
return new Embed(new StringLiteral(getResourceName()),
@@ -83,4 +93,38 @@ public class Embed extends AIFunction {
public <R, C> R accept(ExpressionVisitor<R, C> visitor, C context) {
return visitor.visitEmbed(this, context);
}
+
+ @Override
+ public void checkLegalityBeforeTypeCoercion() {
+ if (arity() == 1) {
+ return;
+ }
+ if (arity() == 2) {
+ String aiResourceName = requireStringLiteral(child(0), "resource name",
+ "AI Function must accept literal for the resource name.");
+ validateAIResource(aiResourceName);
+ return;
+ }
+ throw new AnalysisException("Function EMBED only accepts 1 or 2 arguments");
+ }
+
+ private static String requireStringLiteral(Expression arg, String argName, String errorMsg) {
+ if (!(arg instanceof StringLikeLiteral)) {
+ throw new AnalysisException(errorMsg);
+ }
+ String value = ((StringLikeLiteral) arg).getStringValue();
+ if (value == null || value.isEmpty()) {
+ throw new AnalysisException("EMBED " + argName + " can not be empty.");
+ }
+ return value;
+ }
+
+ private static void validateAIResource(String resourceName) {
+ Resource resource = Env.getCurrentEnv().getResourceMgr().getResource(resourceName);
+ if (!(resource instanceof AIResource)) {
+ throw new AnalysisException("AI resource '" + resourceName + "' does not exist");
+ }
+ Resource.registerUsedAIResourceName(resourceName);
+ }
+
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/Coordinator.java b/fe/fe-core/src/main/java/org/apache/doris/qe/Coordinator.java
index d73dad2fd6b..5181a77b042 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/Coordinator.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/Coordinator.java
@@ -1667,6 +1667,21 @@ public class Coordinator implements CoordInterface {
return backend.getArrowFlightAddress();
}
+ private Map<String, TAIResource> getNeededAiResources() {
+ Map<String, TAIResource> aiResourceMap = Maps.newLinkedHashMap();
+ if (context == null || context.getStatementContext() == null) {
+ return aiResourceMap;
+ }
+ for (String resourceName : context.getStatementContext().getUsedAIResourceNames()) {
+ Resource resource = Env.getCurrentEnv().getResourceMgr().getResource(resourceName);
+ if (!(resource instanceof AIResource)) {
+ throw new IllegalStateException("AI resource '" + resourceName + "' does not exist");
+ }
+ aiResourceMap.put(resourceName, ((AIResource) resource).toThrift());
+ }
+ return aiResourceMap;
+ }
+
// estimate if this fragment contains UnionNode
private boolean containsUnionNode(PlanNode node) {
if (node instanceof UnionNode) {
@@ -3280,15 +3295,7 @@ public class Coordinator implements CoordInterface {
}
// Used for AI Functions
- Map<String, TAIResource> aiResourceMap = Maps.newLinkedHashMap();
- for (Resource resource : Env.getCurrentEnv().getResourceMgr()
- .getResource(Resource.ResourceType.AI)) {
- if (resource instanceof AIResource) {
- aiResourceMap.put(resource.getName(), ((AIResource) resource).toThrift());
- }
- }
-
- params.setAiResources(aiResourceMap);
+ params.setAiResources(getNeededAiResources());
res.put(instanceExecParam.host, params);
res.get(instanceExecParam.host).setBucketSeqToInstanceIdx(new HashMap<Integer, Integer>());
res.get(instanceExecParam.host).setShuffleIdxToInstanceIdx(new HashMap<Integer, Integer>());
@@ -3536,4 +3543,3 @@ public class Coordinator implements CoordInterface {
this.queryOptions.setEnableProfile(isSafe && queryOptions.isEnableProfile());
}
}
-
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
index 78fc4367b66..6f86e2bc71a 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
@@ -958,6 +958,7 @@ public class SessionVariable implements Serializable, Writable {
public static final String ENABLE_STRICT_CAST = "enable_strict_cast";
public static final String DEFAULT_AI_RESOURCE = "default_ai_resource";
+ public static final String FILE_PRESIGNED_URL_TTL_SECONDS = "file_presigned_url_ttl_seconds";
public static final String HNSW_EF_SEARCH = "hnsw_ef_search";
public static final String HNSW_CHECK_RELATIVE_DISTANCE = "hnsw_check_relative_distance";
public static final String HNSW_BOUNDED_QUEUE = "hnsw_bounded_queue";
@@ -3455,6 +3456,13 @@ public class SessionVariable implements Serializable, Writable {
})
public String defaultAIResource = "";
+ @VarAttrDef.VarAttr(name = FILE_PRESIGNED_URL_TTL_SECONDS, needForward = true,
+ description = {
+ "EMBED 多模态场景中,S3 预签名 URL 的有效期(秒)。",
+ "Expiration time in seconds for S3 presigned URL used by multimodal EMBED."
+ })
+ public long filePresignedUrlTtlSeconds = 3600;
+
public void setEnableEsParallelScroll(boolean enableESParallelScroll) {
this.enableESParallelScroll = enableESParallelScroll;
}
@@ -5427,6 +5435,7 @@ public class SessionVariable implements Serializable, Writable {
tResult.setEnableParquetFilePageCache(enableParquetFilePageCache);
tResult.setEnableOrcFilterByMinMax(enableOrcFilterByMinMax);
tResult.setEnablePaimonCppReader(enablePaimonCppReader);
+ tResult.setFilePresignedUrlTtlSeconds(filePresignedUrlTtlSeconds);
tResult.setCheckOrcInitSargsSuccess(checkOrcInitSargsSuccess);
tResult.setTruncateCharOrVarcharColumns(truncateCharOrVarcharColumns);
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/ThriftPlansBuilder.java b/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/ThriftPlansBuilder.java
index 41b4ac9e765..4f8499970c9 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/ThriftPlansBuilder.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/ThriftPlansBuilder.java
@@ -24,6 +24,7 @@ import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.Resource;
import org.apache.doris.common.Config;
import org.apache.doris.datasource.FileQueryScanNode;
+import org.apache.doris.nereids.StatementContext;
import org.apache.doris.nereids.trees.plans.distribute.DistributedPlan;
import org.apache.doris.nereids.trees.plans.distribute.PipelineDistributedPlan;
import org.apache.doris.nereids.trees.plans.distribute.worker.DistributedPlanWorker;
@@ -213,6 +214,27 @@ public class ThriftPlansBuilder {
});
}
+ static Map<String, TAIResource> collectAiResources(ConnectContext connectContext) {
+ Map<String, TAIResource> aiResourceMap = Maps.newLinkedHashMap();
+ if (connectContext == null) {
+ return aiResourceMap;
+ }
+
+ StatementContext statementContext = connectContext.getStatementContext();
+ if (statementContext == null) {
+ return aiResourceMap;
+ }
+
+ for (String resourceName : statementContext.getUsedAIResourceNames()) {
+ Resource resource = Env.getCurrentEnv().getResourceMgr().getResource(resourceName);
+ if (!(resource instanceof AIResource)) {
+ throw new IllegalStateException("AI resource '" + resourceName + "' does not exist");
+ }
+ aiResourceMap.put(resourceName, ((AIResource) resource).toThrift());
+ }
+ return aiResourceMap;
+ }
+
private static void setParamsForOlapTableSink(List<PipelineDistributedPlan> distributedPlans,
Map<DistributedPlanWorker, TPipelineFragmentParamsList> fragmentsGroupByWorker,
CoordinatorContext coordinatorContext) {
@@ -426,13 +448,7 @@ public class ThriftPlansBuilder {
params.setShuffleIdxToInstanceIdx(computeDestIdToInstanceId(fragmentPlan, w, instanceToIndex));
// Only used for AI Functions
- Map<String, TAIResource> aiResourceMap = Maps.newLinkedHashMap();
- for (Resource resource : Env.getCurrentEnv().getResourceMgr().getResource(Resource.ResourceType.AI)) {
- if (resource instanceof AIResource) {
- aiResourceMap.put(resource.getName(), ((AIResource) resource).toThrift());
- }
- }
- params.setAiResources(aiResourceMap);
+ params.setAiResources(collectAiResources(connectContext));
return params;
});
diff --git a/gensrc/thrift/PaloInternalService.thrift b/gensrc/thrift/PaloInternalService.thrift
index 65c567365b6..968971c395e 100644
--- a/gensrc/thrift/PaloInternalService.thrift
+++ b/gensrc/thrift/PaloInternalService.thrift
@@ -439,6 +439,7 @@ struct TQueryOptions {
200: optional bool enable_adjust_conjunct_order_by_cost;
// Use paimon-cpp to read Paimon splits on BE
201: optional bool enable_paimon_cpp_reader = false;
+
// Whether all fragments of this query are assigned to a single backend.
// When true, the streaming aggregation operator can use more aggressive
// hash table expansion thresholds since all data is local.
@@ -474,6 +475,9 @@ struct TQueryOptions {
210: optional double max_scan_mem_ratio = 0.3;
211: optional bool enable_adaptive_scan = false;
212: optional bool enable_local_exchange_before_agg = true;
+
+ 213: optional i64 file_presigned_url_ttl_seconds = 3600;
+
// For cloud, to control if the content would be written into file cache
// In write path, to control if the content would be written into file cache.
// In read path, read from file cache or remote storage when execute query.
diff --git a/regression-test/suites/ai_p0/test_ai_functions.groovy b/regression-test/suites/ai_p0/test_ai_functions.groovy
index 7ff9ff7777a..57cc2040e14 100644
--- a/regression-test/suites/ai_p0/test_ai_functions.groovy
+++ b/regression-test/suites/ai_p0/test_ai_functions.groovy
@@ -100,7 +100,10 @@ suite("test_ai_functions") {
sql """${sql_text}"""
assertTrue(false)
} catch (Exception e) {
- assertTrue(e.getMessage().contains("timeout") || e.getMessage().contains("requested URL returned error"))
+ assertTrue(e.getMessage().contains("timeout")
+ || e.getMessage().contains("requested URL returned error")
+ || e.getMessage().contains("http status code is not 200"),
+ "Unexpected exception message: " + e.getMessage())
} finally {
sql """UNSET VARIABLE query_timeout;"""
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@doris.apache.org
For additional commands, e-mail: commits-help@doris.apache.org