Data Modeling (P2/3): Denormalization, `$lookup` và thí nghiệm hai mô hình

12 phút đọcSeries: MongoDB: từ gốc đến internals

Ở phần trước: embed giữ dữ liệu liên quan ngay trong một document, còn reference giữ id và đi tra cứu riêng. Chọn bên nào phụ thuộc vào access pattern và số lượng document liên quan.

Cái giá của $lookup

Ý chính

$lookup là một stage trong aggregation pipeline. Với mỗi document đi vào, nó tìm các document khớp ở collection khác và gắn chúng vào thành một mảng mới. Nó giống LEFT OUTER JOIN, nhưng chạy từng document một.

Mental model: đi lấy hàng trong kho

Bạn là nhân viên soạn đơn. Mỗi đơn cần 5 món nằm ở kho bên cạnh.

  • Embed: món hàng đã được đóng sẵn trong thùng của đơn. Bạn nhấc thùng lên là xong.
  • Reference có sổ tra (index): bạn mở sổ tra "đơn 4242 → kệ B3, ô 1–5", đi thẳng đến đó lấy.
  • Reference không có sổ tra: bạn đi dọc mọi kệ trong kho, nhìn từng món xem có ghi "đơn 4242" không. Rồi làm lại y như vậy cho đơn tiếp theo.
for each order đi vào $lookup:
    tìm order_items có orderId = order._id
        có index  → seek thẳng vào vài key
        không có  → quét cả collection
    gắn kết quả vào order.items (mảng)

Tài liệu $lookup nói thẳng: phép so khớp bằng nhau với một điều kiện join chạy tốt hơn khi collection bị join có index trên foreignField. Nếu không có index đó, $lookup "nhiều khả năng có hiệu năng kém". Trang anti-pattern "Reduce $lookup Operations" bổ sung: $lookup hữu ích khi dùng thỉnh thoảng, nhưng có thể chậm và tốn tài nguyên hơn so với thao tác chỉ đụng một collection.

Câu hỏi hay hơn là: chậm hơn bao nhiêu, và vì sao? Hãy đo.

Thí nghiệm: một màn hình, hai mô hình

Setup

MongoDB version : 8.3.11 (image mongo:8), standalone
Hardware        : Docker trên Apple M4, giới hạn 4 CPU, 4 GB RAM
Configuration   : WiredTiger cache 1 GB
Database        : lab03
Client          : mongosh chạy trong cùng container (không có network thật)

Toàn bộ dữ liệu và index (khoảng 125 MB chưa nén) nằm gọn trong cache 1 GB sau lần đọc đầu tiên. Nghĩa là đây là phép đo khi dữ liệu đã nằm sẵn trong cache (warm cache). Khi dữ liệu không vừa cache, mỗi document phải đọc thêm có thể đồng nghĩa với một lần đọc đĩa, và khoảng cách giữa hai mô hình thường lớn hơn. Tôi không đo trường hợp đó ở đây.

Dataset

50 tenant, 10.000 khách, 2.000 sản phẩm, 100.000 đơn. Mỗi đơn có 1–9 dòng hàng (phân bố đều, trung bình 5). Cùng một dữ liệu được ghi theo hai mô hình:

  • A (embed): orders_emb. Mỗi đơn là một document chứa items[], một bản chép customer {_id, name, phone} và total tính sẵn.
  • B (reference): orders_ref (header đơn có customerId) cộng order_items (mỗi dòng hàng một document có orderId) cộng customers.

Script tạo dữ liệu dùng PRNG có seed cố định, nên chạy lại sẽ ra đúng bộ dữ liệu này:

// seed.js — chạy: docker exec -i mongo-lab mongosh --quiet --eval "$(cat seed.js)"
db = db.getSiblingDB("lab03");
db.dropDatabase();

let seed = 42;                       // mulberry32: PRNG có seed cố định
function rnd() {
  seed = (seed + 0x6D2B79F5) | 0;
  let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
const pick = n => Math.floor(rnd() * n);
const ho = ["Nguyễn", "Trần", "Lê", "Phạm", "Hoàng", "Võ", "Đặng", "Bùi"];
const ten = ["An", "Bình", "Chi", "Dũng", "Hà", "Khoa", "Linh", "Minh", "Nam", "Thảo"];

const customers = [];
for (let c = 1; c <= 10000; c++) customers.push({
  _id: c, tenantId: "t" + String(c % 50).padStart(2, "0"),
  name: ho[pick(8)] + " " + ten[pick(10)], email: "user" + c + "@example.com",
  phone: "09" + String(10000000 + c), address: { street: c + " Lê Lợi", city: "TP.HCM" }
});
db.customers.insertMany(customers);

const products = [];
for (let p = 1; p <= 2000; p++) products.push({ _id: p, name: "Sản phẩm " + p, price: (1 + pick(50)) * 10000 });

const t0 = new Date("2026-01-01T00:00:00Z").getTime();
let emb = [], ref = [], items = [];
for (let o = 1; o <= 100000; o++) {
  const cust = customers[pick(10000)], lines = [];
  for (let i = 0, n = 1 + pick(9); i < n; i++) {
    const p = products[pick(2000)];
    lines.push({ productId: p._id, name: p.name, qty: 1 + pick(3), price: p.price });
  }
  const total = lines.reduce((s, l) => s + l.qty * l.price, 0);
  const createdAt = new Date(t0 + o * 60000), status = ["PAID", "SHIPPED", "DELIVERED"][pick(3)];
  emb.push({ _id: o, tenantId: cust.tenantId,
             customer: { _id: cust._id, name: cust.name, phone: cust.phone },
             status, createdAt, items: lines, total });
  ref.push({ _id: o, tenantId: cust.tenantId, customerId: cust._id, status, createdAt });
  for (const l of lines) items.push({ orderId: o, tenantId: cust.tenantId, ...l });
  if (emb.length === 5000) {
    db.orders_emb.insertMany(emb); db.orders_ref.insertMany(ref); db.order_items.insertMany(items);
    emb = []; ref = []; items = [];
  }
}

Kết quả sau khi seed (db.collection.stats()):

customers    count:   10000  avgObjSize:   164 B  size: 1.6 MB
orders_emb   count:  100000  avgObjSize:   511 B  size: 48.8 MB
orders_ref   count:  100000  avgObjSize:    86 B  size: 8.3 MB
order_items  count:  497736  avgObjSize:   115 B  size: 54.8 MB

Phân bố thực tế: mỗi khách có 1–26 đơn (trung bình 10), mỗi tenant có 1.888–2.101 đơn.

Experiment 1: xem chi tiết đơn 4242

Mọi query đều mang tenantId, như một ứng dụng multi-tenant thật phải làm, để tenant này không bao giờ đọc được đơn của tenant khác.

// A: embed
db.orders_emb.findOne({ _id: 4242, tenantId: "t43" })

// B: reference + 2 lần $lookup
db.orders_ref.aggregate([
  { $match: { _id: 4242, tenantId: "t43" } },
  { $lookup: { from: "order_items", localField: "_id",        foreignField: "orderId", as: "items" } },
  { $lookup: { from: "customers",   localField: "customerId", foreignField: "_id",     as: "customer" } }
])

Cả hai đều trả về cùng một đơn với 7 dòng hàng. Xem explain("executionStats") (đã rút gọn: in cây stage và các con số chính):

--- A. embed: find
FETCH
  IXSCAN _id_
{ nReturned: 1, totalKeysExamined: 1, totalDocsExamined: 1, executionTimeMillis: 0 }
--- B. reference: aggregate + 2 x $lookup   (order_items CHƯA có index trên orderId)
EQ_LOOKUP from=customers strategy=IndexedLoopJoin index=_id_
  EQ_LOOKUP from=order_items strategy=NestedLoopJoin
    FETCH
      IXSCAN _id_
{
  nReturned: 1,
  totalKeysExamined: 2,
  totalDocsExamined: 497738,
  executionTimeMillis: 65,
  collectionScans: 1,
  indexesUsed: [ '_id_', '_id_' ]
}

Đọc từ dưới lên:

  • IXSCAN _id_ → FETCH: tìm đơn 4242 qua index _id rồi lấy document ra. tenantId được kiểm tra sau khi fetch, vì đơn này chỉ có index _id. Index ghép {tenantId, ...} là chuyện của bài Compound Indexes & ESR.
  • EQ_LOOKUP from=order_items: không có index trên order_items.orderId, nên server quét toàn bộ 497.736 dòng hàng để tìm 7 dòng của đơn này (collectionScans: 1). Đó là lý do totalDocsExamined = 497.738 (1 đơn + 497.736 dòng hàng + 1 khách).
  • EQ_LOOKUP from=customers ... index=_id_: join sang customers theo _id thì luôn có index, nên chỉ mất 1 key và 1 document.

EQ_LOOKUP là cách explain trên bản 8.3.11 hiển thị $lookup, và trường strategy cho biết server chọn cách join nào. Bài này chỉ cần đọc một điều từ đó: phía bị join có index hay bị quét. Các strategy là gì và server chọn chúng ra sao để dành cho bài $lookup & Joins; vì sao stage có tên EQ_LOOKUP (slot-based execution engine) là chuyện của bài Query Execution Engine.

Bây giờ thêm index trên foreignField. Bài Index Fundamentals sẽ giải thích index hoạt động thế nào. Ở đây chỉ cần biết nó là "sổ tra" trong ví dụ cái kho:

db.order_items.createIndex({ orderId: 1 })
--- B. reference: aggregate + 2 x $lookup   (order_items CÓ index orderId_1)
EQ_LOOKUP from=customers strategy=IndexedLoopJoin index=_id_
  EQ_LOOKUP from=order_items strategy=IndexedLoopJoin index=orderId_1
    FETCH
      IXSCAN _id_
{
  nReturned: 1,
  totalKeysExamined: 9,
  totalDocsExamined: 9,
  executionTimeMillis: 1,
  collectionScans: 0,
  indexesUsed: [ '_id_', 'orderId_1', '_id_' ]
}
BEFORE (không index)                  AFTER (có index orderId_1)        EMBED

$match đơn 4242                       $match đơn 4242                   IXSCAN _id_
   ↓                                     ↓                                 ↓
quét 497.736 order_items              seek 7 key orderId                1 document
   ↓                                     ↓                                 ↓
7 dòng khớp                           7 document                        xong
   ↓                                     ↓
seek customers _id                    seek customers _id
   ↓                                     ↓
docsExamined = 497.738                docsExamined = 9                  docsExamined = 1

Đo thời gian qua nhiều lần chạy

Một lần explain không đủ để kết luận. Tôi chạy cùng thao tác trên 2.000 đơn khác nhau (cố định, rải đều trên 100.000 đơn), chạy warm-up cache trước, và đo từng lần gọi bằng performance.now() trong mongosh:

function bench(label, fn, n) {
  for (let i = 0; i < Math.min(n, 50); i++) fn(ids[i], tenantOf[ids[i]]);   // warm-up
  const t = [];
  for (let i = 0; i < n; i++) {
    const a = performance.now(); fn(ids[i], tenantOf[ids[i]]); t.push(performance.now() - a);
  }
  t.sort((x, y) => x - y);
  // in p50, p95, avg
}

Container được chạy chung với lab của các bài khác. Tôi chỉ đo khi CPU của container đã về dưới 30% trong 3 lần kiểm tra liên tiếp, và chạy hai vòng; mỗi vòng đo mô hình A và B có index hai lần. Trường hợp không index chỉ chạy 30 đơn vì mỗi lần mất hàng chục ms. Kết quả thật:

=== round 1
B. reference + $lookup (no index)  n=30    p50=76.98ms  p95=99.53ms  avg=79.50ms
A. embed (findOne theo _id)        n=2000  p50=0.18ms   p95=0.78ms   avg=0.30ms
B. reference + $lookup (index)     n=2000  p50=0.32ms   p95=0.52ms   avg=0.35ms
A. embed (findOne theo _id)        n=2000  p50=0.15ms   p95=0.35ms   avg=0.19ms
B. reference + $lookup (index)     n=2000  p50=0.29ms   p95=0.36ms   avg=0.30ms
=== round 2
B. reference + $lookup (no index)  n=30    p50=72.23ms  p95=78.87ms  avg=72.58ms
A. embed (findOne theo _id)        n=2000  p50=0.15ms   p95=0.23ms   avg=0.16ms
B. reference + $lookup (index)     n=2000  p50=0.29ms   p95=0.37ms   avg=0.30ms
A. embed (findOne theo _id)        n=2000  p50=0.15ms   p95=0.27ms   avg=0.17ms
B. reference + $lookup (index)     n=2000  p50=0.28ms   p95=0.34ms   avg=0.29ms

Thời gian này bao gồm cả phần mongosh dựng lệnh và nhận kết quả, không chỉ thời gian server. Hãy so sánh tương đối giữa các dòng, đừng xem đó là con số latency production.

Experiment 2: đọc 1.000 đơn một lượt

Màn hình danh sách, export hay job đồng bộ thường đọc nhiều đơn cùng lúc. Ở đây là 1.000 đơn qua $in trên _id, đều có index:

A embed     keys=2000 docs=1000 nReturned=1000
B reference keys=7024 docs=7024 nReturned=1000

(các vòng chạy, p50 của 20 lần mỗi vòng)
A embed      p50=6.5ms   9.2ms   7.6ms   6.1ms
B reference  p50=15.7ms  16.1ms  15.5ms  16.6ms

Hãy nhìn docsExamined. Mô hình B phải đụng 7.024 document (1.000 đơn + 5.024 dòng hàng + 1.000 khách) để trả về 1.000 kết quả. Mô hình A đụng đúng 1.000. Con số keys=2000 của A đến từ cách engine đếm key khi nhảy giữa các giá trị trong $in. Bài Index Fundamentals sẽ nói kỹ hơn. Ở đây, docsExamined là con số phản ánh công việc thật.

Interpretation

Ba điều rút ra, theo thứ tự quan trọng:

  1. Thiếu index ở foreignField mới là thảm họa, không phải bản thân $lookup. Không có index: khoảng 75 ms mỗi đơn, quét gần 500.000 document. Có index: khoảng 0,3 ms. Cùng một mô hình, khác nhau hơn 200 lần (đo trên lab này). Quét gần nửa triệu document cho mỗi đơn sẽ đốt CPU và cache theo số request, nên ở production nó không chỉ chậm một query mà kéo cả hệ thống xuống.
  2. Có index rồi, $lookup vẫn có giá, nhưng không khủng khiếp. Đọc một đơn: p50 khoảng 0,15–0,18 ms (embed) so với 0,28–0,32 ms (lookup), tức chậm hơn khoảng 2 lần. Đọc 1.000 đơn: khoảng 6–9 ms so với 15–17 ms. Nguồn gốc nằm ở số document phải đụng: 1 so với 9, hay 1.000 so với 7.024.
  3. Con số này là khi mọi thứ nằm trong cache. Khi working set lớn hơn RAM, mỗi document thêm có thể là một lần đọc đĩa. Có thể "9 document rải ở 3 collection" sẽ đắt hơn "1 document" nhiều hơn tỉ lệ 2 lần ở trên, nhưng đây vẫn chỉ là suy luận, chưa được xác nhận: bài về cache đo thử và thấy tỉ lệ dao động 1,6–2,7 lần, khi tăng khi giảm. Chỉ số page là chắc chắn: 3,3 lần page được yêu cầu, 2,2 lần miss.

Dung lượng: copy dữ liệu có làm database to ra?

Trực giác bảo embed thì trùng lặp, nên tốn chỗ hơn. Số đo trên dataset này nói ngược lại:

orders_emb   docs=100000  data=48.8MB onDisk=13.7MB  indexes: _id_ 1.04MB
orders_ref   docs=100000  data=8.3MB  onDisk=2.5MB   indexes: _id_ 1.04MB
order_items  docs=497736  data=54.8MB onDisk=14.1MB  indexes: _id_ 5.04MB, orderId_1 2.30MB
Mô hình A (embed)Mô hình B (reference)
Dữ liệu chưa nén48,8 MB63,1 MB (8,3 + 54,8)
Trên đĩa (đã nén)13,7 MB16,6 MB (2,5 + 14,1)
Index cần cho màn chi tiết đơn1,04 MB8,38 MB (1,04 + 5,04 + 2,30)

Lý do: ở mô hình B, mỗi dòng hàng là một document riêng. Mỗi document đó mang thêm _id ObjectId của riêng nó, lặp lại orderId và tenantId, và cần thêm hai entry index. Gần 500.000 lần như vậy cộng lại còn nhiều hơn phần mô hình A chép tên và SĐT khách. Đừng tổng quát hóa thành "embed luôn nhỏ hơn". Nếu bạn chép một object lớn vào hàng nghìn document, kết quả sẽ ngược lại. Nhưng nó cho thấy "trùng lặp" và "tốn chỗ" không phải là một.

Experiment 3: cái giá của denormalization khi khách đổi tên

Ở mô hình A, tên khách nằm trong mọi đơn của khách đó. Đổi tên một khách (khách 777 có 14 đơn) trông như sau:

// B: sửa 1 chỗ
db.customers.updateOne({ _id: 777 }, { $set: { name: "Trần Thị Mai" } })

// A: sửa mọi bản chép
db.orders_emb.updateMany({ "customer._id": 777 }, { $set: { "customer.name": "Trần Thị Mai" } })

explain của lệnh update (qua db.runCommand({ explain: { update: ... }, verbosity: "executionStats" })):

B customers.updateOne          matched=1 modified=1
A no index:   plan=UPDATE<-COLLSCAN keysExamined=0  docsExamined=100000 nWouldModify=14
A with index: plan=UPDATE<-FETCH    keysExamined=14 docsExamined=14     nWouldModify=14

Đo trên 20 khách khác nhau cho mỗi trường hợp (vòng chạy khi container yên):

B customers.updateOne (1 chỗ)            docs modified/lượt≈1.0   p50=0.32ms   max=6.23ms
A orders_emb.updateMany, không index     docs modified/lượt≈10.2  p50=17.65ms  max=49.63ms
A orders_emb.updateMany, có index        docs modified/lượt≈9.9   p50=0.32ms   max=1.23ms

Có index { "customer._id": 1 } thì sửa khoảng 10 bản chép nhanh ngang sửa 1 document. Phần đắt là đi tìm bản chép khi không có index: quét 100.000 đơn để sửa 10 đơn. Nhưng index đó không miễn phí: thêm 0,57 MB, và mỗi lần tạo đơn phải ghi thêm một entry index. Còn nếu một "khách" là một doanh nghiệp B2B có 50.000 đơn, mỗi lần đổi tên sẽ là 50.000 lần ghi document, và mỗi lần ghi đó còn phải được replicate (bài Replica Set & Oplog). Con số 50.000 ở đây là minh họa, không đo.

Conclusion

Đọc theo đơn là thao tác chủ đạo, nên mô hình A đọc ít document hơn, ghi đơn atomic, và trên dataset này còn nhỏ hơn. Cái giá của nó là phải đồng bộ bản chép tên khách, và cái giá đó chấp nhận được vì khách hiếm khi đổi tên. Nếu đảo ngược tần suất, ví dụ field được copy đổi mỗi phút, kết luận cũng đảo theo.

Cột mốc: Bạn đã có thể nêu cái giá của $lookup, đọc kết quả thí nghiệm hai mô hình, và biết vì sao số đo khi dữ liệu nằm trong cache chưa đủ. Tiếp theo: So với PostgreSQL và lỗi thường gặp.

Hỏi & đáp

Mô hình B, order_items chưa có index trên orderId. Chạy $match đơn 4242 rồi $lookup sang order_items và customers. explain cho totalDocsExamined khoảng bao nhiêu?

  1. 9: một đơn, bảy dòng hàng và một khách

    Đó là con số sau khi tạo index orderId_1. Không có index, server không có cách nào đi thẳng tới 7 dòng hàng của đơn này. Xem mục "Experiment 1: xem chi tiết đơn 4242".

  2. 1, vì $match theo _id đã dùng index rồi

    Index _id chỉ giúp tìm đơn 4242 trong orders_ref. Phía bị join (order_items) cần index riêng trên foreignField; index ở collection gốc không giúp gì cho nó. Xem mục "Experiment 1: xem chi tiết đơn 4242".

  3. 497.738: quét hết order_items tìm 7 dòng

    Không có index trên orderId, EQ_LOOKUP sang order_items quét cả 497.736 dòng hàng (collectionScans: 1). Cộng 1 đơn và 1 khách thành 497.738, mất 65 ms trong explain. Xem mục "Experiment 1: xem chi tiết đơn 4242".

Mô hình A chép name của khách vào mọi đơn. Khi khách đổi tên, updateMany({ "customer._id": 777 }, …) chạy p50 khoảng 17,65 ms vì quét 100.000 đơn để sửa khoảng 10 đơn. Cách sửa hợp lý nhất?

  1. Tạo index { "customer._id": 1 }, đổi lấy dung lượng và công ghi

    Phần đắt là đi tìm bản chép, không phải sửa chúng. Có index, p50 còn khoảng 0,32 ms, ngang sửa 1 document. Giá phải trả: thêm 0,57 MB và một entry index mỗi lần tạo đơn. Xem mục "Experiment 3: cái giá của denormalization khi khách đổi tên".

  2. Bỏ embed, chuyển hẳn sang mô hình B để đổi tên chỉ sửa một chỗ

    Đổi tên là thao tác hiếm (vài lần/ngày), còn xem chi tiết đơn chiếm gần hết tải. Đổi cả mô hình vì thao tác hiếm là để tần suất thấp quyết định thiết kế. Xem mục "Conclusion" của thí nghiệm và bảng access pattern ở phần Access pattern, embed/reference và cardinality.

  3. Bọc updateMany trong transaction để lệnh nhanh và atomic hơn

    Transaction không làm việc tìm bản chép nhanh hơn: không có index thì vẫn quét 100.000 đơn. Bài nhắc transaction thường tốn hơn ghi một document. Thứ cần là index trên field dùng để tìm bản chép. Xem phần Denormalization và hai cái bẫy khi đồng bộ bản chép.

"Embed thì dữ liệu bị chép nhiều chỗ, nên luôn tốn dung lượng hơn reference." Lab của bài cho thấy gì?

  1. Đúng: mô hình A lớn hơn vì mọi đơn đều chép tên và SĐT khách

    Số đo nói ngược lại: dữ liệu chưa nén 48,8 MB (A) so với 63,1 MB (B), trên đĩa 13,7 MB so với 16,6 MB. Phần chép tên và SĐT nhỏ hơn phần overhead của gần 500.000 document dòng hàng. Xem mục "Dung lượng: copy dữ liệu có làm database to ra?".

  2. Hai mô hình bằng nhau sau khi nén, nên dung lượng không phải yếu tố

    Sau nén vẫn khác: 13,7 MB so với 16,6 MB, và index cần cho màn chi tiết đơn là 1,04 MB so với 8,38 MB. Xem mục "Dung lượng: copy dữ liệu có làm database to ra?".

  3. Embed luôn nhỏ hơn, vì reference phải lặp _id và khóa ở mỗi document

    Bài cảnh báo đừng tổng quát hóa như vậy: nếu chép một object lớn vào hàng nghìn document, kết quả sẽ ngược lại. Xem mục "Dung lượng: copy dữ liệu có làm database to ra?".

  4. Ở dataset này embed nhỏ hơn, nhưng không được tổng quát hóa

    Ở mô hình B, mỗi dòng hàng là một document mang _id riêng, lặp orderId và tenantId, và cần thêm hai entry index; gần 500.000 lần như vậy nhiều hơn phần A chép tên và SĐT. Nhưng chép object lớn vào nhiều document thì sẽ ngược lại. "Trùng lặp" và "tốn chỗ" không phải là một. Xem mục "Dung lượng: copy dữ liệu có làm database to ra?".

Đã có index orderId_1, dữ liệu nằm trọn trong cache. Đọc một đơn bằng $lookup (mô hình B) so với findOne (mô hình A) thế nào trên lab?

  1. Chậm hơn khoảng 2 lần, vì đụng 9 document thay vì 1

    p50 khoảng 0,28–0,32 ms (lookup) so với 0,15–0,18 ms (embed). Đọc 1.000 đơn: khoảng 15–17 ms so với 6–9 ms, vì B đụng 7.024 document còn A đụng 1.000. Xem mục "Interpretation".

  2. Ngang nhau: có index rồi thì $lookup không còn tốn gì

    Index xóa phần thảm họa (quét cả collection), nhưng mỗi document thêm vẫn là việc thêm: 9 document ở 3 collection so với 1. Xem mục "Đo thời gian qua nhiều lần chạy" và "Interpretation".

  3. Vẫn chậm hơn hơn 200 lần, như khi chưa có index

    Hơn 200 lần là khoảng cách giữa không index (khoảng 75 ms) và có index (khoảng 0,3 ms) của cùng mô hình B. So với embed, B có index chỉ chậm khoảng 2 lần. Xem mục "Interpretation".