Việc nhúng dữ liệu được lưu trữ trong bảng PostgreSQL chắc chắn là hữu ích – với các ứng dụng khác nhau, từ hệ thống đề xuất và tìm kiếm ngữ nghĩa cho đến các ứng dụng AI tổng quát và thế hệ tăng cường truy xuất. Tuy nhiên, việc tạo và quản lý các phần nhúng cho dữ liệu trong các bảng PostgreSQL có thể phức tạp, với nhiều điều cần cân nhắc và các trường hợp đặc biệt cần tính đến, chẳng hạn như luôn cập nhật các phần nhúng cho các bản cập nhật và xóa bảng, đảm bảo khả năng phục hồi trước các lỗi và tác động đến các hệ thống hiện có phụ thuộc vào cái bàn.
Trong bài đăng trên blog này, chúng tôi sẽ thảo luận về các quyết định thiết kế kỹ thuật và những đánh đổi mà chúng tôi đã thực hiện khi xây dựng PGVectorizer để đảm bảo tính đơn giản, khả năng phục hồi và hiệu suất cao. Chúng tôi cũng sẽ thảo luận về các thiết kế thay thế nếu bạn muốn tự cuộn.
Hãy nhảy vào nó.
Đầu tiên, hãy mô tả cách hệ thống chúng ta đang xây dựng sẽ hoạt động. Vui lòng bỏ qua phần này nếu bạn đã đọc
Để làm ví dụ minh họa, chúng tôi sẽ sử dụng một ứng dụng blog đơn giản lưu trữ dữ liệu trong PostgreSQL bằng bảng được xác định như sau:
CREATE TABLE blog ( id SERIAL PRIMARY KEY NOT NULL, title TEXT NOT NULL, author TEXT NOT NULL, contents TEXT NOT NULL, category TEXT NOT NULL, published_time TIMESTAMPTZ NULL --NULL if not yet published );
Chúng tôi muốn tạo các phần nhúng trên nội dung của bài đăng trên blog để sau này chúng tôi có thể sử dụng nó cho việc tạo tăng cường tìm kiếm ngữ nghĩa và truy xuất sức mạnh. Nội dung nhúng chỉ nên tồn tại và có thể tìm kiếm được đối với các blog đã được xuất bản (trong đó published_time
NOT NULL
).
Trong khi xây dựng hệ thống nhúng này, chúng tôi có thể xác định một số mục tiêu mà bất kỳ hệ thống đơn giản và linh hoạt nào tạo ra các phần nhúng đều phải có:
Không có sửa đổi đối với bảng gốc. Điều này cho phép các hệ thống và ứng dụng đã sử dụng bảng này không bị ảnh hưởng bởi những thay đổi đối với hệ thống nhúng. Điều này đặc biệt quan trọng đối với các hệ thống cũ.
Không sửa đổi các ứng dụng tương tác với bảng. Việc phải sửa đổi mã làm thay đổi bảng có thể không thực hiện được đối với các hệ thống cũ. Đây cũng là thiết kế phần mềm kém vì nó kết hợp các hệ thống không sử dụng phần nhúng với mã tạo ra phần nhúng.
Tự động cập nhật các phần nhúng khi các hàng trong bảng nguồn thay đổi (trong trường hợp này là bảng blog). Điều này làm giảm gánh nặng bảo trì và góp phần tạo ra phần mềm không cần lo lắng. Đồng thời, bản cập nhật này không cần phải diễn ra ngay lập tức hoặc trong cùng một cam kết. Đối với hầu hết các hệ thống, “sự nhất quán cuối cùng” là ổn.
Đảm bảo khả năng phục hồi trước các lỗi mạng và dịch vụ: Hầu hết các hệ thống đều tạo ra các phần nhúng thông qua lệnh gọi đến hệ thống bên ngoài, chẳng hạn như API OpenAI. Trong trường hợp hệ thống bên ngoài ngừng hoạt động hoặc xảy ra sự cố mạng, điều bắt buộc là phần còn lại của hệ thống cơ sở dữ liệu của bạn phải tiếp tục hoạt động.
Những hướng dẫn này là cơ sở của một kiến trúc mạnh mẽ mà chúng tôi đã triển khai bằng cách sử dụng
Đây là kiến trúc mà chúng tôi đã quyết định:
Trong thiết kế này, trước tiên, chúng tôi thêm một trình kích hoạt vào bảng blog để theo dõi các thay đổi và khi thấy một sửa đổi, chúng tôi sẽ chèn một công việc vào bảng blog_work_queue để chỉ ra rằng một hàng trong bảng blog đã lỗi thời khi nhúng nó.
Theo lịch trình cố định, công việc của người tạo nội dung nhúng sẽ thăm dò bảng blog_work_queue và nếu tìm thấy công việc cần làm, công việc này sẽ thực hiện các thao tác sau trong một vòng lặp:
Để xem hệ thống này hoạt động như thế nào, hãy xem ví dụ về cách sử dụng
Quay trở lại ví dụ về bảng ứng dụng blog của chúng ta, ở mức độ cao, PGVectorizer phải thực hiện hai việc:
Theo dõi các thay đổi đối với các hàng trong blog để biết hàng nào đã thay đổi.
Cung cấp một phương pháp để xử lý các thay đổi để tạo các phần nhúng.
Cả hai điều này phải diễn ra đồng thời và có hiệu suất cao. Hãy xem nó hoạt động như thế nào.
Bạn có thể tạo một bảng xếp hàng công việc đơn giản như sau:
CREATE TABLE blog_embedding_work_queue ( id INT ); CREATE INDEX ON blog_embedding_work_queue(id);
Đây là một bảng rất đơn giản nhưng có một điều cần lưu ý: bảng này không có khóa duy nhất. Điều này được thực hiện để tránh các vấn đề về khóa khi xử lý hàng đợi, nhưng điều đó có nghĩa là chúng tôi có thể có các bản sao. Chúng ta sẽ thảo luận về sự đánh đổi sau trong Phương án 1 bên dưới.
Sau đó, bạn tạo trình kích hoạt để theo dõi mọi thay đổi được thực hiện đối với blog
:
CREATE OR REPLACE FUNCTION blog_wq_for_embedding() RETURNS TRIGGER LANGUAGE PLPGSQL AS $$ BEGIN IF (TG_OP = 'DELETE') THEN INSERT INTO blog_embedding_work_queue VALUES (OLD.id); ELSE INSERT INTO blog_embedding_work_queue VALUES (NEW.id); END IF; RETURN NULL; END; $$; CREATE TRIGGER track_changes_for_embedding AFTER INSERT OR UPDATE OR DELETE ON blog FOR EACH ROW EXECUTE PROCEDURE blog_wq_for_embedding(); INSERT INTO blog_embedding_work_queue SELECT id FROM blog WHERE published_time is NOT NULL;
Trình kích hoạt sẽ chèn ID của blog đã thay đổi thành blog_work_queue. Chúng tôi cài đặt trình kích hoạt rồi chèn bất kỳ blog hiện có nào vào work_queue. Thứ tự này rất quan trọng để đảm bảo rằng không có ID nào bị bỏ đi.
Bây giờ, hãy mô tả một số thiết kế thay thế và lý do chúng tôi từ chối chúng.
Việc giới thiệu khóa này sẽ loại bỏ vấn đề nhập trùng lặp. Tuy nhiên, không phải là không có thách thức, đặc biệt là vì khóa như vậy sẽ buộc chúng ta phải sử dụng mệnh INSERT…ON CONFLICT DO NOTHING
để chèn ID mới vào bảng và mệnh đề đó sẽ khóa ID trong cây B.
Đây là vấn đề nan giải: trong giai đoạn xử lý, cần phải xóa các hàng đang được xử lý để tránh việc xử lý đồng thời. Tuy nhiên, việc thực hiện việc xóa này chỉ có thể được thực hiện sau khi phần nhúng tương ứng đã được đặt vào blog_embeddings. Điều này đảm bảo không có ID nào bị mất nếu có sự gián đoạn giữa chừng—chẳng hạn như nếu quá trình tạo nội dung nhúng gặp sự cố sau khi xóa nhưng trước khi nội dung nhúng được ghi.
Bây giờ, nếu chúng ta tạo khóa chính hoặc khóa duy nhất thì giao dịch giám sát việc xóa vẫn mở. Do đó, điều này hoạt động như một khóa đối với các ID cụ thể đó, ngăn chặn việc chèn chúng trở lại blog_work_queue trong toàn bộ thời gian của công việc tạo nhúng. Vì việc tạo phần nhúng mất nhiều thời gian hơn so với giao dịch cơ sở dữ liệu thông thường của bạn, điều này gây ra rắc rối. Khóa sẽ ngăn cản quá trình kích hoạt bảng 'blog' chính, dẫn đến hiệu suất của ứng dụng chính bị giảm. Điều tồi tệ hơn là nếu xử lý nhiều hàng cùng lúc, bế tắc cũng trở thành một vấn đề tiềm ẩn.
Tuy nhiên, các vấn đề tiềm ẩn phát sinh từ các mục nhập trùng lặp không thường xuyên có thể được quản lý trong giai đoạn xử lý, như minh họa sau. Một bản sao lẻ tẻ ở đây không có vấn đề gì vì nó chỉ làm tăng nhẹ khối lượng công việc mà công việc nhúng thực hiện. Điều này chắc chắn dễ chịu hơn việc vật lộn với những thử thách khóa nêu trên.
Ví dụ: chúng ta có thể thêm một cột boolean embedded
được đặt thành false khi sửa đổi và chuyển thành true khi quá trình nhúng được tạo. Có ba lý do để từ chối thiết kế này:
Chúng tôi không muốn sửa đổi bảng blog
vì những lý do mà chúng tôi đã đề cập ở trên.
Để có được danh sách các blog không được nhúng một cách hiệu quả sẽ yêu cầu một chỉ mục bổ sung (hoặc chỉ mục một phần) trên bảng blog. Điều này sẽ làm chậm các hoạt động khác.
Điều này làm tăng tỷ lệ xáo trộn trên bảng vì giờ đây mọi sửa đổi sẽ được viết hai lần (một lần với embedding=false và một lần với embedding=true) do tính chất MVCC của PostgreSQL.
Một Work_queue_table riêng biệt sẽ giải quyết những vấn đề này.
Cách tiếp cận này có một số vấn đề:
Nếu dịch vụ nhúng không hoạt động, trình kích hoạt sẽ không thành công (hủy giao dịch của bạn) hoặc bạn cần tạo đường dẫn mã dự phòng để… lưu trữ các ID không thể nhúng vào hàng đợi. Giải pháp thứ hai đưa chúng ta quay lại thiết kế đề xuất nhưng có độ phức tạp cao hơn.
Trình kích hoạt này có thể sẽ chậm hơn nhiều so với các hoạt động cơ sở dữ liệu còn lại do độ trễ cần thiết để liên hệ với dịch vụ bên ngoài. Điều này sẽ làm chậm phần còn lại của hoạt động cơ sở dữ liệu của bạn trên bảng.
Nó buộc người dùng phải viết mã nhúng tạo trực tiếp vào cơ sở dữ liệu. Vì ngôn ngữ chung của AI là Python và việc tạo nhúng thường yêu cầu nhiều thư viện khác nên việc này không phải lúc nào cũng dễ dàng hoặc thậm chí có thể thực hiện được (đặc biệt nếu chạy trong môi trường đám mây PostgreSQL được lưu trữ). Sẽ tốt hơn nhiều nếu có một thiết kế trong đó bạn có thể lựa chọn tạo các phần nhúng bên trong hoặc bên ngoài cơ sở dữ liệu.
Bây giờ chúng ta đã có danh sách các blog cần nhúng, chúng ta cùng xử lý danh sách nhé!
Có nhiều cách để tạo phần nhúng. Chúng tôi khuyên bạn nên sử dụng tập lệnh Python bên ngoài. Tập lệnh này sẽ quét hàng đợi công việc và các bài đăng blog liên quan, gọi một dịch vụ bên ngoài để tạo các phần nhúng và sau đó lưu trữ các phần nhúng này trở lại cơ sở dữ liệu. Lý do của chúng tôi cho chiến lược này là như sau:
Lựa chọn Python : Chúng tôi khuyên dùng Python vì nó cung cấp một hệ sinh thái phong phú, chưa từng có cho các tác vụ dữ liệu AI, nổi bật là các thư viện dữ liệu và phát triển LLM mạnh mẽ như
Chọn tập lệnh bên ngoài thay vì PL/Python : Chúng tôi muốn người dùng có quyền kiểm soát cách họ nhúng dữ liệu của mình. Tuy nhiên, đồng thời, nhiều nhà cung cấp đám mây Postgres không cho phép thực thi mã Python tùy ý bên trong cơ sở dữ liệu vì lo ngại về bảo mật. Vì vậy, để cho phép người dùng linh hoạt trong cả tập lệnh nhúng cũng như nơi họ lưu trữ cơ sở dữ liệu, chúng tôi đã sử dụng một thiết kế sử dụng tập lệnh Python bên ngoài.
Các công việc phải vừa hiệu quả vừa an toàn đồng thời. Tính đồng thời đảm bảo rằng nếu công việc bắt đầu chạy chậm, người lập lịch có thể bắt đầu nhiều công việc hơn để giúp hệ thống bắt kịp và xử lý tải.
Chúng ta sẽ tìm hiểu cách thiết lập từng phương thức đó sau, nhưng trước tiên, hãy xem tập lệnh Python trông như thế nào. Về cơ bản, kịch bản có ba phần:
Đọc hàng đợi công việc và bài đăng trên blog
Tạo phần nhúng cho bài đăng trên blog
Viết phần nhúng vào bảng blog_embedding
Bước 2 và 3 được thực hiện bằng lệnh gọi lại embed_and_write
mà chúng tôi xác định trong
Trước tiên, chúng tôi sẽ hiển thị cho bạn mã và sau đó nêu bật các yếu tố chính đang diễn ra:
def process_queue(embed_and_write_cb, batch_size:int=10): with psycopg2.connect(TIMESCALE_SERVICE_URL) as conn: with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cursor: cursor.execute(f""" SELECT to_regclass('blog_embedding_work_queue')::oid; """) table_oid = cursor.fetchone()[0] cursor.execute(f""" WITH selected_rows AS ( SELECT id FROM blog_embedding_work_queue LIMIT {int(batch_size)} FOR UPDATE SKIP LOCKED ), locked_items AS ( SELECT id, pg_try_advisory_xact_lock( {int(table_oid)}, id) AS locked FROM ( SELECT DISTINCT id FROM selected_rows ORDER BY id ) as ids ), deleted_rows AS ( DELETE FROM blog_embedding_work_queue WHERE id IN ( SELECT id FROM locked_items WHERE locked = true ORDER BY id ) ) SELECT locked_items.id as locked_id, {self.table_name}.* FROM locked_items LEFT JOIN blog ON blog.id = locked_items.id WHERE locked = true ORDER BY locked_items.id """) res = cursor.fetchall() if len(res) > 0: embed_and_write_cb(res) return len(res) process_queue(embed_and_write)
Mã SQL trong đoạn mã trên rất tinh tế vì nó được thiết kế để vừa có hiệu suất vừa an toàn khi chạy đồng thời, vì vậy hãy cùng xem qua nó:
Đưa các mục ra khỏi hàng đợi công việc : Ban đầu, hệ thống truy xuất một số mục nhập được chỉ định từ hàng đợi công việc, được xác định bởi tham số kích thước hàng đợi lô. Khóa FOR UPDATE được thực hiện để đảm bảo rằng các tập lệnh thực thi đồng thời không thử xử lý các mục hàng đợi giống nhau. Lệnh SKIP LOCKED đảm bảo rằng nếu bất kỳ mục nhập nào hiện đang được xử lý bởi tập lệnh khác, hệ thống sẽ bỏ qua mục đó thay vì chờ đợi, tránh sự chậm trễ không cần thiết.
Khóa ID blog : Do khả năng có các mục trùng lặp cho cùng một blog_id trong bảng hàng đợi công việc, nên chỉ khóa bảng nói trên là không đủ. Việc xử lý đồng thời cùng một ID bởi các công việc khác nhau sẽ gây bất lợi. Hãy xem xét điều kiện chủng tộc tiềm năng sau:
Công việc 1 khởi tạo và truy cập blog, lấy phiên bản 1.
Một bản cập nhật bên ngoài cho blog xảy ra.
Sau đó, Công việc 2 bắt đầu, nhận được phiên bản 2.
Cả hai công việc đều bắt đầu quá trình tạo nhúng.
Công việc 2 kết thúc, lưu trữ phần nhúng tương ứng với phiên bản blog 2.
Sau khi kết luận, Công việc 1 đã ghi đè nhầm phần nhúng phiên bản 2 bằng phiên bản 1 đã lỗi thời.
Mặc dù người ta có thể giải quyết vấn đề này bằng cách giới thiệu tính năng theo dõi phiên bản rõ ràng, nhưng nó gây ra sự phức tạp đáng kể mà không mang lại lợi ích về hiệu suất. Chiến lược mà chúng tôi đã chọn không chỉ giảm thiểu vấn đề này mà còn ngăn chặn các hoạt động dư thừa và lãng phí công việc bằng cách thực thi đồng thời các tập lệnh.
Khóa tư vấn Postgres, có tiền tố là mã định danh bảng để tránh sự trùng lặp tiềm ẩn với các khóa khác, được sử dụng. Biến thể try
, tương tự như ứng dụng SKIP LOCKED trước đó, đảm bảo hệ thống tránh phải chờ khóa. Việc đưa vào mệnh đề ORDER BY blog_id giúp ngăn ngừa tình trạng bế tắc tiềm ẩn. Chúng tôi sẽ đề cập đến một số lựa chọn thay thế dưới đây.
Dọn dẹp hàng đợi công việc : Sau đó, tập lệnh sẽ xóa tất cả các mục trong hàng đợi công việc đối với các blog mà chúng ta đã khóa thành công. Nếu các mục hàng đợi này hiển thị thông qua Kiểm soát đồng thời nhiều phiên bản (MVCC), thì các cập nhật của chúng sẽ được hiển thị trong hàng blog được truy xuất. Lưu ý rằng chúng tôi xóa tất cả các mục có ID blog nhất định, không chỉ các mục được đọc khi chọn hàng: điều này xử lý hiệu quả các mục nhập trùng lặp cho cùng một ID blog. Điều quan trọng cần lưu ý là việc xóa này chỉ được thực hiện sau khi gọi hàm embed_and_write() và lưu trữ nội dung nhúng đã cập nhật sau đó. Trình tự này đảm bảo chúng tôi không mất bất kỳ bản cập nhật nào ngay cả khi tập lệnh bị lỗi trong giai đoạn tạo nhúng.
Bắt các blog để xử lý: Ở bước cuối cùng, chúng tôi tìm nạp các blog để xử lý. Lưu ý việc sử dụng phép nối bên trái: cho phép chúng tôi truy xuất ID blog cho các mục đã xóa không có hàng blog. Chúng ta cần theo dõi những mục đó để xóa phần nhúng của chúng. Trong lệnh gọi lại embed_and_write
, chúng tôi sử dụng posted_time là NULL làm trọng điểm cho blog bị xóa (hoặc chưa được xuất bản, trong trường hợp đó, chúng tôi cũng muốn xóa nội dung nhúng).
Nếu hệ thống đã sử dụng khóa tư vấn và bạn lo lắng về xung đột, bạn có thể sử dụng bảng có ID blog làm khóa chính và khóa các hàng. Trên thực tế, đây có thể là bảng blog nếu bạn chắc chắn rằng các khóa này sẽ không làm chậm bất kỳ hệ thống nào khác (hãy nhớ rằng các khóa này phải được giữ trong suốt quá trình nhúng, quá trình này có thể mất một lúc).
Ngoài ra, bạn có thể có bảng blog_embedding_locks chỉ dành cho mục đích này. Chúng tôi không đề xuất tạo bảng đó vì chúng tôi cho rằng nó có thể khá lãng phí về mặt không gian và việc sử dụng khóa tư vấn sẽ tránh được chi phí này.
Trong bài đăng trên blog này, chúng tôi đã cung cấp cho bạn cái nhìn hậu trường về cách chúng tôi tạo ra một hệ thống có khả năng phục hồi, xử lý hiệu quả các thời gian ngừng hoạt động tiềm ẩn của dịch vụ tạo nhúng. Thiết kế của nó rất thành thạo trong việc quản lý tốc độ sửa đổi dữ liệu cao và có thể sử dụng liền mạch các quy trình tạo nhúng đồng thời để đáp ứng tải tăng cao.
Hơn nữa, mô hình cam kết dữ liệu với PostgreSQL và sử dụng cơ sở dữ liệu để quản lý việc tạo nhúng trong nền nổi lên như một cơ chế dễ dàng để giám sát việc bảo trì nhúng trong quá trình sửa đổi dữ liệu. Vô số bản demo và hướng dẫn trong không gian AI tập trung đặc biệt vào việc tạo dữ liệu ban đầu từ tài liệu, bỏ qua các sắc thái phức tạp liên quan đến việc duy trì đồng bộ hóa dữ liệu khi nó phát triển.
Tuy nhiên, trong môi trường sản xuất thực tế, dữ liệu luôn thay đổi và việc vật lộn với sự phức tạp của việc theo dõi và đồng bộ hóa những thay đổi này không phải là một nỗ lực tầm thường. Nhưng đó chính là mục đích của cơ sở dữ liệu! Tại sao không chỉ sử dụng nó?
Viết bởi Matvey Arye.
Cũng được xuất bản ở đây.