You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@doris.apache.org by mo...@apache.org on 2026/05/10 05:42:51 UTC

(doris-website) branch master updated: [refactor](next) add custom-facing key features content (#3629)

This is an automated email from the ASF dual-hosted git repository.

morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-website.git


The following commit(s) were added to refs/heads/master by this push:
     new bfe29bc7eb0 [refactor](next) add custom-facing key features content (#3629)
bfe29bc7eb0 is described below

commit bfe29bc7eb0208ee61753b91a941f61ca0ecbb9a
Author: Mingyu Chen (Rayner) <mo...@163.com>
AuthorDate: Sat May 9 22:42:44 2026 -0700

    [refactor](next) add custom-facing key features content (#3629)
---
 docs-next/key-features/columnar-storage.mdx        | 107 +++++++++++++++
 docs-next/key-features/compute-group.mdx           | 103 +++++++++++++++
 .../key-features/compute-storage-decoupled.mdx     |  11 --
 docs-next/key-features/condition-cache.mdx         |  96 ++++++++++++++
 docs-next/key-features/data-cache-page-cache.mdx   | 114 ++++++++++++++++
 docs-next/key-features/data-compaction.mdx         | 103 +++++++++++++++
 docs-next/key-features/data-pruning.mdx            | 106 +++++++++++++++
 docs-next/key-features/data-update-delete.mdx      | 107 +++++++++++++++
 docs-next/key-features/group-commit.mdx            | 105 +++++++++++++++
 docs-next/key-features/high-availability.mdx       |  21 ---
 .../key-features/high-concurrency-point-query.mdx  | 112 ++++++++++++++++
 docs-next/key-features/hybrid-search.mdx           | 106 +++++++++++++++
 .../key-features/incremental-materialized-view.mdx | 102 ++++++++++++++
 docs-next/key-features/kafka-cdc-integration.mdx   | 121 +++++++++++++++++
 docs-next/key-features/load-transaction.mdx        |  93 +++++++++++++
 docs-next/key-features/metadata-cache.mdx          | 119 +++++++++++++++++
 .../key-features/parquet-reader-optimization.mdx   | 104 +++++++++++++++
 .../key-features/pipeline-execution-engine.mdx     | 103 +++++++++++++++
 .../key-features/preaggregation-and-rollup.mdx     |  95 +++++++++++++
 docs-next/key-features/prepared-statement.mdx      | 117 ++++++++++++++++
 docs-next/key-features/query-cache.mdx             | 108 +++++++++++++++
 docs-next/key-features/resource-group.mdx          |  97 ++++++++++++++
 docs-next/key-features/vectorized-execution.mdx    | 103 +++++++++++++++
 docs-next/key-features/workload-group.mdx          | 103 +++++++++++++++
 src/components/blogs-next/index.tsx                |  32 ++---
 src/components/home-next/DocsSearchSection.scss    |  13 +-
 src/components/home-next/NavbarNext.scss           |   5 +-
 .../use-cases-next/CustomerFacingAnalyticsNext.tsx |  42 +++---
 src/components/why-doris-next/KeyFeaturesNext.scss | 147 +++++++++++----------
 src/components/why-doris-next/KeyFeaturesNext.tsx  | 104 ++++++++-------
 src/theme/SearchBar/SearchBar.module.css           |  12 ++
 .../images/next/key-features/columnar-storage.jpg  | Bin 0 -> 221464 bytes
 static/images/next/key-features/compute-group.jpg  | Bin 0 -> 212748 bytes
 .../images/next/key-features/condition-cache.jpg   | Bin 0 -> 189446 bytes
 .../next/key-features/data-cache-page-cache.jpg    | Bin 0 -> 213785 bytes
 .../images/next/key-features/data-compaction.jpg   | Bin 0 -> 228500 bytes
 static/images/next/key-features/data-pruning.jpg   | Bin 0 -> 182550 bytes
 .../next/key-features/data-update-delete.jpg       | Bin 0 -> 215882 bytes
 static/images/next/key-features/group-commit.jpg   | Bin 0 -> 242330 bytes
 .../key-features/high-concurrency-point-query.jpg  | Bin 0 -> 223538 bytes
 .../key-features/incremental-materialized-view.jpg | Bin 0 -> 233042 bytes
 .../next/key-features/kafka-cdc-integration.jpg    | Bin 0 -> 216949 bytes
 .../images/next/key-features/load-transaction.jpg  | Bin 0 -> 220533 bytes
 static/images/next/key-features/metadata-cache.jpg | Bin 0 -> 260774 bytes
 .../key-features/parquet-reader-optimization.jpg   | Bin 0 -> 203750 bytes
 .../key-features/pipeline-execution-engine.jpg     | Bin 0 -> 191229 bytes
 .../key-features/preaggregation-and-rollup.jpg     | Bin 0 -> 218412 bytes
 .../next/key-features/prepared-statement.jpg       | Bin 0 -> 218435 bytes
 static/images/next/key-features/query-cache.jpg    | Bin 0 -> 188121 bytes
 static/images/next/key-features/resource-group.jpg | Bin 0 -> 217349 bytes
 .../next/key-features/vectorized-execution.jpg     | Bin 0 -> 186169 bytes
 static/images/next/key-features/workload-group.jpg | Bin 0 -> 220333 bytes
 52 files changed, 2523 insertions(+), 188 deletions(-)

diff --git a/docs-next/key-features/columnar-storage.mdx b/docs-next/key-features/columnar-storage.mdx
new file mode 100644
index 00000000000..a9f3ad99129
--- /dev/null
+++ b/docs-next/key-features/columnar-storage.mdx
@@ -0,0 +1,107 @@
+---
+title: Columnar Storage
+description: Doris's on-disk segment file format. Per-column encoding and per-page compression shrink the table, and per-page indexes let queries skip pages they don't need.
+slug: /key-features/columnar-storage
+featureCard:
+  tags:
+    - storage
+    - performance
+---
+
+> **TL;DR** Every Doris OLAP table writes its data in column-oriented Segment files. Each column gets its own encoding (PLAIN, dictionary, RLE, bit-shuffle, frame-of-reference), each page is compressed independently (LZ4 by default, ZSTD optional), and per-page zone maps and bloom filters let queries skip pages without decoding them. The V3 footer added in 4.1 cuts segment-open latency on wide tables by 16x. There's nothing to enable; this is just how Doris stores data.
+
+![Columnar Storage hero illustration](/images/next/key-features/columnar-storage.jpg)
+
+## Why it matters
+
+Three problems show up the moment you put analytics on a row-oriented store.
+
+- A `GROUP BY country` over a 50-column table still reads bytes for the other 49 columns. I/O cost scales with row width, not query width.
+- Compression is weak. Adjacent rows mix unrelated types, so dictionary, run-length, and delta codecs have nothing to chew on.
+- Skipping is hard. To know "this 64 KB block has no rows where `ts >= '2026-05-01'`," you need column-local statistics, and a row layout doesn't give you anywhere natural to keep them.
+
+A column-oriented layout fixes all three. Values from the same column sit together on disk, so the codec can pick a transform that fits the type. And the page indexes give the reader something to test predicates against before it decompresses anything.
+
+## What it is
+
+Columnar storage in Doris is the on-disk segment file format that every OLAP table (Duplicate, Unique, Aggregate) shares. Each load produces one or more Segment files. Inside a segment, the writer handles each column on its own: it streams values into ~64 KB pages, encodes and compresses each page, and writes the skip indexes alongside. A footer at the end of the file points to everything.
+
+**Key terms**
+
+- **`Segment`**: the unit of immutable on-disk data, identified by the magic bytes `D0R1`. Defaults: page 64 KB, dictionary page 256 KB, segment cap 256 MB. Defined in `be/src/storage/segment/segment.h`.
+- **`Page`**: a slice of one column's values. The granularity for both compression and zone-map statistics.
+- **`Encoding`**: per-column transform applied before compression. PLAIN for general numerics, DICT for low-cardinality strings, RLE for booleans, BIT_SHUFFLE for integer columns, FOR (frame-of-reference) for dates, BINARY_PLAIN_V2 for strings on V3.
+- **`Page indexes`**: ordinal index, short key index, zone map (per-page min/max plus has-null), and bloom filter. All written into the segment alongside the data.
+- **`Footer`**: protobuf record at the end of the file describing every column, every encoding, every page pointer, and every index location. V3 (4.1+) externalizes column metadata so opening a wide segment doesn't have to read all of it.
+
+## How it works
+
+The lifecycle of one column on disk:
+
+1. **Encode.** The writer picks an encoding from the column's type and value distribution. An `INT` column with low cardinality gets bit-shuffle; a string column with few distinct values gets a dictionary built into a separate dict page.
+2. **Page and compress.** Encoded values stream into pages of about 64 KB each. Each page goes through LZ4F by default, or ZSTD / Snappy / Zlib if the table sets `compression`. Compression is skipped if the saving falls below 10%.
+3. **Index.** As pages are written, the segment also writes an ordinal index (binary search by row number), a zone map per page (min, max, has-null), and a bloom filter for any column listed in `bloom_filter_columns`. The short key index summarizes the prefix of the table's sort key.
+4. **Footer.** The writer appends a `SegmentFooterPB` describing every column, every page pointer, and every index. V3 splits this in two: a slim footer plus a `column_meta_entries` region loaded only for columns the query actually touches.
+5. **Read.** A scan opens the footer, narrows down by ordinal / short key / zone map / bloom, decompresses only the surviving pages, decodes them, and hands columnar batches straight to the [vectorized engine](./vectorized-execution.mdx).
+
+The hierarchy above the segment is `Tablet → Rowset → Segment → Column → Page`. Compaction merges rowsets but never rewrites at a finer grain; the column-and-page layout below is what every query reads from.
+
+## Quick start
+
+```sql
+CREATE TABLE access_log (
+  ts DATETIME,
+  user_id BIGINT,
+  url VARCHAR(512),
+  country CHAR(2)
+)
+DUPLICATE KEY(ts, user_id)
+DISTRIBUTED BY HASH(user_id) BUCKETS 4
+PROPERTIES (
+  "compression" = "zstd",
+  "bloom_filter_columns" = "url",
+  "storage_format" = "V3"
+);
+
+SELECT country, COUNT(*) AS hits
+FROM access_log
+WHERE ts >= '2026-05-01'
+GROUP BY country
+ORDER BY hits DESC;
+```
+
+**Expected result**
+
+```
++---------+--------+
+| country | hits   |
++---------+--------+
+| US      | 184320 |
+| DE      |  62018 |
+| ...     |        |
++---------+--------+
+```
+
+The query reads only the `ts` and `country` columns. The reader skips pages whose zone map says `max(ts) < 2026-05-01` before any decompression runs. ZSTD typically shrinks the table 5x to 10x, and the V3 footer keeps segment-open cost flat as the column count grows.
+
+## When to use it
+
+**Good fit**
+
+- Every analytic table you run through Doris. Columnar storage isn't optional; it's the default and only OLAP format.
+- Wide tables and dashboard queries. The wider the table and the narrower the column projection, the bigger the savings over a row store.
+- Tables you load into frequently. Per-column compression and per-page zone maps both improve as more rows pile up under the same schema.
+- Wide tables with thousands of columns. Set `"storage_format" = "V3"` (4.1+) to keep segment-open latency and memory bounded.
+
+**Not a good fit**
+
+- `SELECT *` lookups by primary key at thousands of QPS. Each column is a separate read, so a wide-row point query pays N times the I/O. Reach for the [High-Concurrency Point Query](./high-concurrency-point-query.mdx) path, which adds a row-cached column with `"store_row_column" = "true"` on top of a Unique-Key Merge-on-Write table.
+- Tweaking `compression` or `storage_page_size` without a profile. The defaults (LZ4F, 64 KB) fit most workloads. Move to ZSTD only when storage cost matters more than CPU; shrink the page size only when small columns are wasting decompression work.
+- Disabling encodings to "see the raw column." There is no raw mode. The codec is part of the file format, not a layer you peel off.
+- Treating columnar storage as a fix for narrow point lookups on a row-oriented schema. If every query is `WHERE id = ?` and returns one row, you want a different system or the row-store extension, not a different compression codec.
+
+## Performance / numbers
+
+- **Wide-table V3 footer**: a 7,000-column table with 10,000 segments saw segment-open time drop from 65s to 4s (16x faster) and open-time memory from 60 GB to under 1 GB (60x lower). Source: [Storage Format V3](../table-design/storage-format.md).
+- **ZSTD vs. the prior default** on a 25 GB / 110-million-line text-log dataset: nearly 10x compression ratio, 53% better than the previous default, and 30% faster end-to-end read+decompress. Source: [Apache Doris 1.1.0 release notes](https://doris.apache.org/blog/release-note-1.1.0/).
+
diff --git a/docs-next/key-features/compute-group.mdx b/docs-next/key-features/compute-group.mdx
new file mode 100644
index 00000000000..8ad656cfded
--- /dev/null
+++ b/docs-next/key-features/compute-group.mdx
@@ -0,0 +1,103 @@
+---
+title: Compute Group
+description: Storage-compute decoupled isolation that splits stateless BE pools sharing one object store, so you add a query group without copying data.
+slug: /key-features/compute-group
+featureCard:
+  tags:
+    - resource-management
+    - storage-compute-decoupled
+---
+
+> **TL;DR** Compute Group is the storage-compute decoupled equivalent of Resource Group. You split BE nodes into named groups, grant users access, and bind each session to a group; only those BEs serve the query. Because storage is shared (S3, OSS, COS, HDFS), adding a group costs you compute, not a replica. A BE failure inside a group still does not affect the others, and decommissioning a whole group leaves the data untouched.
+
+![Compute Group hero illustration](/images/next/key-features/compute-group.jpg)
+
+## Why it matters
+
+[Resource Group](./resource-group.mdx) buys you BE-level isolation in compute-storage integrated mode, but the bill arrives as storage. Five groups that all need to read the same table means five replicas of that table. Two new tenants means rebuilding replica allocation. Spinning up a temporary pool for tomorrow's batch run means moving data around first.
+
+In a storage-compute decoupled deployment, the BE is stateless. It caches hot tablets locally for speed, but the source of truth lives in shared object storage. So adding another set of BEs costs you compute and a cold cache, nothing else. There is one logical copy of the data, regardless of how many compute groups read from it.
+
+Compute Group is the feature that turns that into workload isolation. You divide BEs into groups the way you would with Resource Group, but the storage budget stops scaling with the number of groups. Spin up an ETL pool for a load window, run BI on a different pool that never feels the load, decommission a group at midnight. The data does not move.
+
+## What it is
+
+Compute Group is a node-level isolation feature in Doris's storage-compute decoupled (cloud) mode. Each BE belongs to exactly one compute group, identified by a name. The same object-storage manifest backs every group, so adding a group means adding compute and a cache that needs warming, not adding a replica. Workload Group then sits inside a compute group, partitioning CPU and memory across the queries running there.
+
+**Key terms**
+
+- **`tag.compute_group_name`**: the BE-side property set when adding a backend. Defaults to `default_compute_group` when omitted.
+- **`default_compute_group`**: a per-user property that picks the compute group for new sessions. Set with `SET PROPERTY FOR '<user>' 'default_compute_group' = '<name>'`.
+- **`USE @<group>`**: a per-session switch that flips the active group for the current connection, optionally combined with a database name (`USE my_db@<group>`).
+- **`USAGE_PRIV ON COMPUTE GROUP`**: the access privilege. Granted with `GRANT USAGE_PRIV ON COMPUTE GROUP <name> TO <user>`. Without it, the user cannot select the group.
+- **File cache**: the local on-BE cache of hot tablet blocks. A new group starts cold and pays an object-storage round-trip on every miss until it warms up.
+
+## How it works
+
+1. **Tag BEs at registration.** `ALTER SYSTEM ADD BACKEND 'host:9050' PROPERTIES ("tag.compute_group_name" = "etl")` adds a backend into the `etl` group. Omitting the property lands the BE in `default_compute_group`. The MetaService persists the assignment, and `SHOW COMPUTE GROUPS` reports the membership.
+2. **Grant access.** A regular user has no compute groups by default. `GRANT USAGE_PRIV ON COMPUTE GROUP etl TO 'etl_user'` is what lets them target the group at all. Admin users see every group.
+3. **Bind a default.** `SET PROPERTY FOR 'etl_user' 'default_compute_group' = 'etl'` makes new sessions land in the right group automatically. The user has to reconnect for the change to take effect.
+4. **Switch on demand.** `USE @bi` flips the active compute group for the current session. Useful for DBAs who carry access to several groups, and for ad-hoc queries that need a different pool than the user's default.
+5. **Plan and execute.** The query planner restricts fragment placement to BEs in the chosen group. Each BE reads from shared storage; the local file cache absorbs repeats. Misses are a network call, not a query failure.
+6. **Scale freely.** `ALTER SYSTEM ADD BACKEND ...` grows a group; `ALTER SYSTEM DECOMMISSION BACKEND ...` shrinks it. There is no tablet rebalance to wait for, because there is no replica to move.
+
+## Quick start
+
+```sql
+-- Add stateless BEs into two compute groups
+ALTER SYSTEM ADD BACKEND 'be1:9050' PROPERTIES ("tag.compute_group_name" = "etl");
+ALTER SYSTEM ADD BACKEND 'be2:9050' PROPERTIES ("tag.compute_group_name" = "bi");
+ALTER SYSTEM ADD BACKEND 'be3:9050' PROPERTIES ("tag.compute_group_name" = "bi");
+
+-- Grant access
+GRANT USAGE_PRIV ON COMPUTE GROUP etl TO 'etl_user';
+GRANT USAGE_PRIV ON COMPUTE GROUP bi  TO 'bi_user';
+
+-- Set defaults (the user has to reconnect for new defaults to apply)
+SET PROPERTY FOR 'etl_user' 'default_compute_group' = 'etl';
+SET PROPERTY FOR 'bi_user'  'default_compute_group' = 'bi';
+
+-- One-off switch for the current session
+USE my_db@bi;
+SELECT region, SUM(amount) FROM orders WHERE dt = CURRENT_DATE() GROUP BY region;
+```
+
+**Expected result**
+
+```
++------+-----------+-------+----------+
+| Name | IsCurrent | Users | Backends |
++------+-----------+-------+----------+
+| etl  | false     | 1     | 1        |
+| bi   | true      | 1     | 2        |
++------+-----------+-------+----------+
+```
+
+`SHOW COMPUTE GROUPS` confirms the layout. The aggregation runs only on `be2` and `be3`. ETL and BI share the same `orders` data on object storage, but they share no BEs and no local cache. Decommission `be1` and the etl group is empty; the data is untouched.
+
+## When to use it
+
+**Good fit**
+
+- Storage-compute decoupled clusters that need workload isolation without paying for extra replicas.
+- Read/write separation: load data through a writer group and query from a reader group, with one shared object-storage copy. See [file cache best practices](../compute-storage-decoupled/rw/file-cache-rw-compute-group-best-practice.md) for warm-up tactics that keep readers fast.
+- Elastic peak/off-peak compute: add a group during business hours, decommission overnight, no data movement either way.
+- Multi-tenant clusters where each tenant gets its own compute pool but the storage bill stays flat.
+- Subdivide CPU and memory inside each compute group with [Workload Group](./workload-group.mdx); see [Workload Group binding](../admin-manual/workload-management/workload-group-bind-compute-group.md) for the create-time scoping.
+
+**Not a good fit**
+
+- Compute-storage integrated deployments. There is no shared object storage, so the equivalent feature is [Resource Group](./resource-group.mdx).
+- Cross-group queries. Like Resource Group, a query cannot stitch BEs from two compute groups together; pick the right group per session.
+- Workloads that cannot tolerate a cold-cache penalty. A brand-new group has nothing in the file cache and pulls every read from object storage on the first query. Use `WARM UP COMPUTE GROUP <target> WITH COMPUTE GROUP <source>` to pre-populate before traffic arrives.
+- Re-binding workload groups across compute groups after the fact. A workload group is created inside one compute group and cannot be moved; drop and recreate to change the binding.
+- Anyone hoping data writes are isolated as a side effect. Reads are pinned to BEs in the chosen group, but the write path ultimately persists to the shared storage layer, so a runaway load can still tax storage bandwidth.
+
+## Further reading
+
+- [Compute Group reference](../admin-manual/workload-management/compute-group.md): the full SQL surface, privilege model, and `USE @group` switching rules.
+- [Managing compute groups](../compute-storage-decoupled/managing-compute-cluster.md): operational walkthrough for read-read, read-write, and write-write isolation patterns, plus the legacy "compute cluster" naming note.
+- [Workload Group binding](../admin-manual/workload-management/workload-group-bind-compute-group.md): how a workload group is scoped to one compute group, and the rules around the auto-created `normal` group.
+- [Resource Group](./resource-group.mdx): the integrated-mode counterpart, with its replica-multiplication tradeoff.
+- [Workload Group](./workload-group.mdx): the in-process layer you usually combine with Compute Group.
+- [File cache best practices for read-write separation](../compute-storage-decoupled/rw/file-cache-rw-compute-group-best-practice.md): warm-up strategies that keep query groups fast even when writes happen elsewhere.
diff --git a/docs-next/key-features/compute-storage-decoupled.mdx b/docs-next/key-features/compute-storage-decoupled.mdx
deleted file mode 100644
index 19cf6ebbe44..00000000000
--- a/docs-next/key-features/compute-storage-decoupled.mdx
+++ /dev/null
@@ -1,11 +0,0 @@
----
-title: Compute-Storage Decoupled
-description: Scale compute and storage independently with a decoupled Doris architecture.
-slug: /key-features/compute-storage-decoupled
-featureCard:
-  tags:
-    - cloud
-    - elastic
-  href: /docs-next/dev/compute-storage-decoupled/intro
----
-
diff --git a/docs-next/key-features/condition-cache.mdx b/docs-next/key-features/condition-cache.mdx
new file mode 100644
index 00000000000..7467dffb734
--- /dev/null
+++ b/docs-next/key-features/condition-cache.mdx
@@ -0,0 +1,96 @@
+---
+title: Condition Cache
+description: A segment-level cache that stores filter evaluation results as bit vectors, so queries that share a WHERE clause reuse the predicate work.
+slug: /key-features/condition-cache
+featureCard:
+  tags:
+    - query-acceleration
+    - caching
+---
+
+> **TL;DR** Condition Cache stores the result of a `WHERE` clause on each segment as a compressed bit vector keyed by `(predicate digest, key range)`. The next query that uses the same filter on the same segment skips the granules the bit vector already says cannot match, even when the SELECT list, GROUP BY, or aggregation is different. Five dashboard panels that all share `WHERE region = 'ASIA' AND order_date >= '2023-01-01'` evaluate that filter once per segment and reuse it across eve [...]
+
+![Condition Cache hero illustration](/images/next/key-features/condition-cache.jpg)
+
+## Why it matters
+
+Most analytical workloads filter a lot more than they aggregate. A dashboard built on a single `orders` table will have ten panels that all share `WHERE region = ? AND order_date >= ?` and differ only in the SELECT list. An application backend reissues the same predicate every few seconds with a new aggregation. A T+1 report runs different aggregations against partitions that have not changed since last night.
+
+The cluster pays the predicate evaluation cost on every one of those queries: read the column, decode it, run the comparison, build the row mask, then carry on with the rest of the plan.
+
+SQL Cache helps when the SQL text is byte-identical and gives up the moment the SELECT list shifts. Query Cache helps for aggregation queries that share whole tablets. Neither helps the case where the WHERE is shared but the SELECT list is not.
+
+Condition Cache fits between them. It caches the predicate evaluation step at the segment level, separate from whatever the surrounding query does with the rows.
+
+## What it is
+
+Condition Cache is a BE-side cache of predicate evaluation results, stored per segment as a granule-level bit vector. When a scan starts on a segment, the BE computes a 64-bit digest from the predicate expression and the segment's key range, then probes the cache. On a hit, it skips every granule whose bit is `0` and only scans the granules whose bit is `1`. On a miss, it runs the predicate as usual and writes the resulting bit vector back to the cache.
+
+The cache is independent of projections, aggregations, joins, and sort. Two queries with identical WHERE clauses share entries even when one runs `SELECT *` and the other runs `SELECT COUNT(DISTINCT customer_id)`.
+
+**Key terms**
+
+- **`enable_condition_cache`**: session variable. On by default; set to `false` to opt out for one query or one session.
+- **Segment**: a sealed file inside a tablet. Once written, a segment never mutates; compaction produces new segments instead. This immutability is what makes the cache safe.
+- **Granule**: a 2048-row block inside a segment. Bit vectors store one bit per granule, not one bit per row.
+- **Predicate digest**: a 64-bit hash of the conjunctive predicate plus the segment's key range. Semantically equivalent predicates produce the same digest.
+- **`condition_cache_limit`**: per-BE memory cap for the cache, in MB. Defaults to `1024`. Lives in `be.conf`.
+
+## How it works
+
+1. **Compile the digest (BE).** When a scan operator starts on a segment, it normalizes its conjunctive predicates and the segment's key range into a 64-bit digest. Different SELECT lists, GROUP BYs, and aggregations all produce the same digest because none of those affect the predicate.
+2. **Probe per segment.** The BE looks up `(digest, segment_id)` in a sharded LRU cache. If the segment carries a delete marker, or if a TopN runtime filter built the predicate at execution time, the BE skips the lookup and falls back to a normal scan.
+3. **On hit.** The scan reads the bit vector, skips every granule whose bit is `0`, and only decodes the columns for the granules whose bit is `1`. A row-level filter pass on those granules confirms the matches.
+4. **On miss.** The scan evaluates the predicate normally, builds the bit vector as it goes, and writes the result back keyed on `(digest, segment_id)`. Subsequent queries with the same digest land on a hit.
+5. **Invalidation by immutability.** Segments do not mutate. A new INSERT or compaction produces a new segment with a new ID, and the old cache entry ages out of the LRU on its own. Doris does not need an explicit invalidation step.
+
+## Quick start
+
+```sql
+SET enable_condition_cache = true;
+
+-- Run 1: misses every segment, builds bit vectors, writes them back
+SELECT region, country, SUM(amount)
+FROM orders
+WHERE region = 'ASIA' AND order_date >= '2023-01-01'
+GROUP BY region, country;
+
+-- Run 2: different SELECT, same WHERE; hits the cache on every segment
+SELECT COUNT(DISTINCT customer_id)
+FROM orders
+WHERE region = 'ASIA' AND order_date >= '2023-01-01';
+```
+
+**Expected result (excerpt from `EXPLAIN PROFILE` on Run 2)**
+
+```
+ConditionCacheSegmentHit:    42
+ConditionCacheFilteredRows:  18,400,219
+```
+
+The first run scans every segment, evaluates the predicate, and writes one bit vector per segment. The second run shares the WHERE so the digest matches; every segment hits the cache, and the BE skips the granules already known to be empty for `region = 'ASIA' AND order_date >= '2023-01-01'`.
+
+## When to use it
+
+**Good fit**
+
+- Dashboards where many panels share the same `WHERE` and differ only in the SELECT list or aggregation.
+- Application traffic that reissues the same predicate at high frequency against tables that take writes infrequently.
+- T+1 reports that run different aggregations over yesterday's already-loaded partitions.
+- High-selectivity filters where most granules turn into `0` bits and the cache lets the scan skip large chunks of each segment.
+
+**Not a good fit**
+
+- Tables under heavy continuous write pressure. New segments mean new digests, and old cache entries age out of the LRU before they get reused. If you want shape-stable result reuse on aggregations, use [Query Cache](./query-cache.mdx) instead.
+- Queries that hit segments with delete markers. The BE skips the cache to keep delete semantics correct, so workloads with frequent `DELETE` traffic rarely fire it.
+- Queries whose predicate is built by a TopN runtime filter at execution time. Not yet supported.
+- Low-selectivity filters that match most rows. You still pay for scanning the matched granules; the cache only saves work on granules already known to be empty.
+- Ad-hoc queries with one-off WHERE clauses that no other query reuses. The cache fills with entries that get one read and then evict each other. Leaving it on does no harm, but expect the hit rate to stay low.
+- Lookups by primary key. Use [High-Concurrency Point Query](./high-concurrency-point-query.mdx) instead.
+
+## Further reading
+
+- [Condition Cache user guide](../query-acceleration/condition-cache.md)
+- [Query Cache: pipeline-level partial aggregation cache](./query-cache.mdx)
+- [SQL Cache: result-level cache for byte-identical SQL](../query-acceleration/sql-cache-manual.md)
+- [Caching in Doris: choosing the right strategy](../query-acceleration/caching-intro.mdx)
diff --git a/docs-next/key-features/data-cache-page-cache.mdx b/docs-next/key-features/data-cache-page-cache.mdx
new file mode 100644
index 00000000000..d223b5e3526
--- /dev/null
+++ b/docs-next/key-features/data-cache-page-cache.mdx
@@ -0,0 +1,114 @@
+---
+title: Data Cache & Page Cache
+description: "Two caching layers that absorb the cost of repeated lakehouse reads: a local-disk cache for remote file bytes and an in-memory cache for decompressed column pages."
+slug: /key-features/data-cache-page-cache
+featureCard:
+  tags:
+    - lakehouse
+    - caching
+    - performance
+---
+
+> **TL;DR** Doris caches storage at two layers. The Data Cache keeps bytes from S3 or HDFS on a BE's local disk so repeat scans skip the network. The Page Cache keeps decompressed column pages in memory so repeat scans also skip decompression and decoding. The first run after a deploy is slow; the next run is local-disk fast; the run after that is RAM-fast. `WARM UP` pre-populates both.
+
+![Data Cache and Page Cache hero illustration](/images/next/key-features/data-cache-page-cache.jpg)
+
+## Why it matters
+
+A query against a Hive or Iceberg table on S3 pays for every byte three times: once to fetch it (HTTP round trip per ranged GET), once to decompress it (snappy, zstd, gzip), and once to decode it (RLE, dictionary, plain). The fetch is usually the worst offender. S3 GET tail latency runs 30 ms to 500 ms; a Parquet scan that issues thousands of ranged reads can spend most of its wall clock waiting for HTTP, before any CPU work starts.
+
+A few common patterns make this hurt:
+
+- The same dashboard runs every five minutes, hitting the same 100 GB of recent partitions, and each run downloads them again.
+- A BE was just added to the cluster (autoscale, replacement, deploy) and the first query that lands on it has to fetch every file fresh, while the rest of the cluster answers in milliseconds.
+- A high-concurrency report fans out to dozens of BEs, all querying the same hot partitions, and the cluster is bottlenecked on egress from the object store rather than CPU.
+
+The two caches sit at different layers. A Data Cache hit removes the network. A Page Cache hit removes the network and the decompression, but only after some earlier read populated it.
+
+## What it is
+
+**Data Cache** is a per-BE local-disk cache for remote file bytes. When a Doris BE reads a Parquet or ORC file from a Hive, Iceberg, Hudi, or Paimon table, the bytes are pulled in 1 MB blocks and written to a configured local directory. Subsequent reads of the same offset hit the disk instead of S3.
+
+**Page Cache** is an in-memory cache for column pages. There are two flavors. The **Storage Page Cache** caches decompressed pages from internal Doris segments. The **Parquet Page Cache** (4.1+) does the same for external Parquet files, and is smart about whether to keep the page compressed or decompressed depending on its compression ratio.
+
+**Key terms**
+
+- **`Block`**: the granule of the Data Cache. 1 MB by default; one block per `(file, offset)` pair on disk.
+- **`Multi-queue LRU`**: the Data Cache's eviction strategy. Separate queues for normal data, TTL'd data, index data, and disposable one-shot reads, so a one-pass scan doesn't evict the dashboard's hot blocks.
+- **`Page`**: the decoding unit inside a column chunk: typically 64 KB to 1 MB of values, RLE/dictionary/plain-encoded.
+- **`WARM UP`**: SQL syntax that pre-populates either cache by running a synthetic scan in the background.
+- **`Admission control`**: a policy that decides whether a given query is even allowed to write into the Data Cache, used to keep one-shot scans from polluting it.
+
+## How it works
+
+A scan flows through the caches in order, top to bottom.
+
+1. **Page Cache lookup (memory).** Before issuing any IO, the reader checks whether the column page it needs is already in the Page Cache, keyed by file path, file size, and offset. A hit returns ready-to-decode (or already-decoded) bytes; the reader skips both IO and decompression.
+2. **Data Cache lookup (local disk).** On a Page Cache miss, the file reader checks the Data Cache for the 1 MB block covering the requested byte range. A hit reads from local NVMe at GB/s; the reader still has to decompress and decode, but the network is bypassed.
+3. **Remote read (S3/HDFS).** On a Data Cache miss, the BE issues a ranged GET against object storage and writes the 1 MB block back to the local cache for next time. Admission control can refuse the write if the query is flagged as one-shot.
+4. **Decode + Page Cache write.** Decompressed pages go back into the Page Cache. The Parquet Page Cache compares each page's `decompressed_size / compressed_size` against `parquet_page_cache_decompress_threshold` (default 1.5): below the threshold it stores the cheaper compressed form, above it stores the decompressed form.
+5. **Eviction.** Both caches use sharded LRU. The Data Cache also runs a background eviction loop (`enable_evict_file_cache_in_advance`) that frees space when the disk crosses a watermark, before a query has to wait.
+
+`WARM UP` walks the same path on demand. It runs the scan, records hits and misses, and pre-fills both caches without returning rows.
+
+## Quick start
+
+```sql
+-- One-time setup on each BE: configure a cache directory.
+-- be.conf:
+--   enable_file_cache = true
+--   file_cache_path = [{"path":"/data/file_cache","total_size":107374182400}]
+
+-- Per-session toggles (defaults shown):
+SET enable_file_cache = true;
+
+-- Pre-populate the Data Cache for today's hot partition.
+WARM UP SELECT l_orderkey, l_shipmode
+FROM hive_catalog.tpch.lineitem
+WHERE l_shipdate = '2026-05-01';
+
+-- Now run the real query: it should hit cache on retries.
+SELECT l_shipmode, count(*)
+FROM hive_catalog.tpch.lineitem
+WHERE l_shipdate = '2026-05-01'
+GROUP BY l_shipmode;
+```
+
+**Expected result (profile excerpt)**
+
+```
+VFileScanNode
+  BytesScannedFromCache:   2.02 GB
+  BytesScannedFromRemote:  0 B
+  PageCacheHitCount:       18432
+  PageCacheMissCount:      0
+```
+
+The first run populated the Data Cache from S3. The second run answered the query without leaving the BE's local disk, and decompressed pages were served from RAM.
+
+## When to use it
+
+**Good fit**
+
+- Repeat queries on hot partitions (rolling-window dashboards, daily reports).
+- Lakehouse tables on S3 or HDFS where network or egress is the dominant cost.
+- High-concurrency workloads where dozens of BEs hit the same files. The cache decouples query throughput from object-store throughput.
+- Compute-group elasticity: warm a new compute group from an existing one before routing traffic to it.
+- Workloads with skewed access (Pareto: 10% of partitions take 90% of queries).
+
+**Not a good fit**
+
+- Working sets larger than the cache. If every query touches new data, the cache thrashes and you pay full miss cost on every read while still spending CPU on eviction. Either grow the cache, narrow the predicate, or disable the cache for that workload.
+- One-shot ETL and backfills. They evict the genuinely hot blocks for data nothing will read again. Use admission control to keep these scans out of the cache, or run them with `enable_file_cache = false`.
+- Queries on internal Doris tables hoping for the Data Cache to help. The Data Cache only kicks in for Hive, Iceberg, Hudi, and Paimon files; internal tables already benefit from the Storage Page Cache and the OS page cache.
+- Mechanical disks. The Data Cache assumes local IO is meaningfully faster than the remote source. On HDDs, the local round trip can be slower than a parallel S3 GET. Use SSD or NVMe.
+- Sizing the Page Cache too aggressively. Beyond 30–40% of memory, you starve the query buffer pools and trigger spilling that costs more than the cache saves. The default `storage_page_cache_limit = 20%` is sane for most workloads.
+
+## Further reading
+
+- [Data Cache reference](../lakehouse/data-cache.md): the full surface, including `WARM UP SELECT`, per-query limits, and admission control.
+- [File Cache (compute-storage decoupled)](../compute-storage-decoupled/file-cache/file-cache.md): the same machinery applied to internal tablets in cloud mode.
+- [Parquet Page Cache and other lakehouse tuning](../lakehouse/best-practices/optimization.md): the in-memory decompressed page cache for external Parquet files.
+- [Parquet Reader Optimization](./parquet-reader-optimization.mdx): the reader that decides which bytes to ask for in the first place. Caches and pruning compose.
+- [Query Cache](./query-cache.mdx) and [Condition Cache](./condition-cache.mdx): result-level and predicate-level caches that sit above the storage caches.
+- [WARM UP statement](../sql-manual/sql-statements/cluster-management/storage-management/WARM-UP.md): the SQL surface for pre-populating caches across compute groups.
diff --git a/docs-next/key-features/data-compaction.mdx b/docs-next/key-features/data-compaction.mdx
new file mode 100644
index 00000000000..7a894b9f11c
--- /dev/null
+++ b/docs-next/key-features/data-compaction.mdx
@@ -0,0 +1,103 @@
+---
+title: Data Compaction
+description: Background merging that keeps Doris fast under high-frequency loads, with policies tuned for general OLAP and append-only time-series workloads.
+slug: /key-features/data-compaction
+featureCard:
+  tags:
+    - storage
+    - performance
+---
+
+> **TL;DR** Every load into Doris produces a new immutable rowset. Compaction merges those rowsets in the background so queries don't have to walk through hundreds of versions. Two policies (`size_based` and `time_series`), plus vertical and segment compaction, let you tune the write-amplification vs. read-amplification trade-off per table.
+
+![Data Compaction hero illustration](/images/next/key-features/data-compaction.jpg)
+
+## Why it matters
+
+Doris stores data the way an LSM tree does: a write never updates a file in place. It lands as a brand-new rowset, and the rowset count on a tablet keeps climbing until something cleans up. Without compaction:
+
+- Once a tablet's version count exceeds `max_tablet_version_num` (default 500), loads fail with error -235. This is one of the most common production incidents on the Doris user list.
+- Every query has to merge all of a tablet's rowsets on the fly. Going from 5 rowsets to 50 turns a one-box lookup into a fifty-box one.
+- Deleted rows linger as tombstones, and small files cost more in metadata than the data they hold.
+
+Compaction is the background process that handles all three. Doris's policies exist because doing it well also means not burning so much CPU that ingestion slows down.
+
+## What it is
+
+Compaction is a tablet-local procedure that picks a window of rowsets, merges their rows, applies deletes, and writes a single replacement rowset. The Backend (BE) runs it on a thread pool, scoring tablets by how messy they are and grabbing the worst offenders first.
+
+**Key terms**
+
+- **`Rowset`**: the immutable file group produced by one load. Rowsets are versioned; queries merge across them at read time.
+- **`Cumulative compaction`**: the fast, frequent kind. Merges small recent rowsets above the cumulative point.
+- **`Base compaction`**: the slow, occasional kind. Merges everything below the cumulative point into the single base rowset.
+- **`Compaction score`**: roughly the number of merge paths a query would walk on this tablet. Higher score, higher priority.
+- **`Compaction policy`**: per-table choice of `size_based` (default) or `time_series`. Picks which rowsets to merge and when.
+- **`Vertical compaction`**: column-group merging for wide tables. Cuts memory use by ~10x compared with row-wise merging.
+
+## How it works
+
+1. **Score every tablet.** The BE keeps a running compaction score for each tablet based on how many rowsets sit above the cumulative point. Tablets with higher scores get scheduled first.
+2. **Pick the rowsets.** The active policy decides the window. `size_based` groups by power-of-two sizes; `time_series` waits until rowsets in a partition cross a goal size, file count, or age threshold, then takes them in one bite.
+3. **Merge and write.** The task reads rows from the inputs, drops duplicates, applies pending deletes, and writes the result to a new rowset. Wide tables go through vertical compaction, which handles columns in groups so memory stays bounded.
+4. **Promote to base.** When a cumulative output grows past `compaction_promotion_size_mbytes` (1 GB by default), it's eligible for base compaction the next round.
+5. **Replace and clean up.** The output rowset replaces its inputs in tablet metadata. Old files stick around for a grace window in case a query is still reading them, then get deleted.
+
+If you have multiple replicas, you can let one replica do the merge and have the others copy the result over the network (`enable_single_replica_compaction`). That cuts CPU usage roughly in proportion to the replica count.
+
+## Quick start
+
+```sql
+CREATE TABLE access_log (
+  ts DATETIME, user_id BIGINT, url VARCHAR(512)
+)
+DUPLICATE KEY(ts, user_id)
+PARTITION BY RANGE(ts) (
+  PARTITION p202605 VALUES LESS THAN ("2026-06-01")
+)
+DISTRIBUTED BY HASH(user_id) BUCKETS 4
+PROPERTIES (
+  "compaction_policy" = "time_series",
+  "time_series_compaction_goal_size_mbytes" = "1024",
+  "time_series_compaction_file_count_threshold" = "2000",
+  "time_series_compaction_time_threshold_seconds" = "3600"
+);
+
+ADMIN COMPACT TABLE access_log PARTITION (p202605) WHERE TYPE = "cumulative";
+```
+
+**Expected result**
+
+```
+Query OK, 0 rows affected
+```
+
+You've created a partitioned log table that uses the time-series policy: small rowsets get merged once they cross 1 GB total, hit 2000 files, or sit for an hour. The `ADMIN COMPACT` line forces a manual cumulative pass on the active partition, which is useful for benchmarks and post-load cleanup. Watch progress with `SELECT * FROM information_schema.doris_be_compaction_tasks WHERE TABLE_ID = ...`.
+
+## When to use it
+
+**Good fit**
+
+- Any table that takes frequent loads. The defaults already work; the question is just whether to switch policies.
+- Append-only logs and metrics. `time_series` reduces write amplification because each rowset participates in compaction once, not repeatedly across size tiers.
+- Wide tables (dozens of columns or more). Keep `enable_vertical_compaction = true` so big merges don't blow up memory.
+- Multi-replica clusters where compaction CPU is a bottleneck. Turn on `enable_single_replica_compaction`.
+
+**Not a good fit**
+
+- High-frequency single-row inserts as a way to "stress test compaction". You'll just create the version-pileup pain you're trying to study. Use [Group Commit](../data-operate/import/load-best-practices/group-commit-manual.md) on the load side instead.
+- Disabling auto-compaction in production to "save CPU". Tablets accumulate versions silently and then loads start failing with -235. Tune the thread counts (`max_cumu_compaction_threads`, `max_base_compaction_threads`) instead.
+- Mixing the `time_series` policy with workloads that update or delete rows by primary key. The policy assumes append-only behavior; for Unique Key tables with frequent updates, stick with `size_based`.
+- Treating compaction as a fix for bad bucketing. If a single tablet is hot enough to constantly trail in version count, the answer is more buckets, not more compaction threads.
+
+## Performance / numbers
+
+- **Vertical compaction**: ~1/10 the memory of row-wise compaction, and roughly 15% faster on wide tables. Source: [Doris compaction tuning docs](https://doris.apache.org/docs/3.x/admin-manual/trouble-shooting/compaction/).
+- **Compaction throughput**: around 300,000 rows/second per task with the default settings, holding cumulative score near 50 under continuous load. Source: [Understanding Data Compaction in 3 Minutes](https://doris.apache.org/blog/Understanding-Data-Compaction-in-3-Minutes/).
+
+## Further reading
+
+- [Compaction principles, types, and scheduling](../admin-manual/trouble-shooting/compaction-principles.md)
+- [Compaction tuning: vertical, segment, single-replica, time-series](../admin-manual/trouble-shooting/compaction.md)
+- [`ADMIN COMPACT TABLE` syntax reference](../sql-manual/sql-statements/table-and-view/data-and-status-management/COMPACT-TABLE.md)
+- [Understanding Data Compaction in 3 Minutes (blog)](https://doris.apache.org/blog/Understanding-Data-Compaction-in-3-Minutes/)
diff --git a/docs-next/key-features/data-pruning.mdx b/docs-next/key-features/data-pruning.mdx
new file mode 100644
index 00000000000..26cb23c0633
--- /dev/null
+++ b/docs-next/key-features/data-pruning.mdx
@@ -0,0 +1,106 @@
+---
+title: Data Pruning
+description: Layered "skip-the-data-you-don't-need" mechanisms across partitions, tablets, segments, and pages, so a query reads as little of the table as physically possible.
+slug: /key-features/data-pruning
+featureCard:
+  tags:
+    - query-acceleration
+    - performance
+---
+
+> **TL;DR** A query in Doris doesn't really scan a table; it scans whatever's left after pruning. Partition keys, distribution keys, ZoneMap min/max statistics, BloomFilter and NGram BloomFilter indexes each peel off another layer of data the engine doesn't have to read. Most of the win is automatic; you control it through DDL choices and watch it land in EXPLAIN output.
+
+![Data Pruning hero illustration](/images/next/key-features/data-pruning.jpg)
+
+## Why it matters
+
+The fastest way to make an OLAP query faster is to read less. A wide events table at 50 GB per day stretches into petabytes over a year, and most queries care about a sliver: one day, one user, one product. Without pruning, the planner fans the query out across every partition and every tablet, and the executor walks every page from disk to evaluate `WHERE`.
+
+This shows up as three concrete pains:
+
+- A `WHERE date BETWEEN '2026-04-01' AND '2026-04-07'` query against a 365-partition table touches every partition until the planner is told it can skip them.
+- Hash-distributed point lookups (`WHERE user_id = 12345`) become full-tablet scans when the optimizer can't tie the predicate to the bucket key.
+- High-cardinality filters on non-key columns (`WHERE phone = '...'`) read every segment because there's no statistic to rule pages out.
+
+Doris addresses each of these with a different pruning layer, and the layers compose: the planner trims partitions and tablets before the scan starts, then the BE skips segments and pages inside whatever survived.
+
+## What it is
+
+Data pruning is the cumulative result of every step in the read path that lets Doris skip data without reading it. The Frontend planner does the coarse work using table metadata: partitions and tablets. The Backend does the fine work during scan: ZoneMap min/max, BloomFilter, NGram BloomFilter, and the inverted index decide whether a segment, a column page, or even a row range can be skipped.
+
+**Key terms**
+
+- **`Partition pruning`**: the planner uses range or list predicates on the partition key to drop irrelevant partitions before any tablet is scheduled.
+- **`Bucket (tablet) pruning`**: equality predicates on the `DISTRIBUTED BY HASH` key let the planner pick only the buckets that could contain the row.
+- **`ZoneMap index`**: per-segment, per-page min/max/null statistics, built automatically on every column. Range predicates use it to skip pages.
+- **`BloomFilter index`**: opt-in per-column probabilistic filter, useful for high-cardinality equality and `IN` predicates.
+- **`NGram BloomFilter`**: opt-in index that turns substring `LIKE '%abc%'` predicates into bloom probes on n-grams.
+- **`Runtime filter`**: a join-time filter generated from one side of a join and pushed down to prune the other side.
+
+## How it works
+
+The five layers run roughly top-down, with each one shrinking the input the next layer has to consider.
+
+1. **Partition pruning (FE).** The Nereids planner runs `PruneOlapScanPartition`, matches `WHERE` predicates against the table's range or list partition tree, and produces the surviving set. EXPLAIN shows it as `partitions=2/4 (p2,p3)`.
+2. **Tablet pruning (FE).** `PruneOlapScanTablet` extracts equality predicates on the bucketing column and asks `HashDistributionPruner` which buckets they hash to. EXPLAIN shows `tablets=1/32`.
+3. **Segment / page pruning (BE).** For each surviving tablet, the segment iterator opens column readers and consults the ZoneMap index. If `[min, max]` doesn't overlap the predicate, the page is dropped without decompressing.
+4. **Index probes (BE).** BloomFilter, NGram BloomFilter, and inverted indexes run before row decoding. A miss skips the page; a hit doesn't promise rows match, only that they might.
+5. **Runtime filters (FE/BE).** Joins generate filters from the build side (for example, `IN (A.id_set)`) and broadcast them so the probe-side scan reuses the same pruning machinery on the fly.
+
+By the time the predicate evaluator runs, most of the table has already been ruled out without being read.
+
+## Quick start
+
+```sql
+CREATE TABLE events (
+  ts DATETIME, user_id BIGINT, action VARCHAR(64)
+)
+DUPLICATE KEY(ts, user_id)
+PARTITION BY RANGE(ts) (
+  PARTITION p202604 VALUES LESS THAN ("2026-05-01"),
+  PARTITION p202605 VALUES LESS THAN ("2026-06-01")
+)
+DISTRIBUTED BY HASH(user_id) BUCKETS 16
+PROPERTIES ("bloom_filter_columns" = "action");
+
+EXPLAIN
+SELECT count(*) FROM events
+WHERE ts >= '2026-05-08' AND user_id = 42 AND action = 'click';
+```
+
+**Expected result (excerpt)**
+
+```
+0:VOlapScanNode
+  TABLE: events
+  PREDICATES: ts >= '2026-05-08', user_id = 42, action = 'click'
+  partitions=1/2 (p202605)
+  tablets=1/16
+```
+
+The planner kept one partition out of two (date range) and one tablet out of sixteen (hash on `user_id`). At read time, the BE will use ZoneMap on `ts` plus the BloomFilter on `action` to skip pages inside that surviving tablet. Four pruning layers handle three predicates without any manual tuning.
+
+## When to use it
+
+**Good fit**
+
+- Time-bounded analytical queries on partitioned tables. The latency curve flattens once the date predicate makes it into the planner's hands.
+- High-concurrency point lookups on hash-distributed tables. Pair the bucket key with the most common equality predicate.
+- Substring search on long text columns. Add an NGram BloomFilter and let `LIKE '%...%'` become a bloom probe instead of a column scan.
+- Star-schema joins. Runtime filters propagate the small-side keys to the fact-table scan.
+
+**Not a good fit**
+
+- BloomFilter on low-cardinality columns (status flags, gender). The filter accepts almost every page anyway and you pay storage for nothing. Use ZoneMap, which is built in, or skip the index entirely.
+- BloomFilter on range predicates. Bloom answers "is X in the set?", not "is X less than Y." Range filters fall back to ZoneMap.
+- Bucketing on a column you don't filter on. You'll pay for hash distribution at write time and gain no read-side pruning. Pick a bucket key that matches your most selective equality predicate.
+- One giant catch-all partition. Nothing for the planner to drop, and tablet/segment pruning has to compensate alone. Partition by the column queries actually filter on, usually time.
+- Treating BloomFilter as a substitute for full-text search. For phrase or token search use the [inverted index](../table-design/index/inverted-index/overview.md); BloomFilter only handles equality.
+
+## Further reading
+
+- [Partition pruning optimization](../query-acceleration/tuning/tuning-plan/optimizing-table-scanning.md)
+- [Index overview: skip indexes](../table-design/index/index-overview.md)
+- [BloomFilter index](../table-design/index/bloomfilter.md)
+- [NGram BloomFilter index](../table-design/index/ngram-bloomfilter-index.md)
+- [Deep dive: Data Pruning in Apache Doris (blog)](https://doris.apache.org/blog/data-pruning-250908/)
diff --git a/docs-next/key-features/data-update-delete.mdx b/docs-next/key-features/data-update-delete.mdx
new file mode 100644
index 00000000000..21166fd653b
--- /dev/null
+++ b/docs-next/key-features/data-update-delete.mdx
@@ -0,0 +1,107 @@
+---
+title: Data Update and Delete
+description: SQL UPDATE, predicate DELETE, partial-column upserts, and atomic partition swaps, all backed by Merge-on-Write on Unique Key tables.
+slug: /key-features/data-update-delete
+featureCard:
+  tags:
+    - update
+    - delete
+---
+
+> **TL;DR** Doris speaks SQL `UPDATE` and `DELETE` like a transactional database, plus partial-column upserts from streaming loads, predicate-based bulk deletes, and atomic partition swaps. On Unique Key tables, the Merge-on-Write engine keeps query latency low by marking deleted rows in a per-rowset bitmap at write time instead of merging versions at read time.
+
+![Data Update and Delete hero illustration](/images/next/key-features/data-update-delete.jpg)
+
+## Why it matters
+
+Most analytical engines were built around the idea that warehouses are append-only. Then CDC happened, and so did GDPR, and so did the boring fact that production data has bugs. If your warehouse can't fix a single bad row without rewriting a partition, every late-arriving correction turns into an ops ticket.
+
+- A Flink CDC job watches MySQL and needs every row update to land within seconds.
+- A user requests deletion under GDPR. You need it gone from the warehouse, not "gone after the next compaction."
+- One report shows the wrong number because a single row in a 10-billion-row fact table has a typo.
+
+Doris handles all three with first-class SQL: `UPDATE`, `DELETE FROM ... WHERE`, partial-column upserts from Stream Load, and `TRUNCATE` or atomic partition replace for bulk cleanup. None of them require the table to be small, and none rely on you to schedule a rewrite job afterward.
+
+## What it is
+
+Doris exposes four user-facing ways to change data after it lands: row-level `UPDATE` SQL on Unique Key tables, predicate-based `DELETE FROM ... WHERE` on any table, partial-column upserts driven by ingestion (the `partial_columns: true` Stream Load header or the matching session variable), and partition-level operations like `TRUNCATE PARTITION` and atomic temp-partition replacement. Underneath, the Unique Key model uses Merge-on-Write (MoW) so deletes and updates take effect at write t [...]
+
+**Key terms**
+
+- **`Unique Key model`**: a table model where one row per primary key is kept. Required for `UPDATE` and for batch upserts.
+- **`Merge-on-Write (MoW)`**: at write time, Doris looks up each new key in the existing data, marks the old row in a delete bitmap, and writes the new row to a fresh rowset.
+- **`Delete bitmap`**: a per-rowset bitmap of row IDs that queries should skip, replacing per-key version merging at read time.
+- **`Partial column update`**: a write that only touches some columns. Doris reads the missing values from existing rows and writes a complete row back.
+- **`Sequence column`** (`function_column.sequence_col`): a user-chosen column that tells MoW which version of a key wins when out-of-order writes arrive.
+- **`__DORIS_DELETE_SIGN__`**: a hidden column you set to `1` to soft-delete a row through any load path.
+
+## How it works
+
+Take an `UPDATE` against a Unique Key MoW table:
+
+1. **Plan and read.** The FE parses the statement, resolves the WHERE predicate, and routes a read across BE replicas. For partial-column writes, BEs additionally fetch the missing column values for each affected key.
+2. **Look up keys.** Each BE consults the in-memory primary key index per rowset to find which rowset and row IDs the affected keys currently live in.
+3. **Mark old rows.** Every rowset that holds an affected key has the corresponding row IDs flipped on in its delete bitmap. The old rows stay on disk until compaction sweeps them up.
+4. **Write new rows.** New values, plus any unchanged columns Doris had to read for partial updates, land in a fresh rowset with the next version number.
+5. **Publish.** The transaction commits, the new version becomes visible, and queries from that point on filter through the merged delete bitmap. Old data is reclaimed at compaction.
+
+`DELETE FROM ... WHERE` follows the same flow without the write step. On Aggregate or Duplicate tables, Doris records the predicate in tablet metadata instead, and queries apply it at read time.
+
+## Quick start
+
+```sql
+CREATE TABLE orders (
+  order_id BIGINT,
+  status   VARCHAR(20),
+  amount   DECIMAL(10, 2),
+  updated  DATETIME
+)
+UNIQUE KEY(order_id)
+DISTRIBUTED BY HASH(order_id) BUCKETS 4
+PROPERTIES ("function_column.sequence_col" = "updated");
+
+INSERT INTO orders VALUES
+  (1, 'created', 99.50,  '2026-05-08 10:00:00'),
+  (2, 'created', 149.00, '2026-05-08 10:01:00');
+
+UPDATE orders SET status = 'shipped', updated = NOW() WHERE order_id = 1;
+DELETE FROM orders WHERE order_id = 2;
+```
+
+**Expected result**
+
+```
++----------+---------+--------+---------------------+
+| order_id | status  | amount | updated             |
++----------+---------+--------+---------------------+
+|        1 | shipped |  99.50 | 2026-05-08 11:42:31 |
++----------+---------+--------+---------------------+
+```
+
+Order 1 was rewritten in place; order 2 is gone. The `function_column.sequence_col` property protects the table from out-of-order writes: a CDC event with an older `updated` timestamp would lose to the row that's already there, even if it lands after the newer one.
+
+## When to use it
+
+**Good fit**
+
+- Flink and Spark CDC pipelines that upsert into a Unique Key table, with the source primary key as the Doris key.
+- Hot-column updates on wide tables (order status, last-seen timestamp, current balance) via partial-column Stream Load.
+- GDPR-style row-level deletes by user ID, including on append-only Duplicate tables.
+- Backfills that swap a corrected partition in atomically with `ALTER TABLE ... REPLACE PARTITION`.
+- Multi-stream wide-table assembly, where each upstream owns a column group and writes only its own columns.
+
+**Not a good fit**
+
+- `UPDATE` on Aggregate or Duplicate tables. The statement is rejected. Reload through ingestion (Aggregate uses `REPLACE_IF_NOT_NULL` to overwrite values), or switch the table model.
+- High-frequency single-row `UPDATE`s in a tight loop. Each statement is its own transaction and the commit overhead dominates. Batch updates through Stream Load with `partial_columns: true` instead, or use [Group Commit](../data-operate/import/load-best-practices/group-commit-manual.md).
+- Renaming a primary key. There is no key-rewrite path; soft-delete the old row via `__DORIS_DELETE_SIGN__` and insert a new one, ideally in the same load.
+- Predicate `DELETE` over many billions of rows that match. Each delete records a predicate or bitmap that compaction has to chew through; a partition replace or `INSERT OVERWRITE` is faster.
+- Aggregate-model `DELETE` on non-key columns. The predicate must filter on key columns only; the engine has no way to evaluate value-column conditions before aggregation.
+
+## Further reading
+
+- [Data update and delete overview](../data-operate/update-and-delete.mdx)
+- [Update overview: SQL UPDATE, partial column update, sequence column](../data-operate/update/update-overview.md)
+- [Partial column update](../data-operate/update/partial-column-update.md)
+- [Delete overview: predicate delete, batch delete, atomic replace](../data-operate/delete/delete-overview.md)
+- [Apache Doris 2.1.0: Merge-on-Write becomes the Unique Key default](https://doris.apache.org/blog/release-note-2.1.0/)
diff --git a/docs-next/key-features/group-commit.mdx b/docs-next/key-features/group-commit.mdx
new file mode 100644
index 00000000000..d40d6427da6
--- /dev/null
+++ b/docs-next/key-features/group-commit.mdx
@@ -0,0 +1,105 @@
+---
+title: Group Commit
+description: Merge many small INSERTs and Stream Loads server-side into one transaction, with sync and async modes for the latency vs throughput tradeoff.
+slug: /key-features/group-commit
+featureCard:
+  tags:
+    - load
+    - ingest
+---
+
+> **TL;DR** Group Commit lets Doris batch many small `INSERT VALUES` statements or Stream Loads into one server-side transaction, so high-frequency clients stop drowning the FE in parse and plan work and the BE in tiny rowsets. Pick `sync_mode` if the load should wait for visibility, `async_mode` if it should return as soon as the row is durable in the write-ahead log. The same workload that hits `-235` backpressure errors goes to six figures of rows per second.
+
+![Group Commit hero illustration](/images/next/key-features/group-commit.jpg)
+
+## Why it matters
+
+Per-row `INSERT INTO ... VALUES` and tiny-batch Stream Loads are how a lot of real systems write to Doris. A microservice calls `INSERT` over JDBC every time a user clicks something. A Kafka consumer flushes whatever it has every few hundred milliseconds. An IoT collector pushes each event the moment it arrives. None of those clients can batch larger because the events don't exist yet. And without batching, every load opens its own transaction, takes its own trip through the FE planner,  [...]
+
+- High-frequency `INSERT VALUES` saturates the FE's parser and plan cache before it saturates the BE's CPU.
+- Small Stream Loads from streaming pipelines pile up tablet versions until background compaction can't keep up. The classic symptom is the `-235` "too many segments" error.
+- Each load becomes its own transaction, which spends as much time on the commit handshake as on the actual data.
+
+Group Commit folds many of those small writes into one transaction, on the server, without the client knowing or caring.
+
+## What it is
+
+Group Commit is a server-side buffer for small writes. The BE collects rows from many concurrent loads and commits them as one transaction every few seconds, or when the buffer fills. It layers on top of `INSERT INTO ... VALUES`, Stream Load, and JDBC prepared statements without changing their public API. You turn it on with one knob: a session variable for INSERT, an HTTP header for Stream Load, or a table property as the default. The mode you pick decides how the load returns.
+
+**Key terms**
+
+- **`group_commit`**: session variable (for INSERT) and HTTP header (for Stream Load) that selects the mode. Values: `off_mode`, `sync_mode`, `async_mode`.
+- **`sync_mode`**: rows are merged with concurrent loads on the BE, and the request blocks until the merged transaction commits and becomes visible.
+- **`async_mode`**: rows are written to a write-ahead log on the BE, the request returns immediately with status `PREPARE`, and the merged transaction commits later.
+- **`WAL`**: per-BE write-ahead log used by `async_mode`. Single-replica, lives on the receiving BE.
+- **`group_commit_interval_ms`** and **`group_commit_data_bytes`**: per-table flush triggers. Defaults: 10s and 128 MB.
+
+## How it works
+
+1. **Open or join a queue.** A request lands on a BE. Instead of starting a fresh transaction, the BE drops the rows into a `LoadBlockQueue` keyed by table and the current open group-commit transaction.
+2. **(Async only) write to the WAL.** In `async_mode`, the rows are also persisted to the local WAL so the request can return as soon as they are durable. In `sync_mode`, this step is skipped.
+3. **Wait for a flush trigger.** The BE flushes the queue when either `group_commit_interval_ms` elapses or the queue accumulates `group_commit_data_bytes`. Both are per-table properties.
+4. **Commit one transaction for the batch.** The BE pushes the merged batch through the storage engine and the FE commits exactly one transaction. The FE assigns one Label, prefixed `group_commit_`, that every merged load shares.
+5. **Return.** `sync_mode` requests have been blocking up to this point and return now. `async_mode` requests already returned earlier; data becomes queryable here.
+
+If a request hits a fallback condition (explicit transaction, user-specified Label, partial-column update, expressions in `VALUES`, low WAL disk in `async_mode`), Doris quietly takes the regular load path for that one request. The shape of the request is the only signal.
+
+## Quick start
+
+```sql
+CREATE TABLE events (
+  id BIGINT, ts DATETIME, payload STRING
+)
+DUPLICATE KEY(id)
+DISTRIBUTED BY HASH(id) BUCKETS 4
+PROPERTIES ("group_commit_interval_ms" = "1000");
+
+SET group_commit = async_mode;
+INSERT INTO events VALUES (1, NOW(), 'a');
+INSERT INTO events VALUES (2, NOW(), 'b');
+-- the second insert merges into the first one's transaction
+```
+
+**Expected result**
+
+```
+Query OK, 1 row affected (0.01 sec)
+{'label':'group_commit_a145ce07f1c972fc-bd2c54597052a9ad', 'status':'PREPARE', 'txnId':'181508'}
+```
+
+Both inserts return the same `label` and `txnId`, which is how you know they merged. The rows are not visible until the BE flushes (here, one second later, because the table set `group_commit_interval_ms` to `1000`). For Stream Load, swap the session variable for a header: `-H "group_commit:async_mode"`.
+
+## When to use it
+
+**Good fit**
+
+- Microservices doing per-event `INSERT INTO ... VALUES` over JDBC at tens of TPS or more.
+- Kafka or Flink sinks that can only batch to sub-MB chunks and are tripping `-235` errors on the BE.
+- IoT or click-stream pipelines where each event has to land within seconds but consolidating clients isn't an option.
+- Tables where the tablet-version churn from individual loads is overwhelming background compaction.
+
+**Not a good fit**
+
+- Loads that need to be exactly one transaction with their callers' bookkeeping. Group Commit silently falls back when you use explicit `BEGIN ... COMMIT`. For transactional sinks reach for [Load Transactions](./load-transaction.mdx) directly.
+- Stream Load 2PC and writes that specify their own Label. Both bypass Group Commit; if you need exactly-once over checkpoints, stay with Stream Load 2PC.
+- Partial-column updates. Group Commit does not support `partial_columns: true` and falls back to the normal load path.
+- Unique Key tables that need strict commit-order semantics. Group Commit does not preserve order across merged loads; declare a `function_column.sequence_col` so the freshest version wins by user-supplied timestamp.
+- Single-load batches that are already large (hundreds of MB or more). Group Commit's coordination is wasted work compared with one regular load. Just submit the load.
+
+## Performance / numbers
+
+From the Apache Doris 2.1.0 release notes:
+
+- JDBC, 1 FE + 1 BE, TPC-H SF10 Lineitem, concurrency 20, fewer than 100 rows per insert: **106,900 rows/s** on `async_mode`.
+- Stream Load, 3 BEs, concurrency 10, sub-1 MB batches: **810,000 rows/s, 104 MB/s**. The same workload without Group Commit returns `-235` errors and stalls.
+- Stream Load, 3 BEs, 1 MB batches at concurrency 30: **2,077,723 rows/s**.
+
+Source: [Apache Doris 2.1.0 release notes](https://doris.apache.org/blog/release-note-2.1.0/).
+
+## Further reading
+
+- [High-concurrency load optimization (Group Commit)](../data-operate/import/load-best-practices/group-commit-manual.md)
+- [Stream Load HTTP header reference](../data-operate/import/import-way/stream-load-manual.md)
+- [INSERT INTO load](../data-operate/import/import-way/insert-into-manual.md)
+- [Load Transactions: when you need explicit `BEGIN ... COMMIT` or 2PC](./load-transaction.mdx)
+- [Apache Doris 2.1.0: Group Commit benchmarks](https://doris.apache.org/blog/release-note-2.1.0/)
diff --git a/docs-next/key-features/high-availability.mdx b/docs-next/key-features/high-availability.mdx
deleted file mode 100644
index 64f11c1f587..00000000000
--- a/docs-next/key-features/high-availability.mdx
+++ /dev/null
@@ -1,21 +0,0 @@
----
-title: High Availability
-description: Keep Doris available through replication, failover, and fault tolerance.
-slug: /key-features/high-availability
-featureCard:
-  tags:
-    - fault-tolerance
-    - replica
----
-
-High availability is the set of mechanisms that help Doris stay online when nodes fail or need maintenance. Replication and failover are central to that behavior.
-
-## When to use
-
-Use this feature whenever the cluster must tolerate node loss or maintenance events without significant service interruption.
-
-## Key points
-
-- Replication protects data availability.
-- Operational procedures matter as much as system design.
-- Failover planning should be part of deployment planning.
diff --git a/docs-next/key-features/high-concurrency-point-query.mdx b/docs-next/key-features/high-concurrency-point-query.mdx
new file mode 100644
index 00000000000..6259af260f0
--- /dev/null
+++ b/docs-next/key-features/high-concurrency-point-query.mdx
@@ -0,0 +1,112 @@
+---
+title: High-Concurrency Point Query
+description: A short-circuit query path that turns Unique Key tables into a low-latency KV store, sustaining tens of thousands of QPS on primary-key equality lookups.
+slug: /key-features/high-concurrency-point-query
+featureCard:
+  tags:
+    - query-acceleration
+    - performance
+---
+
+> **TL;DR** Doris is a columnar OLAP engine, but for primary-key equality lookups it switches lanes. Combine the Unique Key model with Merge-on-Write, row-format storage (`store_row_column`), the short-circuit query path, and a server-side `PreparedStatement`, and a `SELECT * FROM t WHERE pk = ?` query stops behaving like an OLAP query at all. One round-trip to the BE, one row back, no plan parsing, no fragment scheduling. That is how you serve a dashboard widget and a fact-table aggrega [...]
+
+![High-Concurrency Point Query hero illustration](/images/next/key-features/high-concurrency-point-query.jpg)
+
+## Why it matters
+
+Most analytics workloads have a long tail of "look up one row by ID" queries hiding inside them. A user-facing dashboard fetches the row for the customer who just clicked. A risk service reads a feature vector by entity ID on every API call. A recommendation backend pulls the latest profile per user. None of these are analytical, but the data lives next to the analytical data, and nobody wants to run a separate KV store just to serve them.
+
+Default Doris is a poor fit for that traffic, and the reasons are structural:
+
+- Columnar storage reads many small column files to reconstruct one row, amplifying random IO on wide tables.
+- The Frontend planner runs the same parse, analyze, optimize, and fragment pipeline whether the query reads one row or one billion. At thousands of QPS the FE CPU saturates before the BE notices.
+- Every query opens its own coordinator, allocates fragments, and ships RPCs. That is overhead a `WHERE id = 42` cannot afford.
+- Page Cache is column-oriented, and large analytical scans evict it constantly, so point queries miss cache exactly when they need it.
+
+High-concurrency point query is Doris's answer to that traffic. It reshapes storage, planning, execution, and caching so a primary-key lookup costs one round trip and a few microseconds of FE CPU.
+
+## What it is
+
+High-concurrency point query is a four-layer optimization that activates automatically when the table and the query both fit a strict shape: a Unique Key table with Merge-on-Write and row-store enabled, queried with equality predicates that cover the full primary key. When the conditions hold, the planner takes the short-circuit path and the BE serves the row from a row-format column instead of stitching it from per-column files.
+
+**Key terms**
+
+- **`Unique Key model`**: a Doris table model where rows are addressed by a primary key. With `enable_unique_key_merge_on_write=true`, the latest version of each row is materialized at write time, so reads do not merge versions on the fly.
+- **`store_row_column`**: a table property that adds a hidden column holding each row in a row-encoded blob, so the BE reads one column instead of N to return a whole row.
+- **`SHORT-CIRCUIT`**: a marker in `EXPLAIN` output that appears once the planner has skipped the normal distributed plan and routed the query through a single-tablet, single-RPC path.
+- **`PreparedStatement`**: server-side statement caching over the MySQL protocol. Doris caches the parsed statement, output expressions, and descriptor table per session, then reuses them across `EXECUTE` calls.
+- **`Row Cache`**: a separate LRU cache that holds whole rows. It survives the eviction pressure that the columnar Page Cache suffers under mixed analytical and point-query workloads.
+
+## How it works
+
+1. **Storage layout.** When you set `store_row_column=true` at table creation, the BE writes each row both into the columnar segments and into a row-encoded hidden column. From Doris 3.0 onward you can scope this to a subset with `row_store_columns="k1,v1,v2"` to limit the storage overhead.
+2. **Plan rewrite (FE).** The Nereids rule `LogicalResultSinkToShortCircuitPointQuery` checks the query shape: single Unique table, equality conjuncts on every key column, no joins or subqueries, no aggregations. If everything matches, it flips the `isShortCircuitQuery` flag in the `StatementContext`.
+3. **Short-circuit dispatch.** Instead of building fragments and shipping them, the FE resolves the bucket through `PartitionPruneV2ForShortCircuitPlan`, picks the one tablet that can hold the key, and sends a single RPC to the BE that owns it.
+4. **Server-side `PreparedStatement` reuse.** With `useServerPrepStmts=true`, the FE caches the `ShortCircuitQueryContext` per session UUID. Subsequent `EXECUTE` calls skip parsing and planning entirely; only the parameter values change.
+5. **BE row lookup.** The `point_query_executor` locates the row by key, reads the row-format column, and returns the bytes. The optional Row Cache short-circuits even the segment read on a hit.
+
+## Quick start
+
+```sql
+CREATE TABLE tbl_point_query (
+  k1 INT,
+  v1 VARCHAR(64),
+  v2 DECIMAL(27, 9)
+)
+UNIQUE KEY(k1)
+DISTRIBUTED BY HASH(k1) BUCKETS 1
+PROPERTIES (
+  "enable_unique_key_merge_on_write" = "true",
+  "light_schema_change" = "true",
+  "store_row_column" = "true"
+);
+
+EXPLAIN SELECT * FROM tbl_point_query WHERE k1 = 42;
+```
+
+**Expected result (excerpt)**
+
+```
+0:VOlapScanNode
+  TABLE: tbl_point_query, PREAGGREGATION: ON
+  PREDICATES: k1 = 42 AND __DORIS_DELETE_SIGN__ = 0
+  partitions=1/1, tablets=1/1
+  SHORT-CIRCUIT
+```
+
+The `SHORT-CIRCUIT` line is the one that matters. Without it, the same DDL plus a query that cannot satisfy the conditions (a join, a missing key column, an inequality) falls back to the regular distributed plan. To collect the FE CPU savings on top, connect the client with `jdbc:mysql://host:9030/db?useServerPrepStmts=true&cachePrepStmts=true` and use `PreparedStatement` with `?` placeholders. Confirm in `fe.audit.log` that repeat queries log `Stmt=EXECUTE(...)` rather than the raw SQL.
+
+## When to use it
+
+**Good fit**
+
+- Online services that read a row by primary key per request: user profiles, feature vectors, status flags, lookup tables.
+- Mixed OLAP and point-query workloads where a separate KV store would duplicate data and operational load.
+- High-QPS clients that already use `PreparedStatement` over JDBC.
+- Wide Unique Key tables where you can scope the row store to a few hot columns with `row_store_columns`.
+
+**Not a good fit**
+
+- Range queries (`k1 BETWEEN 1 AND 1000`). The short-circuit path requires equality on every key column. Run them through a normal scan and lean on [data pruning](./data-pruning.mdx).
+- Multi-row aggregations or joins. Even a `GROUP BY pk` disqualifies the rewrite. Run them as ordinary OLAP queries.
+- Lookups by a non-key column. Add a [secondary or inverted index](../table-design/index/index-overview.md), not row-store.
+- Tables you forgot to enable row-store on at create time. `store_row_column` is only settable in `CREATE TABLE`. If you need it on an existing table, recreate and reload.
+- Pure write-heavy workloads with rare reads. Row-format storage inflates space and write IO. If you barely read by key, skip it and live with column-only storage.
+
+## Performance and verification
+
+Doris reports that enabling server-side `PreparedStatement` on top of the short-circuit path delivers more than a 4x throughput improvement when FE CPU is the bottleneck. Numbers depend on row width, row cache hit rate, and how many FE Observers absorb traffic, so treat that as a planning floor, not a benchmark.
+
+Two checks tell you the path is live:
+
+- `EXPLAIN` on the query shows `SHORT-CIRCUIT` in the scan node.
+- `fe.audit.log` shows `Stmt=EXECUTE(...)` for repeat lookups, not the raw SQL string.
+
+If the FE CPU is still the ceiling, scale Observers and use JDBC load balancing (`jdbc:mysql:loadbalance://host1,host2,host3/db?useServerPrepStmts=true&cachePrepStmts=true`) to spread connections. On compute-storage decoupled deployments, also consider `SET GLOBAL enable_snapshot_point_query=false` and the BE flag `enable_file_cache_keep_base_compaction_output=1` so Base Compaction output stays in the file cache.
+
+## Further reading
+
+- [High-Concurrency Point Query (full guide)](../query-acceleration/high-concurrent-point-query.md)
+- [Row Store storage layout](../table-design/row-store.md)
+- [Index overview: secondary, bloom, inverted](../table-design/index/index-overview.md)
+- [Data pruning: making non-point queries fast](./data-pruning.mdx)
diff --git a/docs-next/key-features/hybrid-search.mdx b/docs-next/key-features/hybrid-search.mdx
new file mode 100644
index 00000000000..f50ab7bb2ee
--- /dev/null
+++ b/docs-next/key-features/hybrid-search.mdx
@@ -0,0 +1,106 @@
+---
+title: Hybrid Search
+description: Apache Doris combines full-text matching and vector similarity in one SQL query on one table, with no separate search cluster to run.
+slug: /key-features/hybrid-search
+featureCard:
+  tags:
+    - search
+    - ai
+---
+
+> **TL;DR** Doris lets you combine full-text matching and vector similarity in one SQL query, on one table. You declare an inverted index and an ANN index on the columns you'd normally split between Elasticsearch and a vector database, then mix `MATCH_ANY` with `l2_distance_approximate` in the same `WHERE` clause.
+
+## Why it matters
+
+Search workloads rarely pick just one retrieval mode. A user searching "lightweight running shoes for trail" wants exact matches on "running" and "trail", but also semantic matches against listings that talk about "off-road" or "rugged terrain". The typical fix, splitting work across two systems, comes with the typical costs:
+
+- Two ingest pipelines that have to stay in sync, or recall drifts.
+- Two query languages, glued together by hand-written fusion logic.
+- Two clusters to size, monitor, and pay for.
+
+Doris collapses that into one table and one SQL statement, served by the same MPP engine that runs the rest of your queries.
+
+## What it is
+
+Hybrid search in Doris is a query pattern, not a separate engine. A single table can carry both an **inverted index** (which powers `MATCH_*` operators and BM25 scoring via `score()`) and an **ANN index** (which powers approximate vector distance functions like `l2_distance_approximate` and `inner_product_approximate`). The Doris planner reads from both indexes in one query and returns one ranked result set.
+
+**Key terms**
+
+- **Inverted index**: a full-text index on a `STRING` column. Tokenizers include `english`, `standard`, and Chinese.
+- **ANN index**: an approximate-nearest-neighbor index on an `ARRAY<FLOAT>` column. Backends are HNSW, IVF, and IVF on-disk.
+- **Pre-filter**: Doris's default strategy. Apply scalar and text predicates first, then take TopN by vector distance over the rows that survive.
+
+## How it works
+
+1. **Index build at load time.** When you ingest data, the inverted index tokenizes each text column; the ANN index builds an HNSW graph or IVF codebook over each vector. Both live alongside the base table, in the same storage tier.
+2. **Plan in one shot.** When a query mixes `MATCH_*` operators and a vector distance function, the planner routes the text predicate to the inverted index and the distance function to the ANN index. There is no second query, no client-side join.
+3. **Filter, then rank.** Doris pre-filters with the inverted index first, then runs ANN TopN over the surviving row set. This is the inverse of the common "ANN first, filter after" pattern, and it keeps recall high when filters are selective.
+4. **Adaptive fallback.** If the filter is so selective that ANN traversal would skip too many candidates, Doris falls back to brute-force vector scoring on the filtered set. The same SQL keeps working; only the execution path changes.
+5. **Return.** One sorted, limited result set. No external fusion service, no application-side ranking glue.
+
+## Quick start
+
+```sql
+CREATE TABLE docs (
+  id INT NOT NULL,
+  embedding ARRAY<FLOAT> NOT NULL,
+  body STRING NOT NULL,
+  INDEX idx_body(body) USING INVERTED PROPERTIES("parser"="english"),
+  INDEX idx_vec(embedding) USING ANN
+    PROPERTIES("index_type"="hnsw","metric_type"="l2_distance","dim"="8")
+) DUPLICATE KEY(id) DISTRIBUTED BY HASH(id) BUCKETS 1;
+
+SELECT id, body,
+       l2_distance_approximate(embedding, [0.1,0.1,0.2,0.2,0.3,0.3,0.4,0.4]) AS dist
+FROM docs
+WHERE body MATCH_ANY 'music'   -- inverted index pre-filter
+ORDER BY dist ASC              -- ANN TopN over the filtered rows
+LIMIT 2;
+```
+
+**Expected result**
+
+```
++----+---------------------+----------+
+| id | body                | dist     |
++----+---------------------+----------+
+|  1 | this is about music | 0.663325 |
+|  3 | latest music trend  | 1.280625 |
++----+---------------------+----------+
+```
+
+`MATCH_ANY 'music'` shrinks the candidate set to rows whose `body` contains "music"; the ANN index then ranks those rows by distance to the query vector and returns the closest two.
+
+## When to use it
+
+**Good fit**
+
+- RAG retrieval where keyword filters (tenant, doc type, date range) need to narrow the corpus before semantic ranking.
+- Product or catalog search that mixes structured filters (brand, price, in-stock) with embedding similarity over title and description.
+- Log and document search where users routinely mix "must contain X" with "looks like Y".
+- Workloads that already store the data in Doris for SQL analytics and don't want to ship a copy to a vector database.
+
+**Not a good fit**
+
+- Pure billion-scale ANN with no filters and no analytics. A dedicated vector DB may use less memory and cost less per query.
+- Tables that need `AGGREGATE KEY`, or `UNIQUE KEY` without merge-on-write. ANN indexes today require `DUPLICATE KEY` or MoW `UNIQUE KEY` tables.
+- Nullable vector columns. The vector column must be `NOT NULL ARRAY<FLOAT>` with a fixed dimension that matches the index.
+- Workloads that depend on Reciprocal Rank Fusion across two scorers. Doris fuses via SQL `ORDER BY`, not RRF. If you need RRF, issue two queries from the application and fuse the rankings yourself.
+
+## How it compares
+
+| Aspect | Doris hybrid search | Elasticsearch + vector DB | Vector DB only |
+|---|---|---|---|
+| Ingest pipelines | one | two, kept in sync | one |
+| Query path | one SQL | app-side fusion | one API |
+| Filter expressiveness | full SQL (joins, range, agg) | DSL, limited joins | tag and metadata only |
+| Analytics on same data | full MPP SQL | limited | none |
+
+Hybrid search in Doris pays off when you already want joins or aggregations on the same data. For pure vector lookup at extreme scale, a dedicated vector DB is usually simpler to operate.
+
+## Further reading
+
+- [Vector index overview](https://doris.apache.org/docs/table-design/index/vector-index/overview/)
+- [Inverted index overview](https://doris.apache.org/docs/table-design/index/inverted-index/)
+- [Vector search practical guide](https://doris.apache.org/docs/table-design/index/vector-index/practical-guide/)
+- [AI on Doris](https://doris.apache.org/docs/ai/ai-overview/)
diff --git a/docs-next/key-features/incremental-materialized-view.mdx b/docs-next/key-features/incremental-materialized-view.mdx
new file mode 100644
index 00000000000..93cbb61988a
--- /dev/null
+++ b/docs-next/key-features/incremental-materialized-view.mdx
@@ -0,0 +1,102 @@
+---
+title: Incremental Materialized View
+description: Partitioned async materialized views in REFRESH AUTO mode rebuild only the partitions whose base data changed, so dashboards and lakehouse queries stay fresh without recomputing the whole result.
+slug: /key-features/incremental-materialized-view
+featureCard:
+  tags:
+    - materialized-view
+    - performance
+---
+
+> **TL;DR** Mark an async materialized view with `PARTITION BY` and `REFRESH AUTO` and Doris tracks staleness per partition. Today's load only touches today's base partition, so the next refresh re-runs the aggregation for that one day and leaves the rest of the view alone. Queries that span fresh and stale days still hit the view: the optimizer reads valid partitions from the MV and `UNION ALL`s the stale ones from the base table.
+
+![Incremental Materialized View hero illustration](/images/next/key-features/incremental-materialized-view.jpg)
+
+## Why it matters
+
+You have a fact table partitioned by day and a materialized view that aggregates it for tomorrow's dashboard. Today's load only writes into today's partition, but a naive refresh recomputes the whole view anyway. It re-aggregates 29 days that did not change, runs for an hour, and finishes after the dashboard is already stale.
+
+- A 30-day partitioned fact table where each load only touches the latest day. A full refresh re-aggregates 29 days that did not change.
+- BI dashboards that fall behind the load schedule because the MV refresh window is longer than the load interval.
+- Hive- or Iceberg-backed lakehouse tables where rereading every partition through object storage is slow and expensive.
+
+`REFRESH AUTO` on a partitioned async MV cuts that down to what actually changed. Doris tracks staleness per partition, runs `INSERT OVERWRITE` only on the partitions whose base data moved, and leaves the rest of the view exactly as it was.
+
+## What it is
+
+An incremental materialized view in Doris is a partitioned async materialized view (an MTMV) created with `REFRESH AUTO`. Doris derives the view's partitions from one base table you point at in `PARTITION BY`, with an optional `DATE_TRUNC` that rolls daily base partitions up to monthly view partitions. When a base partition's data changes, only the view partition mapped to it is flagged invalid. The next refresh runs `INSERT OVERWRITE` for the invalid partitions, one statement at a time.
+
+**Key terms**
+
+- **`MTMV`**: the internal table type backing every async MV. It's a Duplicate-model OLAP table whose partitions are managed by the refresh job, not by you.
+- **`REFRESH AUTO`**: the refresh method that tries partitioned incremental refresh first and falls back to full refresh only when the MV's definition can't be partition-derived. `REFRESH COMPLETE` is the opt-out.
+- **`Partition derivation`**: the FE's analysis that maps an MV partition column back to a base partition column through the MV's SQL. If derivation fails, Doris rejects the partition clause at create time.
+- **`Partition invalidation`**: the per-partition stale flag set when base data backing that partition changes. Visible via `SHOW PARTITIONS FROM <mv>`.
+- **`Partition-granularity transparent rewrite`**: the rewriter can read valid MV partitions and `UNION ALL` the rest from the base table, so a single stale partition no longer disqualifies the whole MV.
+
+## How it works
+
+1. **You declare the partition mapping.** `PARTITION BY (DATE_TRUNC(o_orderdate, 'MONTH'))` tells Doris to derive the MV's partitions from `orders.o_orderdate`. Internal tables and Hive tables both work; for Iceberg, Paimon, and Hudi, partitioned refresh shipped in 3.1.
+2. **Doris materializes a partition layout.** At create time, the FE walks the base partitions, applies the roll-up function, and emits matching MV partitions. New base partitions trigger new MV partitions on the next refresh.
+3. **Loads invalidate partitions, not the whole view.** A load that lands in `orders` partition `p_2026_05_08` only flags the MV partitions whose key set covers that day. Loads on referenced non-partitioned tables still invalidate everything by default; list those tables in `excluded_trigger_tables` if you know they only insert and never update.
+4. **The refresh job rebuilds invalid partitions only.** Each invalid partition becomes one `INSERT OVERWRITE`. The `refresh_partition_num` property controls how many partitions ride per statement (default 1), trading transactional granularity for throughput. A failure stops the task without rolling back partitions that already landed.
+5. **The optimizer rewrites at partition granularity.** A query that touches both fresh and stale days reads the fresh days from the MV and the stale day from the base table through `UNION ALL`. Set `grace_period` if you want the rewriter to accept partitions that are stale by less than N seconds.
+
+## Quick start
+
+```sql
+CREATE TABLE lineitem (
+  l_orderkey INT, l_extendedprice DECIMAL(15,2),
+  l_discount DECIMAL(15,2), l_ordertime DATETIME
+) DUPLICATE KEY(l_orderkey)
+PARTITION BY RANGE(l_ordertime)
+  (FROM ('2026-05-01') TO ('2026-05-09') INTERVAL 1 DAY)
+DISTRIBUTED BY HASH(l_orderkey) BUCKETS 3;
+
+CREATE MATERIALIZED VIEW daily_revenue
+BUILD IMMEDIATE REFRESH AUTO ON SCHEDULE EVERY 1 HOUR
+PARTITION BY (DATE_TRUNC(l_ordertime, 'DAY'))
+DISTRIBUTED BY RANDOM BUCKETS 2 AS
+SELECT DATE_TRUNC(l_ordertime, 'DAY') AS d,
+       SUM(l_extendedprice * (1 - l_discount)) AS revenue
+FROM lineitem GROUP BY DATE_TRUNC(l_ordertime, 'DAY');
+```
+
+**Expected result**
+
+After loading new rows into the `2026-05-08` base partition only, `SHOW PARTITIONS FROM daily_revenue` confirms that one MV partition is the only one that ticked:
+
+```
++--------------+--------+---------------------+
+| PartitionName| State  | VisibleVersionTime  |
++--------------+--------+---------------------+
+| p_20260507   | NORMAL | 2026-05-07 14:00:02 |
+| p_20260508   | NORMAL | 2026-05-08 15:00:01 |
++--------------+--------+---------------------+
+```
+
+The earlier partitions kept their old `VisibleVersionTime` because nothing recomputed them. Only `p_20260508` rebuilt, in one `INSERT OVERWRITE` statement against one day's data.
+
+## When to use it
+
+**Good fit**
+
+- Append-mostly fact tables partitioned by day or hour, with downstream dashboards that aggregate by the same column.
+- Multi-layer (DWD, DWS, ADS) modeling where a high-frequency base layer feeds a coarser-grained presentation layer through partition-mapped MVs.
+- Hive, Iceberg (3.1+), or Paimon (3.1+) lakehouse tables that you want to materialize into Doris-local storage with cheap incremental refresh.
+- Long-window queries where partition-granularity rewrite still serves results from the MV even while the most recent partition is briefly stale.
+
+**Not a good fit**
+
+- Real-time freshness in the seconds. Use a [sync materialized view](../query-acceleration/materialized-view/sync-materialized-view.md) instead; sync MVs are merged into the base table's write path.
+- Tables that don't have a partition column. Without a partition mapping the MV degrades to a full refresh, and you get none of the incremental benefits.
+- MV definitions that can't be partition-derived. Doris rejects the `PARTITION BY` clause at create time when any of the eight derivation rules fails: aggregating the partition column (`min(o_orderdate)`), placing it on the NULL-generating side of an OUTER JOIN, dropping it from `GROUP BY` or a window's `PARTITION BY`, using a roll-up function other than `DATE_TRUNC`, and so on.
+- Hudi base tables. Doris cannot detect base data changes on Hudi today, so only manual refresh is meaningful, and the incremental decision can't be made automatically.
+- Tables you also write to from Spark or Flink without going through Doris. The metadata cache won't see those writes, so the MV will think it's still in sync. Force a refresh manually after each external write, or pick a different acceleration path.
+
+## Further reading
+
+- [Async materialized view overview: refresh modes, transparent rewrite, lakehouse caveats](../query-acceleration/materialized-view/async-materialized-view/overview.md)
+- [Async MV best practices: scenario assessment, refresh strategy, partitioned MV examples](../query-acceleration/materialized-view/async-materialized-view/use-guide.md)
+- [`CREATE ASYNC MATERIALIZED VIEW`: full syntax, partition derivation rules, table properties](../sql-manual/sql-statements/table-and-view/async-materialized-view/CREATE-ASYNC-MATERIALIZED-VIEW.md)
+- [Sync materialized view: real-time, single-table acceleration](../query-acceleration/materialized-view/sync-materialized-view.md)
diff --git a/docs-next/key-features/kafka-cdc-integration.mdx b/docs-next/key-features/kafka-cdc-integration.mdx
new file mode 100644
index 00000000000..e11326546e6
--- /dev/null
+++ b/docs-next/key-features/kafka-cdc-integration.mdx
@@ -0,0 +1,121 @@
+---
+title: Kafka and CDC Integration
+description: Pull Kafka topics straight into Doris with Routine Load, or stream MySQL/Postgres CDC through Flink with exactly-once via Stream Load 2PC.
+slug: /key-features/kafka-cdc-integration
+featureCard:
+  tags:
+    - load
+    - streaming
+    - cdc
+---
+
+> **TL;DR** Doris ingests Kafka topics and CDC change streams natively. Use **Routine Load** when you want a Kafka consumer that lives inside the FE and needs no external service. Use the **Flink Doris Connector** with Flink CDC when you want whole-database MySQL/Postgres/Oracle sync with exactly-once via Stream Load 2PC. Use the **Doris Kafka Connector** when Kafka Connect is already running and your payloads are Avro, Protobuf, or Debezium. All three paths land in the same Unique Key M [...]
+
+![Kafka and CDC Integration hero illustration](/images/next/key-features/kafka-cdc-integration.jpg)
+
+## Why it matters
+
+Most warehouses make you bolt on streaming ingest. You stand up a Kafka consumer, re-implement offset bookkeeping, retry, schema mapping, and deletes, and hope the consumer dies cleanly when the warehouse falls over. Or you run Flink in front and pay for an extra cluster. Or you give up on "real-time" and ship five-minute batches.
+
+- A CDC pipeline replays its last batch after a checkpoint failure and doubles every row it had buffered.
+- An out-of-order update on a Unique Key table loses to the older version because nothing told the engine which write is fresher.
+- A Kafka topic carrying Avro or Protobuf does not fit an HTTP load API that only speaks JSON or CSV.
+
+Doris pushes most of this into the engine, so the path from a Kafka topic or a MySQL binlog to a queryable row is short and the failure modes are ones you can name.
+
+## What it is
+
+Doris ships four ingestion paths for streaming and CDC traffic. They share the same backbone: transactional loads identified by a `Label`, Unique Key Merge-on-Write tables for upserts and deletes, and a sequence column that pins the winning version when events arrive out of order. They differ in who runs the consumer.
+
+**Key terms**
+
+- **`Routine Load`**: a long-running Kafka consumer managed by the FE. One `CREATE ROUTINE LOAD` statement and the FE schedules tasks to BEs that pull batches and turn each batch into a transactional load.
+- **`Stream Load 2PC`**: an HTTP load that stops at `PRECOMMITTED` until a follow-up call sends `txn_operation: commit` or `txn_operation: abort`. The two-phase split is what makes external sinks exactly-once.
+- **`Flink Doris Connector`**: an external Flink sink that drives Stream Load 2PC and ties commits to Flink checkpoints. Combined with Flink CDC, it does whole-database sync and propagates DDL.
+- **`Doris Kafka Connector`**: a Kafka Connect sink plugin. Inherits Kafka Connect's converter ecosystem, so Avro, Protobuf, and Debezium payloads work without a custom serializer.
+- **`function_column.sequence_col`**: a per-table property on Unique Key MOW tables that selects the column whose value decides which version of a row wins. The safety net for out-of-order CDC.
+
+## How it works
+
+The two paths most teams pick share the same lifecycle once data hits the FE.
+
+1. **The consumer pulls a batch.** Routine Load schedules a task on a BE that pulls from Kafka up to `max_batch_rows` (default 20M), `max_batch_size` (default 1 GB), or `max_batch_interval` (default 60 s), whichever hits first. Flink CDC streams rows over a chunked HTTP Stream Load, which stays open across the checkpoint window.
+2. **The batch becomes a transaction.** The BE opens a transaction with a `Label`. Routine Load generates the label; Flink Doris Connector tags it with the checkpoint id. Either way, resubmitting the same label deduplicates.
+3. **Commit waits for confirmation.** Routine Load commits as soon as the batch lands and advances the Kafka offset on success only. Flink CDC stops at `PRECOMMITTED`; the next checkpoint barrier triggers `txn_operation: commit`, and a failed checkpoint triggers `abort`, which discards the staged data.
+4. **Publish makes rows visible.** A background daemon ships `PublishVersion` to every replica. When the transaction reaches `VISIBLE`, the new rows are queryable. Tablets in the same transaction never tear.
+5. **Errors pause the job, not the data.** If a Routine Load batch breaches `max_error_number` (default 0), the job moves to `PAUSED` and keeps the last error URL on the FE. Resume after fixing the source data; offsets stay where they were.
+
+## Quick start
+
+```sql
+CREATE TABLE events (
+  user_id BIGINT, event_type STRING, ts DATETIME
+)
+DUPLICATE KEY(user_id)
+DISTRIBUTED BY HASH(user_id) BUCKETS 8;
+
+CREATE ROUTINE LOAD events_kafka ON events
+COLUMNS(user_id, event_type, ts)
+PROPERTIES (
+  "format" = "json",
+  "jsonpaths" = "[\"$.user_id\",\"$.event_type\",\"$.ts\"]"
+)
+FROM KAFKA (
+  "kafka_broker_list" = "localhost:9092",
+  "kafka_topic" = "events",
+  "property.kafka_default_offsets" = "OFFSET_BEGINNING"
+);
+```
+
+**Expected result**
+
+```
++--------+--------------+---------+----------------------+
+| Id     | Name         | State   | Statistic            |
++--------+--------------+---------+----------------------+
+| 100123 | events_kafka | RUNNING | {"loadedRows":4821,..|
++--------+--------------+---------+----------------------+
+```
+
+`SHOW ROUTINE LOAD FOR events_kafka` reports a job in `RUNNING` state, the last consumed offset per partition, and the count of loaded rows. `PAUSE`, `RESUME`, and `STOP` work the same as on any other Doris job.
+
+For CDC, swap the consumer for the Flink Doris Connector with Flink CDC (one Flink job, MySQL source, Doris sink, `sink.enable-2pc = true`). The schema and sequence column live on the Doris side:
+
+```sql
+CREATE TABLE orders (
+  order_id BIGINT, status STRING, updated DATETIME
+)
+UNIQUE KEY(order_id) DISTRIBUTED BY HASH(order_id) BUCKETS 8
+PROPERTIES ("function_column.sequence_col" = "updated");
+```
+
+The `sequence_col` line is what protects you when a delayed CDC event arrives after a fresher one: the older `updated` loses, the newer wins, and the result is the same regardless of arrival order.
+
+## When to use it
+
+**Good fit**
+
+- A Kafka topic carrying CSV or JSON that you want in Doris with no extra service. Routine Load is the shortest path.
+- MySQL, Postgres, or Oracle CDC into Doris with exactly-once delivery and DDL propagation. Flink Doris Connector with Flink CDC.
+- Kafka Connect already runs in your stack and the payloads are Avro, Protobuf, or Debezium. Doris Kafka Connector skips the format mismatch.
+- Real-time dashboards over Unique Key tables that need correct ordering on out-of-order writes. Pair any path above with `function_column.sequence_col`.
+
+**Not a good fit**
+
+- Avro from Kafka through Routine Load. Routine Load reads CSV and JSON only. Either convert upstream or use the Doris Kafka Connector with the appropriate converter.
+- Per-event `INSERT INTO ... VALUES` from a microservice. That is not a Kafka problem and not a CDC problem; reach for [Group Commit](./group-commit.mdx) so the BE batches small writes server-side.
+- Routine Load on a topic with short Kafka retention. If retention is 24 h and the job stays paused for two days, the persisted offset falls behind the earliest available offset, and Kafka rejects the resume with "out of range". Raise retention, or run a connector that buffers.
+- Monitoring offset lag through the FE alone. The FE does not currently expose a lag metric ([open issue](https://github.com/apache/doris/issues/7113)). Scrape the Kafka-side consumer-lag exporter or watch BE-side rates.
+- Stream Load 2PC against Merge-on-Write Unique Key tables. The combination is rejected; for those tables, lean on `Label`-based dedup and the sequence column instead.
+
+## Performance and numbers
+
+From "How Flink's real-time writes to Apache Doris stay both fast and exactly-once" (July 2022 release blog): under 30 concurrent Stream Loads, end-to-end latency stayed below one second; 20 Flink tasks sustained an upstream rate near 100,000 events per second; CPU on the BE dropped about 25% versus the previous load path under high concurrency. Source: [Apache Doris blog](https://doris.apache.org/blog/Flink-realtime-write/).
+
+## Further reading
+
+- [Routine Load reference](../data-operate/import/import-way/routine-load-manual.md)
+- [Flink Doris Connector and Flink CDC](../connection-integration/data-integration/flink-doris-connector.md)
+- [Doris Kafka Connector for Avro, Protobuf, and Debezium](../connection-integration/data-integration/doris-kafka-connector.md)
+- [Load Transactions: the model behind 2PC](./load-transaction.mdx)
+- [Data Update and Delete: sequence column and Merge-on-Write](./data-update-delete.mdx)
diff --git a/docs-next/key-features/load-transaction.mdx b/docs-next/key-features/load-transaction.mdx
new file mode 100644
index 00000000000..9c2d9ebc63b
--- /dev/null
+++ b/docs-next/key-features/load-transaction.mdx
@@ -0,0 +1,93 @@
+---
+title: Load Transactions
+description: Run multi-statement loads as one atomic unit, with label-based dedup and 2PC for exactly-once streaming sinks.
+slug: /key-features/load-transaction
+featureCard:
+  tags:
+    - load
+    - streaming
+---
+
+> **TL;DR** Doris wraps every data load in a transaction. You can group several inserts under one `BEGIN ... COMMIT`, dedupe retries with a load `Label`, and drive a two-phase commit from headers in a Stream Load HTTP request. That last part is what lets Flink, Spark, and CDC pipelines write to Doris exactly once without keeping their own bookkeeping table.
+
+## Why it matters
+
+Loading data into a warehouse goes wrong in predictable ways. A Flink job restarts mid-checkpoint and replays the last batch, doubling rows. A CDC pipeline updates a fact table but its companion dimension write fails, leaving the two out of sync until the next backfill. A retry script re-uploads the same file because it has no way to know the previous attempt actually landed. Each of these forces ugly bookkeeping in the application.
+
+- Replays from a stalled streaming job duplicate rows.
+- Multi-table writes that should move together can stop halfway.
+- Resubmissions of the same file land twice unless you track them yourself.
+
+Doris solves all three on the server, so the load client can stay thin.
+
+## What it is
+
+Every write into Doris runs inside a transaction. That covers `INSERT INTO`, Stream Load, Routine Load, and Broker Load. Each one gets a unique Label and walks through a tracked set of lifecycle states managed by the FE. You can let Doris open and commit that transaction implicitly per load, take control with explicit `BEGIN`/`COMMIT`/`ROLLBACK`, or split the commit into two phases over HTTP for streaming sinks.
+
+**Key terms**
+
+- **`Label`**: a per-database string that identifies a transaction. Resubmitting the same Label deduplicates the load.
+- **`Implicit transaction`**: every single load is its own transaction; auto-commits on success.
+- **`Explicit transaction`**: `BEGIN ... COMMIT;` groups several writes into one atomic unit on the same database.
+- **`Stream Load 2PC`**: a Stream Load that stops at PRECOMMITTED until a follow-up HTTP call commits or aborts it.
+- **`Lifecycle states`**: `PREPARE` → `PRECOMMITTED` → `COMMITTED` → `VISIBLE` (or `ABORTED`). Rows are queryable only after `VISIBLE`.
+
+## How it works
+
+1. **Open.** A load opens a transaction in `PREPARE`. The FE assigns a `TxnId` and ties it to the user-supplied or generated Label. A second submission with the same Label short-circuits to the original transaction's result.
+2. **Write.** BEs receive the data, build segments, and report success. The transaction moves to `PRECOMMITTED` once all expected loads acknowledge.
+3. **Commit.** A `COMMIT` (or the second-phase `txn_operation: commit` HTTP call) moves the transaction to `COMMITTED`. The data is on disk but not yet readable.
+4. **Publish.** A background daemon ships a `PublishVersion` task to every replica. When all replicas of every affected tablet acknowledge, the transaction reaches `VISIBLE`.
+5. **Read.** Queries running after `VISIBLE` see the new rows. Earlier queries see the prior version. Tablets in the same transaction never produce a torn read.
+
+If anything fails along the way, or the client calls `ROLLBACK` (or `txn_operation: abort`), the transaction goes to `ABORTED` and the data is discarded.
+
+## Quick start
+
+```sql
+CREATE TABLE orders (
+  id INT, amount DECIMAL(10,2)
+) DUPLICATE KEY(id) DISTRIBUTED BY HASH(id) BUCKETS 4;
+
+CREATE TABLE order_audit (
+  id INT, action STRING
+) DUPLICATE KEY(id) DISTRIBUTED BY HASH(id) BUCKETS 4;
+
+BEGIN;
+INSERT INTO orders       VALUES (1, 99.50), (2, 149.00);
+INSERT INTO order_audit  VALUES (1, 'created'), (2, 'created');
+COMMIT;
+```
+
+**Expected result**
+
+```
+Query OK, 2 rows affected
+{'label':'txn_insert_b55db21aad7451b','status':'VISIBLE','txnId':'10013'}
+```
+
+Both tables become readable at the same instant. If the second `INSERT` had failed or the connection had dropped before `COMMIT`, neither table would carry the new rows.
+
+## When to use it
+
+**Good fit**
+
+- Flink and Spark sinks that rely on Stream Load 2PC for exactly-once semantics across checkpoints.
+- CDC pipelines that need fact and dimension tables to move together.
+- Backfills that swap a batch of related rows in or out as a unit.
+- Workloads where idempotent retries matter and you want the warehouse to enforce dedup via Label rather than the client.
+
+**Not a good fit**
+
+- High-frequency, single-row inserts. Each transaction adds a commit round-trip and a tablet version, and small loads bottleneck on the commit stage. Use [Group Commit](https://doris.apache.org/docs/data-operate/import/group-commit-manual/) instead.
+- Atomicity across two databases. All tables in one transaction must live in the same database.
+- Stream Load 2PC against Merge-on-Write Unique Key tables. The combination is rejected.
+- Long-horizon deduplication. Labels are evicted after roughly 3 days or 2,000 entries per database, whichever hits first; reusing one after that does not dedupe. Track long-lived idempotency keys in the application.
+- Reading your own writes inside the same transaction. Each statement runs against the snapshot taken at its start; a previous `INSERT` is not visible until after `COMMIT`.
+
+## Further reading
+
+- [Transactions, isolation, and Label retention](https://doris.apache.org/docs/data-operate/transaction/)
+- [Stream Load 2PC walkthrough](https://doris.apache.org/docs/data-operate/import/import-way/stream-load-manual/)
+- [How Flink's real-time writes to Doris stay both fast and exactly-once](https://doris.apache.org/blog/Flink-realtime-write/)
+- [Group Commit, the right tool for high-frequency small inserts](https://doris.apache.org/docs/data-operate/import/group-commit-manual/)
diff --git a/docs-next/key-features/metadata-cache.mdx b/docs-next/key-features/metadata-cache.mdx
new file mode 100644
index 00000000000..a1170d018ac
--- /dev/null
+++ b/docs-next/key-features/metadata-cache.mdx
@@ -0,0 +1,119 @@
+---
+title: Metadata Cache
+description: "A multi-layer cache for external catalog metadata: table lists, schemas, partitions, file listings, and Iceberg manifests. Keeps lakehouse query planning in milliseconds."
+slug: /key-features/metadata-cache
+featureCard:
+  tags:
+    - lakehouse
+    - caching
+    - performance
+---
+
+> **TL;DR** When Doris queries Hive, Iceberg, Hudi, or Paimon, most of the wall clock can be spent listing partitions, parsing manifests, and asking the Hive Metastore questions. Doris caches all of it: schemas, partition values, file listings, Iceberg manifest entries, Paimon snapshots. Caches are TTL-bounded, can refresh from Hive Metastore events for seconds-level lag, and can be invalidated explicitly with `REFRESH CATALOG`. Iceberg and Paimon get format-aware invalidation, so immuta [...]
+
+![Metadata Cache hero illustration](/images/next/key-features/metadata-cache.jpg)
+
+## Why it matters
+
+Lakehouse query planning is dominated by metadata, not by data. A query against a partitioned Hive table needs to:
+
+- Round-trip the Hive Metastore for the database list, table list, column schema, and partition list.
+- Issue an S3 or HDFS LIST against each surviving partition path to discover the data files.
+- For Iceberg, read `metadata.json`, then a manifest list, then N manifest files (one remote read each).
+- For Hudi, fetch the timeline, build a `FileSystemView`, and resolve which file slices are visible at the query timestamp.
+
+A cold catalog with 10K partitions can spend longer in planning than in execution. Common shapes of this:
+
+- A dashboard that filters by date hits the planner with a fresh partition lookup every minute, hammering HMS until it falls over.
+- An Iceberg table with 30 manifests pays 30 S3 GETs of manifest metadata per query, on top of the data reads.
+- A new FE comes up after a deploy, and the first round of queries spend seconds in `getPartitionsByNames`.
+
+Metadata Cache absorbs all of these, with refresh paths that recover correctness whenever the source actually changes.
+
+## What it is
+
+Metadata Cache is an in-memory cache on the Doris FE for everything an external catalog can tell you, short of the file bytes themselves. Each backend (Hive, Iceberg, Hudi, Paimon, JDBC) has its own implementation of a shared framework, so the user-facing model is consistent: per-cache TTL, per-cache size limit, and a small set of refresh primitives.
+
+**Key terms**
+
+- **`Schema cache`**: column lists, types, and table identities. Keyed by table identifier and (for Iceberg/Paimon) schema ID.
+- **`Partition cache`**: the partition list of a Hive or Hudi table, including partition values and locations.
+- **`File cache (FE-side)`**: the file listing and basic file metadata for a partition path, populated by an S3/HDFS LIST. Distinct from the BE-side [Data Cache](./data-cache-page-cache.mdx).
+- **`Manifest cache`**: parsed Iceberg manifest entries (lists of data and delete files for a manifest). Bounded by entry count, not TTL.
+- **`HMS event listener`**: a polling client that consumes Hive Metastore notification events and incrementally invalidates affected entries.
+
+## How it works
+
+A query traverses the cache before issuing any external RPC.
+
+1. **Lookup.** When the planner asks for a table's schema, partitions, or files, the cache is checked first using a Caffeine-backed loader. A miss triggers a fetch from the underlying catalog (HMS Thrift call, Iceberg metadata read, Hudi timeline scan) and the result is stored.
+2. **TTL eviction.** Each cache module has its own TTL. After 3.0.7, the timer resets on every access, so a hot table stays cached as long as it's queried. Defaults are conservative: name lists at 24 hours with a 10-minute refresh, partition and file caches at 8 hours.
+3. **Size eviction.** Caches use LRU under a size cap (`max_external_schema_cache_num`, `max_external_file_cache_num`, etc.) so a long tail of cold tables can't displace the hot ones forever.
+4. **Format-aware invalidation.** Iceberg manifest cache entries are keyed by manifest path and live without a TTL: once a manifest is parsed, it's correct as long as some snapshot still references it. Paimon caches the latest snapshot reference and reloads only when the table moves forward. Hive does not have this luxury, so HMS events fill the gap.
+5. **HMS event-based refresh.** When `enable_hms_events_incremental_sync` is on, the FE polls the Hive Metastore notification log every `hms_events_polling_interval_ms` and replays CREATE / ALTER / DROP / INSERT events. Affected entries are invalidated within seconds, without a global flush.
+6. **Explicit refresh.** `REFRESH CATALOG`, `REFRESH DATABASE`, and `REFRESH TABLE` provide the escape hatch for writes that don't go through HMS (direct file uploads, Iceberg commits from outside Doris) and for testing.
+
+The common path serves metadata at memory latency. The recovery path catches up in seconds when the source moves.
+
+## Quick start
+
+```sql
+-- Inspect cache state.
+SELECT * FROM information_schema.catalog_meta_cache_statistics
+WHERE catalog_name = 'hive_ctl';
+
+-- Refresh just the table whose partition you just added.
+REFRESH TABLE hive_ctl.sales.orders;
+
+-- For a fast-changing fact table, disable the file listing cache.
+ALTER CATALOG hive_ctl SET PROPERTIES (
+  "meta.cache.hive.file.ttl-second" = "0"
+);
+
+-- For seconds-level auto-refresh on Hive, turn on the HMS event listener
+-- (FE config; restart required):
+--   enable_hms_events_incremental_sync = true
+--   hms_events_polling_interval_ms = 10000
+```
+
+**Expected result**
+
+```
++--------------+-------------+-----------+---------+----------+
+| catalog_name | cache_name  | hit_count | miss_count | size  |
++--------------+-------------+-----------+---------+----------+
+| hive_ctl     | schema      |     12480 |       7 |       42 |
+| hive_ctl     | partition   |      9821 |      14 |       28 |
+| hive_ctl     | file        |      9520 |      31 |      612 |
++--------------+-------------+-----------+---------+----------+
+```
+
+After warmup, hit rates are above 99% on the schema and partition caches. Misses correspond to the first access of new tables or to entries that aged out.
+
+## When to use it
+
+The Metadata Cache is on by default for every external catalog; the question is how to tune it.
+
+**Good fit**
+
+- Hive tables with stable schemas and a moderate write rate. Pair the default TTLs with the HMS event listener for seconds-level freshness.
+- Iceberg, Paimon, and Hudi tables of any size. Snapshot immutability lets the cache hold manifest entries indefinitely; the only thing that needs invalidating is the current snapshot pointer, which Doris does on every query.
+- Multi-tenant lakehouse with thousands of catalogs and tables. Schema and partition caches keep planning fast even when the long tail of objects exceeds memory: hot ones stay, cold ones get evicted.
+- High-concurrency dashboards. Without a cache, every concurrent query would re-list the same partitions; with one, the listing is computed once and shared.
+
+**Not a good fit**
+
+- Hive tables that are written outside HMS (`hadoop fs -put`, raw S3 uploads). HMS never sees the change, so the event listener won't pick it up. Either route writes through HMS, set the file cache TTL to 0, or call `REFRESH TABLE` after each batch.
+- Tables that change every few seconds and demand strong freshness. A TTL-based cache is the wrong tool. Disable the relevant cache module (`ttl-second = 0`) for that table, or accept up-to-N-second staleness from the event listener.
+- Catalogs that contain millions of tables when memory is tight. Bumping `max_external_schema_cache_num` past your FE heap can cause OOMs. Right-size the limit and keep an eye on `catalog_meta_cache_statistics`.
+- Treating Iceberg like Hive. Aggressively running `REFRESH CATALOG` on Iceberg every minute throws away perfectly valid immutable manifest entries. Trust the snapshot model and let the cache do its job.
+
+## Further reading
+
+- [Metadata Cache reference](../lakehouse/meta-cache.md): module-by-module breakdown of TTLs, size limits, and refresh strategies.
+- [REFRESH statement](../sql-manual/sql-statements/catalog/REFRESH.md): the SQL surface for `REFRESH CATALOG`, `REFRESH DATABASE`, and `REFRESH TABLE`, plus the `invalid_cache` property.
+- [Hive catalog](../lakehouse/catalogs/hive-catalog.mdx): per-cache `meta.cache.*` properties and the HMS event listener configuration (`enable_hms_events_incremental_sync`).
+- [Iceberg catalog](../lakehouse/catalogs/iceberg-catalog.mdx): manifest cache configuration and snapshot-aware invalidation.
+- [Data Cache & Page Cache](./data-cache-page-cache.mdx): the byte-level storage caches that sit beneath the metadata cache.
+- [Parquet Reader Optimization](./parquet-reader-optimization.mdx): once metadata says which files to read, this is what reads them efficiently.
+- [Building the next-generation data lakehouse (Apache Doris blog)](https://doris.apache.org/blog/Building-the-Next-Generation-Data-Lakehouse-10X-Performance/): the architectural overview that introduces the Meta Cache and HMS event listener.
diff --git a/docs-next/key-features/parquet-reader-optimization.mdx b/docs-next/key-features/parquet-reader-optimization.mdx
new file mode 100644
index 00000000000..a51e5fd5772
--- /dev/null
+++ b/docs-next/key-features/parquet-reader-optimization.mdx
@@ -0,0 +1,104 @@
+---
+title: Parquet Reader Optimization
+description: A native C++ vectorized Parquet reader that prunes row groups and pages, decodes dictionaries directly, and reads payload columns only after filters resolve.
+slug: /key-features/parquet-reader-optimization
+featureCard:
+  tags:
+    - lakehouse
+    - query-acceleration
+---
+
+> **TL;DR** When Doris queries Parquet files in an external catalog or through a TVF, it uses its own C++ vectorized reader instead of going through Java or Arrow. The reader prunes whole row groups and individual pages from the file's own statistics, evaluates predicates on dictionary IDs, reads filter columns before payload columns, and coalesces remote reads into fewer requests. Most queries finish without ever decoding the columns they don't need.
+
+![Parquet Reader Optimization hero illustration](/images/next/key-features/parquet-reader-optimization.jpg)
+
+## Why it matters
+
+A Hive or Iceberg table on S3 is, in the end, a pile of Parquet files. Reading them is dominated by three costs: fetching bytes from object storage, decompressing and decoding pages, and converting values into the engine's runtime representation. A naive reader pays all three on every query.
+
+That cost shows up in shapes anyone who has tuned a lakehouse query has hit:
+
+- A `WHERE order_date >= '2026-04-01'` filter scans every row group of every Parquet file because the reader never looked at the file's per-row-group min/max.
+- A point lookup like `WHERE user_id = 12345` reads the entire `user_id` column when only one row group could possibly contain that ID.
+- A `SELECT *` over a wide event table on S3 issues hundreds of small HTTP GETs per file, one per column chunk.
+- A predicate like `country = 'JP'` decompresses and decodes UTF-8 across millions of rows, even though the column is dictionary-encoded and the answer could be one integer comparison per row.
+
+Doris's reader is built to avoid each of these before any page leaves storage.
+
+## What it is
+
+Doris ships its own native C++ Parquet reader. Instead of going through a Java library or an Arrow round trip, it reads Parquet metadata, prunes at every available granularity (row group, page, dictionary, column), and decodes values directly into the same `Block` format the vectorized executor consumes.
+
+**Key terms**
+
+- **`Row group`**: the unit a Parquet file is partitioned into for parallel reads. Each one carries min/max and null-count statistics per column.
+- **`PageIndex`**: an optional Parquet structure (ColumnIndex + OffsetIndex) that exposes the same statistics at page granularity, plus the byte offset of each page so a reader can seek directly to it.
+- **`Lazy materialization`**: a two-pass read that decodes filter columns first, then fetches payload columns only for the rows that survived.
+- **`Dictionary filtering`**: evaluating a predicate against a column chunk's dictionary page once, then comparing rows against the resulting set of dictionary IDs.
+
+## How it works
+
+Each step shrinks what the next step has to look at.
+
+1. **Row-group pruning.** When the file is opened, the reader walks each row group's column statistics and drops the whole group if the predicate cannot match its min/max range. The same step probes the Parquet bloom filter when the file has one. Both checks run on metadata only, before any column page is fetched. Controlled by `enable_parquet_filter_by_min_max` and `enable_parquet_filter_by_bloom_filter`.
+2. **Page pruning via PageIndex.** For each surviving row group, the reader parses ColumnIndex and OffsetIndex to drop individual pages whose min/max cannot match. OffsetIndex gives the byte range of each surviving page, so the reader skips straight to the next one without scanning the rejected bytes. Toggle with the BE config `enable_parquet_page_index`.
+3. **Dictionary filtering.** When a column chunk is dictionary-encoded and the predicate is a single-slot expression, the reader decodes only the dictionary page, evaluates the predicate against it, and rewrites the predicate to operate on dictionary IDs. The data pages then turn into bit-packed integer comparisons.
+4. **Lazy materialization.** Surviving rows still have to be assembled. The reader splits columns into filter columns (referenced by predicates) and payload columns, decodes the filter columns to build a row-selection bitmap, and only then fetches the payload columns for the surviving rows. Controlled by the session variable `enable_parquet_lazy_mat`, on by default.
+5. **IO coalescing and direct decode.** Adjacent column chunks within a row group are merged into a single ranged read, so the engine issues one HTTP GET instead of many. Decoded values land directly in Doris vectorized columns, with no Arrow round trip.
+
+The result: rows that survive to the executor have already been confirmed to match, and only the columns the query asked for were ever decoded.
+
+## Quick start
+
+```sql
+SELECT region, count(*) AS orders, sum(amount) AS revenue
+FROM s3(
+  "uri"    = "s3://demo-bucket/orders/year=2026/*.parquet",
+  "format" = "parquet",
+  "s3.endpoint"   = "s3.us-east-1.amazonaws.com",
+  "s3.access_key" = "AK...",
+  "s3.secret_key" = "SK..."
+)
+WHERE order_date >= '2026-04-01' AND status = 'PAID'
+GROUP BY region;
+```
+
+**Expected result (profile excerpt)**
+
+```
+VFileScanNode
+  FilteredRowGroups:           18 / 24
+  FilteredPages:               210 / 980
+  LazyReadFilteredRows:        92.1%
+  RemoteIOMergedRanges:        14 (from 96 chunks)
+```
+
+The reader dropped 18 of 24 row groups on the date predicate, then dropped 210 pages inside the survivors using PageIndex on `status`. Lazy materialization skipped decoding `region` and `amount` for the 92.1% of rows that didn't match. Ninety-six column chunks were merged into 14 ranged GETs against S3.
+
+## When to use it
+
+This path is used automatically whenever Doris reads Parquet, so the question is how to make sure it can do its job.
+
+**Good fit**
+
+- Hive, Iceberg, Hudi, and Paimon catalogs whose data files are Parquet.
+- Direct file analysis through the [s3()](../lakehouse/file-analysis.md) and `hdfs()` TVFs.
+- Selective queries on wide tables: predicate-heavy filters give lazy materialization the most to skip.
+- Equality and range predicates on columns that the writer recorded statistics for.
+- Dictionary-encoded string columns with point or `IN` predicates.
+
+**Not a good fit**
+
+- Tables stored as thousands of tiny Parquet files. Each footer must be parsed and each chunk is at least one ranged read; per-file overhead dominates. Compact toward 128 MB to 1 GB files.
+- Files written without column statistics or PageIndex. The reader has nothing to prune on and falls back to full scans. Enable statistics in the writer (Spark 3.2+ and parquet-mr 1.11+ both write PageIndex).
+- Selective predicates inside deeply nested struct fields. Some optimizations (page index, dictionary filtering) are disabled for complex types; the reader walks the surviving rows in full. Flatten the access path or use top-level columns when possible.
+- DATETIME columns written as INT96 by older Hive writers. INT96 statistics are routinely corrupted, and the reader skips min/max pruning on them. If you control the writer, write INT64 timestamps. See the [Hive catalog notes](../lakehouse/catalogs/hive-catalog.mdx) on INT96.
+
+## Further reading
+
+- [Parquet file format reference](../lakehouse/file-formats/parquet.md): the user-facing knobs (`enable_parquet_lazy_mat`, `enable_parquet_page_index`, buffer-size BE configs) and version availability.
+- [Analyzing files on S3/HDFS](../lakehouse/file-analysis.md): how to query Parquet files directly with the `s3()` and `hdfs()` TVFs.
+- [Data Pruning](./data-pruning.mdx): the broader story of how Doris skips reading data, of which the Parquet reader is one layer.
+- [Vectorized Execution](./vectorized-execution.mdx): the engine the reader decodes into, and why skipping the Arrow round trip matters.
+- [Data Cache](../lakehouse/data-cache.md): when to cache decoded Parquet pages locally so repeated scans skip the remote read entirely.
+- [Building the next-generation data lakehouse (Apache Doris blog)](https://doris.apache.org/blog/Building-the-Next-Generation-Data-Lakehouse-10X-Performance/): the original public writeup of the native Parquet reader.
diff --git a/docs-next/key-features/pipeline-execution-engine.mdx b/docs-next/key-features/pipeline-execution-engine.mdx
new file mode 100644
index 00000000000..2df15672ca5
--- /dev/null
+++ b/docs-next/key-features/pipeline-execution-engine.mdx
@@ -0,0 +1,103 @@
+---
+title: Pipeline Execution Engine
+description: A push-based, work-stealing scheduler that breaks each query into a DAG of operator pipelines and runs them on a fixed BE thread pool.
+slug: /key-features/pipeline-execution-engine
+featureCard:
+  tags:
+    - query-acceleration
+    - performance
+---
+
+> **TL;DR** Pipeline is Doris's only execution engine since v3.0. Each query fragment is split into a DAG of pipelines; each pipeline starts at a source, ends at a sink, and runs as several PipelineTasks on a fixed-size thread pool whose width is bounded by the BE's CPU budget. Operators yield instead of blocking, dependencies coordinate the DAG, and Local Shuffle re-balances skewed data inside the BE. The volcano model and its one-thread-per-fragment-instance bill are gone.
+
+![Pipeline Execution Engine hero illustration](/images/next/key-features/pipeline-execution-engine.jpg)
+
+## Why it matters
+
+The volcano model that Doris used to ship gave every fragment instance its own OS thread. With a few concurrent queries this is fine. With a hundred it becomes a problem: thousands of threads stack up, most of them blocked on IO or on each other, and the BE eventually thrashes the kernel scheduler and runs out of memory before it runs out of work.
+
+There are two more wounds the old engine left open. Wide-table aggregations and joins on skewed data underused the cores: when one bucket holds 7 rows and the others hold 1, the 7-row task is the long pole and the rest of the cluster sits idle waiting for it. And tuning concurrency at the fragment-instance level was a guessing game with too many knobs.
+
+Pipeline closes all three. The thread count is bounded, not query-count-bounded. Operators yield when they cannot make progress, freeing threads instead of holding them. And Local Shuffle redistributes skewed data inside the BE so the long pole is short.
+
+## What it is
+
+Pipeline is a push-based, async query execution model. The FE compiles the query into a Plan, splits it into PlanFragments (one per BE), and each BE breaks each fragment into a DAG of Pipelines. A Pipeline is a chain that begins with one SourceOperator (a scan or an exchange), ends with one SinkOperator (a network shuffle or a hash-table writer), and may have any number of operators in between. Pipelines that have to wait on each other are coordinated by Dependencies. Each Pipeline mater [...]
+
+**Key terms**
+
+- **`PlanFragment`**: the unit of work the FE sends to one BE. A query usually has several.
+- **`Pipeline`**: a chain of operators on one BE. Runs as multiple PipelineTasks in parallel.
+- **`PipelineTask`**: an executable instance of a pipeline bound to a parallelism slot. Yields when it blocks or when its time slice expires, freeing the thread.
+- **`Operator`**: the node in the chain. Some PlanNodes split into a Sink + Source pair when they have to materialize: Join becomes `JoinBuildOperator` + `JoinProbeOperator`, Agg becomes `AggSinkOperator` + `AggSourceOperator`, Sort becomes `SortSinkOperator` + `SortSourceOperator`.
+- **`Dependency`**: the ready signal between pipelines. When Pipeline-0 finishes building a hash table it calls `set_ready`, and Pipeline-1's probe wakes up.
+- **Local Shuffle (Local Exchange)**: a Pipeline Breaker that re-shards data locally inside the BE using HASH or Round Robin, so a skewed scan does not turn into a skewed join.
+- **`parallel_pipeline_task_num`**: the session knob for per-fragment parallelism. Default `0` means half the CPU cores; usually best left alone.
+
+## How it works
+
+1. **FE plans, splits, ships.** The FE compiles the query, inserts `ExchangeNode` and `DataSink` for inter-BE shuffle, and sends each PlanFragment to a BE.
+2. **BE turns each fragment into a pipeline DAG.** Blocking operators (hash-table build, sort, aggregation) become a Sink that ends one pipeline plus a Source that starts the next one. Dependencies wire the DAG together; a Join's probe waits on its build's `set_ready`.
+3. **Spin up PipelineTasks.** Each Pipeline becomes N PipelineTasks. All N share the same Operator chain; what differs is the per-task LocalState (which scan range, which hash-table partition, which output buffer).
+4. **Run on a bounded thread pool.** PipelineTasks are submitted to a fixed-size pool sized from the BE's CPU budget (`pipeline_executor_size` if you override it). A task runs in a tight loop until either its time slice expires (`pipeline_task_exec_time_slice`, default 100ms) or it blocks on a Dependency. Either way it yields, and the thread picks up the next runnable task.
+5. **Local Shuffle when needed.** If the planner sees a join, aggregation, or window function over potentially skewed input, it inserts a Local Exchange. Upstream tasks push batches in, downstream tasks pull rebalanced batches out, smoothing `(1, 1, 7)` into `(3, 3, 3)` so every core finishes around the same time.
+6. **Parallel scan.** A ScanOperator dynamically spawns multiple Scanners under the hood. Each Scanner takes 1 to 2 million rows, decompresses and filters them in place, and pushes the result into a DataQueue that the ScanOperator drains, so a slow bucket does not stall the query.
+
+## Quick start
+
+The Pipeline engine is on by default in v3.0+; there is nothing to enable. The one knob you may ever need is `parallel_pipeline_task_num`.
+
+```sql
+-- Inspect the default (0 means "half the CPU cores")
+SHOW VARIABLES LIKE 'parallel_pipeline_task_num';
+
+-- Pin parallelism for one heavy query without touching the session
+SELECT /*+ SET_VAR(parallel_pipeline_task_num=16) */
+       l_orderkey, COUNT(*) AS lines, SUM(l_extendedprice) AS gross
+FROM lineitem
+GROUP BY l_orderkey
+ORDER BY gross DESC
+LIMIT 100;
+
+-- See the resulting plan and Local Exchange placement
+EXPLAIN SELECT /*+ SET_VAR(parallel_pipeline_task_num=16) */
+        l_orderkey, COUNT(*) FROM lineitem GROUP BY l_orderkey;
+```
+
+**Expected result**
+
+```
++-------------+-------+----------------+
+| l_orderkey  | lines | gross          |
++-------------+-------+----------------+
+|     7382118 |     7 |  431295.18     |
+|     ...                              |
++-------------+-------+----------------+
+```
+
+The hint applies only to this query. `EXPLAIN` shows the pipeline IDs, the Local Exchange (if planned), and the operator chain. With profiling enabled, the runtime profile reports per-PipelineTask `ExecTime`, `YieldCounts`, and `MemoryReserveFailedTimes`, which are the levers you actually use to tune real workloads.
+
+## When to use it
+
+**Good fit**
+
+- Every Doris workload on v3.0+. There is no other engine.
+- Mixed concurrent workloads on a shared BE: the bounded thread pool keeps queries from stepping on each other.
+- Join-heavy or aggregation-heavy queries on skewed input: Local Shuffle smooths the imbalance instead of letting the long pole drag the wall clock.
+- Long-running scans on uneven buckets: parallel scan keeps the slow bucket from stalling the rest.
+
+**Not a good fit**
+
+- Tuning `parallel_pipeline_task_num` on every ad-hoc query. The default is calibrated. Raise it only when profiling shows CPU is idle on a long-running fragment, and drop it to `1` for high-concurrency or trivial point queries instead.
+- Reading old advice that mentions enabling `enable_pipeline_engine` or `experimental_enable_pipeline_x_engine`. Those were experimental switches in the 2.x line; in 3.0+ they are no-ops because the volcano model has been deleted.
+- Confusing this engine with vectorized execution. They are different layers: vectorized execution decides what an operator does to a batch; Pipeline decides how those batches get scheduled across cores. See [Vectorized Execution](./vectorized-execution.mdx) for the other half.
+- Single-row primary-key lookups served at thousands of QPS. The cost of spinning up multiple PipelineTasks is not paid back at that point; use the [High-Concurrency Point Query](./high-concurrency-point-query.mdx) path, which sidesteps the heavy planner.
+
+## Further reading
+
+- [Pipeline Execution Engine reference](../query-acceleration/optimization-technology-principle/pipeline-execution-engine.md): full architecture, including Dependency rules, Local Shuffle planning, and the parallel-scan mechanism.
+- [Parallelism tuning](../query-acceleration/tuning/parallelism-tuning.md): when and how to set `parallel_pipeline_task_num` per scenario, with the safe defaults table.
+- [Data skew handling](../query-acceleration/tuning/tuning-execution/data-skew-handling.md): the pragmatic counterpart to Local Shuffle for queries the planner could not auto-fix.
+- [Vectorized Execution](./vectorized-execution.mdx): the operator layer Pipeline schedules. Often confused with Pipeline; the two are orthogonal.
+- [Workload Group](./workload-group.mdx): the layer that binds pipeline threads to per-group cgroups for CPU and concurrency limits.
+- [Evolution of the Apache Doris execution engine](/blog/evolution-of-the-apache-doris-execution-engine/): the design history of how Doris went from volcano to Pipeline to PipelineX.
diff --git a/docs-next/key-features/preaggregation-and-rollup.mdx b/docs-next/key-features/preaggregation-and-rollup.mdx
new file mode 100644
index 00000000000..e0a4f45208c
--- /dev/null
+++ b/docs-next/key-features/preaggregation-and-rollup.mdx
@@ -0,0 +1,95 @@
+---
+title: Preaggregation and Rollup
+description: Aggregate Key tables fold rows together at load time, and ROLLUPs add precomputed alternate indexes the planner picks transparently.
+slug: /key-features/preaggregation-and-rollup
+featureCard:
+  tags:
+    - storage
+    - performance
+---
+
+> **TL;DR** Doris's Aggregate Key model merges rows that share a key while you load them, using per-column functions like `SUM`, `MAX`, `REPLACE`, and `BITMAP_UNION`. A `ROLLUP` does the same thing for query shapes the base table can't serve well: you keep an extra materialized index with fewer columns or a different sort order, and the planner picks it for you. You read precomputed numbers instead of grouping raw rows on every query.
+
+![Preaggregation and Rollup hero illustration](/images/next/key-features/preaggregation-and-rollup.jpg)
+
+## Why it matters
+
+A typical fact table is wide and granular: per-event ad clicks, per-request API logs, per-second device readings. The same dashboard then asks for daily totals, top-N users, or distinct viewers across a month. Without preaggregation, every query rescans millions of rows and re-runs the same `GROUP BY` you ran yesterday.
+
+- Repeated work. Every dashboard refresh redoes the same aggregation over the same raw data.
+- Wide scans. A coarse query reads every value column on the base table even when it only needs two.
+- Cardinality math is expensive. `COUNT(DISTINCT user_id)` on a billion-row table is a job, not a query.
+
+Preaggregation moves the cost from query time to load time. A `ROLLUP` adds a second precomputed view for the queries the base table doesn't fit by itself.
+
+## What it is
+
+The Aggregate Key model is a table type. You declare which columns are keys and stamp each value column with an aggregation function. Doris then merges rows that share a key both at load and during compaction, so the table never stores duplicates that have a defined merge rule.
+
+A `ROLLUP` is a second materialized index over the same base table. It holds a column subset, possibly in a different sort order, with the same per-column functions applied. The query planner scores all available indexes for an incoming query and reads from whichever one matches best.
+
+**Key terms**
+
+- **Aggregate Key model**: a table model where rows with identical key values are merged using the value columns' declared aggregation functions.
+- **Aggregation functions**: `SUM`, `MIN`, `MAX`, `REPLACE`, `REPLACE_IF_NOT_NULL`, `HLL_UNION`, `BITMAP_UNION`, `QUANTILE_UNION`. These also work on `ROLLUP` value columns.
+- **Base index**: the on-disk layout that mirrors your `CREATE TABLE`. Always present.
+- **Rollup index**: an extra materialized layout the planner can read from instead of the base index.
+- **Sync materialized view**: the `CREATE MATERIALIZED VIEW` syntax that supersedes `ALTER TABLE ADD ROLLUP`. Same machinery, richer expressions, recommended for new designs.
+
+## How it works
+
+1. **Define the aggregation contract.** In `CREATE TABLE`, the `AGGREGATE KEY(...)` clause names the key columns. Every other column gets a function: `clicks BIGINT SUM`, `last_seen DATETIME REPLACE`, `unique_users BITMAP BITMAP_UNION`. Doris uses these functions to merge any two rows that share the keys.
+2. **Merge on load.** When data arrives, Backend nodes fold incoming rows against existing rows that share the keys, then write a new immutable rowset. Background compaction merges those rowsets later using the same rules. Queries see the merged view, not the raw inputs.
+3. **Add a rollup for skew query patterns.** `ALTER TABLE ad_events ADD ROLLUP rollup_ad (dt, ad_id, clicks, cost)` creates a second materialized index sorted by `(dt, ad_id)`. Doris rebuilds it as an asynchronous schema change, then keeps it in sync on every subsequent load inside the same transaction as the base write.
+4. **Pick an index per query.** The Nereids optimizer scores every index against the query's predicates and group keys. It prefers indexes where the predicate columns sit at the prefix and where fewer columns need scanning. The chosen index shows up in `EXPLAIN` and in the BE profile.
+5. **Use sync MV for new code.** `CREATE MATERIALIZED VIEW` covers everything `ADD ROLLUP` does plus expressions like `bitmap_union(to_bitmap(user_id))`. The Doris source comment is blunt: *"In function level, the mv completely covers the rollup in the future."* Existing rollups keep working.
+
+## Quick start
+
+```sql
+CREATE TABLE ad_events (
+  dt DATE,
+  ad_id INT,
+  user_id BIGINT,
+  clicks BIGINT SUM,
+  cost DECIMAL(10,2) SUM
+)
+AGGREGATE KEY(dt, ad_id, user_id)
+DISTRIBUTED BY HASH(ad_id) BUCKETS 4;
+
+ALTER TABLE ad_events ADD ROLLUP rollup_ad (dt, ad_id, clicks, cost);
+
+SELECT dt, ad_id, SUM(clicks) FROM ad_events
+WHERE dt = '2026-05-01' GROUP BY dt, ad_id;
+```
+
+**Expected result**
+
+```
+EXPLAIN ... shows OlapScanNode: rollupName=rollup_ad
+PREAGGREGATION: ON
+```
+
+The rollup drops `user_id`, so the planner picks it for any aggregate query that groups only by `(dt, ad_id)`. Inserting the same `(dt, ad_id, user_id)` twice produces one row with summed `clicks` and `cost`, and the rollup ends up with one row per `(dt, ad_id)`.
+
+## When to use it
+
+**Good fit**
+
+- Append-mostly fact tables that get aggregated by a stable handful of dimensions.
+- Distinct counts and quantiles. `BITMAP_UNION` and `HLL_UNION` let you merge precomputed sketches across days or partitions in milliseconds.
+- Top-N or "by hour" reports built from per-event data, where the same `GROUP BY` runs thousands of times per day.
+
+**Not a good fit**
+
+- Tables where you also need raw per-row lookups. Aggregate Key has already merged the raw rows. Use the Duplicate Key model instead, with an async materialized view if you also want preaggregated views.
+- Joins across tables. Both `ROLLUP` and sync MV are single-table only. Use [async materialized views](../query-acceleration/materialized-view/async-materialized-view/overview.md) when you need multi-table rewrites or partitioned refresh.
+- Frequent point updates by primary key. Use the Unique Key model with merge-on-write.
+- Greenfield design that reaches for `ALTER TABLE ADD ROLLUP`. Write `CREATE MATERIALIZED VIEW` instead. The grammars differ but the runtime is shared, and the MV path is where new features land.
+
+## Further reading
+
+- [Aggregate Model: data layout, merge rules, and tuning](../table-design/data-model/aggregate.md)
+- [Sync Materialized View: the modern replacement for `ADD ROLLUP`](../query-acceleration/materialized-view/sync-materialized-view.md)
+- [Async Materialized View: multi-table preaggregation with refresh modes](../query-acceleration/materialized-view/async-materialized-view/overview.md)
+- [`ALTER TABLE ROLLUP` syntax reference](../sql-manual/sql-statements/table-and-view/table/ALTER-TABLE-ROLLUP.md)
diff --git a/docs-next/key-features/prepared-statement.mdx b/docs-next/key-features/prepared-statement.mdx
new file mode 100644
index 00000000000..e854f279fc5
--- /dev/null
+++ b/docs-next/key-features/prepared-statement.mdx
@@ -0,0 +1,117 @@
+---
+title: Prepared Statement
+description: Server-side prepared statement support over the MySQL protocol that caches parsed plans per session, cutting FE CPU on repeated queries.
+slug: /key-features/prepared-statement
+featureCard:
+  tags:
+    - query-acceleration
+    - performance
+---
+
+> **TL;DR** Doris speaks the MySQL wire protocol, including server-side prepared statements. Set `useServerPrepStmts=true` on the JDBC URL and Doris parses and plans your statement once per session, then reuses that plan on every `EXECUTE`. For high-QPS point-query traffic on the short-circuit path, that cuts FE CPU enough to lift throughput by roughly 4x. For batch INSERTs and any other shape that repeats, you stop paying parse and plan cost on every row.
+
+![Prepared Statement hero illustration](/images/next/key-features/prepared-statement.jpg)
+
+## Why it matters
+
+Most analytical engines pay parse and plan cost on every query. That cost is invisible in a daily report job. It is the whole story once a service starts asking the same query thousands of times per second. A user-facing dashboard reads a row by primary key on every click. A feature store reads a vector by entity ID on every prediction. A bulk loader fires the same `INSERT INTO t VALUES (?, ?, ?)` over and over.
+
+In all three cases the SQL text is identical; only the parameters change. Re-parsing and re-planning that statement on every call burns FE CPU for no reason. At a few hundred QPS the FE saturates before the BE is doing any real work, and adding BE nodes does nothing for you. PreparedStatement is what you reach for there.
+
+- The Frontend planner is the bottleneck at high QPS, not the storage engine.
+- Repeating queries pay parse, analyze, optimize, and serialization cost on every call.
+- A separate KV store is a heavy answer to "make my point queries cheap."
+
+## What it is
+
+PreparedStatement in Doris is the MySQL wire-protocol pair `COM_STMT_PREPARE` / `COM_STMT_EXECUTE` (with `COM_STMT_CLOSE` to evict), served by the FE. When the client opens a connection with `useServerPrepStmts=true`, the JDBC driver issues `COM_STMT_PREPARE` once per distinct SQL text. The FE parses, analyzes, and stores the plan in a per-session cache keyed by statement ID. Every subsequent `EXECUTE` ships only the binary parameter bytes. The FE binds the values to the cached plan and  [...]
+
+**Key terms**
+
+- **`useServerPrepStmts=true`**: JDBC URL flag that switches the MySQL Connector/J driver from client-side string interpolation to the binary `COM_STMT_PREPARE` / `COM_STMT_EXECUTE` round trips.
+- **`cachePrepStmts=true`**: a separate JDBC flag that caches the prepared-statement handle on the *client*. Without it, every call re-issues `COM_STMT_PREPARE` and throws away the speedup.
+- **Per-session statement cache**: an in-memory map on the FE (`ConnectContext.preparedStatementContextMap`) holding the parsed `PrepareCommand`, the `StatementContext`, and an optional `ShortCircuitQueryContext`. Capped by `max_prepared_stmt_count` (default 100,000).
+- **Placeholder (`?`)**: a positional parameter bound at execute time. A single statement can carry up to 65,536 of them.
+- **`ShortCircuitQueryContext`**: an extra cache that lives alongside the prepared plan when the query qualifies for the short-circuit point-query path. It holds the serialized descriptor table, output expressions, and a UUID the BE uses to look up its own pre-built structures.
+
+## How it works
+
+1. **Prepare.** The client sends `COM_STMT_PREPARE "SELECT ... WHERE k1 = ?"`. The FE parses the SQL, runs Nereids analysis, stores a `PreparedStatementContext` in the connection's map, and returns the statement ID and parameter count.
+2. **Cache the short-circuit path (if eligible).** When the query is a single Unique Key table with equality predicates on the full key, the rewrite rule `LogicalResultSinkToShortCircuitPointQuery` flips a flag, and the FE serializes the descriptor table and output expressions into a `ShortCircuitQueryContext` keyed by a UUID.
+3. **Execute.** Each `COM_STMT_EXECUTE` carries the statement ID, a null bitmap, type codes (the first time), and the parameter bytes. The FE decodes them into `Literal` values, drops them into the placeholder slots, and runs the cached plan. No re-parse, no re-analyze, no re-optimize.
+4. **Audit (optional).** With `enable_prepared_stmt_audit_log=true`, `fe.audit.log` reconstructs the statement with parameter values for debugging. The default is off because high-QPS traffic floods the log.
+5. **Close.** `COM_STMT_CLOSE` evicts the entry from the session cache. Closed connections drop the whole map.
+
+## Quick start
+
+```sql
+CREATE TABLE tbl_point_query (
+  k1 INT,
+  v1 VARCHAR(64)
+)
+UNIQUE KEY(k1)
+DISTRIBUTED BY HASH(k1) BUCKETS 1
+PROPERTIES (
+  "enable_unique_key_merge_on_write" = "true",
+  "light_schema_change" = "true",
+  "store_row_column" = "true"
+);
+```
+
+```java
+String url = "jdbc:mysql://fe-host:9030/db"
+    + "?useServerPrepStmts=true&cachePrepStmts=true"
+    + "&prepStmtCacheSize=500&prepStmtCacheSqlLimit=1024";
+
+try (Connection c = DriverManager.getConnection(url, "root", "");
+     PreparedStatement ps = c.prepareStatement(
+         "SELECT * FROM tbl_point_query WHERE k1 = ?")) {
+    ps.setInt(1, 42);
+    try (ResultSet rs = ps.executeQuery()) { /* ... */ }
+}
+```
+
+**Expected result**
+
+In `fe.audit.log`, the second and later calls show:
+
+```
+Stmt=EXECUTE(-2147481418)
+```
+
+instead of the raw SQL. That is the signal the cached plan is being reused. Pair it with `EXPLAIN`, which should show `SHORT-CIRCUIT` on the scan node, to confirm both layers are active.
+
+## When to use it
+
+**Good fit**
+
+- High-QPS point queries on Unique Key tables, where parse cost dominates.
+- Batch INSERTs over [Group Commit](./group-commit.mdx), where a loader fires the same `INSERT INTO t VALUES (?, ?, ?)` thousands of times per second.
+- Any query the same client repeats at meaningful rates: feature lookups, status checks, allow-listing, online-service reads.
+- Workloads where you would rather not stand up a separate KV store next to Doris.
+
+**Not a good fit**
+
+- One-off ad-hoc queries. The `PREPARE` round trip costs more than the parse it saves.
+- Drivers without clean server-side prepared-statement support. The Rust `sqlx` driver, for example, has [reported](https://github.com/launchbadge/sqlx/issues/3335) issues with Doris's implementation. Use a MySQL Connector that talks the binary protocol cleanly.
+- Connections that set `useServerPrepStmts=true` without `cachePrepStmts=true`. Every call re-issues `COM_STMT_PREPARE`, which is slower than the default client-side mode.
+- Statements with non-deterministic functions (`NOW()`, `RAND()`). The BE refuses to reuse the cached short-circuit context, so you only save parse cost.
+- Range and aggregate queries, if you are hoping for the short-circuit speedup. PreparedStatement still saves parse cost, but the BE runs a normal distributed plan. For those workloads, lean on [data pruning](./data-pruning.mdx) instead.
+
+## Performance and verification
+
+Doris reports a [4x or better throughput improvement](https://doris.apache.org/docs/2.1/query-acceleration/high-concurrent-point-query/) from PreparedStatement on point-query workloads where FE CPU is the bottleneck. The full high-concurrency stack (Unique Key + Merge-on-Write + row store + row cache + short-circuit + PreparedStatement) reaches [30,000+ QPS per node](https://doris.apache.org/blog/How-We-Increased-Database-Query-Concurrency-by-20-Times/) on YCSB on a 16-core / 64GB machin [...]
+
+Two checks tell you the path is live:
+
+- `fe.audit.log` shows `Stmt=EXECUTE(stmtId)` for repeat calls instead of the raw SQL.
+- For point queries, `EXPLAIN` shows `SHORT-CIRCUIT` on the scan node.
+
+If FE CPU is still the ceiling, scale Observers and use JDBC load balancing: `jdbc:mysql:loadbalance://h1,h2,h3/db?useServerPrepStmts=true&cachePrepStmts=true`.
+
+## Further reading
+
+- [High-concurrency point query (full guide)](../query-acceleration/high-concurrent-point-query.md)
+- [High-Concurrency Point Query feature card](./high-concurrency-point-query.mdx)
+- [Group Commit for high-throughput INSERTs](./group-commit.mdx)
+- [Data pruning for non-point queries](./data-pruning.mdx)
diff --git a/docs-next/key-features/query-cache.mdx b/docs-next/key-features/query-cache.mdx
new file mode 100644
index 00000000000..af625686ff8
--- /dev/null
+++ b/docs-next/key-features/query-cache.mdx
@@ -0,0 +1,108 @@
+---
+title: Query Cache
+description: A pipeline-level cache that stores intermediate aggregation results at tablet granularity, so different queries that share tablets reuse work across executions.
+slug: /key-features/query-cache
+featureCard:
+  tags:
+    - query-acceleration
+    - caching
+---
+
+> **TL;DR** Query Cache is Doris's pipeline-engine cache for aggregation queries on internal OLAP tables. It stores partial aggregation results at tablet granularity, keyed by a digest of the plan plus the tablet ID and version. Two queries that share a tablet share the cache for that tablet, even when their date ranges only partly overlap. The dashboard that re-runs `SELECT region, SUM(revenue) FROM orders WHERE dt = '...' GROUP BY region` every fifteen seconds stops re-scanning the sam [...]
+
+![Query Cache hero illustration](/images/next/key-features/query-cache.jpg)
+
+## Why it matters
+
+Most BI traffic is repetitive aggregation. A dashboard tile re-runs the same `SUM/COUNT/AVG` every refresh. A T+1 report aggregates last night's partition once, then re-aggregates it every time someone opens the page today. A trader's panel polls the same `GROUP BY symbol` every five seconds against an order book that only changes a few tablets per minute.
+
+The same tablet contributes the same partial aggregate again and again, and the cluster pays the full scan and aggregation cost every time.
+
+SQL Cache helps when the SQL text is byte-identical, but it gives up as soon as a single filter shifts or one tablet gets a new version. A materialized view fixes the shape, but it commits you to maintaining the view and its refresh schedule.
+
+Query Cache works at a smaller grain than either of those: the contribution of one tablet to one aggregation. That is the level at which dashboard refreshes actually overlap, where filter ranges slide a day forward and only one or two partitions are new per refresh.
+
+## What it is
+
+Query Cache is a pipeline-engine optimization that activates when a fragment matches the shape `AggregationNode -> OlapScanNode`, or `AggregationNode -> AggregationNode -> OlapScanNode` for two-stage aggregation. Filter and project nodes are allowed in between. Joins, sorts, unions, window functions, and exchange nodes are not. When a fragment qualifies, the BE checks the cache before scanning. On a hit, the scan range is skipped and the cached blocks are emitted directly. On a miss, the [...]
+
+**Key terms**
+
+- **`enable_query_cache`**: session variable. Off by default; turn it on per session, or set globally for dashboard accounts.
+- **`CacheSourceOperator`**: pipeline operator inserted between the aggregation and the scan. Its profile section reports `HitCache`, `InsertCache`, and `CacheTabletId` per tablet.
+- **`SQL digest`**: a SHA-256 over the normalized aggregation plan (functions, group keys, non-partition predicates, projected columns, and result-affecting session variables). Semantically equivalent SQL produces the same digest.
+- **`Tablet range`**: derived from partition predicates. Included in the cache key so two queries can reuse the cache for partitions they have in common.
+- **`LRU-K (K=2)`**: admission policy. A new entry has to be touched at least twice before it earns a slot, so a one-off ad-hoc query cannot evict the entries that the dashboards depend on.
+
+## How it works
+
+1. **Plan check (FE).** Nereids walks each fragment looking for the agg-over-scan pattern. Joins, sorts, runtime-filter targets, external table scans, and non-deterministic expressions disqualify the fragment.
+2. **Build the cache key (FE).** For single-column RANGE partitioned tables, the planner extracts partition predicates and computes the intersection of the predicate range with each scanned partition. The plan digest covers the rest of the query. The per-tablet key is `(digest, tablet_id, tablet_range)`.
+3. **Probe per tablet (BE).** For each assigned tablet, `QueryCache::lookup(key, version)` checks both the cache key and the tablet's version. Any `INSERT`, `DELETE`, `UPDATE`, or compaction since the entry was written counts as a miss.
+4. **On hit.** The scan operator skips the range, the agg operator produces nothing, and `CacheSourceOperator` emits the cached blocks. If the projection order differs from the cached entry (`SELECT a, b` vs `SELECT b, a` with the same digest), columns are reordered automatically.
+5. **On miss.** The fragment computes its partial aggregate, sends rows downstream as usual, and buffers a copy. After the fragment finishes, results within `query_cache_entry_max_bytes` (5 MB) and `query_cache_entry_max_rows` (500,000) are written to the cache.
+
+## Quick start
+
+```sql
+SET enable_query_cache = true;
+
+-- Run 1: warms the cache for partitions 2024-01-01 .. 2024-01-07
+SELECT region, SUM(revenue), COUNT(*)
+FROM orders
+WHERE dt >= '2024-01-01' AND dt < '2024-01-08'
+GROUP BY region;
+
+-- Run 2: overlapping window, four shared days
+SELECT region, SUM(revenue), COUNT(*)
+FROM orders
+WHERE dt >= '2024-01-05' AND dt < '2024-01-12'
+GROUP BY region;
+```
+
+**Expected result (excerpt from `EXPLAIN PROFILE`)**
+
+```
+CacheSourceOperator
+  HitCache:      true
+  InsertCache:   false
+  CacheTabletId: 12345
+```
+
+The first run misses every tablet and writes per-tablet partial aggregates. The second run hits the cache for the four overlapping days (`2024-01-05` through `2024-01-07`) and only scans the new days (`2024-01-08` through `2024-01-11`). To confirm a hit, look at `CacheSourceOperator` in the profile: a per-tablet `HitCache: true` means that tablet was served from memory.
+
+## When to use it
+
+**Good fit**
+
+- BI dashboards and tile widgets that repeat the same aggregation many times per minute.
+- T+1 reports. Once the day's load finishes, repeat reads serve from the cache until tomorrow's load invalidates the new partition.
+- Aggregation queries with rolling date windows where only the most recent partition changes between refreshes.
+- Single-column `RANGE` partitioned tables. These are the shape that earns tablet-level reuse across different filter ranges.
+
+**Not a good fit**
+
+- Queries with `JOIN`, `ORDER BY`, `UNION`, or window functions in the cached subtree. Aggregate first and then join, or build an [async materialized view](../query-acceleration/materialized-view/async-materialized-view/overview.md) for the join shape.
+- External tables (Hive, Iceberg, Hudi, Paimon, JDBC). The cache keys on tablet ID and version, which external tables do not have. Use [SQL Cache](../query-acceleration/sql-cache-manual.md) instead.
+- Hot tables that take writes every few seconds. Each version bump invalidates the relevant tablet entry, so the hit rate stays low. If filter reuse is what you actually need, look at [Condition Cache](../query-acceleration/condition-cache.md).
+- Queries with `now()`, `rand()`, `uuid()`, or non-deterministic UDFs. The plan is treated as non-deterministic and the cache is disabled. Replace with day-bucketed expressions like `WHERE dt = date(now())`.
+- Multi-column `RANGE` or `LIST` partitioned tables with shifting filter ranges. The partition predicate is included in the digest verbatim, so different ranges produce different keys. Tablet-level reuse only kicks in for single-column `RANGE`.
+- Lookups by primary key. Query Cache is for aggregation. For `WHERE pk = ?` patterns, use [High-Concurrency Point Query](./high-concurrency-point-query.mdx) instead.
+
+## Configuration knobs
+
+| Knob                          | Where      | Default | When to change                                                          |
+| ----------------------------- | ---------- | ------- | ----------------------------------------------------------------------- |
+| `enable_query_cache`          | session    | `false` | Always: set per session, or globally for dashboard accounts.            |
+| `query_cache_entry_max_bytes` | session    | 5 MB    | Raise for very wide group-bys; results above this are silently uncached. |
+| `query_cache_entry_max_rows`  | session    | 500,000 | Raise for high-cardinality `GROUP BY`.                                  |
+| `query_cache_size`            | `be.conf`  | 512 MB  | Per-BE memory cap. Raise for cache-bound dashboards.                    |
+| `query_cache_force_refresh`   | session    | `false` | Set to `true` for one query when you suspect a stale cached result, then reset. |
+
+## Further reading
+
+- [Query Cache user guide](../query-acceleration/query-cache.md)
+- [SQL Cache: result-level caching for any query](../query-acceleration/sql-cache-manual.md)
+- [Condition Cache: caches filter results on segments](../query-acceleration/condition-cache.md)
+- [Async materialized views: pre-aggregate complex pipelines](../query-acceleration/materialized-view/async-materialized-view/overview.md)
+- [Caching in Doris: choosing the right strategy](../query-acceleration/caching-intro.mdx)
diff --git a/docs-next/key-features/resource-group.mdx b/docs-next/key-features/resource-group.mdx
new file mode 100644
index 00000000000..76e60edb538
--- /dev/null
+++ b/docs-next/key-features/resource-group.mdx
@@ -0,0 +1,97 @@
+---
+title: Resource Group
+description: Physical, BE-node-level isolation that splits a Doris cluster into tagged sub-pools so an offline ETL group can never crash an online query group.
+slug: /key-features/resource-group
+featureCard:
+  tags:
+    - resource-management
+    - multi-tenancy
+---
+
+> **TL;DR** Resource Group tags BE nodes into named pools, places table replicas across those pools, and binds each user to one or more pools. A query runs only on BEs in the user's pools and only reads replicas placed there. Because the pools are different BE processes, a crash or OOM in one group cannot take down another. The cost is storage: every pool that needs to read a table needs its own replica.
+
+![Resource Group hero illustration](/images/next/key-features/resource-group.jpg)
+
+## Why it matters
+
+A real Doris cluster usually serves more than one workload, and the workloads do not trust each other. The 2 a.m. ETL job rewrites yesterday's partitions on the same BEs that an executive dashboard polls every fifteen seconds during the day. A finance batch reruns a heavy report at 9 a.m. while an analyst kicks off an unbounded `SELECT *`.
+
+[Workload Group](./workload-group.mdx) handles a lot of this in-process: cgroups for CPU, a per-group memory tracker, a per-group queue. It is cheap and flexible. What it cannot do is survive a BE crash. If the kernel OOM-kills a BE, every group on that BE goes with it. If the BE segfaults inside a query, the dashboard and the ETL job both reconnect. For workloads that need to be insulated from each other at the process layer, in-process tools are not enough.
+
+Resource Group fixes that without standing up a second cluster. You tag each BE with a label, you tell each table how many replicas belong to each tag, and you bind each user to the tags they are allowed to use. The cluster keeps one FE quorum, one schema catalog, one set of credentials, but the BEs are now divided into pools that share nothing at runtime.
+
+## What it is
+
+Resource Group is a placement-and-routing feature built on **resource tags**. Every BE belongs to exactly one location tag. Every replica of every table belongs to exactly one tag, decided at create time by `replication_allocation`. Every user has a set of tags they are allowed to use, decided by the `resource_tags.location` property. The query planner intersects them: it picks BEs in the user's tag set and reads only the replicas placed on those BEs.
+
+**Key terms**
+
+- **`tag.location`**: the BE-side label. Set with `ALTER SYSTEM MODIFY BACKEND ... SET ("tag.location" = "<name>")`. A BE can carry only one location tag, and the default is `default`.
+- **`replication_allocation`**: a table or database property mapping tags to replica counts, written as `tag.location.<name>:<n>`. Comma-separate entries to spread replicas across pools (e.g., `tag.location.online:2, tag.location.offline:1`).
+- **`resource_tags.location`**: a user property listing the tags the user is allowed to access. Comma-separate to grant several. Empty means the default behavior described below.
+- **Default tag**: the built-in `default` tag. Every BE starts there, and it cannot be dropped. From 2.0.3 onward, ordinary users with no `resource_tags.location` set can only see `default`; `root` and `admin` can see everything.
+
+## How it works
+
+1. **Tag the BEs.** `ALTER SYSTEM MODIFY BACKEND "host:9050" SET ("tag.location" = "online")`. The FE writes the change to its edit log so the assignment survives restarts. `SHOW BACKENDS` reports the tag in its `Tag` column.
+2. **Allocate replicas across tags.** When a table is created with `replication_allocation = "tag.location.online:2, tag.location.offline:1"`, the FE checks that enough BEs carry each tag, then asks the tablet scheduler to place one replica per slot. A failed check reports "Failed to find enough host with tag(...)" and the DDL aborts. You can also set the property at the database level so new tables inherit it.
+3. **Bind users to tags.** `SET PROPERTY FOR 'bi_user' 'resource_tags.location' = 'online'`. The change is global, but a user has to reconnect for it to take effect on their session.
+4. **Route the query.** When the user submits a query, the FE resolves their allowed tags into a working set of BEs and plans the query against only the replicas placed on those BEs. If the table has no replica on any of the user's allowed tags, the plan fails with "no queryable replicas".
+5. **Split loads in half.** A load job has a compute part (read the source, transform, distribute) and a write part (encode, compress, persist). The compute part respects the user's tag. The write part has to land on whichever BEs own the table's replicas, regardless of tag. So if a Stream Load is submitted by a user bound to `offline`, the compute side runs on offline BEs but the writes still touch every replica owner.
+
+## Quick start
+
+```sql
+-- Tag two BEs into 'online', one into 'offline'
+ALTER SYSTEM MODIFY BACKEND "be1:9050" SET ("tag.location" = "online");
+ALTER SYSTEM MODIFY BACKEND "be2:9050" SET ("tag.location" = "online");
+ALTER SYSTEM MODIFY BACKEND "be3:9050" SET ("tag.location" = "offline");
+
+-- Place 2 replicas in 'online', 1 in 'offline'
+CREATE TABLE orders (id BIGINT, region STRING, amount DECIMAL(18,2))
+DISTRIBUTED BY HASH(id) BUCKETS 8
+PROPERTIES ("replication_allocation" = "tag.location.online:2, tag.location.offline:1");
+
+-- Bind users to their pools
+SET PROPERTY FOR 'bi_user'  'resource_tags.location' = 'online';
+SET PROPERTY FOR 'etl_user' 'resource_tags.location' = 'offline';
+```
+
+**Expected result**
+
+```
++----------+-------+---------+
+| Host     | Alive | Tag     |
++----------+-------+---------+
+| be1:9050 | true  | online  |
+| be2:9050 | true  | online  |
+| be3:9050 | true  | offline |
++----------+-------+---------+
+```
+
+`SHOW BACKENDS` confirms the assignment. After `bi_user` reconnects, their queries plan against the two `online` replicas and never touch `be3`. `etl_user` runs on `be3` and reads the single `offline` replica. A BE crash on `be3` is invisible to `bi_user`.
+
+## When to use it
+
+**Good fit**
+
+- Read/write separation on one cluster: an offline pool for ETL, an online pool for dashboards, a single shared dataset with one replica per pool.
+- Multi-tenant clusters where one tenant's BE crash or OOM must not page the others.
+- Cases where you want to consolidate several physical clusters into one without giving up workload-level fault isolation.
+- Pair Workload Group inside each pool to subdivide CPU and memory at the per-query level. See [Workload Group](./workload-group.mdx).
+
+**Not a good fit**
+
+- You only need soft CPU and memory limits and you can accept that a BE crash takes everything down. Use [Workload Group](./workload-group.mdx) on its own. It also supports cross-group queries, which Resource Group cannot.
+- Storage cost is your tightest budget. Every pool that reads a table needs its own replica. Five pools means five replicas of every shared table; the storage bill scales linearly with the number of groups.
+- You run the storage-compute decoupled (cloud) deployment. There the equivalent is [Compute Group](./compute-group.mdx), which gives you the same node-level isolation without multiplying replicas, because storage is shared.
+- Heavy cross-pool ad-hoc analytics. The planner cannot stitch BEs from different tags into one query; missing replicas surface as "no queryable replicas" rather than a fallback.
+- Loads where you expect the writes to be isolated too. The write side always lands on the replica owners; only the compute side respects the tag.
+
+## Further reading
+
+- [Resource Group reference](../admin-manual/workload-management/resource-group.md): the full SQL surface, the validation rules, and the loading split in detail.
+- [Workload management overview](../admin-manual/workload-management/workload-management-summary.md): the side-by-side comparison of Resource Group, Workload Group, and Compute Group.
+- [Workload Group](./workload-group.mdx): the in-process isolation feature you usually pair with this one.
+- [Compute Group](../admin-manual/workload-management/compute-group.md): the storage-compute decoupled equivalent that does not multiply storage.
+- [Multi-tenant workload isolation in Apache Doris](/blog/multi-tenant-workload-isolation-in-apache-doris/): the design post covering the whole isolation landscape, including how Resource Group complements Workload Group.
diff --git a/docs-next/key-features/vectorized-execution.mdx b/docs-next/key-features/vectorized-execution.mdx
new file mode 100644
index 00000000000..b7f881ec53c
--- /dev/null
+++ b/docs-next/key-features/vectorized-execution.mdx
@@ -0,0 +1,103 @@
+---
+title: Vectorized Execution
+description: Doris's column-batch SIMD execution engine, the substrate every operator runs on since v2.0 deleted the last row-based code path.
+slug: /key-features/vectorized-execution
+featureCard:
+  tags:
+    - query-acceleration
+    - performance
+---
+
+> **TL;DR** Every Doris query runs on the vectorized engine. Operators move data in `Block`s of up to 4,096 column-oriented rows, the inner loops are tight enough for the compiler and hand-written intrinsics to use SIMD, and the row-based engine was deleted entirely in v2.0. There is nothing to enable. The only knob most users will ever touch is `batch_size`.
+
+![Vectorized Execution hero illustration](/images/next/key-features/vectorized-execution.jpg)
+
+## Why it matters
+
+A classic Volcano-style executor calls `next()` once per row, walking through a tree of virtual function dispatches to produce a single tuple. On a modern CPU, the actual arithmetic is a rounding error on top of the dispatch, branch mispredictions, and cache misses. Aggregating a billion rows row-by-row turns a fast machine into a slow one.
+
+- One virtual call per row, per operator, multiplies into millions of indirect jumps the branch predictor cannot help with.
+- Random row layout in memory keeps the CPU's L1/L2 caches mostly empty of useful data.
+- Tight numeric loops cannot be auto-vectorized when the data is interleaved across rows.
+
+Vectorized execution flips the inner loop. An operator receives a batch of column values, runs a straight loop over that batch, and the hot path ends up looking like the kind of code AVX2 was built for.
+
+## What it is
+
+Vectorized execution in Doris is a column-batch query engine: every operator's `get_block()` consumes one `Block` and produces another, where a `Block` is a small set of columnar arrays describing 1 to `batch_size` rows. SIMD intrinsics accelerate the parts that benefit most: filter selection, decimal arithmetic, string comparison, JSON parsing. The model traces back to MonetDB/X100 and Vectorwise. ClickHouse, StarRocks, and DuckDB use the same design.
+
+**Key terms**
+
+- **`Block`**: the unit of data passed between operators. Holds typed columnar arrays plus column metadata. Implemented in `be/src/vec/core/block.h`.
+- **`Column`**: one typed array inside a Block. Specializations include `ColumnVector`, `ColumnString`, `ColumnDecimal`, `ColumnNullable`, and `ColumnDictionary`, each tuned for its data shape.
+- **`batch_size`**: maximum rows in a Block. Default 4,096, configurable per session. Larger batches amortize per-call overhead; smaller ones reduce memory footprint and tail latency.
+- **SIMD path**: hand-written code in `be/src/util/simd/` plus compiler-auto-vectorized loops, dispatched at runtime to AVX2 on x86 or NEON on ARM.
+
+## How it works
+
+1. **Plan to operator chain.** The optimizer produces a plan, the FE rewrites it as a pipeline of operators, and each operator implements `get_block(state, block, eos)`.
+2. **Pull a batch.** A scan operator reads up to `batch_size` rows from a tablet's columnar storage, fills a Block in place, and returns it. Rows never get materialized into structs.
+3. **Run column-at-a-time.** Filter, project, aggregate, and join operators iterate over each column with a straight loop. Where it pays off, the loop calls into SIMD primitives, like `bytes32_mask_to_bits32_mask` for filter compression or vectorized decimal compare.
+4. **Hand off downstream.** The Block moves through the pipeline as a value, with the Pipeline scheduler interleaving CPU-bound and IO-bound stages across cores. Multiple pipeline tasks process different Blocks in parallel.
+5. **Return to the client.** The final operator serializes the last Blocks into the MySQL wire protocol and the query is done.
+
+The Pipeline engine introduced in v2.0 is built on top of this. Vectorization decides what an operator does to a batch; Pipeline decides how those batches get scheduled across cores. The two are different layers, often confused.
+
+## Quick start
+
+You don't need to enable vectorized execution: it's the only execution mode. The one knob worth knowing is `batch_size`.
+
+```sql
+-- Default; safe to leave alone for most workloads
+SHOW VARIABLES LIKE 'batch_size';
+
+-- Larger batches: better throughput on big aggregations, more memory per fragment
+SET batch_size = 8192;
+
+SELECT user_id, COUNT(*) AS events, SUM(bytes) AS total_bytes
+FROM access_log
+WHERE ts BETWEEN '2026-05-01' AND '2026-05-08'
+GROUP BY user_id
+ORDER BY total_bytes DESC
+LIMIT 10;
+```
+
+**Expected result**
+
+```
++---------+--------+-------------+
+| user_id | events | total_bytes |
++---------+--------+-------------+
+|   42718 |  18374 |   924518127 |
+|     ...                        |
++---------+--------+-------------+
+```
+
+The aggregation runs column-at-a-time over 8,192-row Blocks. On a wide-table scan with millions of matching rows, the larger batch trades a bit more BE memory for fewer scheduling round-trips, which usually wins on long scans and loses on tiny point queries.
+
+## When to use it
+
+**Good fit**
+
+- Any workload Doris already runs. Scan-heavy aggregations, GROUP BY, hash joins, and sorts all live on this engine. There is no "non-vectorized" option to compare against.
+- Wide-table analytics and dashboard queries. Column-at-a-time loops and SIMD filters do their best work here.
+- Workloads on machines with AVX2 or NEON. Doris recommends AVX2-capable x86 CPUs in the install checklist; NEON is the default ARM path. Older CPUs still work, but SIMD-accelerated paths fall back to scalar code.
+
+**Not a good fit**
+
+- Single-row primary-key lookups served at thousands of QPS. Per-Block setup cost works against you when the Block holds one row. Use the [High-Concurrency Point Query](./high-concurrency-point-query.mdx) path instead.
+- Tuning `batch_size` for ad-hoc queries. The default of 4,096 was chosen for a reason, and changing it usually does nothing or makes things slightly worse. Reach for it only when profiling tells you the per-batch overhead is dominating, or when fragments are running out of memory.
+- Reading old advice that mentions `enable_vectorized_engine`. That session variable was demoted to a no-op in v1.2.1 and the underlying row-based code was deleted in v2.0. If a doc or blog post tells you to flip it, the doc is stale.
+- Confusing this feature with vector search. "Vectorized execution" is a query engine model. ANN-style vector search lives in a separate index family under `docs/ai/vector-search/`.
+
+## Performance
+
+- **3 to 5x faster than the prior version** when v1.1 made vectorized execution the default for all queries. Source: [Apache Doris 1.1.0 release notes](https://doris.apache.org/releases/v1.1/release-1.1.0/).
+- **5 to 10x improvement on wide-table aggregations**, attributed to the columnar memory layout combined with SIMD acceleration. Source: [Doris product concepts](../features-architecture/product-concepts.md).
+
+## Further reading
+
+- [Product concepts: Vectorized Execution Engine](../features-architecture/product-concepts.md)
+- [Pipeline execution engine](../query-acceleration/optimization-technology-principle/pipeline-execution-engine.md)
+- [System architecture overview](../features-architecture/system-architecture.md)
+- [Apache Doris 2.0 release notes (row-based engine removed)](https://doris.apache.org/blog/release-note-2.0.0/)
diff --git a/docs-next/key-features/workload-group.mdx b/docs-next/key-features/workload-group.mdx
new file mode 100644
index 00000000000..8403f91abe0
--- /dev/null
+++ b/docs-next/key-features/workload-group.mdx
@@ -0,0 +1,103 @@
+---
+title: Workload Group
+description: An in-process resource isolation mechanism that partitions CPU, memory, IO, and concurrency across query groups so heavy ETL never starves a BI dashboard.
+slug: /key-features/workload-group
+featureCard:
+  tags:
+    - resource-management
+    - multi-tenancy
+---
+
+> **TL;DR** Workload Group slices a single Doris cluster into named groups, each with its own CPU share, memory ceiling, query concurrency cap, and IO budget. Heavy ETL goes in one group, dashboards in another, ad-hoc analysts in a third. A runaway query in one group no longer takes the others down with it. Linux cgroups handle CPU isolation; the BE process handles memory, IO, and concurrency.
+
+![Workload Group hero illustration](/images/next/key-features/workload-group.jpg)
+
+## Why it matters
+
+A production Doris cluster almost never runs one workload. A typical day looks like this: a 2 a.m. ETL pipeline rewrites yesterday's partitions, a finance team reruns their daily report at 9 a.m., an executive dashboard polls the same `GROUP BY region` every fifteen seconds during business hours, and an analyst kicks off a `SELECT *` because they forgot the `WHERE` clause.
+
+Without resource controls, those four workloads share one global pool. The ETL job runs on the same threads that serve the dashboard. A bad ad-hoc query fills the BE's memory and triggers a kill on the report. Concurrency is unbounded, so a sudden spike of dashboard refreshes piles up against the ETL load and degrades everyone.
+
+Workload Group fixes this without adding hardware. You define one group per workload class, set the limits each group is allowed, and bind users to the right group. The cluster keeps running on the same set of BEs, but the BE schedulers, memory tracker, and cgroup controllers now divide capacity along the lines you drew.
+
+## What it is
+
+Workload Group is an in-process isolation feature: every BE process serves every group, but the BE knows which threads, scan slots, and memory belong to which group. CPU isolation routes BE worker threads into per-group cgroups. Memory isolation tracks per-group usage in the BE memory tracker and triggers spilling or query cancellation when a group exceeds its ceiling. Concurrency control sits at the FE: each group has a queue, and queries past the limit wait or fail fast.
+
+**Key terms**
+
+- **`min_cpu_percent` / `max_cpu_percent`**: the CPU bandwidth a group is reserved (soft floor) and the cap it can never exceed (hard ceiling). The sum of `min_cpu_percent` across groups must stay under 100%.
+- **`min_memory_percent` / `max_memory_percent`**: the share of BE memory the group is guaranteed and the share it can grow to before queries spill or get killed.
+- **`max_concurrency` / `max_queue_size` / `queue_timeout`**: the running-query cap, the depth of the wait queue, and how long a queued query is willing to wait. All three are per-FE, not cluster-wide.
+- **`read_bytes_per_second` / `remote_read_bytes_per_second`**: per-group IO throttles, applied per data directory on local tables and per BE for external sources.
+- **`normal` group**: the default group, auto-created on first start, used for any session that has not been bound elsewhere. You cannot drop it.
+
+## How it works
+
+1. **Resolve the group (FE).** The FE picks a group in this order: an explicit `/*+ SET_VAR(workload_group='...') */` hint, the session variable `workload_group`, the user property `default_workload_group`, then `normal`. The FE checks USAGE privilege on the chosen group.
+2. **Admit or queue (FE).** The group's queue checks how many queries are already running in this group on this FE. If under `max_concurrency`, the query gets a slot and proceeds. If over, it waits, capped by `max_queue_size` and timed out by `queue_timeout`. A full queue rejects fast.
+3. **Pin threads to a cgroup (BE).** Each BE keeps one cgroup per group under `/sys/fs/cgroup/doris/<wg_id>` (cgroup v2) or `/sys/fs/cgroup/cpu/doris/<wg_id>` (v1). When a fragment for the group runs, its workers join that cgroup. The kernel's CFS scheduler enforces `min_cpu_percent` (via `cpu.weight`) and `max_cpu_percent` (via `cpu.max`).
+4. **Track memory per group (BE).** Every allocation is tagged with the group ID. When the group's tracker crosses `memory_low_watermark`, spillable operators start spilling to disk. When it crosses `memory_high_watermark`, the BE pauses queries in the group and may cancel the largest one to bring usage back below the line.
+5. **Throttle scans (BE).** Local and remote scan thread pools have per-group ceilings (`scan_thread_num`, `max_remote_scan_thread_num`), and `read_bytes_per_second` caps how much disk IO each group's scans can consume. This is the knob that keeps an unindexed full-table scan from saturating the SSDs.
+
+## Quick start
+
+```sql
+-- One group for ETL, one for BI dashboards
+CREATE WORKLOAD GROUP etl PROPERTIES (
+  'min_cpu_percent'='30','max_cpu_percent'='70',
+  'min_memory_percent'='30','max_memory_percent'='70',
+  'max_concurrency'='10');
+
+CREATE WORKLOAD GROUP bi PROPERTIES (
+  'min_cpu_percent'='20','max_cpu_percent'='50',
+  'min_memory_percent'='20','max_memory_percent'='50',
+  'max_concurrency'='50','max_queue_size'='100','queue_timeout'='3000');
+
+-- Bind users persistently
+SET PROPERTY FOR 'etl_user' 'default_workload_group'='etl';
+SET PROPERTY FOR 'bi_user'  'default_workload_group'='bi';
+
+-- One-off override for a single query
+SELECT /*+ SET_VAR(workload_group='bi') */ region, SUM(revenue)
+FROM orders WHERE dt = CURRENT_DATE() GROUP BY region;
+```
+
+**Expected result**
+
+```
++------+-------------------+-------------------+-----------------+
+| name | running_query_num | waiting_query_num | max_concurrency |
++------+-------------------+-------------------+-----------------+
+| etl  | 4                 | 0                 | 10              |
+| bi   | 47                | 12                | 50              |
++------+-------------------+-------------------+-----------------+
+```
+
+`SHOW WORKLOAD GROUPS` (or `SELECT ... FROM information_schema.workload_groups`) reports live counts. The 12 queries waiting in `bi` are queued behind the 47 already running. The four ETL queries get a guaranteed 30% of CPU even when BI is busy, and they cannot push past 70%.
+
+## When to use it
+
+**Good fit**
+
+- Mixed-workload clusters: ETL, scheduled reports, dashboards, and ad-hoc analysts on shared BEs.
+- Multi-tenant deployments where cost matters more than perfect isolation: one cluster, several teams, predictable behavior under load.
+- Throttling a known troublemaker. A team's `SELECT *` habit, a Kafka backfill that occasionally turns into a flood, anything with a tail you want to clip.
+- Pair it with [Compute Group](../admin-manual/workload-management/compute-group.md) in storage-compute decoupled mode. The compute group gives you a separate set of BEs; the workload group divides resources within it.
+
+**Not a good fit**
+
+- You need physical isolation: separate SLAs, separate security domains, or billable tenants. A BE crash or kernel OOM still hits every group in the same process. Use [Resource Group](../admin-manual/workload-management/resource-group.md) or a dedicated Compute Group instead.
+- You expect cluster-wide concurrency to be a single number. With three FEs, `max_concurrency=10` allows up to 30 concurrent queries cluster-wide. Either size the per-FE cap accordingly, or front the cluster with a load balancer that pins users to one FE.
+- You run BEs in non-privileged Docker or Kubernetes containers. The BE process needs read and write access to the host's cgroup tree to enforce CPU limits. Without privileged mode (or the Doris Operator), CPU caps silently fail. Memory and concurrency still work, but you have lost the most useful knob.
+- You set memory overcommit on and let the sum of `max_memory_percent` reach 100%. There is no headroom left for the soft-limit overflow path, and the BE will cancel the largest in-group queries when it hits the global ceiling. Leave 10 to 20% room.
+- Your workloads are bursty and short-lived enough that a Snowflake-style auto-scaled warehouse would be cheaper than a fixed allocation. Workload Group divides a fixed pool; it does not provision new compute.
+
+## Further reading
+
+- [Workload Group reference](../admin-manual/workload-management/workload-group.md): full property table, cgroup setup steps, and migration notes for the 4.0 property rename.
+- [Workload management overview](../admin-manual/workload-management/workload-management-summary.md): how Workload Group, Resource Group, and Compute Group fit together, and when to pick which.
+- [Concurrency control and queueing](../admin-manual/workload-management/concurrency-control-and-queuing.md): how the per-FE queue behaves, the `bypass_workload_group` admin override, and queueing pitfalls under multiple FEs.
+- [Spill to disk](../admin-manual/workload-management/spill-disk.md): what happens when a group hits its `memory_low_watermark` and how slot-based memory policies work.
+- [`information_schema.workload_groups`](../admin-manual/system-tables/information_schema/workload_groups.md): the system table for live group state, used by the example above.
+- [Multi-tenant workload isolation in Apache Doris](https://doris.apache.org/blog/multi-tenant-workload-isolation-in-apache-doris/): the original design blog with internal benchmarks for soft- and hard-limit CPU on a 16-core BE.
diff --git a/src/components/blogs-next/index.tsx b/src/components/blogs-next/index.tsx
index ec9c86b06f8..26bd3072947 100644
--- a/src/components/blogs-next/index.tsx
+++ b/src/components/blogs-next/index.tsx
@@ -172,14 +172,15 @@ export default function BlogsNext(): JSX.Element {
     const [currentPage, setCurrentPage] = useState(initialQuery.page);
 
     useEffect(() => {
-        if (initialQuery.category && initialQuery.category !== ALL_TEXT) {
-            return;
-        }
-
+        if (location.search) return;
         const storedTag = sessionStorage.getItem('tag');
-        if (storedTag) {
-            setActive(storedTag);
+        if (storedTag && storedTag !== ALL_TEXT) {
+            history.replace(
+                `${location.pathname}?currentPage=1&currentCategory=${encodeURIComponent(storedTag)}`,
+                location.state,
+            );
         }
+        // eslint-disable-next-line react-hooks/exhaustive-deps
     }, []);
 
     useEffect(() => {
@@ -189,13 +190,15 @@ export default function BlogsNext(): JSX.Element {
     }, [location.search]);
 
     useEffect(() => {
-        const nextSearch = `?currentPage=${currentPage}&currentCategory=${encodeURIComponent(active)}#blog`;
-        if (location.search !== nextSearch.replace('#blog', '')) {
-            history.push(`${location.pathname}${nextSearch}`, location.state);
-        }
-
         sessionStorage.setItem('tag', active);
-    }, [active, currentPage, history, location.pathname, location.search, location.state]);
+    }, [active]);
+
+    const handleSelectCategory = (label: string) => {
+        history.push(
+            `${location.pathname}?currentPage=1&currentCategory=${encodeURIComponent(label)}#blog`,
+            location.state,
+        );
+    };
 
     const currentCategory = blogCategories.find(item => item.label === active) ?? blogCategories[0];
     const currentCards = currentCategory.values.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
@@ -229,10 +232,7 @@ export default function BlogsNext(): JSX.Element {
                                         className={`block cursor-pointer whitespace-nowrap rounded-[2.5rem] px-4 py-2 text-sm shadow-[0px_1px_4px_0px_rgba(0,89,68,0.10)] hover:bg-primary hover:text-white lg:px-6 lg:py-3 lg:text-base ${
                                             active === item.label ? 'bg-primary text-white' : ''
                                         }`}
-                                        onClick={() => {
-                                            setActive(item.label);
-                                            setCurrentPage(1);
-                                        }}
+                                        onClick={() => handleSelectCategory(item.label)}
                                     >
                                         {item.label}
                                     </button>
diff --git a/src/components/home-next/DocsSearchSection.scss b/src/components/home-next/DocsSearchSection.scss
index d2ef8c7c2c1..05f2fc5c953 100644
--- a/src/components/home-next/DocsSearchSection.scss
+++ b/src/components/home-next/DocsSearchSection.scss
@@ -4,7 +4,9 @@
 .docs-next-search-section {
     position: sticky;
     top: calc(var(--home-next-banner-height, 0px) + 64px);
-    z-index: 99;
+    // Above the docs sidebar (.theme-doc-sidebar-container) which uses z-index 100,
+    // so the search dropdown is not covered by the left sidebar.
+    z-index: 200;
     width: 100%;
     background: #FAF6EE;
     border-bottom: 1px solid rgba(15, 26, 20, 0.08);
@@ -26,6 +28,15 @@
         max-width: 1080px;
         margin: 0;
 
+        // autocomplete.js wraps the input in a span.algolia-autocomplete with
+        // display: inline-block by default. Make it fill the search container
+        // so the dropdown (positioned relative to this wrapper) aligns with the
+        // input edges instead of collapsing to the input's intrinsic width.
+        .algolia-autocomplete {
+            display: block !important;
+            width: 100%;
+        }
+
         input.navbar__search-input {
             width: 100% !important;
             height: 2.5rem !important;
diff --git a/src/components/home-next/NavbarNext.scss b/src/components/home-next/NavbarNext.scss
index 2b5d72e1a73..d1e1bad4b1c 100644
--- a/src/components/home-next/NavbarNext.scss
+++ b/src/components/home-next/NavbarNext.scss
@@ -13,7 +13,10 @@
 
     position: sticky;
     top: var(--home-next-banner-height, 0px);
-    z-index: 100;
+    // Above .docs-next-search-section (z-index 200) so the navbar's hover
+    // dropdowns (which extend below the navbar bar) stay on top of the
+    // sticky docs search strip.
+    z-index: 300;
     width: 100%;
     background: var(--nb-green-dark);
     border-bottom: 1px solid rgba(245, 239, 228, 0.08);
diff --git a/src/components/use-cases-next/CustomerFacingAnalyticsNext.tsx b/src/components/use-cases-next/CustomerFacingAnalyticsNext.tsx
index 7ecc7009309..d527161b59c 100644
--- a/src/components/use-cases-next/CustomerFacingAnalyticsNext.tsx
+++ b/src/components/use-cases-next/CustomerFacingAnalyticsNext.tsx
@@ -422,13 +422,13 @@ const capabilities: Capability[] = [
         desc: 'New data becomes queryable within seconds, while dashboards and in-product analytics stay fast and interactive. Users see the latest activity as it happens, not after the next batch cycle.',
         poweredLabel: 'Powered by',
         poweredBy: [
-            { label: 'Load Transaction', href: '#' },
-            { label: 'Data Compaction', href: '#' },
-            { label: 'Data Update/Delete', href: '#' },
-            { label: 'Preaggregation', href: '#' },
-            { label: 'Group Commit', href: '#' },
-            { label: 'Kafka/CDC Integration', href: '#' },
-            { label: 'Incremental Materialized View', href: '#' },
+            { label: 'Load Transaction', href: '/docs-next/dev/key-features/load-transaction' },
+            { label: 'Data Compaction', href: '/docs-next/dev/key-features/data-compaction' },
+            { label: 'Data Update/Delete', href: '/docs-next/dev/key-features/data-update-delete' },
+            { label: 'Preaggregation', href: '/docs-next/dev/key-features/preaggregation-and-rollup' },
+            { label: 'Group Commit', href: '/docs-next/dev/key-features/group-commit' },
+            { label: 'Kafka/CDC Integration', href: '/docs-next/dev/key-features/kafka-cdc-integration' },
+            { label: 'Incremental Materialized View', href: '/docs-next/dev/key-features/incremental-materialized-view' },
         ],
     },
     {
@@ -444,13 +444,13 @@ const capabilities: Capability[] = [
         desc: 'Apache Doris sustains fast, predictable query response times as concurrent users and data volumes grow, not just in single-query benchmarks.',
         poweredLabel: 'Powered by',
         poweredBy: [
-            { label: 'Data Pruning', href: '#' },
-            { label: 'High Concurrency Point Query', href: '#' },
-            { label: 'Vectorized Execution', href: '#' },
-            { label: 'Columnar Storage', href: '#' },
-            { label: 'Prepared Statement', href: '#' },
-            { label: 'Query Cache', href: '#' },
-            { label: 'Condition Cache', href: '#' },
+            { label: 'Data Pruning', href: '/docs-next/dev/key-features/data-pruning' },
+            { label: 'High Concurrency Point Query', href: '/docs-next/dev/key-features/high-concurrency-point-query' },
+            { label: 'Vectorized Execution', href: '/docs-next/dev/key-features/vectorized-execution' },
+            { label: 'Columnar Storage', href: '/docs-next/dev/key-features/columnar-storage' },
+            { label: 'Prepared Statement', href: '/docs-next/dev/key-features/prepared-statement' },
+            { label: 'Query Cache', href: '/docs-next/dev/key-features/query-cache' },
+            { label: 'Condition Cache', href: '/docs-next/dev/key-features/condition-cache' },
         ],
     },
     {
@@ -466,10 +466,10 @@ const capabilities: Capability[] = [
         desc: 'Serve many users, teams, or tenants from a single platform. Isolate workloads and control resource usage so heavy queries from one tenant do not impact others.',
         poweredLabel: 'Powered by',
         poweredBy: [
-            { label: 'Workload Group', href: '#' },
-            { label: 'Resource Group', href: '#' },
-            { label: 'Compute Group', href: '#' },
-            { label: 'Pipleline Execution Engine', href: '#' },
+            { label: 'Workload Group', href: '/docs-next/dev/key-features/workload-group' },
+            { label: 'Resource Group', href: '/docs-next/dev/key-features/resource-group' },
+            { label: 'Compute Group', href: '/docs-next/dev/key-features/compute-group' },
+            { label: 'Pipeline Execution Engine', href: '/docs-next/dev/key-features/pipeline-execution-engine' },
         ],
     },
     {
@@ -483,9 +483,9 @@ const capabilities: Capability[] = [
         desc: 'Query open lakehouse formats directly. Combine real-time serving with existing data lake architectures, without copying data twice.',
         poweredLabel: 'Powered by',
         poweredBy: [
-            { label: 'Parquet Reader Optzimation', href: '#' },
-            { label: 'Data Cache & Page Cache', href: '#' },
-            { label: 'Metadata Cache', href: '#' },
+            { label: 'Parquet Reader Optimization', href: '/docs-next/dev/key-features/parquet-reader-optimization' },
+            { label: 'Data Cache & Page Cache', href: '/docs-next/dev/key-features/data-cache-page-cache' },
+            { label: 'Metadata Cache', href: '/docs-next/dev/key-features/metadata-cache' },
         ],
     },
 ];
diff --git a/src/components/why-doris-next/KeyFeaturesNext.scss b/src/components/why-doris-next/KeyFeaturesNext.scss
index 8cd5d005990..ce9d33afe1b 100644
--- a/src/components/why-doris-next/KeyFeaturesNext.scss
+++ b/src/components/why-doris-next/KeyFeaturesNext.scss
@@ -407,10 +407,10 @@
     padding: 0;
     margin: 0;
     display: grid;
-    grid-template-columns: repeat(12, 1fr);
-    grid-auto-rows: 96px;
+    grid-template-columns: repeat(4, 1fr);
+    grid-auto-rows: 132px;
     grid-auto-flow: dense;
-    gap: 8px;
+    gap: 10px;
 }
 
 .kf-next__tile {
@@ -421,61 +421,71 @@
         transform 0.25s cubic-bezier(.2, .8, .2, 1),
         box-shadow 0.25s cubic-bezier(.2, .8, .2, 1),
         opacity 0.2s ease;
-    grid-column: span 3;
-    grid-row: span 1;
+    min-height: 96px;
+    display: flex;
 }
 
-.kf-next__tile:hover {
-    transform: translateY(-6px);
-    box-shadow: 0 18px 30px -14px rgba(0, 0, 0, 0.45);
-    z-index: 2;
+.kf-next__tile--wide {
+    grid-column: span 2;
 }
 
-.kf-next__tile.is-hidden {
-    display: none;
+.kf-next__tile--tall {
+    grid-row: span 2;
 }
 
-.kf-next__tile.span-s {
+.kf-next__tile--big {
     grid-column: span 2;
-    grid-row: span 1;
-}
-
-.kf-next__tile.span-m {
-    grid-column: span 3;
-    grid-row: span 1;
+    grid-row: span 2;
 }
 
-.kf-next__tile.span-l {
-    grid-column: span 4;
-    grid-row: span 1;
+.kf-next__tile:hover {
+    transform: translateY(-6px);
+    box-shadow: 0 18px 30px -14px rgba(0, 0, 0, 0.45);
+    z-index: 2;
 }
 
-.kf-next__tile.span-t {
-    grid-column: span 2;
-    grid-row: span 2;
+.kf-next__tile.is-hidden {
+    display: none;
 }
 
 .kf-next__tile-link {
     position: relative;
     display: grid;
-    grid-template-rows: auto minmax(0, 1fr) auto;
+    grid-template-rows: minmax(0, 1fr) auto;
     width: 100%;
-    height: 100%;
     padding: 16px 18px 14px;
     text-decoration: none;
     color: inherit;
     gap: 10px;
+    min-height: 100%;
+}
+
+.kf-next__tile--tall .kf-next__tile-link,
+.kf-next__tile--big .kf-next__tile-link {
+    padding: 22px 22px 18px;
+    gap: 14px;
 }
 
 .kf-next__tile-badge-row {
+    position: absolute;
+    top: 12px;
+    right: 14px;
+    z-index: 2;
     display: flex;
     justify-content: flex-end;
-    min-height: 22px;
+    pointer-events: none;
 }
 
 .kf-next__tile-top {
     min-width: 0;
-    min-height: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    overflow: hidden;
+}
+
+.kf-next__tile-link:has(.kf-next__tile-badge-row) .kf-next__tile-top {
+    padding-right: 44px;
 }
 
 .kf-next__tile-badge {
@@ -504,16 +514,42 @@
     font-family: var(--kf-mono);
     font-weight: 700;
     font-size: clamp(13px, 1.05vw, 16px);
-    line-height: 1.06;
+    line-height: 1.15;
     letter-spacing: -0.005em;
     word-spacing: -0.08em;
     text-transform: none;
     overflow-wrap: anywhere;
     hyphens: auto;
+}
+
+.kf-next__tile--wide .kf-next__tile-name {
+    font-size: clamp(15px, 1.25vw, 19px);
+}
+
+.kf-next__tile--tall .kf-next__tile-name {
+    font-size: clamp(15px, 1.3vw, 20px);
+}
+
+.kf-next__tile--big .kf-next__tile-name {
+    font-size: clamp(18px, 1.7vw, 26px);
+    line-height: 1.1;
+}
+
+.kf-next__tile-desc {
     display: -webkit-box;
-    -webkit-line-clamp: 2;
     -webkit-box-orient: vertical;
+    -webkit-line-clamp: 4;
     overflow: hidden;
+    font-size: clamp(11px, 0.85vw, 13px);
+    line-height: 1.5;
+    opacity: 0.78;
+    overflow-wrap: anywhere;
+}
+
+.kf-next__tile--big .kf-next__tile-desc {
+    -webkit-line-clamp: 7;
+    font-size: clamp(12px, 0.95vw, 14px);
+    opacity: 0.82;
 }
 
 .kf-next__tile-meta {
@@ -599,35 +635,14 @@
     color: var(--kf-ink);
 }
 
-.kf-next__tile.span-t .kf-next__tile-link::after {
-    content: none;
-}
-
 @media (max-width: 1100px) {
     .kf-next {
         --kf-gutter: 36px;
     }
 
     .kf-next__grid {
-        grid-template-columns: repeat(8, 1fr);
-        grid-auto-rows: 88px;
-    }
-
-    .kf-next__tile.span-s {
-        grid-column: span 2;
-    }
-
-    .kf-next__tile.span-m {
-        grid-column: span 3;
-    }
-
-    .kf-next__tile.span-l {
-        grid-column: span 4;
-    }
-
-    .kf-next__tile.span-t {
-        grid-column: span 2;
-        grid-row: span 2;
+        grid-template-columns: repeat(3, 1fr);
+        grid-auto-rows: 124px;
     }
 }
 
@@ -650,24 +665,17 @@
     }
 
     .kf-next__grid {
-        grid-template-columns: repeat(4, 1fr);
-        grid-auto-rows: 78px;
+        grid-template-columns: repeat(2, 1fr);
+        grid-auto-rows: 110px;
     }
 
-    .kf-next__tile.span-s,
-    .kf-next__tile.span-m {
+    .kf-next__tile--big {
         grid-column: span 2;
-        grid-row: span 1;
-    }
-
-    .kf-next__tile.span-l {
-        grid-column: span 4;
-        grid-row: span 1;
+        grid-row: span 2;
     }
 
-    .kf-next__tile.span-t {
-        grid-column: span 4;
-        grid-row: span 1;
+    .kf-next__tile--wide {
+        grid-column: span 2;
     }
 
     .kf-next__tile-link {
@@ -675,8 +683,13 @@
         gap: 8px;
     }
 
+    .kf-next__tile-link:has(.kf-next__tile-badge-row) .kf-next__tile-top {
+        padding-right: 38px;
+    }
+
     .kf-next__tile-badge-row {
-        min-height: 20px;
+        top: 10px;
+        right: 12px;
     }
 
     .kf-next__tile-badge {
diff --git a/src/components/why-doris-next/KeyFeaturesNext.tsx b/src/components/why-doris-next/KeyFeaturesNext.tsx
index abc3fdcad55..a44e738e57f 100644
--- a/src/components/why-doris-next/KeyFeaturesNext.tsx
+++ b/src/components/why-doris-next/KeyFeaturesNext.tsx
@@ -4,14 +4,6 @@ import { LayoutNext } from '../home-next/LayoutNext';
 import { keyFeatureCards, type KeyFeatureCard } from '@site/src/generated/key-features';
 import './KeyFeaturesNext.scss';
 
-type CardSpan = 's' | 'm' | 'l' | 't';
-
-const CARD_SPANS: CardSpan[] = ['s', 'm', 'l', 't'];
-
-function randomSpan(): CardSpan {
-    return CARD_SPANS[Math.floor(Math.random() * CARD_SPANS.length)];
-}
-
 function useRevealObserver(): void {
     useEffect(() => {
         const items = document.querySelectorAll<HTMLElement>('[data-reveal]');
@@ -66,6 +58,20 @@ function themeFor(index: number): string {
     return themes[index % themes.length];
 }
 
+type TileSize = 'sm' | 'wide' | 'tall' | 'big';
+
+const SIZE_PATTERN: TileSize[] = [
+    'wide', 'sm', 'sm', 'tall',
+    'sm', 'big', 'sm', 'wide',
+    'sm', 'sm', 'tall', 'sm',
+    'wide', 'sm', 'sm', 'sm',
+    'tall', 'sm', 'wide', 'sm',
+];
+
+function sizeFor(index: number): TileSize {
+    return SIZE_PATTERN[index % SIZE_PATTERN.length];
+}
+
 function KeyFeaturesHero(): JSX.Element {
     return (
         <section className="kf-next__hero" id="hero">
@@ -95,18 +101,9 @@ function KeyFeaturesHero(): JSX.Element {
 
 function GlossarySection(): JSX.Element {
     const [query, setQuery] = useState('');
-    const [spanById, setSpanById] = useState<Record<string, CardSpan>>({});
     const inputId = useId();
     const liveId = useId();
 
-    useEffect(() => {
-        const next: Record<string, CardSpan> = {};
-        keyFeatureCards.forEach((entry) => {
-            next[entry.id] = randomSpan();
-        });
-        setSpanById(next);
-    }, []);
-
     const filtered = useMemo(
         () =>
             keyFeatureCards.map((entry, index) => ({
@@ -174,43 +171,50 @@ function GlossarySection(): JSX.Element {
                 </div>
 
                 <ul className="kf-next__grid" data-reveal data-reveal-delay="1">
-                    {filtered.map(({ entry, originalIdx, visible }) => (
-                        <li
-                            key={entry.id}
-                            className={[
-                                'kf-next__tile',
-                                themeFor(originalIdx),
-                                `span-${spanById[entry.id] ?? 'm'}`,
-                                visible ? '' : 'is-hidden',
-                            ]
-                                .filter(Boolean)
-                                .join(' ')}
-                            aria-hidden={visible ? 'false' : 'true'}
-                        >
-                            <Link className="kf-next__tile-link" to={entry.href}>
-                                {entry.badge === 'doc' && (
-                                    <span className="kf-next__tile-badge-row">
-                                        <span className="kf-next__tile-badge" aria-label="Formal documentation">
-                                            Doc
+                    {filtered.map(({ entry, originalIdx, visible }) => {
+                        const size = sizeFor(originalIdx);
+                        const showDesc = size === 'tall' || size === 'big';
+                        return (
+                            <li
+                                key={entry.id}
+                                className={[
+                                    'kf-next__tile',
+                                    themeFor(originalIdx),
+                                    `kf-next__tile--${size}`,
+                                    visible ? '' : 'is-hidden',
+                                ]
+                                    .filter(Boolean)
+                                    .join(' ')}
+                                aria-hidden={visible ? 'false' : 'true'}
+                            >
+                                <Link className="kf-next__tile-link" to={entry.href}>
+                                    {entry.badge === 'doc' && (
+                                        <span className="kf-next__tile-badge-row">
+                                            <span className="kf-next__tile-badge" aria-label="Formal documentation">
+                                                Doc
+                                            </span>
                                         </span>
+                                    )}
+                                    <span className="kf-next__tile-top">
+                                        <span className="kf-next__tile-name">{entry.title}</span>
+                                        {showDesc && entry.description && (
+                                            <span className="kf-next__tile-desc">{entry.description}</span>
+                                        )}
                                     </span>
-                                )}
-                                <span className="kf-next__tile-top">
-                                    <span className="kf-next__tile-name">{entry.title}</span>
-                                </span>
-                                <span className="kf-next__tile-meta">
-                                    {entry.tags && entry.tags.length > 0 && (
-                                        <span className="kf-next__tile-tags">
-                                            {entry.tags.slice(0, 2).join(' · ')}
+                                    <span className="kf-next__tile-meta">
+                                        {entry.tags && entry.tags.length > 0 && (
+                                            <span className="kf-next__tile-tags">
+                                                {entry.tags.slice(0, 2).join(' · ')}
+                                            </span>
+                                        )}
+                                        <span className="kf-next__tile-arrow" aria-hidden="true">
+                                            →
                                         </span>
-                                    )}
-                                    <span className="kf-next__tile-arrow" aria-hidden="true">
-                                        →
                                     </span>
-                                </span>
-                            </Link>
-                        </li>
-                    ))}
+                                </Link>
+                            </li>
+                        );
+                    })}
                 </ul>
 
                 {visibleCount === 0 && (
diff --git a/src/theme/SearchBar/SearchBar.module.css b/src/theme/SearchBar/SearchBar.module.css
index fe7e0418568..380e2c05986 100644
--- a/src/theme/SearchBar/SearchBar.module.css
+++ b/src/theme/SearchBar/SearchBar.module.css
@@ -16,6 +16,18 @@
     left: 0 !important;
 }
 
+/*
+ * In the dedicated docs-next search strip, the input fills the full width of
+ * the section, so the navbar-style hardcoded `left: -20%; width: 560px` rule
+ * pulls the dropdown off to the left of the input. Anchor it to the input
+ * instead and let it inherit the input's full width.
+ */
+:global(.docs-next-search-section) .searchBar .dropdownMenu {
+    left: 0 !important;
+    right: 0 !important;
+    width: 100% !important;
+}
+
 @media not (max-width: 996px) {
     .searchBar.searchBarLeft .dropdownMenu {
         right: auto !important;
diff --git a/static/images/next/key-features/columnar-storage.jpg b/static/images/next/key-features/columnar-storage.jpg
new file mode 100644
index 00000000000..6d04add0096
Binary files /dev/null and b/static/images/next/key-features/columnar-storage.jpg differ
diff --git a/static/images/next/key-features/compute-group.jpg b/static/images/next/key-features/compute-group.jpg
new file mode 100644
index 00000000000..59cc6ed6e57
Binary files /dev/null and b/static/images/next/key-features/compute-group.jpg differ
diff --git a/static/images/next/key-features/condition-cache.jpg b/static/images/next/key-features/condition-cache.jpg
new file mode 100644
index 00000000000..96c8899ea73
Binary files /dev/null and b/static/images/next/key-features/condition-cache.jpg differ
diff --git a/static/images/next/key-features/data-cache-page-cache.jpg b/static/images/next/key-features/data-cache-page-cache.jpg
new file mode 100644
index 00000000000..50c0dafe82e
Binary files /dev/null and b/static/images/next/key-features/data-cache-page-cache.jpg differ
diff --git a/static/images/next/key-features/data-compaction.jpg b/static/images/next/key-features/data-compaction.jpg
new file mode 100644
index 00000000000..dbb0035a528
Binary files /dev/null and b/static/images/next/key-features/data-compaction.jpg differ
diff --git a/static/images/next/key-features/data-pruning.jpg b/static/images/next/key-features/data-pruning.jpg
new file mode 100644
index 00000000000..c807c855293
Binary files /dev/null and b/static/images/next/key-features/data-pruning.jpg differ
diff --git a/static/images/next/key-features/data-update-delete.jpg b/static/images/next/key-features/data-update-delete.jpg
new file mode 100644
index 00000000000..8d15fc5a60c
Binary files /dev/null and b/static/images/next/key-features/data-update-delete.jpg differ
diff --git a/static/images/next/key-features/group-commit.jpg b/static/images/next/key-features/group-commit.jpg
new file mode 100644
index 00000000000..1c67f1b0cb9
Binary files /dev/null and b/static/images/next/key-features/group-commit.jpg differ
diff --git a/static/images/next/key-features/high-concurrency-point-query.jpg b/static/images/next/key-features/high-concurrency-point-query.jpg
new file mode 100644
index 00000000000..c363b57c174
Binary files /dev/null and b/static/images/next/key-features/high-concurrency-point-query.jpg differ
diff --git a/static/images/next/key-features/incremental-materialized-view.jpg b/static/images/next/key-features/incremental-materialized-view.jpg
new file mode 100644
index 00000000000..52ec39a02c3
Binary files /dev/null and b/static/images/next/key-features/incremental-materialized-view.jpg differ
diff --git a/static/images/next/key-features/kafka-cdc-integration.jpg b/static/images/next/key-features/kafka-cdc-integration.jpg
new file mode 100644
index 00000000000..954b082dca0
Binary files /dev/null and b/static/images/next/key-features/kafka-cdc-integration.jpg differ
diff --git a/static/images/next/key-features/load-transaction.jpg b/static/images/next/key-features/load-transaction.jpg
new file mode 100644
index 00000000000..0ee41c078be
Binary files /dev/null and b/static/images/next/key-features/load-transaction.jpg differ
diff --git a/static/images/next/key-features/metadata-cache.jpg b/static/images/next/key-features/metadata-cache.jpg
new file mode 100644
index 00000000000..d0d55306792
Binary files /dev/null and b/static/images/next/key-features/metadata-cache.jpg differ
diff --git a/static/images/next/key-features/parquet-reader-optimization.jpg b/static/images/next/key-features/parquet-reader-optimization.jpg
new file mode 100644
index 00000000000..1117897c2de
Binary files /dev/null and b/static/images/next/key-features/parquet-reader-optimization.jpg differ
diff --git a/static/images/next/key-features/pipeline-execution-engine.jpg b/static/images/next/key-features/pipeline-execution-engine.jpg
new file mode 100644
index 00000000000..1712393b780
Binary files /dev/null and b/static/images/next/key-features/pipeline-execution-engine.jpg differ
diff --git a/static/images/next/key-features/preaggregation-and-rollup.jpg b/static/images/next/key-features/preaggregation-and-rollup.jpg
new file mode 100644
index 00000000000..4d01e937658
Binary files /dev/null and b/static/images/next/key-features/preaggregation-and-rollup.jpg differ
diff --git a/static/images/next/key-features/prepared-statement.jpg b/static/images/next/key-features/prepared-statement.jpg
new file mode 100644
index 00000000000..96931450cad
Binary files /dev/null and b/static/images/next/key-features/prepared-statement.jpg differ
diff --git a/static/images/next/key-features/query-cache.jpg b/static/images/next/key-features/query-cache.jpg
new file mode 100644
index 00000000000..623d7657469
Binary files /dev/null and b/static/images/next/key-features/query-cache.jpg differ
diff --git a/static/images/next/key-features/resource-group.jpg b/static/images/next/key-features/resource-group.jpg
new file mode 100644
index 00000000000..c01e0849188
Binary files /dev/null and b/static/images/next/key-features/resource-group.jpg differ
diff --git a/static/images/next/key-features/vectorized-execution.jpg b/static/images/next/key-features/vectorized-execution.jpg
new file mode 100644
index 00000000000..3f95dd3fe1c
Binary files /dev/null and b/static/images/next/key-features/vectorized-execution.jpg differ
diff --git a/static/images/next/key-features/workload-group.jpg b/static/images/next/key-features/workload-group.jpg
new file mode 100644
index 00000000000..9eddc574101
Binary files /dev/null and b/static/images/next/key-features/workload-group.jpg differ


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@doris.apache.org
For additional commands, e-mail: commits-help@doris.apache.org