Apache Doris connector

Write rows to Apache Doris tables via stream load with automatic upserts and deletions, HNSW/IVF vector indexes, and inverted indexes for full-text search — with optional schema enforcement.

Version
v 1.0.14
Last reviewed
Nov 4, 2026
ExperimentalThe Rust SDK is experimental. It is outside CocoIndex's compatibility promise: any release, including a minor one, may change or remove its API. It is not yet published on crates.io.

The doris connector provides utilities for writing rows to Apache Doris databases, with support for vector indexes (HNSW, IVF) and inverted indexes for full-text search.

use cocoindex::connectors::doris::{
    self, ColumnDef, DorisConfig, DorisConnection, DorisTableOptions, InvertedIndexDef, TableSchema,
};
Dependencies

Enable the doris Cargo feature of the cocoindex crate.

Connection setup

DorisConfig

Configure the connection to your Doris cluster:

let mut cfg = DorisConfig::new(fe_host, database)
    .fe_http_port(http_port)
    .query_port(query_port);
if let Ok(user) = std::env::var("DORIS_USERNAME") {
    cfg = cfg.username(user);
}
if let Ok(pw) = std::env::var("DORIS_PASSWORD") {
    cfg = cfg.password(pw);
}

DorisConfig::new(fe_host, database) sets the defaults of the table below; the builder methods fe_http_port, query_port, username, password, enable_https, be_load_host, replication_num, buckets and batch_size change them. The fields are public too.

Parameters:

FieldTypeDefaultDescription
fe_hostString(required)Frontend host address
databaseString(required)Database name
fe_http_portu168080Frontend HTTP port (for stream load)
query_portu169030MySQL-compatible query port
usernameString"root"Username
passwordString""Password
enable_httpsboolfalseUse HTTPS for stream load
be_load_hostOption<String>NoneOverride backend host for stream load (defaults to fe_host)
batch_sizeusize10000Max rows per stream load batch
stream_load_timeoutu64600Timeout (seconds) for stream load
max_retriesu323Retries of a failed stream load
retry_base_delayf641.0First retry delay (seconds)
retry_max_delayf6430.0Longest retry delay (seconds)
replication_numu321Replication factor for new tables
bucketsString"auto"Bucket count for new tables

connect

Create a managed connection:

impl DorisConnection {
    pub async fn connect(config: DorisConfig) -> Result<Self>
    pub fn pool(&self) -> &MySqlPool
    pub fn config(&self) -> &DorisConfig
}

Example:

let conn = DorisConnection::connect(cfg).await?;

connect opens an sqlx MySQL pool to the FE query port, for DDL and deletes, and an HTTP client for Stream Load. The database must already exist.

As target

The doris connector provides target state APIs for writing rows to tables. CocoIndex tracks what rows should exist and automatically handles upserts and deletions via Doris stream load.

Declaring target states

Setting up a connection

Declare a context key for a DorisConnection with cocoindex::context_key!, then provide the connection when you build the environment:

Note

The key name is load-bearing across runs — it’s the stable identity CocoIndex uses to track managed rows. See ContextKey as stable identity before renaming.

CocoIndex tracks each table by the context key and the table name. The database in DorisConfig is not part of that identity. If you change database under the same key, CocoIndex treats it as if you moved the tables there. It assumes that they already exist in the new database, so it does not create them, and it leaves the old database as it is. To write to a new, empty database, use a new context key.

use cocoindex::connectors::doris::{DorisConfig, DorisConnection};

cocoindex::context_key!(static DORIS_DB: DorisConnection, key = "doris_target_test_db");

let conn = DorisConnection::connect(cfg).await?;
let app = Environment::builder()
    .db_path(&db)
    .provide_key(&DORIS_DB, conn.clone())
    .build()
    .await?
    .app("DorisInvertedIndex")
    .await?;

context_key! declares the key. Its stable name is the identifier, or the key = "…" string when given. provide_key(&DORIS_DB, conn) provides the connection, in a #[cocoindex::lifespan] function or on Environment::builder() as here.

Tables (parent state)

Declares a table as a target state. Returns a TableTarget for declaring rows.

pub async fn mount_table_target(
    ctx: &Ctx,
    conn: &ContextKey<DorisConnection>,
    table_name: impl Into<String>,
    table_schema: TableSchema,
) -> Result<TableTarget>

pub async fn mount_table_target_with_options(
    ctx: &Ctx,
    conn: &ContextKey<DorisConnection>,
    table_name: impl Into<String>,
    table_schema: TableSchema,
    options: DorisTableOptions,
) -> Result<TableTarget>

pub struct DorisTableOptions {
    pub managed_by: ManagedBy,
    pub vector_indexes: Vec<VectorIndexDef>,
    pub inverted_indexes: Vec<InvertedIndexDef>,
}

managed_by, vector_indexes and inverted_indexes go in DorisTableOptions. declare_table_target and declare_table_target_with_options take the same arguments and return a handle whose rows resolve when the current component commits.

Parameters:

  • conn — The context key of the DorisConnection to use.
  • table_name — Name of the table.
  • table_schema — Schema definition including columns and primary key (see Table schema).
  • options.managed_by — Whether CocoIndex manages the table lifecycle (ManagedBy::System, the default) or assumes it exists (ManagedBy::User).
  • options.vector_indexes — Vector index definitions (see Vector indexes).
  • options.inverted_indexes — Inverted index definitions (see Inverted indexes).

Returns: A TableTarget. It is Clone and serializes to its stable key, the table name, so it can be a mount_each! / use_mount! argument or a memoized function argument:

#[cocoindex::function]
async fn process_facts(ctx: &Ctx, file: FileEntry, table: doris::TableTarget) -> Result<()> {
    // ...
}

mount_each!("facts", facts, |file| process_facts(ctx, file, table)).await?;

Rows (child states)

Once a TableTarget is resolved, declare rows to be upserted:

impl TableTarget {
    pub fn declare_row<R: Serialize>(&self, ctx: &Ctx, row: &R) -> Result<()>
}

target.declare_row(
    &ctx,
    &serde_json::json!({"id": "1", "content": "the quick brown fox"}),
)?;

Parameters:

  • row — Any Serialize value whose fields match the schema’s columns, such as a row struct or a serde_json::Value object. Must include all primary key columns.

Table schema: from a Rust struct

Define the table structure with a struct that derives SchemaFields:

impl TableSchema {
    pub fn from_row<T: SchemaFields>(
        primary_key: impl IntoIterator<Item = impl Into<String>>,
    ) -> Result<Self>

    pub fn with_vector_dim(self, field_name: &str, dim: usize) -> Result<Self>
    pub async fn with_vector_schema(
        self,
        field_name: &str,
        provider: &(impl VectorSchemaProvider + ?Sized),
    ) -> Result<Self>
    pub fn with_column_type(self, field_name: &str, doris_type: impl Into<String>) -> Result<Self>
}

The row type is a struct that derives SchemaFields (and Serialize). Per-column overrides are builder calls on the derived schema: with_vector_schema (from an embedder or other VectorSchemaProvider) or with_vector_dim for a vector column, with_column_type for a type override, which keeps the column’s nullability and makes it a non-vector column.

Example:

#[derive(Clone, Serialize, Deserialize, SchemaFields)]
struct DocEmbedding {
    id: i64,
    text: String,
    #[coco(vector)]
    embedding: Vec<f32>,
}

let schema = doris::TableSchema::from_row::<DocEmbedding>(["id"])?
    .with_vector_schema("embedding", ctx.get_key(&EMBEDDER)?)
    .await?;

#[coco(vector)] marks an ARRAY<FLOAT> vector column whose dimension is set with with_vector_dim or with_vector_schema; #[coco(vector = 384)] fixes it in the struct. Option<T> fields become nullable columns.

Rust field types are automatically mapped to Doris types:

Rust TypeDoris Type
boolBOOLEAN
i16, i32, i64BIGINT
f32, f64DOUBLE
DecimalTEXT
StringTEXT
Vec<u8>STRING (base64)
UuidVARCHAR(36)
DateTime, NaiveDateTimeDATETIME(6)
NaiveDateDATE
NaiveTimeVARCHAR(20)
DurationBIGINT
other Vec<T>, maps, nested structs, #[coco(json)]JSON
Vec<f32> with #[coco(vector)]ARRAY<FLOAT> (NOT NULL)

Vec<u8> values are base64-encoded before writing to STRING so arbitrary binary data survives Doris’s JSON Stream Load path.

Custom column types

Use #[coco(type = "…")] on a field to specify a custom Doris type:

#[derive(Serialize, SchemaFields)]
struct MyRow {
    #[coco(type = "INT")]
    id: i64,
    #[coco(type = "FLOAT")]
    value: f64,
}

Or on the derived schema, for this schema only: TableSchema::from_row::<MyRow>(["id"])?.with_column_type("value", "FLOAT")?. #[coco(type = "…")] applies to every connector the struct is used with.

Vector indexes

Doris supports vector similarity search via HNSW and IVF indexes. Define them with VectorIndexDef:

pub struct VectorIndexDef {
    pub field_name: String,
    pub index_type: String,   // "HNSW" (default) or "IVF"
    pub metric_type: String,  // "l2_distance" (default), "inner_product", "cosine_distance"
    pub max_degree: Option<u32>,
    pub ef_construction: Option<u32>,
    pub nlist: Option<u32>,
}

impl VectorIndexDef {
    pub fn new(field_name: impl Into<String>) -> Self
    pub fn index_type(self, index_type: impl Into<String>) -> Self
    pub fn metric_type(self, metric: impl Into<String>) -> Self
    pub fn max_degree(self, v: u32) -> Self
    pub fn ef_construction(self, v: u32) -> Self
    pub fn nlist(self, v: u32) -> Self
}

VectorIndexDef::new("embedding") is an HNSW index with l2_distance; the builder methods change the rest. Pass the list in DorisTableOptions::vector_indexes.

Parameters:

ParameterDefaultDescription
field_name(required)Column to index
index_type"HNSW"Index type: "HNSW" or "IVF"
metric_type"l2_distance"Distance metric: "l2_distance", "inner_product" or "cosine_distance"
max_degreeNoneHNSW max degree
ef_constructionNoneHNSW construction parameter
nlistNoneIVF number of partitions

Inverted indexes

Doris supports inverted indexes for full-text search. Define them with InvertedIndexDef:

let options = DorisTableOptions {
    inverted_indexes: vec![InvertedIndexDef::new("content").parser("english")],
    ..Default::default()
};

let target =
    doris::mount_table_target_with_options(&ctx, &DORIS_DB, table, schema, options)
        .await?;

InvertedIndexDef::new(field_name) has no parser (exact matching only); .parser(name) sets one.

Parameters:

  • field_name — Column to index.
  • parser — Optional tokenizer for full-text search (e.g., "unicode", "english", "chinese"). Without one, the index supports exact matching only.

Query helpers

build_vector_search_query

Build a vector similarity search SQL query:

pub fn build_vector_search_query(
    table: &str,
    vector_field: &str,
    query_vector: &[f32],
    options: &VectorSearchOptions,
) -> Result<String>

pub struct VectorSearchOptions {
    pub metric: String,               // default "l2_distance"
    pub limit: usize,                 // default 10
    pub select_columns: Vec<String>,  // empty selects `*`
    pub where_clause: Option<String>,
}

Example:

let sql = doris::build_vector_search_query(
    "doc_embeddings",
    "embedding",
    &query_vector,
    &doris::VectorSearchOptions {
        select_columns: vec!["id".into(), "text".into()],
        ..Default::default()
    },
)?;

Running queries

DorisConnection::pool() returns the sqlx::MySqlPool the connection holds; run queries on it.

Different from Python

There is no connect_async: queries run on the pool of the DorisConnection itself.

Example

use cocoindex::connectors::doris::{
    self, DorisConfig, DorisConnection, DorisTableOptions, VectorIndexDef,
};
use cocoindex::prelude::*;

cocoindex::context_key!(static DORIS_DB: DorisConnection, key = "doris_target_test_db");

#[derive(Clone, Serialize, Deserialize, SchemaFields)]
struct DocEmbedding {
    id: i64,
    text: String,
    #[coco(vector = 384)]
    embedding: Vec<f32>,
}

let conn = DorisConnection::connect(DorisConfig::new("localhost", "my_database")).await?;
let app = Environment::builder()
    .db_path(&db)
    .provide_key(&DORIS_DB, conn.clone())
    .build()
    .await?
    .app("DorisExample")
    .await?;

app.run(move |ctx| async move {
    let options = DorisTableOptions {
        vector_indexes: vec![
            VectorIndexDef::new("embedding")
                .index_type("HNSW")
                .metric_type("cosine_distance"),
        ],
        ..Default::default()
    };
    let target = doris::mount_table_target_with_options(
        &ctx,
        &DORIS_DB,
        "doc_embeddings",
        doris::TableSchema::from_row::<DocEmbedding>(["id"])?,
        options,
    )
    .await?;

    // Declare rows
    for doc in &documents {
        target.declare_row(&ctx, doc)?;
    }
    Ok(())
})
.await?;

examples/rust/sec_edgar_analytics is a runnable project: one Doris table with a vector index and an inverted index, fed per file through mount_each!. rust/sdk/cocoindex/tests/doris_target.rs is the end-to-end test.