AI TỐT
Chia sẻ kiến thức AI để tốt hơn

Xây dựng một pipeline RAG có tư duy sâu và khả năng tự chủ để giải quyết các truy vấn phức tạp

68 phút đọc
Fareed Khan
Fareed Khan

Một hệ thống RAG thường thất bại không phải vì LLM thiếu thông minh, mà vì kiến trúc của nó quá đơn giản. Nó cố gắng xử lý một vấn đề có tính chu kỳ, nhiều bước bằng một phương pháp tuyến tính, một lần duy nhất.

Nhiều truy vấn phức tạp đòi hỏi suy luận, phản tư, và quyết định thông minh về thời điểm hành động, giống như cách chúng ta truy xuất thông tin khi đối mặt với một câu hỏi. Đó là lúc các hành động do agent điều khiển trong pipeline RAG phát huy tác dụng. Hãy cùng xem một pipeline RAG có tư duy sâu điển hình trông như thế nào…

Pipeline RAG tư duy sâu (Tạo bởi Fareed Khan)

Trong blog này, chúng ta sẽ triển khai toàn bộ pipeline RAG tư duy sâu và so sánh nó với một pipeline RAG cơ bản để chứng minh cách nó giải quyết các truy vấn đa bước phức tạp.

Toàn bộ code và lý thuyết có trong Repository GitHub của tôi:

GitHub - FareedKhan-dev/deep-thinking-rag: A Deep Thinking RAG Pipeline to Solve Complex Queries

A Deep Thinking RAG Pipeline to Solve Complex Queries - GitHub - FareedKhan-dev/deep-thinking-rag: A Deep Thinking RAG…

github.com

Mục lục

Thiết lập môi trường

Trước khi có thể bắt đầu code pipeline Deep RAG, chúng ta cần bắt đầu với một nền tảng vững chắc vì một hệ thống AI cấp sản xuất không chỉ là về thuật toán cuối cùng, mà còn là về những lựa chọn có chủ ý mà chúng ta thực hiện trong quá trình thiết lập.

Mỗi bước chúng ta sắp triển khai đều quan trọng trong việc xác định hiệu quả và độ tin cậy của hệ thống cuối cùng.

Khi bắt đầu phát triển một pipeline và thực hiện thử nghiệm, tốt hơn là nên định nghĩa cấu hình của chúng ta ở định dạng từ điển đơn giản vì sau này, khi pipeline trở nên phức tạp, chúng ta có thể dễ dàng tham chiếu lại từ điển này để thay đổi cấu hình và xem tác động của nó lên hiệu suất tổng thể.

# Central Configuration Dictionary to manage all system parameters
config = {
    "data_dir": "./data",                           # Directory to store raw and cleaned data
    "vector_store_dir": "./vector_store",           # Directory to persist our vector store
    "llm_provider": "openai",                       # The LLM provider we are using
    "reasoning_llm": "gpt-4o",                      # The powerful model for planning and synthesis
    "fast_llm": "gpt-4o-mini",                      # A faster, cheaper model for simpler tasks like the baseline RAG
    "embedding_model": "text-embedding-3-small",    # The model for creating document embeddings
    "reranker_model": "cross-encoder/ms-marco-MiniLM-L-6-v2", # The model for precision reranking
    "max_reasoning_iterations": 7,                  # A safeguard to prevent the agent from getting into an infinite loop
    "top_k_retrieval": 10,                          # Number of documents for initial broad recall
    "top_n_rerank": 3,                              # Number of documents to keep after precision reranking
}

Các key này khá dễ hiểu nhưng có ba key đáng chú ý:

  • llm_provider: Đây là nhà cung cấp LLM chúng ta đang sử dụng, trong trường hợp này là OpenAI. Tôi đang sử dụng OpenAI vì chúng ta có thể dễ dàng hoán đổi các model và nhà cung cấp trong LangChain, nhưng bạn có thể chọn bất kỳ nhà cung cấp nào phù hợp với nhu cầu của mình như Ollama.
  • reasoning_llm: Đây phải là model mạnh nhất trong toàn bộ thiết lập của chúng ta vì nó sẽ được sử dụng để lập kế hoạch và tổng hợp.
  • fast_llm: Đây nên là một model nhanh hơn và rẻ hơn vì nó sẽ được sử dụng cho các tác vụ đơn giản hơn như RAG cơ bản.

Bây giờ chúng ta cần nhập các thư viện cần thiết mà chúng ta sẽ sử dụng trong suốt pipeline cùng với việc thiết lập các khóa API làm biến môi trường để tránh để lộ chúng trong các khối code.

import os                  # For interacting with the operating system (e.g., managing environment variables)
import re                  # For regular expression operations, useful for text cleaning
import json                # For working with JSON data
from getpass import getpass # To securely prompt for user input like API keys without echoing to the screen
from pprint import pprint   # For pretty-printing Python objects, making them more readable
import uuid                # To generate unique identifiers
from typing import List, Dict, TypedDict, Literal, Optional # For type hinting to create clean, readable, and maintainable code

# Helper function to securely set environment variables if they are not already present
def _set_env(var: str):
    # Check if the environment variable is not already set
    if not os.environ.get(var):
        # If not, prompt the user to enter it securely
        os.environ[var] = getpass(f"Enter your {var}: ")

# Set the API keys for the services we will use
_set_env("OPENAI_API_KEY")      # For accessing OpenAI models (GPT-4o, embeddings)
_set_env("LANGSMITH_API_KEY")   # For tracing and debugging with LangSmith
_set_env("TAVILY_API_KEY")      # For the web search tool

# Enable LangSmith tracing to get detailed logs and visualizations of our agent's execution
os.environ["LANGSMITH_TRACING"] = "true"
# Define a project name in LangSmith to organize our runs
os.environ["LANGSMITH_PROJECT"] = "Advanced-Deep-Thinking-RAG"

Chúng ta cũng đang bật LangSmith để theo dõi. Khi bạn làm việc với một hệ thống agentic có quy trình làm việc phức tạp, theo chu kỳ, việc theo dõi không chỉ là một tiện ích mà còn rất quan trọng. Nó giúp bạn hình dung những gì đang diễn ra và giúp việc gỡ lỗi quá trình tư duy của agent trở nên dễ dàng hơn nhiều.

Tìm kiếm cơ sở tri thức

Một hệ thống RAG cấp sản xuất đòi hỏi một cơ sở tri thức vừa phức tạp vừa đòi hỏi cao để thực sự chứng minh được hiệu quả của nó. Vì mục đích này, chúng ta sẽ sử dụng báo cáo 10-K năm 2023 của NVIDIA, một tài liệu toàn diện dài hơn một trăm trang, chi tiết về hoạt động kinh doanh, hiệu suất tài chính và các yếu tố rủi ro đã được công bố của công ty.

Tìm kiếm cơ sở tri thức (Tạo bởi Fareed Khan)

Đầu tiên, chúng ta sẽ triển khai một hàm tùy chỉnh để tải xuống báo cáo 10-K một cách có lập trình trực tiếp từ cơ sở dữ liệu SEC EDGAR, phân tích HTML thô và chuyển đổi nó thành định dạng văn bản sạch và có cấu trúc, phù hợp để nạp vào pipeline RAG của chúng ta. Vậy hãy code hàm đó.

import requests # For making HTTP requests to download the document
from bs4 import BeautifulSoup # A powerful library for parsing HTML and XML documents
from langchain.docstore.document import Document # LangChain's standard data structure for a piece of text

def download_and_parse_10k(url, doc_path_raw, doc_path_clean):
    # Check if the cleaned file already exists to avoid re-downloading
    if os.path.exists(doc_path_clean):
        print(f"Cleaned 10-K file already exists at: {doc_path_clean}")
        return

    print(f"Downloading 10-K filing from {url}...")
    # Set a User-Agent header to mimic a browser, as some servers block scripts
    headers = {'User-Agent': 'Mozilla/5.0'}
    # Make the GET request to the URL
    response = requests.get(url, headers=headers)
    # Raise an error if the download fails (e.g., 404 Not Found)
    response.raise_for_status()

    # Save the raw HTML content to a file for inspection
    with open(doc_path_raw, 'w', encoding='utf-8') as f:
        f.write(response.text)
    print(f"Raw document saved to {doc_path_raw}")

    # Use BeautifulSoup to parse and clean the HTML content
    soup = BeautifulSoup(response.content, 'html.parser')

    # Extract text from common HTML tags, attempting to preserve paragraph structure
    text = ''
    for p in soup.find_all(['p', 'div', 'span']):
        # Get the text from each tag, stripping extra whitespace, and add newlines
        text += p.get_text(strip=True) + '\n\n'

    # Use regex to clean up excessive newlines and spaces for a cleaner final text
    clean_text = re.sub(r'\n{3,}', '\n\n', text).strip() # Collapse 3+ newlines into 2
    clean_text = re.sub(r'\s{2,}', ' ', clean_text).strip() # Collapse 2+ spaces into 1

    # Save the final cleaned text to a .txt file
    with open(doc_path_clean, 'w', encoding='utf-8') as f:
        f.write(clean_text)
    print(f"Cleaned text content extracted and saved to {doc_path_clean}")

Đoạn code khá dễ hiểu, chúng ta đang sử dụng beautifulsoup4 để phân tích nội dung HTML và trích xuất văn bản. Nó sẽ giúp chúng ta dễ dàng điều hướng cấu trúc HTML và truy xuất thông tin liên quan trong khi bỏ qua bất kỳ yếu tố không cần thiết nào như script hoặc style.

Bây giờ, hãy thực thi nó và xem nó hoạt động như thế nào.

print("Downloading and parsing NVIDIA's 2023 10-K filing...")
# Execute the download and parsing function
download_and_parse_10k(url_10k, doc_path_raw, doc_path_clean)

# Open the cleaned file and print a sample to verify the result
with open(doc_path_clean, 'r', encoding='utf-8') as f:
    print("\n--- Sample content from cleaned 10-K ---")
    print(f.read(1000) + "...")

#### OUTPUT ####
Downloading and parsing NVIDIA 2023 10-K filing...
Successfully downloaded 10-K filing from https://www.sec.gov/Archives/edgar/data/1045810/000104581023000017/nvda-20230129.htm
Raw document saved to ./data/nvda_10k_2023_raw.html
Cleaned text content extracted and saved to ./data/nvda_10k_2023_clean.txt

# --- Sample content from cleaned 10-K ---
Item 1. Business. 
 OVERVIEW 
 NVIDIA is the pioneer of accelerated computing. We are a full-stack computing company with a platform strategy that brings together hardware, systems, software, algorithms, libraries, and services to create unique value for the markets we serve. Our work in accelerated computing and AI is reshaping the worlds largest industries and profoundly impacting society. 
 Founded in 1993, we started as a PC graphics chip company, inventing the graphics processing unit, or GPU. The GPU was essential for the growth of the PC gaming market and has since been repurposed to revolutionize computer graphics, high performance computing, or HPC, and AI. 
 The programmability of our GPUs made them ...

Chúng ta chỉ đơn giản là gọi hàm này để lưu trữ tất cả nội dung vào một tệp txt, tệp này sẽ đóng vai trò là ngữ cảnh cho pipeline RAG của chúng ta.

Khi chạy đoạn code trên, bạn có thể thấy nó bắt đầu tải xuống báo cáo cho chúng ta và chúng ta có thể xem một mẫu nội dung đã tải xuống trông như thế nào.

Hiểu rõ truy vấn đa nguồn, đa bước của chúng ta

Để kiểm tra pipeline đã triển khai và so sánh nó với RAG cơ bản, chúng ta cần sử dụng một truy vấn rất phức tạp bao gồm các khía cạnh khác nhau của tài liệu mà chúng ta đang làm việc.

Truy vấn phức tạp của chúng ta:

"Dựa trên báo cáo 10-K năm 2023 của NVIDIA, hãy xác định các rủi ro chính của họ liên quan đến cạnh tranh. Sau đó, tìm tin tức gần đây (sau ngày nộp báo cáo, từ năm 2024) về chiến lược chip AI của AMD và giải thích chiến lược mới này trực tiếp giải quyết hoặc làm trầm trọng thêm một trong những rủi ro đã nêu của NVIDIA như thế nào."

Hãy phân tích tại sao truy vấn này lại khó đối với một pipeline RAG tiêu chuẩn:

  1. Đa bước (Multi-hop): Nó không phải là một câu hỏi duy nhất. Nó yêu cầu một chuỗi các bước suy luận: đầu tiên tìm rủi ro của NVIDIA, sau đó tìm chiến lược của AMD, và cuối cùng là tổng hợp và phân tích mối liên hệ giữa hai điều đó.
  2. Đa nguồn (Multi-source): Nó yêu cầu thông tin từ hai nguồn hoàn toàn khác nhau: một tài liệu tĩnh, lịch sử (báo cáo 10-K năm 2023) và thông tin động, cập nhật từ web (tin tức năm 2024).
  3. Phụ thuộc thời gian (Temporal Dependency): Nó yêu cầu kiến thức về các sự kiện xảy ra sau khi tài liệu nguồn chính được xuất bản, một điểm yếu kinh điển của các hệ thống RAG đơn giản.

Trong phần tiếp theo, chúng ta sẽ triển khai pipeline RAG cơ bản và thực sự xem RAG đơn giản thất bại với truy vấn này như thế nào.

Xây dựng một pipeline RAG nông sẽ thất bại

Bây giờ chúng ta đã cấu hình xong môi trường và có sẵn cơ sở tri thức đầy thách thức, bước hợp lý tiếp theo là xây dựng một pipeline RAG vanilla (cơ bản) tiêu chuẩn. Điều này phục vụ một mục đích quan trọng…

Bằng cách xây dựng giải pháp đơn giản nhất có thể trước tiên, chúng ta có thể chạy truy vấn phức tạp của mình trên đó và quan sát chính xác cách thứclý do nó thất bại.

Đây là những gì chúng ta sẽ làm trong phần này:

Pipeline RAG nông (Tạo bởi Fareed Khan)

  • Tải và phân mảnh tài liệu: Chúng ta sẽ nạp báo cáo 10-K đã được làm sạch và chia nó thành các đoạn nhỏ, có kích thước cố định — một cách tiếp cận phổ biến nhưng ngây thơ về mặt ngữ nghĩa.
  • Tạo một kho vector: Sau đó, chúng ta sẽ nhúng các đoạn này và lập chỉ mục chúng trong một kho vector ChromaDB để cho phép tìm kiếm ngữ nghĩa cơ bản.
  • Lắp ráp chuỗi RAG: Chúng ta sẽ sử dụng Ngôn ngữ Biểu thức LangChain (LCEL), sẽ kết nối retriever, một mẫu prompt và một LLM thành một pipeline tuyến tính.
  • Chứng minh sự thất bại nghiêm trọng: Chúng ta sẽ thực thi truy vấn đa bước, đa nguồn của mình trên hệ thống đơn giản này và phân tích phản hồi không đầy đủ của nó.

Đầu tiên, chúng ta cần tải tài liệu đã được làm sạch và phân chia nó. Chúng ta sẽ sử dụng RecursiveCharacterTextSplitter, một công cụ tiêu chuẩn trong hệ sinh thái LangChain.

from langchain_community.document_loaders import TextLoader # A simple loader for .txt files
from langchain.text_splitter import RecursiveCharacterTextSplitter # A standard text splitter

print("Loading and chunking the document...")
# Initialize the loader with the path to our cleaned 10-K file
loader = TextLoader(doc_path_clean, encoding='utf-8')
# Load the document into memory
documents = loader.load()

# Initialize the text splitter with a defined chunk size and overlap
# chunk_size=1000: Each chunk will be approximately 1000 characters long.
# chunk_overlap=150: Each chunk will share 150 characters with the previous one to maintain some context.
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=150)
# Split the loaded document into smaller, manageable chunks
doc_chunks = text_splitter.split_documents(documents)

print(f"Document loaded and split into {len(doc_chunks)} chunks.")

#### OUTPUT ####
Loading and chunking the document...
Document loaded and split into 378 chunks.

Chúng ta có 378 đoạn (chunks) từ tài liệu chính, bước tiếp theo là làm cho chúng có thể tìm kiếm được. Để làm điều này, chúng ta cần tạo các vector embedding và lưu trữ chúng trong một cơ sở dữ liệu. Chúng ta sẽ sử dụng ChromaDB, một kho vector trong bộ nhớ phổ biến, và model text-embedding-3-small của OpenAI như đã định nghĩa trong cấu hình của chúng ta.

from langchain_community.vectorstores import Chroma # The vector store we will use
from langchain_openai import OpenAIEmbeddings # The function to create embeddings

print("Creating baseline vector store...")
# Initialize the embedding function using the model specified in our config
embedding_function = OpenAIEmbeddings(model=config['embedding_model'])

# Create the Chroma vector store from our document chunks
# This process takes each chunk, creates an embedding for it, and indexes it.
baseline_vector_store = Chroma.from_documents(
    documents=doc_chunks,
    embedding=embedding_function
)
# Create a retriever from the vector store
# The retriever is the component that will actually perform the search.
# search_kwargs={"k": 3}: This tells the retriever to return the top 3 most relevant chunks for any given query.
baseline_retriever = baseline_vector_store.as_retriever(search_kwargs={"k": 3})

print(f"Vector store created with {baseline_vector_store._collection.count()} embeddings.")

#### OUTPUT ####
Creating baseline vector store...
Vector store created with 378 embeddings.

Chroma.from_documents tổ chức quá trình này và lưu trữ tất cả các vector trong một chỉ mục có thể tìm kiếm. Bước cuối cùng là lắp ráp chúng thành một chuỗi RAG duy nhất, có thể chạy được bằng Ngôn ngữ Biểu thức LangChain (LCEL).

Chuỗi này sẽ định nghĩa luồng dữ liệu tuyến tính: từ câu hỏi của người dùng đến retriever, sau đó đến prompt, và cuối cùng là đến LLM.

from langchain_core.prompts import ChatPromptTemplate # For creating prompt templates
from langchain_openai import ChatOpenAI # The OpenAI chat model interface
from langchain_core.runnable import RunnablePassthrough # A tool to pass inputs through the chain
from langchain_core.output_parsers import StrOutputParser # To parse the LLM's output as a simple string

# This template instructs the LLM on how to behave.
# {context}: This is where we will inject the content from our retrieved documents.
# {question}: This is where the user's original question will go.
template = """You are an AI financial analyst. Answer the question based only on the following context:
{context}

Question: {question}
"""
prompt = ChatPromptTemplate.from_template(template)
# We use our 'fast_llm' for this simple task, as defined in our config
llm = ChatOpenAI(model=config["fast_llm"], temperature=0)

# A helper function to format the list of retrieved documents into a single string
def format_docs(docs):
    return "\n\n---\n\n".join(doc.page_content for doc in docs)

# The complete RAG chain defined using LCEL's pipe (|) syntax
baseline_rag_chain = (
    # The first step is a dictionary that defines the inputs to our prompt
    {"context": baseline_retriever | format_docs, "question": RunnablePassthrough()}
    # The context is generated by taking the question, passing it to the retriever, and formatting the result
    # The original question is passed through unchanged
    | prompt # The dictionary is then passed to the prompt template
    | llm # The formatted prompt is passed to the language model
    | StrOutputParser() # The LLM's output message is parsed into a string
)

Bạn có biết rằng chúng ta định nghĩa một từ điển là bước đầu tiên. Key context của nó được điền bởi một chuỗi con, câu hỏi đầu vào đi đến baseline_retriever, và đầu ra của nó (một danh sách các đối tượng Document) được định dạng thành một chuỗi duy nhất bởi format_docs. Key question được điền bằng cách đơn giản là truyền đầu vào ban đầu qua bằng RunnablePassthrough.

Hãy chạy pipeline đơn giản này và hiểu tại sao nó lại thất bại.

from rich.console import Console # For pretty-printing output with markdown
from rich.markdown import Markdown

# Initialize the rich console for better output formatting
console = Console()

# Our complex, multi-hop, multi-source query
complex_query_adv = "Based on NVIDIA's 2023 10-K filing, identify their key risks related to competition. Then, find recent news (post-filing, from 2024) about AMD's AI chip strategy and explain how this new strategy directly addresses or exacerbates one of NVIDIA's stated risks."

print("Executing complex query on the baseline RAG chain...")
# Invoke the chain with our challenging query
baseline_result = baseline_rag_chain.invoke(complex_query_adv)

console.print("\n--- BASELINE RAG FAILED OUTPUT ---")
# Print the result using markdown formatting for readability
console.print(Markdown(baseline_result))

Khi bạn chạy đoạn code trên, chúng ta nhận được kết quả sau.

#### OUTPUT ####
Executing complex query on the baseline RAG chain...

--- BASELINE RAG FAILED OUTPUT ---
Dựa trên ngữ cảnh được cung cấp, NVIDIA hoạt động trong một ngành công nghiệp bán dẫn
cạnh tranh khốc liệt và đối mặt với sự cạnh tranh từ các công ty như AMD. Ngữ cảnh đề cập
rằng ngành này được đặc trưng bởi sự thay đổi công nghệ nhanh chóng. Tuy nhiên, các tài liệu được cung cấp không chứa bất kỳ thông tin cụ thể nào về chiến lược chip AI gần đây của AMD từ năm 2024 hoặc cách nó có thể ảnh hưởng đến các rủi ro đã nêu của NVIDIA.

Có ba điều bạn có thể nhận thấy trong pipeline RAG thất bại này và kết quả của nó.

  • Ngữ cảnh không liên quan: Retriever lấy các đoạn chung chung về “NVIDIA”, “cạnh tranh”“AMD” nhưng bỏ lỡ các chi tiết cụ thể về chiến lược của AMD năm 2024.
  • Thiếu thông tin: Thất bại chính là dữ liệu năm 2023 không thể bao gồm các sự kiện năm 2024. Hệ thống không nhận ra rằng nó đang thiếu thông tin quan trọng.
  • Không có kế hoạch hay sử dụng công cụ: Nó coi truy vấn phức tạp như một truy vấn đơn giản. Nó không thể chia nhỏ thành các bước hoặc sử dụng các công cụ như tìm kiếm web để lấp đầy khoảng trống.

Hệ thống thất bại không phải vì LLM ngu ngốc mà vì kiến trúc quá đơn giản. Đó là một quy trình tuyến tính, một lần duy nhất cố gắng giải quyết một vấn đề có tính chu kỳ, nhiều bước.

Bây giờ chúng ta đã hiểu các vấn đề với pipeline RAG cơ bản, chúng ta có thể bắt đầu triển khai phương pháp tư duy sâu của mình và xem nó giải quyết truy vấn phức tạp của chúng ta tốt như thế nào.

Định nghĩa RAG State cho hệ thống Agent trung tâm

Để xây dựng agent suy luận của chúng ta, trước tiên chúng ta cần một cách để quản lý trạng thái của nó. Trong chuỗi RAG đơn giản của chúng ta, mỗi bước đều không có trạng thái, nhưng…

một agent thông minh, tuy nhiên, cần một bộ nhớ. Nó cần nhớ câu hỏi ban đầu, kế hoạch nó đã tạo ra, và bằng chứng nó đã thu thập được cho đến nay.

Trạng thái RAG (Tạo bởi Fareed Khan)

RAGState sẽ hoạt động như một bộ nhớ trung tâm, được truyền giữa mọi node trong quy trình làm việc LangGraph của chúng ta. Để xây dựng nó, chúng ta sẽ định nghĩa một loạt các lớp dữ liệu có cấu trúc, bắt đầu với khối xây dựng cơ bản nhất: một bước duy nhất trong một kế hoạch nghiên cứu.

Chúng ta muốn định nghĩa đơn vị nguyên tử trong kế hoạch của agent. Mỗi Step không chỉ phải chứa một câu hỏi cần trả lời, mà còn cả lý do đằng sau nó và, quan trọng là, công cụ cụ thể mà agent nên sử dụng. Điều này buộc quá trình lập kế hoạch của agent phải rõ ràng và có cấu trúc.

from langchain_core.documents import Document
from langchain_core.pydantic_v1 import BaseModel, Field

# Pydantic model for a single step in the agent's reasoning plan
class Step(BaseModel):
    # A specific, answerable sub-question for this research step
    sub_question: str = Field(description="A specific, answerable question for this step.")
    # The agent's justification for why this step is necessary
    justification: str = Field(description="A brief explanation of why this step is necessary to answer the main query.")
    # The specific tool to use for this step: either internal document search or external web search
    tool: Literal["search_10k", "search_web"] = Field(description="The tool to use for this step.")
    # A list of critical keywords to improve the accuracy of the search
    keywords: List[str] = Field(description="A list of critical keywords for searching relevant document sections.")
    # (Optional) A likely document section to perform a more targeted, filtered search within
    document_section: Optional[str] = Field(description="A likely document section title (e.g., 'Item 1A. Risk Factors') to search within. Only for 'search_10k' tool.")

Lớp Step của chúng ta, sử dụng Pydantic BaseModel, hoạt động như một hợp đồng nghiêm ngặt cho Agent lập kế hoạch của chúng ta. Trường tool: Literal[...] buộc LLM phải đưa ra quyết định cụ thể giữa việc sử dụng kiến thức nội bộ của chúng ta (search_10k) hoặc tìm kiếm thông tin bên ngoài (search_web).

Đầu ra có cấu trúc này đáng tin cậy hơn nhiều so với việc cố gắng phân tích một kế hoạch bằng ngôn ngữ tự nhiên.

Bây giờ chúng ta đã định nghĩa một Step duy nhất, chúng ta cần một container để chứa toàn bộ chuỗi các bước. Chúng ta sẽ tạo một lớp Plan đơn giản là một danh sách các đối tượng Step. Điều này đại diện cho chiến lược nghiên cứu từ đầu đến cuối hoàn chỉnh của agent.

# Pydantic model for the overall plan, which is a list of individual steps
class Plan(BaseModel):
    # A list of Step objects that outlines the full research plan
    steps: List[Step] = Field(description="A detailed, multi-step plan to answer the user's query.")

Chúng ta đã code một lớp Plan sẽ cung cấp cấu trúc cho toàn bộ quá trình nghiên cứu. Khi chúng ta gọi Agent lập kế hoạch của mình, chúng ta sẽ yêu cầu nó trả về một đối tượng JSON tuân thủ lược đồ này. Điều này đảm bảo rằng chiến lược của agent rõ ràng, tuần tự và có thể đọc được bằng máy trước khi bất kỳ hành động truy xuất nào được thực hiện.

Tiếp theo, khi agent thực hiện kế hoạch của mình, nó cần một cách để ghi nhớ những gì nó đã học được. Chúng ta sẽ định nghĩa một từ điển PastStep để lưu trữ kết quả của mỗi bước đã hoàn thành. Điều này sẽ hình thành lịch sử nghiên cứu hoặc sổ tay phòng thí nghiệm của agent.

# A TypedDict to store the results of a completed step in our research history
class PastStep(TypedDict):
    step_index: int              # The index of the completed step (e.g., 1, 2, 3)
    sub_question: str            # The sub-question that was addressed in this step
    retrieved_docs: List[Document] # The precise documents retrieved and reranked for this step
    summary: str                 # The agent's one-sentence summary of the findings from this step

Cấu trúc PastStep này rất quan trọng cho vòng lặp tự phê bình của agent. Sau mỗi bước, chúng ta sẽ điền vào một trong những từ điển này và thêm nó vào trạng thái của chúng ta. Agent sau đó sẽ có thể xem lại danh sách tóm tắt ngày càng tăng này để hiểu những gì nó biết và quyết định xem nó có đủ thông tin để hoàn thành nhiệm vụ của mình hay không.

Cuối cùng, chúng ta sẽ tập hợp tất cả các mảnh này lại thành từ điển RAGState chính. Đây là đối tượng trung tâm sẽ chảy qua toàn bộ đồ thị của chúng ta, chứa truy vấn ban đầu, kế hoạch đầy đủ, lịch sử các bước đã qua và tất cả dữ liệu trung gian cho bước hiện tại đang được thực thi.

# The main state dictionary that will be passed between all nodes in our LangGraph agent
class RAGState(TypedDict):
    original_question: str     # The initial, complex query from the user that starts the process
    plan: Plan                 # The multi-step plan generated by the Planner Agent
    past_steps: List[PastStep] # A cumulative history of completed research steps and their findings
    current_step_index: int    # The index of the current step in the plan being executed
    retrieved_docs: List[Document] # Documents retrieved in the current step (results of broad recall)
    reranked_docs: List[Document]  # Documents after precision reranking in the current step
    synthesized_context: str   # The concise, distilled context generated from the reranked docs
    final_answer: str          # The final, synthesized answer to the user's original question

RAGState TypedDict này là tâm trí hoàn chỉnh của agent của chúng ta. Mọi node trong đồ thị của chúng ta sẽ nhận từ điển này làm đầu vào và trả về một phiên bản cập nhật của nó làm đầu ra.

Ví dụ, plan_node sẽ điền vào trường plan, retrieval_node sẽ điền vào trường retrieved_docs, và cứ thế. Trạng thái chung, bền vững này là thứ cho phép suy luận phức tạp, lặp đi lặp lại mà chuỗi RAG đơn giản của chúng ta thiếu.

Với bản thiết kế cho bộ nhớ của agent đã được định nghĩa, chúng ta đã sẵn sàng để xây dựng thành phần nhận thức đầu tiên của hệ thống: Agent lập kế hoạch sẽ điền vào trạng thái này.

Lập kế hoạch chiến lược và xây dựng truy vấn

Với RAGState đã được định nghĩa, giờ đây chúng ta có thể xây dựng thành phần nhận thức đầu tiên và có thể nói là quan trọng nhất của agent: khả năng lập kế hoạch. Đây là nơi hệ thống của chúng ta thực hiện bước nhảy vọt từ một trình tìm nạp dữ liệu đơn giản thành một công cụ suy luận thực sự. Thay vì ngây thơ coi truy vấn phức tạp của người dùng như một tìm kiếm duy nhất, agent của chúng ta trước tiên sẽ dừng lại, suy nghĩ và xây dựng một chiến lược nghiên cứu chi tiết, từng bước.

Lập kế hoạch chiến lược (Tạo bởi Fareed Khan)

Phần này được chia thành ba bước kỹ thuật chính:

  • Planner nhận biết công cụ: Chúng ta sẽ xây dựng một agent được hỗ trợ bởi LLM có công việc duy nhất là phân rã truy vấn của người dùng thành một đối tượng Plan có cấu trúc, quyết định công cụ nào sẽ sử dụng cho mỗi bước.
  • Agent viết lại truy vấn: Chúng ta sẽ tạo một agent chuyên biệt để biến các câu hỏi phụ đơn giản của planner thành các truy vấn tìm kiếm hiệu quả, được tối ưu hóa cao.
  • Phân mảnh nhận biết metadata: Chúng ta sẽ xử lý lại tài liệu nguồn của mình để thêm metadata cấp độ phần, một bước quan trọng mở ra khả năng truy xuất có độ chính xác cao, được lọc.

Phân rã vấn đề với Planner nhận biết công cụ

Về cơ bản, chúng ta muốn xây dựng bộ não của hoạt động. Điều đầu tiên bộ não này cần làm khi nhận được một câu hỏi phức tạp là tìm ra một kế hoạch hành động.

Bước phân rã (Tạo bởi Fareed Khan)

Chúng ta không thể chỉ ném toàn bộ câu hỏi vào cơ sở dữ liệu và hy vọng vào điều tốt nhất. Chúng ta cần dạy cho agent cách chia nhỏ vấn đề thành các phần nhỏ hơn, dễ quản lý.

Để làm điều này, chúng ta sẽ tạo một Agent lập kế hoạch chuyên dụng. Chúng ta cần cung cấp cho nó một bộ hướng dẫn rất rõ ràng, hoặc một prompt, cho nó biết chính xác công việc của nó là gì.

from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from rich.pretty import pprint as rprint

# The system prompt that instructs the LLM how to behave as a planner
planner_prompt = ChatPromptTemplate.from_messages([
    ("system", """You are an expert research planner. Your task is to create a clear, multi-step plan to answer a complex user query by retrieving information from multiple sources.
You have two tools available:
1. `search_10k`: Use this to search for information within NVIDIA's 2023 10-K financial filing. This is best for historical facts, financial data, and stated company policies or risks from that specific time period.
2. `search_web`: Use this to search the public internet for recent news, competitor information, or any topic that is not specific to NVIDIA's 2023 10-K.
Decompose the user's query into a series of simple, sequential sub-questions. For each step, decide which tool is more appropriate.
For `search_10k` steps, also identify the most likely section of the 10-K (e.g., 'Item 1A. Risk Factors', 'Item 7. Management's Discussion and Analysis...').
It is critical to use the exact section titles found in a 10-K filing where possible."""),
    ("human", "User Query: {question}") # The user's original, complex query
])

Về cơ bản, chúng ta đang trao cho LLM một vai trò mới: một chuyên gia lập kế hoạch nghiên cứu. Chúng ta nói rõ cho nó biết về hai công cụ mà nó có (search_10ksearch_web) và hướng dẫn nó khi nào nên sử dụng mỗi công cụ. Đây là phần "nhận biết công cụ".

Chúng ta không chỉ yêu cầu nó một kế hoạch mà còn yêu cầu nó tạo ra một kế hoạch ánh xạ trực tiếp đến các khả năng mà chúng ta đã xây dựng.

Bây giờ chúng ta có thể khởi tạo model suy luận và kết nối nó với prompt của chúng ta. Một bước rất quan trọng ở đây là nói cho LLM biết rằng đầu ra cuối cùng của nó phải ở định dạng của lớp Pydantic Plan của chúng ta. Điều này làm cho đầu ra có cấu trúc và có thể dự đoán được.

# Initialize our powerful reasoning model, as defined in the config
reasoning_llm = ChatOpenAI(model=config["reasoning_llm"], temperature=0)

# Create the planner agent by piping the prompt to the LLM and instructing it to use our structured 'Plan' output
planner_agent = planner_prompt | reasoning_llm.with_structured_output(Plan)
print("Tool-Aware Planner Agent created successfully.")

# Let's test the planner agent with our complex query to see its output
print("\n--- Testing Planner Agent ---")
test_plan = planner_agent.invoke({"question": complex_query_adv})

# Use rich's pretty print for a clean, readable display of the Pydantic object
rprint(test_plan)

Chúng ta lấy planner_prompt, kết nối nó với reasoning_llm mạnh mẽ của chúng ta, và sau đó sử dụng phương thức .with_structured_output(Plan). Điều này yêu cầu LangChain sử dụng khả năng gọi hàm của model để định dạng phản hồi của nó thành một đối tượng JSON hoàn toàn khớp với lược đồ Pydantic Plan của chúng ta. Điều này đáng tin cậy hơn nhiều so với việc cố gắng phân tích một phản hồi văn bản thuần túy.

Hãy xem kết quả khi chúng ta kiểm tra nó với truy vấn thách thức của mình.

#### OUTPUT ####
Tool-Aware Planner Agent created successfully.

--- Testing Planner Agent ---
Plan(
│   steps=[
│   │   Step(
│   │   │   sub_question="What are the key risks related to competition as stated in NVIDIA's 2023 10-K filing?",
│   │   │   justification="This step is necessary to extract the foundational information about competitive risks directly from the source document as requested by the user.",
│   │   │   tool='search_10k',
│   │   │   keywords=['competition', 'risk factors', 'semiconductor industry', 'competitors'],
│   │   │   document_section='Item 1A. Risk Factors'
│   │   ),
│   │   Step(
│   │   │   sub_question="What are the recent news and developments in AMD's AI chip strategy in 2024?",
│   │   │   justification="This step requires finding up-to-date, external information that is not available in the 2023 10-K filing. A web search is necessary to get the latest details on AMD's strategy.",
│   │   │   tool='search_web',
│   │   │   keywords=['AMD', 'AI chip strategy', '2024', 'MI300X', 'Instinct accelerator'],
│   │   │   document_section=None
│   │   )
│   ]
)

Nếu chúng ta nhìn vào kết quả, bạn có thể thấy rằng agent không chỉ cho chúng ta một kế hoạch mơ hồ, nó đã tạo ra một đối tượng Plan có cấu trúc. Nó đã xác định chính xác rằng truy vấn có hai phần.

  1. Bước 1: Nó cần tìm các rủi ro cạnh tranh trong báo cáo 10-K. Nó đã chọn đúng công cụ (search_10k), đề xuất các từ khóa liên quan và thậm chí xác định phần có khả năng nhất để tìm kiếm: Item 1A. Risk Factors.
  2. Bước 2: Nó cần tìm tin tức về chiến lược chip AI của AMD. Nó nhận ra thông tin này không có trong tài liệu năm 2023 và đã chọn đúng công cụ (search_web) cùng với các từ khóa tìm kiếm phù hợp.

Tối ưu hóa truy xuất với Agent viết lại truy vấn

Về cơ bản, chúng ta có một kế hoạch với các câu hỏi phụ tốt.

Nhưng một câu hỏi như "Rủi ro là gì?" không phải là một truy vấn tìm kiếm tốt. Nó quá chung chung. Các công cụ tìm kiếm, dù là cơ sở dữ liệu vector hay tìm kiếm web, hoạt động tốt nhất với các truy vấn cụ thể, giàu từ khóa.

Agent viết lại truy vấn (Tạo bởi Fareed Khan)

Để khắc phục điều này, chúng ta sẽ xây dựng một agent nhỏ, chuyên biệt khác: Agent viết lại truy vấn. Công việc duy nhất của nó là lấy câu hỏi phụ cho bước hiện tại và làm cho nó tốt hơn cho việc tìm kiếm bằng cách thêm các từ khóa và ngữ cảnh liên quan từ những gì chúng ta đã học được.

Đầu tiên, hãy thiết kế prompt cho agent mới này.

from langchain_core.output_parsers import StrOutputParser # To parse the LLM's output as a simple string

# The prompt for our query rewriter, instructing it to act as a search expert
query_rewriter_prompt = ChatPromptTemplate.from_messages([
    ("system", """You are a search query optimization expert. Your task is to rewrite a given sub-question into a highly effective search query for a vector database or web search engine, using keywords and context from the research plan.
The rewritten query should be specific, use terminology likely to be found in the target source (a financial 10-K or news articles), and be structured to retrieve the most relevant text snippets."""),
    ("human", "Current sub-question: {sub_question}\n\nRelevant keywords from plan: {keywords}\n\nContext from past steps:\n{past_context}")
])

Về cơ bản, chúng ta đang yêu cầu agent này hành động như một chuyên gia tối ưu hóa truy vấn tìm kiếm. Chúng ta cung cấp cho nó ba mẩu thông tin để làm việc: sub_question đơn giản, keywords mà planner của chúng ta đã xác định, và past_context từ bất kỳ bước nghiên cứu nào trước đó. Điều này cung cấp cho nó tất cả nguyên liệu thô cần thiết để xây dựng một truy vấn tốt hơn nhiều.

Bây giờ chúng ta có thể khởi tạo agent này. Đây là một chuỗi đơn giản vì chúng ta chỉ cần một chuỗi làm đầu ra.

# Create the agent by piping the prompt to our reasoning LLM and a string output parser
query_rewriter_agent = query_rewriter_prompt | reasoning_llm | StrOutputParser()
print("Query Rewriter Agent created successfully.")

# Let's test the rewriter agent. We'll pretend we've already completed the first two steps of our plan.
print("\n--- Testing Query Rewriter Agent ---")

# Let's imagine we are at a final synthesis step that needs context from the first two.
test_sub_q = "How does AMD's 2024 AI chip strategy potentially exacerbate the competitive risks identified in NVIDIA's 10-K?"
test_keywords = ['impact', 'threaten', 'competitive pressure', 'market share', 'technological change']

# We create some mock "past context" to simulate what the agent would know at this point in a real run.
test_past_context = "Step 1 Summary: NVIDIA's 10-K lists intense competition and rapid technological change as key risks. Step 2 Summary: AMD launched its MI300X AI accelerator in 2024 to directly compete with NVIDIA's H100."

# Invoke the agent with our test data
rewritten_q = query_rewriter_agent.invoke({
    "sub_question": test_sub_q,
    "keywords": test_keywords,
    "past_context": test_past_context
})

print(f"Original sub-question: {test_sub_q}")
print(f"Rewritten Search Query: {rewritten_q}")

Để kiểm tra điều này một cách đúng đắn, chúng ta phải mô phỏng một kịch bản thực tế. Chúng ta tạo một chuỗi test_past_context đại diện cho các tóm tắt mà agent đã tạo ra từ hai bước đầu tiên của kế hoạch. Sau đó, chúng ta cung cấp điều này, cùng với câu hỏi phụ tiếp theo, cho query_rewriter_agent của chúng ta.

Hãy xem kết quả.

#### OUTPUT ####
Query Rewriter Agent created successfully.

--- Testing Query Rewriter Agent ---
Original sub-question: How does AMD 2024 AI chip strategy potentially exacerbate the competitive risks identified in NVIDIA 10-K?
Rewritten Search Query: analysis of how AMD 2024 AI chip strategy, including products like the MI300X, exacerbates NVIDIA's stated competitive risks such as rapid technological change and market share erosion in the data center and AI semiconductor industry

Câu hỏi ban đầu dành cho một nhà phân tích, truy vấn được viết lại dành cho một công cụ tìm kiếm. Nó đã được gán các thuật ngữ cụ thể như “MI300X”, “suy giảm thị phần”“trung tâm dữ liệu”, tất cả đều được tổng hợp từ các từ khóa và ngữ cảnh trong quá khứ.

Một truy vấn như thế này có nhiều khả năng truy xuất chính xác các tài liệu phù hợp, làm cho toàn bộ hệ thống của chúng ta chính xác và hiệu quả hơn. Bước viết lại này sẽ là một phần quan trọng trong vòng lặp agentic chính của chúng ta.

Độ chính xác với việc phân mảnh nhận biết metadata

Về cơ bản, Agent lập kế hoạch của chúng ta đang cho chúng ta một cơ hội tốt. Nó không chỉ nói tìm rủi ro, nó còn cho chúng ta một gợi ý: tìm rủi ro trong phần Item 1A. Risk Factors.

Nhưng hiện tại, retriever của chúng ta không thể sử dụng gợi ý đó. Kho vector của chúng ta chỉ là một danh sách phẳng, lớn gồm 378 đoạn văn bản. Nó không biết "phần" là gì.

Phân mảnh nhận biết metadata (Tạo bởi Fareed Khan)

Chúng ta cần khắc phục điều này. Chúng ta sẽ xây dựng lại các đoạn tài liệu của mình từ đầu. Lần này, đối với mỗi đoạn chúng ta tạo ra, chúng ta sẽ thêm một nhãn hoặc một thẻmetadata của nó — cho hệ thống biết chính xác nó đến từ phần nào của báo cáo 10-K. Điều này sẽ cho phép agent của chúng ta thực hiện các tìm kiếm có độ chính xác cao, được lọc sau này.

Đầu tiên, chúng ta cần một cách để tìm ra vị trí bắt đầu của mỗi phần trong tệp văn bản thô của mình một cách có lập trình. Nếu chúng ta nhìn vào tài liệu, chúng ta có thể thấy một mẫu rõ ràng: mỗi phần chính bắt đầu bằng từ “ITEM” theo sau là một số, như “ITEM 1A” hoặc “ITEM 7”. Đây là một công việc hoàn hảo cho một biểu thức chính quy.

# This regex is designed to find section titles like 'ITEM 1A.' or 'ITEM 7.' in the 10-K text.
# It looks for the word 'ITEM', followed by a space, a number, an optional letter, a period, and then captures the title text.
# The `re.IGNORECASE | re.DOTALL` flags make the search case-insensitive and allow '.' to match newlines.
section_pattern = r"(ITEM\\s+\\d[A-Z]?\\.\\s*.*?)(?=\\nITEM\\s+\\d[A-Z]?\\.|$)"

Về cơ bản, chúng ta đang tạo ra một mẫu sẽ hoạt động như bộ phát hiện phần của chúng ta. Nó nên được thiết kế để đủ linh hoạt để bắt các định dạng khác nhau trong khi đủ cụ thể để không lấy nhầm văn bản.

Bây giờ chúng ta có thể sử dụng mẫu này để cắt tài liệu của mình thành hai danh sách riêng biệt: một chứa chỉ các tiêu đề phần, và một chứa nội dung trong mỗi phần.

# We'll work with the raw text loaded earlier from our Document object
raw_text = documents[0].page_content

# Use re.findall to apply our pattern and extract all section titles into a list
section_titles = re.findall(section_pattern, raw_text, re.IGNORECASE | re.DOTALL)

# A quick cleanup step to remove any extra whitespace or newlines from the titles
section_titles = [title.strip().replace('\\n', ' ') for title in section_titles]

# Now, use re.split to break the document apart at each point where a section title occurs
sections_content = re.split(section_pattern, raw_text, flags=re.IGNORECASE | re.DOTALL)

# The split results in a list with titles and content mixed, so we filter it to get only the content parts
sections_content = [content.strip() for content in sections_content if content.strip() and not content.strip().lower().startswith('item ')]
print(f"Identified {len(section_titles)} document sections.")

# This is a crucial sanity check: if the number of titles doesn't match the number of content blocks, something went wrong.
assert len(section_titles) == len(sections_content), "Mismatch between titles and content sections"

Đây là một cách rất hiệu quả để phân tích một tài liệu bán cấu trúc. Chúng ta đã sử dụng mẫu regex của mình hai lần: một lần để có được một danh sách sạch tất cả các tiêu đề phần, và một lần nữa để chia văn bản chính thành một danh sách các khối nội dung. Câu lệnh assert cho chúng ta sự tự tin rằng logic phân tích của chúng ta là đúng đắn.

Được rồi, bây giờ chúng ta có các mảnh ghép: một danh sách các tiêu đề và một danh sách nội dung tương ứng. Bây giờ chúng ta có thể lặp qua chúng và tạo ra các đoạn cuối cùng, giàu metadata của mình.

import uuid # We'll use this to give each chunk a unique ID, which is good practice

# This list will hold our new, metadata-rich document chunks
doc_chunks_with_metadata = []

# Loop through each section's content along with its title using enumerate
for i, content in enumerate(sections_content):
    # Get the corresponding title for the current content block
    section_title = section_titles[i]
    # Use the same text splitter as before, but this time, we run it ONLY on the content of the current section
    section_chunks = text_splitter.split_text(content)
    
    # Now, loop through the smaller chunks created from this one section
    for chunk in section_chunks:
        # Generate a unique ID for this specific chunk
        chunk_id = str(uuid.uuid4())
        # Create a new LangChain Document object for the chunk
        doc_chunks_with_metadata.append(
            Document(
                page_content=chunk,
                # This is the most important part: we attach the metadata
                metadata={
                    "section": section_title,      # The section this chunk belongs to
                    "source_doc": doc_path_clean,  # Where the document came from
                    "id": chunk_id                 # The unique ID for this chunk
                }
            )
        )

print(f"Created {len(doc_chunks_with_metadata)} chunks with section metadata.")
print("\n--- Sample Chunk with Metadata ---")

# To prove it worked, let's find a chunk that we know should be in the 'Risk Factors' section and print it
sample_chunk = next(c for c in doc_chunks_with_metadata if "Risk Factors" in c.metadata.get("section", ""))
print(sample_chunk)

Đây là cốt lõi của việc nâng cấp của chúng ta. Chúng ta lặp qua từng phần một. Đối với mỗi phần, chúng ta tạo các đoạn văn bản. Nhưng trước khi thêm chúng vào danh sách cuối cùng, chúng ta tạo một từ điển metadata và đính kèm section_title. Điều này thực sự gắn thẻ mọi đoạn văn với nguồn gốc của nó.

Hãy xem kết quả và thấy sự khác biệt.

#### OUTPUT ####
Processing document and adding metadata...
Identified 22 document sections.
Created 381 chunks with section metadata.

--- Sample Chunk with Metadata ---
Document(
│   page_content='Our industry is intensely competitive. We operate in the semiconductor\\nindustry, which is intensely competitive and characterized by rapid\\ntechnological change and evolving industry standards. We compete with a number of\\ncompanies that have different business models and different combinations of\\nhardware, software, and systems expertise, many of which have substantially\\ngreater resources than we have. We expect competition to increase from existing\\ncompetitors, as well as new and emerging companies. Our competitors include\\nIntel, AMD, and Qualcomm; cloud service providers, or CSPs, such as Amazon Web\\nServices, or AWS, Google Cloud, and Microsoft Azure; and various companies\\ndeveloping or that may develop processors or systems for the AI, HPC, data\\ncenter, gaming, professional visualization, and automotive markets. Some of our\\ncustomers are also our competitors. Our business could be materially and\\nadversely affected if our competitors announce or introduce new products, services,\\nor technologies that have better performance or features, are less expensive, or\\nthat gain market acceptance.',
│   metadata={
│

Theo dõi trên X

Fareed Khan

Bài đăng liên quan