Coverage Report

Created: 2026-08-06 19:00

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
be/src/exprs/function/ai/embed.h
Line
Count
Source
1
// Licensed to the Apache Software Foundation (ASF) under one
2
// or more contributor license agreements.  See the NOTICE file
3
// distributed with this work for additional information
4
// regarding copyright ownership.  The ASF licenses this file
5
// to you under the Apache License, Version 2.0 (the
6
// "License"); you may not use this file except in compliance
7
// with the License.  You may obtain a copy of the License at
8
//
9
//   http://www.apache.org/licenses/LICENSE-2.0
10
//
11
// Unless required by applicable law or agreed to in writing,
12
// software distributed under the License is distributed on an
13
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
// KIND, either express or implied.  See the License for the
15
// specific language governing permissions and limitations
16
// under the License.
17
18
#pragma once
19
20
#include <glog/logging.h>
21
#include <rapidjson/document.h>
22
23
#include <string_view>
24
25
#include "core/data_type/data_type_nullable.h"
26
#include "core/data_type/primitive_type.h"
27
#include "exprs/function/ai/ai_functions.h"
28
#include "util/jsonb_utils.h"
29
#include "util/s3_uri.h"
30
#include "util/s3_util.h"
31
32
namespace doris {
33
class FunctionEmbed : public AIFunction<FunctionEmbed> {
34
public:
35
    static constexpr auto name = "embed";
36
37
    static constexpr size_t number_of_arguments = 2;
38
39
    static constexpr auto system_prompt = "";
40
41
5
    DataTypePtr get_nested_return_type_impl(const DataTypes& /*arguments*/) const {
42
5
        return std::make_shared<DataTypeArray>(make_nullable(std::make_shared<DataTypeFloat32>()));
43
5
    }
44
45
    using PreparedFunctionImpl::execute;
46
47
    Status execute(FunctionContext* context, Block& block, const ColumnNumbers& arguments,
48
                   uint32_t result, size_t input_rows_count, const TAIResource& config,
49
17
                   std::shared_ptr<AIAdapter>& adapter) const {
50
17
        if (arguments.size() != 2) {
51
1
            return Status::InvalidArgument("Function EMBED expects 2 arguments, but got {}",
52
1
                                           arguments.size());
53
1
        }
54
55
16
        const auto& input = block.get_by_position(arguments[1]);
56
16
        ColumnUInt8::MutablePtr result_null_map;
57
16
        if (input.type->is_nullable()) {
58
4
            const auto& [column, is_const] = unpack_if_const(input.column);
59
4
            const auto& nullable =
60
4
                    assert_cast<const ColumnNullable&, TypeCheckOnRelease::DISABLE>(*column);
61
4
            result_null_map = ColumnUInt8::create(input_rows_count, 0);
62
4
            VectorizedUtils::update_null_map(result_null_map->get_data(),
63
4
                                             nullable.get_null_map_data(), is_const);
64
4
        }
65
66
16
        if (result_null_map &&
67
16
            !simd::contain_zero(result_null_map->get_data().data(), input_rows_count)) {
68
1
            block.get_by_position(result).column =
69
1
                    block.get_by_position(result).type->create_column_const(input_rows_count,
70
1
                                                                            Field());
71
1
            return Status::OK();
72
1
        }
73
74
15
        ColumnPtr input_column =
75
15
                input.unnest_nullable(input.type->is_nullable() ? input.get_nullable_column_info()
76
15
                                                                : NullableColumnInfo {},
77
15
                                      false)
78
15
                        .column;
79
15
        PrimitiveType input_type = remove_nullable(input.type)->get_primitive_type();
80
15
        if (input_type == PrimitiveType::TYPE_JSONB) {
81
9
            return _execute_multimodal_embed(context, block, result, input_rows_count, config,
82
9
                                             adapter, input_column, std::move(result_null_map));
83
9
        }
84
6
        if (input_type == PrimitiveType::TYPE_STRING || input_type == PrimitiveType::TYPE_VARCHAR ||
85
6
            input_type == PrimitiveType::TYPE_CHAR) {
86
5
            return _execute_text_embed(context, block, result, input_rows_count, config, adapter,
87
5
                                       input_column, std::move(result_null_map));
88
5
        }
89
1
        return Status::InvalidArgument(
90
1
                "Function EMBED expects the second argument to be STRING or JSON, but got type {}",
91
1
                input.type->get_name());
92
6
    }
93
94
23
    static FunctionPtr create() { return std::make_shared<FunctionEmbed>(); }
95
96
private:
97
14
    static int32_t _get_embed_max_batch_size(FunctionContext* context) {
98
14
        QueryContext* query_ctx = context->state()->get_query_ctx();
99
14
        DORIS_CHECK(query_ctx != nullptr);
100
101
14
        return query_ctx->query_options().embed_max_batch_size;
102
14
    }
103
104
    Status _execute_text_embed(FunctionContext* context, Block& block, uint32_t result,
105
                               size_t input_rows_count, const TAIResource& config,
106
                               std::shared_ptr<AIAdapter>& adapter, const ColumnPtr& input_column,
107
5
                               ColumnUInt8::MutablePtr result_null_map) const {
108
5
        auto col_result = ColumnArray::create(
109
5
                ColumnNullable::create(ColumnFloat32::create(), ColumnUInt8::create()));
110
5
        std::vector<std::string> batch_prompts;
111
5
        size_t current_batch_size = 0;
112
5
        const int32_t max_batch_size = _get_embed_max_batch_size(context);
113
5
        const size_t max_context_window_size =
114
5
                static_cast<size_t>(get_ai_context_window_size(context));
115
5
        const NullMap* null_map = result_null_map ? &result_null_map->get_data() : nullptr;
116
5
        const Columns prompt_columns {input_column};
117
118
27
        for (size_t i = 0; i < input_rows_count; ++i) {
119
22
            if (null_map && (*null_map)[i]) {
120
9
                continue;
121
9
            }
122
123
13
            std::string prompt;
124
13
            RETURN_IF_ERROR(build_prompt(prompt_columns, i, prompt));
125
126
13
            const size_t prompt_size = prompt.size();
127
128
13
            if (prompt_size > max_context_window_size) {
129
                // flush history batch
130
0
                RETURN_IF_ERROR(_flush_text_embedding_batch(batch_prompts, *col_result, config,
131
0
                                                            adapter, context));
132
0
                current_batch_size = 0;
133
134
0
                batch_prompts.emplace_back(std::move(prompt));
135
0
                RETURN_IF_ERROR(_flush_text_embedding_batch(batch_prompts, *col_result, config,
136
0
                                                            adapter, context));
137
0
                continue;
138
0
            }
139
140
13
            if (!batch_prompts.empty() &&
141
13
                (current_batch_size + prompt_size > max_context_window_size ||
142
8
                 batch_prompts.size() >= static_cast<size_t>(max_batch_size))) {
143
3
                RETURN_IF_ERROR(_flush_text_embedding_batch(batch_prompts, *col_result, config,
144
3
                                                            adapter, context));
145
3
                current_batch_size = 0;
146
3
            }
147
148
13
            batch_prompts.emplace_back(std::move(prompt));
149
13
            current_batch_size += prompt_size;
150
13
        }
151
152
5
        RETURN_IF_ERROR(
153
5
                _flush_text_embedding_batch(batch_prompts, *col_result, config, adapter, context));
154
155
5
        block.replace_by_position(result, _expand_and_wrap_nullable_result(
156
5
                                                  std::move(col_result), std::move(result_null_map),
157
5
                                                  input_rows_count));
158
5
        return Status::OK();
159
5
    }
160
161
    Status _execute_multimodal_embed(FunctionContext* context, Block& block, uint32_t result,
162
                                     size_t input_rows_count, const TAIResource& config,
163
                                     std::shared_ptr<AIAdapter>& adapter,
164
                                     const ColumnPtr& input_column,
165
9
                                     ColumnUInt8::MutablePtr result_null_map) const {
166
9
        auto col_result = ColumnArray::create(
167
9
                ColumnNullable::create(ColumnFloat32::create(), ColumnUInt8::create()));
168
9
        std::vector<MultimodalType> batch_media_types;
169
9
        std::vector<std::string> batch_media_content_types;
170
9
        std::vector<std::string> batch_media_urls;
171
9
        const NullMap* null_map = result_null_map ? &result_null_map->get_data() : nullptr;
172
173
9
        int64_t ttl_seconds = 3600;
174
9
        QueryContext* query_ctx = context->state()->get_query_ctx();
175
9
        if (query_ctx && query_ctx->query_options().__isset.file_presigned_url_ttl_seconds) {
176
9
            ttl_seconds = query_ctx->query_options().file_presigned_url_ttl_seconds;
177
9
            if (ttl_seconds <= 0) {
178
1
                ttl_seconds = 3600;
179
1
            }
180
9
        }
181
182
9
        const int32_t max_batch_size = _get_embed_max_batch_size(context);
183
184
24
        for (size_t i = 0; i < input_rows_count; ++i) {
185
19
            if (null_map && (*null_map)[i]) {
186
3
                continue;
187
3
            }
188
189
16
            rapidjson::Document file_input;
190
16
            RETURN_IF_ERROR(_parse_file_input(*input_column, i, file_input));
191
192
16
            std::string content_type;
193
16
            MultimodalType media_type;
194
16
            RETURN_IF_ERROR(_infer_media_type(file_input, content_type, media_type));
195
196
14
            std::string media_url;
197
14
            RETURN_IF_ERROR(_resolve_media_url(file_input, ttl_seconds, media_url));
198
199
12
            if (!batch_media_urls.empty() &&
200
12
                batch_media_urls.size() >= static_cast<size_t>(max_batch_size)) {
201
1
                RETURN_IF_ERROR(_flush_multimodal_embedding_batch(
202
1
                        batch_media_types, batch_media_content_types, batch_media_urls, *col_result,
203
1
                        config, adapter, context));
204
1
            }
205
206
12
            batch_media_types.emplace_back(media_type);
207
12
            batch_media_content_types.emplace_back(std::move(content_type));
208
12
            batch_media_urls.emplace_back(std::move(media_url));
209
12
        }
210
211
5
        RETURN_IF_ERROR(_flush_multimodal_embedding_batch(
212
5
                batch_media_types, batch_media_content_types, batch_media_urls, *col_result, config,
213
5
                adapter, context));
214
215
5
        block.replace_by_position(result, _expand_and_wrap_nullable_result(
216
5
                                                  std::move(col_result), std::move(result_null_map),
217
5
                                                  input_rows_count));
218
5
        return Status::OK();
219
5
    }
220
221
    // EMBED-private helper.
222
    // Sends one embedding request with a prebuilt request body and validates returned row count.
223
    Status _execute_prebuilt_embedding_request(const std::string& request_body,
224
                                               std::vector<std::vector<float>>& results,
225
                                               size_t expected_size, const TAIResource& config,
226
                                               std::shared_ptr<AIAdapter>& adapter,
227
0
                                               FunctionContext* context) const {
228
0
        std::string response;
229
#ifdef BE_TEST
230
        if (config.provider_type == "MOCK") {
231
            results.clear();
232
            results.reserve(expected_size);
233
            for (size_t i = 0; i < expected_size; ++i) {
234
                results.emplace_back(std::initializer_list<float> {0, 1, 2, 3, 4});
235
            }
236
            return Status::OK();
237
        }
238
#endif
239
240
0
        RETURN_IF_ERROR(
241
0
                this->send_request_to_llm(request_body, response, config, adapter, context));
242
243
0
        RETURN_IF_ERROR(adapter->parse_embedding_response(response, results));
244
0
        if (results.empty()) {
245
0
            return Status::InternalError("AI returned empty result");
246
0
        }
247
0
        if (results.size() != expected_size) [[unlikely]] {
248
0
            return Status::InternalError(
249
0
                    "AI embedding returned {} results, but {} inputs were sent", results.size(),
250
0
                    expected_size);
251
0
        }
252
0
        return Status::OK();
253
0
    }
254
255
    // EMBED-private helper.
256
    // Flushes one accumulated text embedding batch into the output array column.
257
    Status _flush_text_embedding_batch(std::vector<std::string>& batch_prompts,
258
                                       ColumnArray& col_result, const TAIResource& config,
259
                                       std::shared_ptr<AIAdapter>& adapter,
260
8
                                       FunctionContext* context) const {
261
8
        if (batch_prompts.empty()) {
262
0
            return Status::OK();
263
0
        }
264
265
8
        std::string request_body;
266
8
        RETURN_IF_ERROR(adapter->build_embedding_request(batch_prompts, request_body));
267
8
        std::vector<std::vector<float>> batch_results;
268
8
        RETURN_IF_ERROR(_execute_prebuilt_embedding_request(
269
8
                request_body, batch_results, batch_prompts.size(), config, adapter, context));
270
13
        for (const auto& batch_result : batch_results) {
271
13
            _insert_embedding_result(col_result, batch_result);
272
13
        }
273
8
        batch_prompts.clear();
274
8
        return Status::OK();
275
8
    }
276
277
    // EMBED-private helper.
278
    // Flushes one accumulated multimodal embedding batch into the output array column.
279
    Status _flush_multimodal_embedding_batch(std::vector<MultimodalType>& batch_media_types,
280
                                             std::vector<std::string>& batch_media_content_types,
281
                                             std::vector<std::string>& batch_media_urls,
282
                                             ColumnArray& col_result, const TAIResource& config,
283
                                             std::shared_ptr<AIAdapter>& adapter,
284
6
                                             FunctionContext* context) const {
285
6
        if (batch_media_urls.empty()) {
286
0
            return Status::OK();
287
0
        }
288
289
6
        std::string request_body;
290
6
        RETURN_IF_ERROR(adapter->build_multimodal_embedding_request(
291
6
                batch_media_types, batch_media_urls, batch_media_content_types, request_body));
292
293
6
        std::vector<std::vector<float>> batch_results;
294
6
        RETURN_IF_ERROR(_execute_prebuilt_embedding_request(
295
6
                request_body, batch_results, batch_media_urls.size(), config, adapter, context));
296
12
        for (const auto& batch_result : batch_results) {
297
12
            _insert_embedding_result(col_result, batch_result);
298
12
        }
299
6
        batch_media_types.clear();
300
6
        batch_media_content_types.clear();
301
6
        batch_media_urls.clear();
302
6
        return Status::OK();
303
6
    }
304
305
    static void _insert_embedding_result(ColumnArray& col_array,
306
25
                                         const std::vector<float>& float_result) {
307
25
        auto& offsets = col_array.get_offsets();
308
25
        auto& nested_nullable_col = assert_cast<ColumnNullable&>(col_array.get_data());
309
25
        auto& nested_col =
310
25
                assert_cast<ColumnFloat32&>(*(nested_nullable_col.get_nested_column_ptr()));
311
25
        nested_col.reserve(nested_col.size() + float_result.size());
312
313
25
        size_t current_offset = nested_col.size();
314
25
        nested_col.insert_many_raw_data(reinterpret_cast<const char*>(float_result.data()),
315
25
                                        float_result.size());
316
25
        offsets.push_back(current_offset + float_result.size());
317
25
        auto& null_map = nested_nullable_col.get_null_map_column();
318
25
        null_map.insert_many_vals(0, float_result.size());
319
25
    }
320
321
    static ColumnPtr _expand_and_wrap_nullable_result(ColumnArray::MutablePtr result,
322
                                                      ColumnUInt8::MutablePtr result_null_map,
323
10
                                                      size_t input_rows_count) {
324
10
        if (!result_null_map) {
325
7
            return result;
326
7
        }
327
328
3
        auto& offsets = result->get_offsets();
329
3
        size_t compact_row = offsets.size();
330
3
        offsets.resize(input_rows_count);
331
        // For example, embedding rows 1 and 3 produces compact offsets [5, 10]. Given
332
        // result_null_map [1, 0, 1, 0, 1], expand them to [0, 5, 5, 10, 10], where NULL rows
333
        // reuse the previous offset. Fill backwards to avoid overwriting unread compact offsets.
334
24
        for (size_t row = input_rows_count; row-- > 0;) {
335
21
            if (result_null_map->get_data()[row]) {
336
12
                offsets[row] = compact_row == 0 ? 0 : offsets[compact_row - 1];
337
12
            } else {
338
9
                offsets[row] = offsets[--compact_row];
339
9
            }
340
21
        }
341
3
        return ColumnNullable::create(std::move(result), std::move(result_null_map));
342
10
    }
343
344
52
    static bool _starts_with_ignore_case(std::string_view s, std::string_view prefix) {
345
52
        if (s.size() < prefix.size()) {
346
0
            return false;
347
0
        }
348
243
        return std::equal(prefix.begin(), prefix.end(), s.begin(), [](char a, char b) {
349
243
            return std::tolower(static_cast<unsigned char>(a)) ==
350
243
                   std::tolower(static_cast<unsigned char>(b));
351
243
        });
352
52
    }
353
354
    static Status _infer_media_type(const rapidjson::Value& file_input, std::string& content_type,
355
16
                                    MultimodalType& media_type) {
356
16
        RETURN_IF_ERROR(_get_required_string_field(file_input, "content_type", content_type));
357
358
15
        if (_starts_with_ignore_case(content_type, "image/")) {
359
9
            media_type = MultimodalType::IMAGE;
360
9
            return Status::OK();
361
9
        } else if (_starts_with_ignore_case(content_type, "video/")) {
362
3
            media_type = MultimodalType::VIDEO;
363
3
            return Status::OK();
364
3
        } else if (_starts_with_ignore_case(content_type, "audio/")) {
365
2
            media_type = MultimodalType::AUDIO;
366
2
            return Status::OK();
367
2
        }
368
369
1
        return Status::InvalidArgument("Unsupported content_type for EMBED: {}", content_type);
370
15
    }
371
372
    // Parse the FILE-like JSONB argument into a JSON object for downstream field reads.
373
    static Status _parse_file_input(const IColumn& file_column, size_t row_num,
374
16
                                    rapidjson::Document& file_input) {
375
16
        StringRef file_ref = file_column.get_data_at(row_num);
376
16
        std::string file_json = JsonbToJson::jsonb_to_json_string(file_ref.data, file_ref.size);
377
16
        file_input.Parse(file_json.c_str());
378
16
        DORIS_CHECK(!file_input.HasParseError() && file_input.IsObject());
379
16
        return Status::OK();
380
16
    }
381
382
    // TODO(lzq): After support FILE type, We should use the interface provided by FILE to get the fields
383
    // replacing this function
384
    static Status _get_required_string_field(const rapidjson::Value& obj, const char* field_name,
385
35
                                             std::string& value) {
386
35
        auto iter = obj.FindMember(field_name);
387
35
        if (iter == obj.MemberEnd() || !iter->value.IsString()) {
388
3
            return Status::InvalidArgument(
389
3
                    "EMBED file json field '{}' is required and must be a string", field_name);
390
3
        }
391
32
        value = iter->value.GetString();
392
32
        if (value.empty()) {
393
0
            return Status::InvalidArgument("EMBED file json field '{}' can not be empty",
394
0
                                           field_name);
395
0
        }
396
32
        return Status::OK();
397
32
    }
398
399
    static Status init_s3_client_conf_from_json(const rapidjson::Value& file_input,
400
3
                                                S3ClientConf& s3_client_conf) {
401
3
        std::string endpoint;
402
3
        RETURN_IF_ERROR(_get_required_string_field(file_input, "endpoint", endpoint));
403
2
        std::string region;
404
2
        RETURN_IF_ERROR(_get_required_string_field(file_input, "region", region));
405
406
4
        auto get_optional_string_field = [&](const char* field_name, std::string& value) {
407
4
            auto iter = file_input.FindMember(field_name);
408
4
            if (iter == file_input.MemberEnd() || iter->value.IsNull()) {
409
0
                return;
410
0
            }
411
4
            DORIS_CHECK(iter->value.IsString());
412
4
            value = iter->value.GetString();
413
4
        };
414
415
1
        get_optional_string_field("ak", s3_client_conf.ak);
416
1
        get_optional_string_field("sk", s3_client_conf.sk);
417
1
        get_optional_string_field("role_arn", s3_client_conf.role_arn);
418
1
        get_optional_string_field("external_id", s3_client_conf.external_id);
419
1
        s3_client_conf.endpoint = endpoint;
420
1
        s3_client_conf.region = region;
421
422
1
        return Status::OK();
423
2
    }
424
425
    Status _resolve_media_url(const rapidjson::Value& file_input, int64_t ttl_seconds,
426
14
                              std::string& media_url) const {
427
14
        std::string uri;
428
14
        RETURN_IF_ERROR(_get_required_string_field(file_input, "uri", uri));
429
430
        // If it's a direct http/https URL, use it as-is
431
14
        if (_starts_with_ignore_case(uri, "http://") || _starts_with_ignore_case(uri, "https://")) {
432
11
            media_url = uri;
433
11
            return Status::OK();
434
11
        }
435
436
3
        S3ClientConf s3_client_conf;
437
3
        RETURN_IF_ERROR(init_s3_client_conf_from_json(file_input, s3_client_conf));
438
1
        auto s3_client = S3ClientFactory::instance().create(s3_client_conf);
439
1
        if (s3_client == nullptr) {
440
0
            return Status::InternalError("Failed to create S3 client for EMBED file input");
441
0
        }
442
443
1
        S3URI s3_uri(uri);
444
1
        RETURN_IF_ERROR(s3_uri.parse());
445
1
        std::string bucket = s3_uri.get_bucket();
446
1
        std::string key = s3_uri.get_key();
447
1
        DORIS_CHECK(!bucket.empty() && !key.empty());
448
1
        media_url = s3_client->generate_presigned_url({.bucket = bucket, .key = key}, ttl_seconds,
449
1
                                                      s3_client_conf);
450
1
        return Status::OK();
451
1
    }
452
};
453
454
}; // namespace doris