Semua orang menyukai model penyematan teks, dan ada alasannya: Model ini unggul dalam mengodekan teks tak terstruktur, sehingga memudahkan untuk menemukan konten yang secara semantik mirip. Tidak mengherankan bahwa model ini menjadi tulang punggung sebagian besar aplikasi RAG, terutama dengan penekanan saat ini pada pengodean dan pengambilan informasi relevan dari dokumen dan sumber tekstual lainnya. Namun, ada beberapa contoh pertanyaan yang mungkin ditanyakan di mana pendekatan penyematan teks pada aplikasi RAG tidak berhasil dan memberikan informasi yang salah.
Seperti yang disebutkan, penyematan teks sangat bagus dalam mengodekan teks yang tidak terstruktur. Di sisi lain, penyematan teks tidak begitu bagus dalam menangani informasi terstruktur dan operasi seperti pemfilteran , pengurutan , atau agregasi . Bayangkan pertanyaan sederhana seperti:
Apa film dengan rating tertinggi yang dirilis pada tahun 2024?
Untuk menjawab pertanyaan ini, pertama-tama kita harus memfilter berdasarkan tahun rilis, diikuti dengan pengurutan berdasarkan peringkat. Kita akan memeriksa bagaimana pendekatan naif dengan penyematan teks bekerja, lalu menunjukkan cara menangani pertanyaan tersebut. Tulisan blog ini menunjukkan bahwa saat menangani operasi data terstruktur seperti pemfilteran, pengurutan, atau agregasi, Anda perlu menggunakan alat lain yang menyediakan struktur seperti grafik pengetahuan.
Kode tersebut tersedia di GitHub .
Untuk posting blog ini, kami akan menggunakan proyek rekomendasi di Neo4j Sandbox . Proyek rekomendasi menggunakan dataset MovieLens , yang berisi film, aktor, rating, dan informasi lainnya.
Kode berikut akan membuat pembungkus LangChain untuk terhubung ke Basis Data Neo4j:
os.environ["NEO4J_URI"] = "bolt://44.204.178.84:7687" os.environ["NEO4J_USERNAME"] = "neo4j" os.environ["NEO4J_PASSWORD"] = "minimums-triangle-saving" graph = Neo4jGraph(refresh_schema=False)
Selain itu, Anda akan memerlukan kunci API OpenAI yang Anda masukkan dalam kode berikut:
os.environ["OPENAI_API_KEY"] = getpass.getpass("OpenAI API Key:")
Basis data berisi 10.000 film, tetapi teks yang disematkan belum disimpan. Untuk menghindari penghitungan teks yang disematkan untuk semua film, kami akan memberi label sekunder yang disebut Target pada 1.000 film dengan rating teratas:
graph.query(""" MATCH (m:Movie) WHERE m.imdbRating IS NOT NULL WITH m ORDER BY m.imdbRating DESC LIMIT 1000 SET m:Target """)
Memutuskan apa yang akan disematkan merupakan pertimbangan penting. Karena kita akan mendemonstrasikan pemfilteran berdasarkan tahun dan pengurutan berdasarkan peringkat, tidak adil untuk mengecualikan detail tersebut dari teks yang disematkan. Itulah sebabnya saya memilih untuk menyertakan tahun rilis, peringkat, judul, dan deskripsi setiap film.
Berikut adalah contoh teks yang akan kami sisipkan untuk film The Wolf of Wall Street :
plot: Based on the true story of Jordan Belfort, from his rise to a wealthy stock-broker living the high life to his fall involving crime, corruption and the federal government. title: Wolf of Wall Street, The year: 2013 imdbRating: 8.2
Anda mungkin mengatakan ini bukan pendekatan yang baik untuk menanamkan data terstruktur, dan saya tidak akan membantahnya karena saya tidak tahu pendekatan terbaik. Mungkin alih-alih item nilai kunci, kita harus mengubahnya menjadi teks atau semacamnya. Beri tahu saya jika Anda punya beberapa ide tentang apa yang mungkin lebih baik.
Objek Vektor Neo4j di LangChain memiliki metode yang mudah digunakan from_existing_graph, di mana Anda dapat memilih properti teks mana yang harus dikodekan:
embedding = OpenAIEmbeddings(model="text-embedding-3-small") neo4j_vector = Neo4jVector.from_existing_graph( embedding=embedding, index_name="movies", node_label="Target", text_node_properties=["plot", "title", "year", "imdbRating"], embedding_node_property="embedding", )
Dalam contoh ini, kami menggunakan model text-embedding-3-small OpenAI untuk pembuatan embedding. Kami menginisialisasi objek Neo4jVector menggunakan metode from_existing_graph. Parameter node_label memfilter node yang akan dikodekan, khususnya yang diberi label Target . Parameter text_node_properties menentukan properti node yang akan di-embedding, termasuk plot , title , year , dan imdbRating . Terakhir, embedding_node_property menentukan properti tempat embedding yang dihasilkan akan disimpan, yang ditetapkan sebagai embedding .
Mari kita mulai dengan mencoba menemukan film berdasarkan plot atau deskripsinya:
pretty_print( neo4j_vector.similarity_search( "What is a movie where a little boy meets his hero?" ) )
Hasil:
plot: A young boy befriends a giant robot from outer space that a paranoid government agent wants to destroy. title: Iron Giant, The year: 1999 imdbRating: 8.0 plot: After the death of a friend, a writer recounts a boyhood journey to find the body of a missing boy. title: Stand by Me year: 1986 imdbRating: 8.1 plot: A young, naive boy sets out alone on the road to find his wayward mother. Soon he finds an unlikely protector in a crotchety man and the two have a series of unexpected adventures along the way. title: Kikujiro (Kikujirô no natsu) year: 1999 imdbRating: 7.9 plot: While home sick in bed, a young boy's grandfather reads him a story called The Princess Bride. title: Princess Bride, The year: 1987 imdbRating: 8.1
Hasilnya tampak cukup solid secara keseluruhan. Selalu ada anak laki-laki yang terlibat, meskipun saya tidak yakin apakah dia selalu bertemu dengan pahlawannya. Namun, kumpulan data tersebut hanya mencakup 1.000 film, jadi pilihannya agak terbatas.
Sekarang mari kita coba kueri yang memerlukan beberapa penyaringan dasar:
pretty_print( neo4j_vector.similarity_search( "Which movies are from year 2016?" ) )
Hasil:
plot: Six short stories that explore the extremities of human behavior involving people in distress. title: Wild Tales year: 2014 imdbRating: 8.1 plot: A young man who survives a disaster at sea is hurtled into an epic journey of adventure and discovery. While cast away, he forms an unexpected connection with another survivor: a fearsome Bengal tiger. title: Life of Pi year: 2012 imdbRating: 8.0 plot: Based on the true story of Jordan Belfort, from his rise to a wealthy stock-broker living the high life to his fall involving crime, corruption and the federal government. title: Wolf of Wall Street, The year: 2013 imdbRating: 8.2 plot: After young Riley is uprooted from her Midwest life and moved to San Francisco, her emotions - Joy, Fear, Anger, Disgust and Sadness - conflict on how best to navigate a new city, house, and school. title: Inside Out year: 2015 imdbRating: 8.3
Lucu, tetapi tidak ada satu pun film dari tahun 2016 yang dipilih. Mungkin kita bisa mendapatkan hasil yang lebih baik dengan persiapan teks yang berbeda untuk pengodean. Namun, penyematan teks tidak berlaku di sini karena kita berurusan dengan operasi data terstruktur sederhana di mana kita perlu memfilter dokumen atau, dalam contoh ini, film berdasarkan properti metadata. Pemfilteran metadata adalah teknik mapan yang sering digunakan untuk meningkatkan akurasi sistem RAG.
Kueri berikutnya yang akan kita coba memerlukan sedikit penyortiran:
pretty_print( neo4j_vector.similarity_search("Which movie has the highest imdb score?") )
Hasil:
plot: A silent film production company and cast make a difficult transition to sound. title: Singin' in the Rain year: 1952 imdbRating: 8.3 plot: A film about the greatest pre-Woodstock rock music festival. title: Monterey Pop year: 1968 imdbRating: 8.1 plot: This movie documents the Apollo missions perhaps the most definitively of any movie under two hours. Al Reinert watched all the footage shot during the missions--over 6,000,000 feet of it, ... title: For All Mankind year: 1989 imdbRating: 8.2 plot: An unscrupulous movie producer uses an actress, a director and a writer to achieve success. title: Bad and the Beautiful, The year: 1952 imdbRating: 7.9
Jika Anda familier dengan rating IMDb, Anda tahu ada banyak film yang mendapat skor di atas 8,3. Judul dengan rating tertinggi dalam basis data kami sebenarnya adalah sebuah serial — Band of Brothers — dengan rating 9,6 yang mengesankan. Sekali lagi, penyematan teks berkinerja buruk dalam hal menyortir hasil.
Mari kita juga mengevaluasi pertanyaan yang memerlukan semacam agregasi:
pretty_print(neo4j_vector.similarity_search("How many movies are there?"))
Hasil:
plot: Ten television drama films, each one based on one of the Ten Commandments. title: Decalogue, The (Dekalog) year: 1989 imdbRating: 9.2 plot: A documentary which challenges former Indonesian death-squad leaders to reenact their mass-killings in whichever cinematic genres they wish, including classic Hollywood crime scenarios and lavish musical numbers. title: Act of Killing, The year: 2012 imdbRating: 8.2 plot: A meek Hobbit and eight companions set out on a journey to destroy the One Ring and the Dark Lord Sauron. title: Lord of the Rings: The Fellowship of the Ring, The year: 2001 imdbRating: 8.8 plot: While Frodo and Sam edge closer to Mordor with the help of the shifty Gollum, the divided fellowship makes a stand against Sauron's new ally, Saruman, and his hordes of Isengard. title: Lord of the Rings: The Two Towers, The year: 2002 imdbRating: 8.7
Hasilnya jelas tidak membantu di sini karena kami mendapatkan empat film acak. Hampir mustahil untuk mendapatkan kesimpulan dari keempat film acak ini bahwa ada total 1.000 film yang kami tandai dan sisipkan untuk contoh ini.
Jadi, apa solusinya? Solusinya mudah: Pertanyaan yang melibatkan operasi terstruktur seperti penyaringan, pengurutan, dan agregasi memerlukan alat yang dirancang untuk beroperasi dengan data terstruktur.
Saat ini, tampaknya sebagian besar orang berpikir tentang pendekatan text2query, di mana LLM menghasilkan kueri basis data untuk berinteraksi dengan basis data berdasarkan pertanyaan dan skema yang diberikan. Untuk Neo4j, ini adalah text2cypher, tetapi ada juga text2sql untuk basis data SQL. Namun, ternyata dalam praktiknya pendekatan ini tidak dapat diandalkan dan tidak cukup kuat untuk penggunaan produksi.
Evaluasi pembuatan pernyataan Cypher. Diambil dari posting blog saya tentang evaluasi Cypher .
Anda dapat menggunakan teknik seperti rangkaian pemikiran, contoh-contoh yang diambil dengan beberapa kali, atau penyempurnaan, tetapi mencapai akurasi yang tinggi masih hampir mustahil pada tahap ini. Pendekatan text2query bekerja dengan baik untuk pertanyaan-pertanyaan sederhana pada skema basis data yang mudah dipahami, tetapi itu bukanlah realitas lingkungan produksi. Untuk mengatasi hal ini, kami mengalihkan kompleksitas pembuatan kueri basis data dari LLM dan memperlakukannya sebagai masalah kode tempat kami membuat kueri basis data secara deterministik berdasarkan masukan fungsi. Keuntungannya adalah ketahanan yang ditingkatkan secara signifikan, meskipun hal itu mengorbankan fleksibilitas yang berkurang. Lebih baik mempersempit cakupan aplikasi RAG dan menjawab pertanyaan-pertanyaan tersebut secara akurat, daripada mencoba menjawab semuanya tetapi melakukannya dengan tidak akurat.
Karena kita membuat kueri basis data — dalam hal ini, pernyataan Cypher — berdasarkan masukan fungsi, kita dapat memanfaatkan kemampuan alat LLM. Dalam proses ini, LLM mengisi parameter yang relevan berdasarkan masukan pengguna, sementara fungsi menangani pengambilan informasi yang diperlukan. Untuk demonstrasi ini, pertama-tama kita akan menerapkan dua alat: satu untuk menghitung film dan satu lagi untuk mencantumkannya, lalu membuat agen LLM menggunakan LangGraph.
Kami mulai dengan menerapkan alat untuk menghitung film berdasarkan filter yang telah ditetapkan sebelumnya. Pertama, kami harus menentukan apa saja filter tersebut dan menjelaskan kepada LLM kapan dan bagaimana menggunakannya:
class MovieCountInput(BaseModel): min_year: Optional[int] = Field( description="Minimum release year of the movies" ) max_year: Optional[int] = Field( description="Maximum release year of the movies" ) min_rating: Optional[float] = Field(description="Minimum imdb rating") grouping_key: Optional[str] = Field( description="The key to group by the aggregation", enum=["year"] )
LangChain menawarkan beberapa cara untuk menentukan input fungsi, tetapi saya lebih suka pendekatan Pydantic. Dalam contoh ini, kami memiliki tiga filter yang tersedia untuk menyaring hasil film: min_year, max_year, dan min_rating. Filter ini didasarkan pada data terstruktur dan bersifat opsional, karena pengguna dapat memilih untuk menyertakan sebagian, semua, atau tidak menyertakannya. Selain itu, kami telah memperkenalkan input grouping_key yang memberi tahu fungsi apakah akan mengelompokkan hitungan berdasarkan properti tertentu. Dalam kasus ini, satu-satunya pengelompokan yang didukung adalah berdasarkan tahun, sebagaimana didefinisikan dalam enumsection.
Sekarang mari kita definisikan fungsi sebenarnya:
@tool("movie-count", args_schema=MovieCountInput) def movie_count( min_year: Optional[int], max_year: Optional[int], min_rating: Optional[float], grouping_key: Optional[str], ) -> List[Dict]: """Calculate the count of movies based on particular filters""" filters = [ ("t.year >= $min_year", min_year), ("t.year <= $max_year", max_year), ("t.imdbRating >= $min_rating", min_rating), ] # Create the parameters dynamically from function inputs params = { extract_param_name(condition): value for condition, value in filters if value is not None } where_clause = " AND ".join( [condition for condition, value in filters if value is not None] ) cypher_statement = "MATCH (t:Target) " if where_clause: cypher_statement += f"WHERE {where_clause} " return_clause = ( f"t.`{grouping_key}`, count(t) AS movie_count" if grouping_key else "count(t) AS movie_count" ) cypher_statement += f"RETURN {return_clause}" print(cypher_statement) # Debugging output return graph.query(cypher_statement, params=params)
Fungsi movie_count menghasilkan kueri Cypher untuk menghitung film berdasarkan filter opsional dan kunci pengelompokan. Fungsi ini dimulai dengan menentukan daftar filter dengan nilai terkait yang diberikan sebagai argumen. Filter digunakan untuk membangun klausa WHERE secara dinamis, yang bertanggung jawab untuk menerapkan kondisi pemfilteran yang ditentukan dalam pernyataan Cypher, termasuk hanya kondisi yang nilainya bukan None.
Klausa RETURN dari kueri Cypher kemudian dibuat, baik dengan mengelompokkan berdasarkan grouping_key yang diberikan atau hanya menghitung jumlah total film. Terakhir, fungsi tersebut mengeksekusi kueri dan mengembalikan hasilnya.
Fungsi tersebut dapat diperluas dengan lebih banyak argumen dan logika yang lebih rumit sesuai kebutuhan, tetapi penting untuk memastikan bahwa fungsi tersebut tetap jelas sehingga LLM dapat memanggilnya dengan benar dan akurat.
Sekali lagi, kita harus mulai dengan mendefinisikan argumen fungsi:
class MovieListInput(BaseModel): sort_by: str = Field( description="How to sort movies, can be one of either latest, rating", enum=["latest", "rating"], ) k: Optional[int] = Field(description="Number of movies to return") description: Optional[str] = Field(description="Description of the movies") min_year: Optional[int] = Field( description="Minimum release year of the movies" ) max_year: Optional[int] = Field( description="Maximum release year of the movies" ) min_rating: Optional[float] = Field(description="Minimum imdb rating")
Kami mempertahankan tiga filter yang sama seperti pada fungsi jumlah film tetapi menambahkan argumen deskripsi. Argumen ini memungkinkan kami mencari dan membuat daftar film berdasarkan alurnya menggunakan pencarian kesamaan vektor. Hanya karena kami menggunakan alat dan filter terstruktur, bukan berarti kami tidak dapat menggabungkan metode penyematan teks dan pencarian vektor. Karena kami tidak ingin mengembalikan semua film di sebagian besar waktu, kami menyertakan input k opsional dengan nilai default. Selain itu, untuk membuat daftar, kami ingin mengurutkan film untuk hanya mengembalikan film yang paling relevan. Dalam kasus ini, kami dapat mengurutkannya berdasarkan peringkat atau tahun rilis.
Mari kita implementasikan fungsinya:
@tool("movie-list", args_schema=MovieListInput) def movie_list( sort_by: str = "rating", k : int = 4, description: Optional[str] = None, min_year: Optional[int] = None, max_year: Optional[int] = None, min_rating: Optional[float] = None, ) -> List[Dict]: """List movies based on particular filters""" # Handle vector-only search when no prefiltering is applied if description and not min_year and not max_year and not min_rating: return neo4j_vector.similarity_search(description, k=k) filters = [ ("t.year >= $min_year", min_year), ("t.year <= $max_year", max_year), ("t.imdbRating >= $min_rating", min_rating), ] # Create parameters dynamically from function arguments params = { key.split("$")[1]: value for key, value in filters if value is not None } where_clause = " AND ".join( [condition for condition, value in filters if value is not None] ) cypher_statement = "MATCH (t:Target) " if where_clause: cypher_statement += f"WHERE {where_clause} " # Add the return clause with sorting cypher_statement += " RETURN t.title AS title, t.year AS year, t.imdbRating AS rating ORDER BY " # Handle sorting logic based on description or other criteria if description: cypher_statement += ( "vector.similarity.cosine(t.embedding, $embedding) DESC " ) params["embedding"] = embedding.embed_query(description) elif sort_by == "rating": cypher_statement += "t.imdbRating DESC " else: # sort by latest year cypher_statement += "t.year DESC " cypher_statement += " LIMIT toInteger($limit)" params["limit"] = k or 4 print(cypher_statement) # Debugging output data = graph.query(cypher_statement, params=params) return data
Fungsi ini mengambil daftar film berdasarkan beberapa filter opsional: deskripsi, rentang tahun, peringkat minimum, dan preferensi pengurutan. Jika hanya deskripsi yang diberikan tanpa filter lain, fungsi ini akan melakukan pencarian kesamaan indeks vektor untuk menemukan film yang relevan. Saat filter tambahan diterapkan, fungsi ini akan membuat kueri Cypher untuk mencocokkan film berdasarkan kriteria yang ditentukan, seperti tahun rilis dan peringkat IMDb, menggabungkannya dengan kesamaan berbasis deskripsi opsional. Hasilnya kemudian diurutkan berdasarkan skor kesamaan, peringkat IMDb, atau tahun, dan dibatasi hingga k film.
Kami akan mengimplementasikan agen ReAct langsung menggunakan LangGraph.
Agen terdiri dari langkah LLM dan alat. Saat berinteraksi dengan agen, pertama-tama kita akan memanggil LLM untuk memutuskan apakah kita harus menggunakan alat. Kemudian kita akan menjalankan loop:
Implementasi kodenya sangat mudah. Pertama, kita hubungkan alat-alat ke LLM dan tentukan langkah asisten:
llm = ChatOpenAI(model='gpt-4-turbo') tools = [movie_count, movie_list] llm_with_tools = llm.bind_tools(tools) # System message sys_msg = SystemMessage(content="You are a helpful assistant tasked with finding and explaining relevant information about movies.") # Node def assistant(state: MessagesState): return {"messages": [llm_with_tools.invoke([sys_msg] + state["messages"])]}
Berikutnya kita mendefinisikan aliran LangGraph:
# Graph builder = StateGraph(MessagesState) # Define nodes: these do the work builder.add_node("assistant", assistant) builder.add_node("tools", ToolNode(tools)) # Define edges: these determine how the control flow moves builder.add_edge(START, "assistant") builder.add_conditional_edges( "assistant", # If the latest message (result) from assistant is a tool call -> tools_condition routes to tools # If the latest message (result) from assistant is a not a tool call -> tools_condition routes to END tools_condition, ) builder.add_edge("tools", "assistant") react_graph = builder.compile()
Kami mendefinisikan dua simpul dalam LangGraph dan menghubungkannya dengan tepi bersyarat. Jika suatu alat dipanggil, aliran diarahkan ke alat tersebut; jika tidak, hasilnya dikirim kembali ke pengguna.
Sekarang mari kita uji agen kita:
messages = [ HumanMessage( content="What are the some movies about a girl meeting her hero?" ) ] messages = react_graph.invoke({"messages": messages}) for m in messages["messages"]: m.pretty_print()
Hasil:
Pada langkah pertama, agen memilih untuk menggunakan alat daftar film dengan parameter deskripsi yang sesuai. Tidak jelas mengapa alat tersebut memilih nilai k sebesar 5, tetapi tampaknya alat tersebut lebih menyukai angka tersebut. Alat tersebut menampilkan lima film paling relevan berdasarkan alur cerita, dan LLM hanya meringkasnya untuk pengguna di bagian akhir.
Bila kita bertanya kepada ChatGPT mengapa ia menyukai nilai k 5, kita akan mendapat respons berikut.
Selanjutnya, mari kita ajukan pertanyaan yang sedikit lebih rumit yang memerlukan penyaringan metadata:
messages = [ HumanMessage( content="What are the movies from the 90s about a girl meeting her hero?" ) ] messages = react_graph.invoke({"messages": messages}) for m in messages["messages"]: m.pretty_print()
Hasil:
Kali ini, argumen tambahan digunakan untuk memfilter film dari tahun 1990-an saja. Contoh ini akan menjadi contoh umum pemfilteran metadata menggunakan pendekatan pra-penyaringan. Pernyataan Cypher yang dihasilkan pertama-tama mempersempit film dengan memfilter berdasarkan tahun rilisnya. Pada bagian berikutnya, pernyataan Cypher menggunakan penyematan teks dan pencarian kesamaan vektor untuk menemukan film tentang seorang gadis kecil yang bertemu dengan pahlawannya.
Mari kita coba menghitung film berdasarkan berbagai kondisi:
messages = [ HumanMessage( content="How many movies are from the 90s have the rating higher than 9.1?" ) ] messages = react_graph.invoke({"messages": messages}) for m in messages["messages"]: m.pretty_print()
Hasil:
Dengan alat khusus untuk penghitungan, kompleksitas beralih dari LLM ke alat tersebut, sehingga LLM hanya bertanggung jawab untuk mengisi parameter fungsi yang relevan. Pemisahan tugas ini membuat sistem lebih efisien dan tangguh serta mengurangi kompleksitas input LLM.
Karena agen dapat memanggil beberapa alat secara berurutan atau paralel, mari mengujinya dengan sesuatu yang lebih kompleks:
messages = [ HumanMessage( content="How many were movies released per year made after the highest rated movie?" ) ] messages = react_graph.invoke({"messages": messages}) for m in messages["messages"]: m.pretty_print()
Hasil
Seperti yang disebutkan, agen dapat memanggil beberapa alat untuk mengumpulkan semua informasi yang diperlukan untuk menjawab pertanyaan. Dalam contoh ini, agen memulai dengan mencantumkan film dengan rating tertinggi untuk mengidentifikasi kapan film dengan rating tertinggi tersebut dirilis. Setelah memperoleh data tersebut, agen memanggil alat penghitungan film untuk mengumpulkan jumlah film yang dirilis setelah tahun yang ditentukan, menggunakan kunci pengelompokan seperti yang ditetapkan dalam pertanyaan.
Meskipun penyematan teks sangat baik untuk menelusuri data tak terstruktur, penyematan teks tidak memadai untuk operasi terstruktur seperti pemfilteran , pengurutan , dan agregasi . Tugas-tugas ini memerlukan alat yang dirancang untuk data terstruktur, yang menawarkan presisi dan fleksibilitas yang dibutuhkan untuk menangani operasi ini. Hal terpenting adalah bahwa memperluas rangkaian alat dalam sistem Anda memungkinkan Anda untuk menangani berbagai kueri pengguna, sehingga aplikasi Anda menjadi lebih tangguh dan serbaguna. Menggabungkan pendekatan data terstruktur dan teknik penelusuran teks tak terstruktur dapat memberikan respons yang lebih akurat dan relevan, yang pada akhirnya meningkatkan pengalaman pengguna dalam aplikasi RAG.
Seperti biasa, kodenya tersedia di GitHub .
Untuk mempelajari lebih lanjut tentang topik ini, bergabunglah dengan kami di NODES 2024 pada tanggal 7 November, konferensi pengembang virtual gratis kami tentang aplikasi cerdas, grafik pengetahuan, dan AI. Daftar Sekarang!