Tích vô hướng, độ dài và cosine similarity
Xem thửMinh được giao làm tính năng gửi ưu đãi của Giao Hàng Anh Tí. Anh có ba khách mẫu, đếm số đơn mỗi tuần theo ba loại hàng (đồ ăn, giấy tờ, tạp hóa): An đặt (2, 1, 3), Bình đặt (4, 2, 6), còn Lan đặt (3, 4, 0). Anh muốn gửi cùng một gói ưu đãi cho những khách "có thói quen giống An".
Đo bằng khoảng cách Euclid như bài trước, An cách Bình 3,742 và cách Lan 4,359: hai con số khá gần nhau, nên máy coi Bình và Lan giống An gần như ngang nhau. Nhưng nhìn kỹ, Bình đặt đúng gấp đôi An ở mọi loại hàng: cùng thói quen, chỉ nhiều đơn hơn. Còn Lan không đặt tạp hóa, loại hàng An đặt nhiều nhất.
Hãy dự đoán trước khi đọc tiếp: có cách đo nào cho Bình điểm giống An tuyệt đối, bất kể Bình đặt nhiều gấp mấy lần? Và cách đo đó liên quan gì tới việc một chatbot tìm đúng đoạn tài liệu để trả lời?
Sau bài này, bạn có thể
- Tính tay và bằng NumPy tích vô hướng, độ dài vector và cosine similarity, giải thích từng ký hiệu trong công thức.
- Chọn giữa khoảng cách Euclid, tích vô hướng và cosine cho một bài toán so sánh cụ thể.
- Tìm các đơn giống một đơn cho trước trong 50.000 đơn bằng một phép nhân ma trận, và đo được nó nhanh hơn vòng lặp Python bao nhiêu lần.
- Giải thích vì sao tìm kiếm ngữ nghĩa trong RAG dùng cosine trên embedding.
Cần biết trước: bài Vector: biến một đơn hàng và một câu thành dãy số (bài trước), đặc biệt phần chuẩn hóa.
Mức độ: Cơ bản · Thời gian: khoảng 20 phút · Từ khóa: tích vô hướng, cosine similarity, embedding, vector search
Tích vô hướng: nhân từng chiều rồi cộng
Tích vô hướng (dot product) của hai vector cùng số chiều là: nhân từng cặp chiều tương ứng, rồi cộng tất cả lại.
a · b = a1×b1 + a2×b2 + ... + an×bn
Với An = (2, 1, 3) và Lan = (3, 4, 0): 2×3 + 1×4 + 3×0 = 6 + 4 + 0 = 10. Mỗi tích nhỏ trả lời câu hỏi "ở chiều này hai người có cùng mạnh không". Chiều đồ ăn góp 6 vì cả hai cùng đặt; chiều tạp hóa góp 0 vì Lan không đặt.
Với An và Bình: 2×4 + 1×2 + 3×6 = 8 + 2 + 18 = 28. Con số lớn hơn, nhưng một phần chỉ vì Bình đặt nhiều đơn. Tích vô hướng trộn lẫn hai thứ: hai vector cùng hướng tới đâu, và mỗi vector dài tới đâu.
Độ dài vector: tách phần "bao nhiêu" ra khỏi phần "theo hướng nào"
Độ dài (norm, còn gọi là chuẩn L2) của vector là khoảng cách từ gốc tọa độ tới điểm đó:
‖a‖ = căn bậc hai của (a1² + a2² + ... + an²)
Độ dài của An là căn của 4 + 1 + 9 = 14, khoảng 3,742. Của Bình là căn của 16 + 4 + 36 = 56, khoảng 7,483, đúng gấp đôi. Của Lan là căn của 9 + 16 + 0 = 25, đúng bằng 5.
Mô hình tư duy của bài: giống nhau là cùng hướng. Một vector có hai phần thông tin: độ dài (khách đặt nhiều hay ít) và hướng (tỉ lệ giữa các loại hàng). Khi ta chỉ quan tâm tới thói quen, ta muốn bỏ độ dài đi và chỉ so hướng.
Cosine similarity: tích vô hướng sau khi bỏ độ dài
Cosine similarity chia tích vô hướng cho tích hai độ dài:
cos(a, b) = (a · b) / (‖a‖ × ‖b‖)
Đọc từng phần: tử số a · b là tích vô hướng; mẫu số là độ dài của a nhân độ dài của b. Chia như vậy tương đương với việc thu mỗi vector về độ dài 1 trước khi nhân. Kết quả là cosine của góc giữa hai vector, luôn nằm trong khoảng từ -1 đến 1: bằng 1 khi cùng hướng, bằng 0 khi vuông góc (không có chiều mạnh chung), bằng -1 khi ngược hướng.
Ví dụ số: cos(An, Lan) = 10 / (3,742 × 5) = 0,535, còn cos(An, Bình) = 28 / (3,742 × 7,483) = 1,000. Câu trả lời cho câu hỏi mở bài là cosine: Bình được điểm tuyệt đối vì cùng tỉ lệ ba loại hàng với An.
Animation dưới đây chạy từng phép tính cho cặp An và Lan; bấm nút biến thể để đổi sang cặp An và Bình rồi so cột cosine với cột Euclid.
Đang tải Animation…
Hai chế độ của animation cho thấy một điều quan trọng: với An và Bình, cosine bằng 1,000 nhưng khoảng cách Euclid vẫn là 3,742. Hai phép đo trả lời hai câu hỏi khác nhau, nên chọn phép đo là một quyết định thiết kế, không phải thói quen.
| Phép đo | Trả lời câu hỏi | Dùng khi | Ví dụ ở Giao Hàng Anh Tí |
|---|---|---|---|
| Khoảng cách Euclid | Hai điểm cách nhau bao xa, tính cả độ lớn | Độ lớn có ý nghĩa | Đơn 1,5 km và đơn 22 km phải khác nhau |
| Tích vô hướng | Cùng hướng và cùng lớn tới đâu | Vector đã được chuẩn hóa độ dài 1, hoặc độ dài có nghĩa là "mức độ" | Điểm số trong một lớp mạng nơ-ron (bài sau) |
| Cosine | Cùng hướng tới đâu, bỏ qua độ lớn | Chỉ tỉ lệ, thói quen, ý nghĩa quan trọng | Khách có cùng thói quen, câu có cùng ý |
Khi mọi vector đã được chia cho độ dài của nó (độ dài bằng 1), tích vô hướng và cosine cho cùng một con số. Đó là lý do nhiều hệ tìm kiếm vector chuẩn hóa vector một lần khi lưu, rồi chỉ cần tính tích vô hướng khi truy vấn.
Từ cosine tới tìm kiếm ngữ nghĩa trong RAG
Ở mô-đun 03, bạn sẽ xây trợ lý tri thức trả lời câu hỏi của nhân viên dựa trên tài liệu nội bộ của Giao Hàng Anh Tí. Kỹ thuật đó gọi là RAG (Retrieval-Augmented Generation, sinh câu trả lời có truy xuất tài liệu). Bước truy xuất của nó chính là bài toán của bài này: tìm những vector gần một vector truy vấn nhất theo cosine. Sơ đồ sau cho thấy cosine nằm ở đâu trong luồng đó.
Đang tải sơ đồ…
Cách đọc hình: có hai đường vào. Bên trái, câu hỏi của người dùng (ô cyan) đi qua embedding model (ô tím) để thành một vector truy vấn. Bên phải, các đoạn tài liệu đã được biến thành vector từ trước và xếp thành một ma trận n hàng. Hai đường gặp nhau ở ô cam: tính cosine giữa vector truy vấn và từng hàng của ma trận. Những đoạn có cosine cao nhất (top-k) được gửi cho mô hình ngôn ngữ. Phần bài này dạy là ô cam; ô tím là chủ đề của mô-đun 02 và 03.
Embedding (embedding) là vector do một mô hình học ra, sao cho hai câu cùng nghĩa có vector cùng hướng. Điều này quan trọng vì cosine chỉ tốt bằng vector đưa vào nó. Với vector đếm từ của bài trước, hai câu cùng nghĩa nhưng khác từ sẽ có cosine thấp. Animation sau so sánh hai loại vector trên cùng một truy vấn "giao gap". Vector ý nghĩa ở chế độ biến thể do ta đặt tay để minh họa, không phải đầu ra của một mô hình.
Đang tải Animation…
Với vector đếm từ, "can giao nhanh" chỉ thắng "goi truoc khi giao" 0,054 điểm, và lý do thắng là câu ngắn hơn chứ không phải cùng nghĩa. Với vector mang ý nghĩa, cùng công thức cosine tách hai ghi chú ra rất xa (0,986 so với 0,110). Công thức không đổi; thứ thay đổi là vector.
Làm cùng mình
Bước 1: tính tay bằng NumPy cho An, Bình, Lan.
import numpy as np
an = np.array([2.0, 1.0, 3.0]) # đơn mỗi tuần: [food, documents, groceries]
binh = np.array([4.0, 2.0, 6.0])
lan = np.array([3.0, 4.0, 0.0])
def cosine(a: np.ndarray, b: np.ndarray) -> float:
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
for name, other in [("Binh", binh), ("Lan", lan)]:
print(f"An vs {name}:")
print(f" dot = {an @ other:.0f}")
print(f" |An| = {np.linalg.norm(an):.3f} |{name}| = {np.linalg.norm(other):.3f}")
print(f" cosine = {cosine(an, other):.3f}")
print(f" euclidean = {np.linalg.norm(an - other):.3f}")
| Dòng code | Máy làm gì | Vì sao |
|---|---|---|
a @ b | Tích vô hướng của hai vector một chiều | Toán tử @ là phép nhân ma trận; với hai vector nó ra một số |
np.linalg.norm(a) | Độ dài L2 của vector | Căn của tổng bình phương |
a @ b / (norm(a) * norm(b)) | Cosine | Đúng công thức ở trên |
np.linalg.norm(an - other) | Khoảng cách Euclid | Để so với cosine |
Kết quả [Đã chạy] Python 3.12, NumPy 2.5, pandas 3.1, CPU x86_64, ngày 04/11/2026:
An vs Binh:
dot = 28
|An| = 3.742 |Binh| = 7.483
cosine = 1.000
euclidean = 3.742
An vs Lan:
dot = 10
|An| = 3.742 |Lan| = 5.000
cosine = 0.535
euclidean = 4.359
Bước 2: tìm đơn giống AT027075 trong 50.000 đơn, và một tình huống thất bại.
def cosine_to_all(M: np.ndarray, i: int) -> np.ndarray:
# Chia mỗi hàng cho độ dài của nó, rồi một phép nhân ma trận cho mọi đơn
unit = M / np.linalg.norm(M, axis=1, keepdims=True)
return unit @ unit[i]
for name, M in [("raw", X), ("standardized", Z)]:
sims = cosine_to_all(M, 0)
sims[0] = -np.inf
top = np.argsort(-sims)[:3]
print(f"{name}: orders with cosine > 0.99 to AT027075: {(sims > 0.99).mean():.1%}")
print(orders.loc[top, ["order_id"] + FEATURES].assign(cosine=sims[top].round(4)).to_string(index=False))
| Dòng code | Máy làm gì | Vì sao |
|---|---|---|
np.linalg.norm(M, axis=1, keepdims=True) | Độ dài của từng hàng, shape (50000, 1) | keepdims giữ trục để chia được cho cả bảng |
unit = M / ... | Đưa mọi đơn về độ dài 1 | Sau bước này, tích vô hướng chính là cosine |
unit @ unit[i] | 50.000 tích vô hướng trong một lệnh | (50000, 4) @ (4,) ra (50000,) |
sims[0] = -np.inf | Loại chính đơn truy vấn | Đơn nào cũng giống chính nó tuyệt đối |
np.argsort(-sims)[:3] | Ba chỉ số có điểm cao nhất | Dấu trừ để xếp giảm dần |
Kết quả [Đã chạy] Python 3.12, NumPy 2.5, pandas 3.1, CPU x86_64, ngày 04/11/2026:
raw: orders with cosine > 0.99 to AT027075: 100.0%
order_id distance_km items weight_kg order_value_vnd cosine
AT025164 1.75 6 5.06 541000 1.0
AT023724 0.84 3 2.46 266000 1.0
AT013207 3.06 10 8.15 928000 1.0
standardized: orders with cosine > 0.99 to AT027075: 0.1%
order_id distance_km items weight_kg order_value_vnd cosine
AT002731 1.0 5 3.99 418000 0.9991
AT037109 1.2 5 4.15 406000 0.9982
AT027621 0.3 5 4.16 429000 0.9978
Đây là tình huống thất bại đáng nhớ. Trên số thô, 100% số đơn có cosine lớn hơn 0,99 với AT027075. Lý do: cột tiền lớn đến mức mọi vector gần như chỉ hướng theo trục tiền, nên mọi đơn đều "cùng hướng". Ba đơn top đầu có từ 3 tới 10 món, giá từ 266.000 tới 928.000 đồng, và đều được điểm 1,0. Cosine không bảo vệ bạn khỏi vấn đề đơn vị; nó chỉ bỏ qua độ dài của cả vector, không bỏ qua chênh lệch thang đo giữa các chiều.
Sau chuẩn hóa, chỉ 34 đơn (khoảng 0,1%) có cosine trên 0,99, và ba đơn top đầu đều có 5 món, giao dưới 1,3 km, giá 406.000 tới 429.000 đồng. Lưu ý: với dữ liệu đã chuẩn hóa, cosine so sánh kiểu lệch khỏi trung bình. AT027621 giao 0,3 km vẫn được 0,9978 vì cũng "ngắn hơn trung bình, nhiều món hơn trung bình" giống AT027075.
Bước 3: đo vòng lặp Python với NumPy.
def cosine_loop() -> list[float]:
nq = math.sqrt(sum(v * v for v in query))
out = []
for row in rows:
dot = sum(a * b for a, b in zip(query, row))
out.append(dot / (nq * math.sqrt(sum(v * v for v in row))))
return out
def cosine_numpy() -> np.ndarray:
return Z @ Z[0] / (np.linalg.norm(Z, axis=1) * np.linalg.norm(Z[0]))
def best_ms(fn, repeat: int = 5) -> float:
times = []
for _ in range(repeat):
t0 = time.perf_counter()
fn()
times.append(time.perf_counter() - t0)
return min(times) * 1000
Kết quả [Đã chạy] Python 3.12, NumPy 2.5, pandas 3.1, CPU x86_64, ngày 04/11/2026 (thời gian phụ thuộc máy, chỉ tỉ lệ là đáng so):
vectors: 50000 x 4
python loop : 84.46 ms
numpy : 0.65 ms
speedup : 129.1x same results: True
Cùng 50.000 cosine, vòng lặp Python mất khoảng 84 ms, còn NumPy chưa tới 1 ms, nhanh hơn khoảng 129 lần trên máy chạy bài, và same results: True xác nhận hai cách cho cùng kết quả. NumPy nhanh vì nó chạy vòng lặp bằng code biên dịch trên một khối bộ nhớ liền, không phải tạo một đối tượng Python cho mỗi phép nhân. Với một triệu đoạn tài liệu ở mô-đun 03, khác biệt này là giữa vài mili giây và vài giây cho mỗi câu hỏi.
Áp dụng vào Giao Hàng Anh Tí
Bài toán của Minh được giải như sau: mỗi khách là vector số đơn theo loại hàng, chuẩn hóa độ dài về 1, rồi nhân ma trận với vector của An để có cosine với mọi khách. Những khách có cosine trên một ngưỡng (ví dụ 0,9) nhận cùng gói ưu đãi. Bình (cosine 1,000) được chọn; Lan (0,535) không.
Lab cuối chương làm đúng việc này trên 4.009 khách thật của bộ dữ liệu, với nhóm khách ẩn (segment_true) để bạn kiểm tra những khách được coi là giống nhau có thật sự cùng nhóm không.
Hiểu lầm thường gặp
Hiểu lầm 1: "Cosine tự xử lý vấn đề đơn vị." Sai. Cosine chỉ bỏ độ dài của toàn vector. Nếu một chiều lớn hơn các chiều khác hàng trăm nghìn lần, mọi vector cùng chỉ theo chiều đó và cosine gần 1 cho tất cả, như 100% đơn ở bước 2.
Hiểu lầm 2: "Cosine bằng 0 nghĩa là hai thứ trái ngược nhau." Sai. Cosine 0 nghĩa là vuông góc, không có chiều mạnh chung. Trái ngược là cosine -1. Với vector đếm từ (không có số âm), cosine không bao giờ âm.
Hiểu lầm 3: "Cosine cao nghĩa là câu trả lời đúng." Cosine chỉ đo hai vector cùng hướng. Ở mô-đun 03, bạn sẽ gặp tình huống top-k luôn trả về kết quả, kể cả khi không đoạn tài liệu nào liên quan; cần ngưỡng và đánh giá riêng.
| Triệu chứng | Nguyên nhân | Cách kiểm chứng | Cách sửa |
|---|---|---|---|
| Gần như mọi cặp có cosine trên 0,99 | Một chiều có thang đo lớn lấn át | Tính tỉ lệ cặp có cosine trên 0,99; in X.std(axis=0) | Chuẩn hóa từng cột trước |
Cosine ra nan | Có vector toàn số 0, chia cho độ dài 0 | np.linalg.norm(X, axis=1).min() | Bỏ vector 0 hoặc chia cho max(norm, eps) |
| Câu cùng nghĩa có cosine thấp | Vector đếm từ không hiểu nghĩa | Thử hai câu đồng nghĩa khác từ | Dùng embedding (mô-đun 02, 03) |
Điều kiện và giới hạn
Cosine giả định hướng mang ý nghĩa và độ dài thì không. Khi độ dài có ý nghĩa (khách đặt 100 đơn khác khách đặt 1 đơn), hãy thêm số đơn như một đặc trưng riêng hoặc dùng Euclid. Vector gần gốc tọa độ (độ dài rất nhỏ sau chuẩn hóa) có hướng gần như ngẫu nhiên, nên cosine của chúng kém tin cậy. Và tìm kiếm bằng cách tính cosine với mọi hàng chỉ ổn tới vài triệu vector; vượt mức đó cần chỉ mục xấp xỉ như HNSW, học ở mô-đun 03 chương 6.
Tự kiểm tra (có đáp án)
Câu 1 (giải thích). Vì sao cos(An, Bình) = 1,000 dù Bình đặt gấp đôi An?
Đáp án: Bình bằng An nhân 2 ở mọi chiều, nên tích vô hướng gấp đôi và độ dài Bình cũng gấp đôi. Thừa số 2 có mặt ở cả tử và mẫu nên bị triệt tiêu; hai vector cùng hướng tuyệt đối.
Câu 2 (dự đoán). Hai vector đã có độ dài 1. Tích vô hướng của chúng là 0,8. Cosine là bao nhiêu?
Đáp án: 0,8. Khi hai độ dài đều bằng 1, mẫu số bằng 1 nên cosine chính là tích vô hướng.
Câu 3 (vận dụng). Minh muốn tìm khách "đặt nhiều đơn và cùng thói quen với Bình". Chỉ dùng cosine có đủ không?
Đáp án: không đủ, vì cosine bỏ qua số lượng đơn. Cần lọc thêm theo tổng số đơn (độ dài vector hoặc một cột riêng), hoặc dùng cosine để chọn thói quen rồi sắp xếp theo số đơn.
Tự tay làm
Trong similar_orders.py, viết thêm một biến thể: chuẩn hóa độ dài mọi hàng của Z về 1 một lần, lưu vào unit, rồi tìm top-3 chỉ bằng unit @ unit[0] (không chia gì thêm). Trước khi chạy, dự đoán kết quả có khác cách cũ không.
Kết quả mong đợi để tự kiểm: ba đơn AT002731, AT037109, AT027621 với điểm 0,9991, 0,9982, 0,9978, giống hệt bước 2. Sau đó thêm một dòng assert np.allclose(np.linalg.norm(unit, axis=1), 1.0) để chắc mọi hàng đã có độ dài 1, đây là cách các hệ vector search lưu vector.
Điều cần nhớ
- Tích vô hướng nhân từng chiều rồi cộng; nó lớn khi hai vector cùng mạnh ở cùng chiều và khi chúng dài.
- Cosine là tích vô hướng chia cho tích hai độ dài: giống nhau là cùng hướng, nằm trong khoảng -1 đến 1.
- Cosine không sửa được vấn đề đơn vị giữa các chiều; chuẩn hóa cột trước, rồi mới đo.
- Tìm kiếm ngữ nghĩa trong RAG là cosine giữa embedding của câu hỏi và embedding của từng đoạn tài liệu, tính bằng một phép nhân ma trận. Bài tiếp theo nhìn phép nhân ma trận như một phép biến đổi và dùng nó để dựng một lớp mạng nơ-ron.
Nguồn tham khảo
- NumPy,
numpy.dot: https://numpy.org/doc/stable/reference/generated/numpy.dot.html - NumPy,
numpy.linalg.norm: https://numpy.org/doc/stable/reference/generated/numpy.linalg.norm.html - scikit-learn,
cosine_similarity: https://scikit-learn.org/stable/modules/generated/sklearn.metrics.pairwise.cosine_similarity.html - Sentence Transformers, Semantic Search: https://sbert.net/examples/sentence_transformer/applications/semantic-search/README.html
- pgvector (toán tử khoảng cách cosine
<=>trong PostgreSQL, dùng ở mô-đun 03): https://github.com/pgvector/pgvector - AI Engineering from Scratch (MIT), phase 01, Norms and distances: https://github.com/rohitg00/ai-engineering-from-scratch/tree/main/phases/01-math-foundations/14-norms-and-distances