№ 72
SceneSearch giúp kỹ sư tìm đúng khoảnh khắc trong video chỉ bằng ngôn ngữ tự nhiên. Tìm kiếm bằng tiếng Việt hoặc tiếng Anh, lọc theo vật thể, vị trí và thời điểm, sau đó duyệt kết quả trước khi export thành dataset. Khuôn mặt và biển số được tự động che ngay từ bước ingest, đảm bảo dữ liệu an toàn và bảo vệ quyền riêng tư.
Một đội xe tự hành giữ hàng nghìn giờ video chưa gán nhãn. Khi kỹ sư cần "đường ướt ban đêm có người đi bộ" để dựng một kịch bản mô phỏng, cách duy nhất hiện nay là tua video hoặc hy vọng đã có ai đó gắn đúng cái tag mình cần.
Giá trị của hệ thống không nằm ở chỗ tìm kiếm nhanh hơn SQL. Nó nằm ở chỗ không ai phải định nghĩa trước bộ nhãn. Một CSDL metadata chỉ trả lời được những câu hỏi mà người viết schema đã đoán ra từ trước, còn kỹ sư ở trên đang hỏi một tổ hợp chưa từng được yêu cầu gắn nhãn. Đó là ngưỡng chấp nhận chúng tôi tự đặt: hệ thống xứng đáng tồn tại nếu kỹ sư tìm được frame dùng được cho một kịch bản chưa bao giờ được gán nhãn.
Bài toán thứ hai ít được nói tới hơn, và là thứ định hình phần lớn thiết kế: phải trả lời trung thực khi kho không có cảnh đó. Với người đi tìm tình huống hiếm, mười kết quả sai nhưng tự tin đắt hơn một kết quả rỗng, vì phải xem hết mới biết là sai. Tệ hơn, đây là loại lỗi người dùng không tự phát hiện được: họ kết luận kho không có thứ mình cần rồi thôi không tìm nữa, trong khi kho thực sự có.
Phạm vi được giới hạn có chủ đích: hệ thống chạy trên video đã ghi, không điều khiển phương tiện, và luồng truy vấn là chỉ đọc.
Kiến trúc chia theo thời điểm công việc chạy, không theo chức năng của code. Hai phân hệ không gọi nhau, chỉ dùng chung một tập artifact.
Ingest (offline, tính bằng giờ, có thể chạy lại) giải mã video, trích key-frame thích ứng, sinh embedding bằng SigLIP2 (openclip:ViT-B-16-SigLIP2:webli), phát hiện vật thể bằng Faster R-CNN resnet50_v2, sinh caption bằng Florence-2, gắn scene tag zero-shot, rồi che mặt, biển số và vùng đầu người. Toàn bộ mô hình đắt tiền chỉ chạy một lần ở đây: caption 4 giây/frame trên CPU và 1,085 giây trên GPU, detector 3,95 và 0,51 giây (đo trên Quadro P2000, 2026-08-25).
Query (online, chỉ đọc) là một graph LangGraph: parse → retrieve → verify → rerank → respond, có nhánh relax giới hạn tối đa 3 vòng. Chỉ đúng một loại lệnh gọi LLM tính tiền, là gpt-4o-mini để chuyển ngôn ngữ tự nhiên thành ràng buộc có cấu trúc; từ v1.6.1 nó chạy thành hai lệnh gọi song song, một phân tích truy vấn và một phát hiện bổ ngữ không được hỗ trợ. Vì các mô hình nặng đã chạy xong ở ingest, một truy vấn chỉ còn hai text tower và một phép nhân ma trận.
Index là 6.858 × 768 số thực, 21 MB nằm trong RAM, tìm kiếm chính xác bằng numpy. Không dùng Chroma hay Qdrant, và đây là lựa chọn có tính toán chứ không phải thiếu sót: một vector DB sẽ thêm một service và một bản sao dữ liệu để làm đúng phép toán đó chậm hơn. M2_DESIGN.md §5 đã đo giới hạn của lựa chọn này: ở quy mô 7,2 triệu frame (một nghìn giờ video ở 2 Hz) thì index chính xác không còn trụ được và phải chuyển sang ANN.
Bốn cơ chế dưới đây là phần thực sự phân biệt hệ thống này với "CLIP chạy trên một đống ảnh", và mỗi cơ chế đều có số đo kèm theo.
Từ chối trung thực, dạng dải chứ không phải công tắc. Hệ thống caption 5 kết quả đầu rồi đối chiếu truy vấn với chính các mô tả đó. Kết quả rơi vào một trong ba mức: tự tin, tự tin thấp (vẫn trả kết quả nhưng kèm cảnh báo), hoặc không có gì khớp. Neg-AUROC đạt 0.877 trên corpus đang phục vụ. Đáng chú ý là cách rẻ hơn lại tốt hơn: trên corpus thử 404 frame, caption 5 frame đạt 0.898 so với 0.907 của một index caption toàn bộ, nhưng trên corpus 6.858 frame thì đảo chiều thành 0.877 so với 0.822, và khoảng cách nới rộng ra khi corpus lớn lên. Ba mức thay vì một ngưỡng là vì một ngưỡng duy nhất giấu mất 32% số truy vấn hợp lệ.
Hệ thống cũng phân biệt hai loại "không có". Một caption chỉ liệt kê thứ có mặt và không bao giờ khẳng định sự vắng mặt, nên a street at night vẫn đạt 0.559 trên một kho toàn ảnh ban ngày. Vì vậy phần từ chối này tra thống kê scene tag của chính kho dữ liệu, tức một bản ghi về những gì kho chứa, chứ không tra năng lực của pipeline.
Truy vấn quan hệ dùng cấu trúc phát hiện được, không dùng mô hình to hơn. "Một chiếc xe phía trước bên trái" thất bại với mọi image embedding đã thử, và không phải vì mô hình không phân biệt được trái phải: xe hơi xuất hiện trong 5.658 trên 6.858 frame (82,5%), nên câu truy vấn gần như không mang tín hiệu về vật thể và sụp về thuần vị trí. Một bộ lọc có cấu trúc thì không quan tâm vật thể phổ biến đến đâu. Bật bộ lọc nâng mAP quan hệ từ 0.397 lên 0.672, và mức đồng thuận với nhãn người tăng từ kappa 0.656 lên 0.857. Điều quan trọng về phương pháp: các dữ kiện này lấy từ pixel, tức detector cộng vị trí trong ảnh, không bao giờ lấy từ annotation có sẵn của bộ dữ liệu. Dùng annotation là so một dữ kiện với chính nó, điểm sẽ rất đẹp mà không chứng minh được gì.
Không trả về mười frame của cùng năm giây. Key-frame lấy ở 2 Hz nên các frame liền nhau gần như trùng nhau. Đo thực tế, top-10 chỉ trải trên 4,13 clip riêng biệt; một giới hạn số frame mỗi clip đưa con số đó lên 6,75. Giới hạn chỉ áp cho phần hiển thị, vì áp sâu hơn làm mất hai phần ba chất lượng xếp hạng mà không thêm được đa dạng.
Truy vấn tiếng Việt hoạt động, và không bị dịch trước khi tìm kiếm. Caption và caption encoder đều chỉ có tiếng Anh, nên một câu tiếng Việt luôn rơi dưới ngưỡng từ chối bất kể kho có gì. Hệ thống chuẩn hóa câu sang tiếng Anh chỉ cho cổng từ chối, còn phần tìm kiếm giữ nguyên câu gốc, vì SigLIP2 đọc tiếng Việt tốt hơn (0.787 so với 0.750 của câu tiếng Anh tương đương).
Quyền riêng tư được xử lý ở ingest, không phải trước khi render. Ảnh lưu trong kho đã bị che sẵn, nên không có đường nào phát ra pixel chưa che. Trên corpus nuScenes, 3.145 trên 6.858 frame (45,9%) có ít nhất một vùng bị che; trên corpus Hà Nội là 2.090 trên 2.211 frame với 16.660 vùng, và số frame chưa được kiểm chứng là 0. Cổng kiểm tra chạy trong make check, còn script xuất bản từ chối thẳng một thư mục chưa được duyệt và cố tình không có cờ --force.
Ngưỡng đi kèm dữ liệu, và hệ thống tự khai báo tình trạng đó. provenance.json đóng dấu bộ ngưỡng đã fit cùng một fingerprint của tập frame và encoder; endpoint /status đọc lại và báo về một trong bốn trạng thái: fitted, unfitted, corpus_mismatch, config_drift. Đây là chỗ chặn một lỗi đã từng đo được: một bộ ngưỡng cũ áp lên corpus mới sẽ sai theo cả hai chiều mà không có triệu chứng nào. Bản đang chạy báo fitted tại fingerprint 27b334b33461.
Mỗi kết quả nhận được một phán quyết của kỹ sư, lưu theo một id ẩn danh. Bản export chính là thứ biến một demo thành dữ liệu có nhãn, nên nó nằm sau operator token chứ không mở.
Về tài khoản: JWT với refresh token trong cookie, bảo vệ CSRF, đăng ký qua OTP, đăng nhập Google, hai vai trò Admin và User. Toàn bộ route sản phẩm để mở, chỉ trang cá nhân và khu quản trị bị chặn, vì không route API sản phẩm nào cần danh tính người dùng.
Về vận hành: Docker multi-stage, GitHub Actions chạy ruff, pytest và một job build image frontend với cache sạch. Bản build chạy trên Google Cloud VM, Caddy cấp và gia hạn TLS. Mỗi lần phát hành là một tag kèm GitHub Release; máy chủ bám nhánh production, không bám main. 470 test, coverage 80% trên src/. Trong đó có một test giữ cho luồng truy vấn của sản phẩm tái lập đúng kết quả của thí nghiệm nghiên cứu 40095e80, nếu không hai bản sao sẽ trôi khỏi nhau trong im lặng.
Hệ thống đã chạy thật, không phải nguyên mẫu trong máy. Địa chỉ https://scenesearch.duckdns.org đã phục vụ liên tục từ 2026-08-09; bản đang chạy là v1.6.1, triển khai 2026-08-24. /health báo 6.858 frame, /status báo dải ngưỡng ở trạng thái fitted.
Hiệu năng. Tổng thời gian xử lý không tính LLM là 116,7 ms, trong đó tìm kiếm index chỉ chiếm 3,99 ms, tức 3,4% một request. Cả request mất 0,6 đến 1,6 giây, phần lớn là lệnh gọi LLM để phân tích câu truy vấn. Khi corpus tăng 17 lần thì thời gian tìm kiếm chỉ tăng 4,4 lần, dưới tuyến tính vì chi phí BLAS được chia đều trên ma trận lớn hơn. Con số này đạt yêu cầu NFR-01.1 (dưới 2 giây) với biên rộng.
Chi phí hạ tầng. Luồng phục vụ chạy hoàn toàn trên CPU và không phụ thuộc GPU. Ingest dùng GPU nếu có, nhờ đó 6.858 frame mất 2 giờ 20 phút thay vì khoảng 9 giờ, nhưng không có bước nào trong một request chạm tới GPU. Chỉ một loại lệnh gọi trả tiền trong toàn hệ thống, hai lệnh song song mỗi truy vấn, và tổng chi phí API của cả dự án nằm trong khoảng 5 đến 10 đô la.
Đã chạy trên video thô đúng kiểu doanh nghiệp. 24 clip đường phố Hà Nội, 190 phút video gốc, cho ra 2.211 key-frame, tức 11,64 frame mỗi phút nguồn. Nguồn quay ở 60 fps, nên đây là mức giảm 99,7%, vượt xa mốc 80 đến 90% mà NFR-01.2 đặt ra trên baseline 30 fps. Toàn bộ 31.269 bounding box được suy lại từ cache detector sẵn có, không cần chạy lại GPU. Điều này chứng minh pipeline không bị buộc vào nuScenes: nó nhận được video thô và không cần dữ liệu tiền xử lý thủ công.
Ranh giới triển khai phù hợp với doanh nghiệp. Vì ingest và query không gọi nhau, doanh nghiệp có thể chạy ingest sau tường lửa và chỉ đưa artifact ra ngoài. Frame gốc độ phân giải đầy đủ không bao giờ rời khỏi kho.
Rủi ro đã đo, không phải phỏng đoán. Phần này quan trọng với tính khả thi hơn là danh sách các thứ chạy được.
Prompt của bộ phân tích truy vấn có trần kích thước. Bản v1.6 vượt trần, mô hình bắt đầu lặp danh mục cho tới khi chạm max_tokens, và mọi truy vấn không gian trả về lỗi. Đã rollback trong khoảng 40 phút và phát hành lại v1.6.1 với cách tách thành hai lệnh gọi song song. Bài học đắt nhất: toàn bộ test vẫn xanh suốt thời gian đó, vì test bắt buộc mock LLM và một mock thì không thể thoái hóa. Từ đó, quy trình deploy có thêm bước chạy thử image trong container tạm bằng chính key production trước khi chuyển traffic.
Detector là nút cổ chai của chất lượng tìm kiếm, không phải encoder. Recall ở các lớp yếu chỉ 0,28 đến 0,40.
Một caption bịa ra có thể làm frame trở nên truy hồi được với độ tự tin cao, vì dải ngưỡng hỏi caption chứ không hỏi vector. Đây được ghi nhận là giới hạn đã biết (issue #92), không phải lỗi sẽ vá vội, vì mọi cách vá rẻ đều phải dịch một ngưỡng đang tái lập chính xác dưới hai lần gán nhãn độc lập.
Một quy tắc từ chối theo danh mục detector đã bị gỡ bỏ vào 2026-08-18 vì nó sai: nó đọc "detector không phát ra được lớp này" thành "kho không có thứ này", trong khi cả ba danh mục nó từ chối đều có thật trong kho (241 trên 6.858 caption có nhắc tới cọc tiêu giao thông). Bài học được giữ lại trong tài liệu: một bản thống kê kho có thể mang một khẳng định về footage, còn một năng lực của pipeline thì không.
Ngắn hạn, đã có thiết kế và số đo, chưa build.
Nạp bổ sung tăng dần (FR-01.5). Hiện bước promote ghi đè cả thư mục artifact, nên chưa gộp được corpus mới vào corpus đang phục vụ. Đây là việc đang chặn khả năng chạy nhiều kho cùng lúc, và là hạng mục ưu tiên số một sau Demo Day.
Xem trước đoạn clip quanh timestamp (FR-03.1). frames.jsonl đã mang video_id và timestamp_s, còn videos.json giữ mẫu deep link về nguồn, nên phần còn thiếu là lớp phục vụ video chứ không phải dữ liệu.
Đóng dấu quy tắc không gian vào provenance. Bộ ngưỡng từ chối đã có cơ chế này, quy tắc không gian thì chưa: hiện không artifact nào ghi lại nó được sinh bằng luật nào, nên một corpus cũ có thể mang luật đã nghỉ hưu mà không có triệu chứng.
Sàn kích thước biểu kiến cho bounding box. Chiều cao box tách được box người thật khỏi box rỗng ở AUC 0,925. Sàn ở mức 0,08 chiều cao khung hình giữ lại 75% số box tự tin. Đây là một quyết định sản phẩm, tức "người dùng có nhìn thấy thứ ta đang khẳng định không", chứ không phải một lần quét ngưỡng.
Fit lại dải ngưỡng cho corpus Hà Nội trước khi đưa nó vào phục vụ, vì provenance.json của nó hiện vẫn ghi ngưỡng kế thừa từ nuScenes.
Trung hạn.
Chuyển sang index xấp xỉ (ANN) ở quy mô 7,2 triệu frame, mốc §5 lấy làm minh họa. Dưới mốc đó, index chính xác rẻ hơn và đã được đo.
Tìm kiếm ảnh sang ảnh (FR-02.3).
Thay hoặc fine-tune detector, vì đó mới là nút cổ chai. Một lần đổi trọng số miễn phí đã nâng số frame truy cập được qua truy vấn danh mục: xe đạp từ 64 lên 136, xe máy từ 159 lên 397, người đi bộ từ 2.373 lên 2.966.
Xét lại phương án open-vocabulary detection. Nó từng bị loại vì một lập luận nay không còn đúng sau khi quy tắc từ chối theo danh mục bị gỡ.
Kiểm thử xâm nhập (DAST/Pentest), phần còn lại của NFR-03.3. Hai phần kia của yêu cầu này, TLS/SSL và đóng gói Docker, đã xong.
Dài hạn, hướng sản phẩm.
Nhận video trực tiếp từ storage của doanh nghiệp theo sự kiện hoặc theo lịch, thay vì nạp thủ công theo thư mục.
Xuất dataset theo manifest chuẩn để nạp thẳng vào pipeline huấn luyện.
Mở rộng bộ đánh giá sang footage doanh nghiệp. Hiện toàn bộ số đo dựa trên nuScenes vì đó là kho duy nhất có annotation để đối chiếu, và mọi kết luận trong tài liệu này đều gắn với giới hạn đó.