Part 2.1: Summarizing Text with LangChain


Series: AI Agents & Applications with LangChain, LangGraph and MCP
Part: 2.1 — Summarizing Text with LangChain

🌐
Switch language / Đổi ngôn ngữ

Trong chương 1, chúng ta đã khám phá ba loại ứng dụng LLM chính: summarization engines, chatbots, and AI agents. In this chapter, you’ll begin building practical summarization chains using LangChain, with a particular focus on the LangChain Expression Language (LCEL) to handle various real-world scenarios. A chain is a sequence of connected operations where the output of one step becomes the input for the next—ideal for automating tasks like summarization. This work lays the foundation for constructing a more advanced summarization engine in the next chapter.

Summarization engines are essential for automating the summarization of large document volumes, a task that would be impractical and costly to handle manually, even with tools such as ChatGPT. Starting with a summarization engine is a practical entry point for developing LLM applications, providing a solid base for more complex projects and showcasing LangChain’s capabilities, which we’ll further explore in later parts.

Before we start building, we’ll look at different summarization techniques, each suited to specific scenarios, including large documents, content consolidation, and handling structured data. You’ve already worked with summarizing small documents using a PromptTemplate in earlier examples, so we’ll skip that and focus on more complex scenarios.


Summarizing a Document Bigger Than the Context Window

As mentioned in Part 1, each LLM has a maximum prompt size, also referred to as the context window. As the context window for popular LLMs continues to grow, you may still encounter situations where a document exceeds the token limit of your chosen model. In these cases, you can use a MapReduce approach, as illustrated in figure 2.1.

Summarizing a document bigger than the LLM's context window Figure 2.1: Summarizing a document bigger than the LLM’s context window involves splitting the document into smaller chunks, summarizing each chunk, and then summarizing the combined chunk summaries.

DEFINITION: The LLM context window refers to the maximum amount of text—comprising both instructions and context—that can be provided to a language model in a single prompt. Different LLMs support different token limits for this context window, where one token roughly corresponds to one word. For example, GPT-3.5 could process up to 16,000 tokens, GPT-4 and Claude 3 supported up to 100,000 tokens, and newer models such as GPT-5 and Gemini can handle more than 1 million tokens.

When working with documents that exceed an LLM’s context window, a common strategy is to break the text into manageable chunks, generate summaries for each chunk, and then produce a final summary from those intermediate results. To start, you need to split the text into chunks using a tokenizer. A tokenizer reads the text and breaks it into tokens, which are the smallest units of text, often parts of words. After tokenizing the document, the tokens are grouped into chunks of a specific size. This lets you control the content size being processed by the LLM and ensures the token count stays within your LLM’s prompt limit.

Here, I’ll show you how to use TokenTextSplitter, part of the tiktoken package, a tokenizer developed by OpenAI.

Chunking the Text into Document Objects

Let’s summarize the book Moby Dick using its text file, Moby-Dick.txt, downloaded from the Project Gutenberg site (www.gutenberg.org). You can locate the Moby-Dick.txt file in the chapter’s subfolder on my GitHub page. Place this file in your ch03 folder, and load the text into a variable:

with open("./Moby-Dick.txt", 'r', encoding='utf-8') as f:
    moby_dick_book = f.read()

NOTE: Keep in mind that running the code on the full Moby Dick text can get expensive. The provided Moby-Dick.txt file is a shorter version, containing only five chapters and about 18,000 tokens. Running the code a few times with this version shouldn’t cost much. However, if you plan to run many tests, you may want to reduce the file size even more to save money. Each time you execute a chain with an LLMChain block, you’ll be charged. If budget allows, you can use the full version of the book, labeled Moby-Dick_ORIGINAL_EXPENSIVE.txt. The entire Moby Dick text has around 300,000 words, or about 350,000 tokens. Using GPT-5, which costs $1.25 per million tokens, processing the full text would cost about $0.37. Running it multiple times will add up. If you switch to GPT-5-nano, which costs $0.05 per million tokens, the cost drops to around $0.015, making it much cheaper.

I’ll cover chunking strategies in detail—such as chunking by size and content structure—in a later part. For now, let’s split the text into chunks of about 3,000 tokens each to simulate a context window shorter than the GPT-5-nano model we’ll be using.

Split

To start the split process, you need to do a little prep work. First, import the necessary libraries:

from langchain_openai import ChatOpenAI
from langchain_text_splitters import TokenTextSplitter
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableLambda, RunnableParallel
import getpass

Next, retrieve your OpenAI API key using the getpass function:

OPENAI_API_KEY = getpass.getpass('Enter your OPENAI_API_KEY')

You’ll be prompted to enter your OPENAI_API_KEY. After that, instantiate the OpenAI model in a new notebook cell:

llm = ChatOpenAI(openai_api_key=OPENAI_API_KEY, model_name="gpt-5-nano")

Now you’re ready to set up the first chain, which will break the document into chunks of the specified size. In this part, you’ll learn the basics of LCEL, with a more detailed exploration in the next part:

# Split
text_chunks_chain = (
    RunnableLambda(lambda x: 
        [
            {{
                'chunk': text_chunk, 
            }}
            for text_chunk in 
               TokenTextSplitter(chunk_size=3000, chunk_overlap=100).split_text(x)
        ]
    )
)

NOTE: LangChain chains are made up of components that implement the Runnable interface, an abstract Python class that defines how a component takes input, processes it, and returns output. Any class implementing Runnable can be part of a chain. RunnableLambda lets you turn any Python callable into a Runnable, making it easy to include custom functions in a LangChain chain. It’s similar to Python’s lambda expression, where you run code with a parameter and optionally return output without defining a full function. With RunnableLambda, you can create a chain component without writing a separate class to implement the Runnable interface. In this example, the code wrapped by RunnableLambda takes input text as a string through the x parameter and passes it to the split_text() function, which breaks the text into chunks.

Map

The next step is to set up the map chain, which will run a summarization prompt for each document chunk and ensure that every piece of the text is processed independently before being combined later:

# Map
summarize_chunk_prompt_template = """
Write a concise summary of the following text, and include the main details.
Text: {{chunk}}
"""

summarize_chunk_prompt = PromptTemplate.from_template(summarize_chunk_prompt_template)
summarize_chunk_chain = summarize_chunk_prompt | llm

summarize_map_chain = (
    RunnableParallel (
        {{
            'summary': summarize_chunk_chain | StrOutputParser()        
        }}
    )
)

In this code, the pipe operator (|) is used to chain components together, passing the output of one object as the input to the next. For instance, the summarize_chunk_prompt is piped into the llm, meaning the generated prompt is sent directly to the model. Similarly, the model’s output is piped into StrOutputParser(), which converts the model’s response into a clean text string.

NOTE: In this chain, I’ve used RunnableParallel, which is similar to RunnableLambda, but it operates on a sequence, processing each element in parallel. In this case, we’ll feed the sequence of text chunks to the summarize_map_chain, and each chunk will be summarized in parallel by the inner summarize_chunk_chain.

MapReduce: MapReduce is a programming model for processing large datasets in two steps. First, the map operation splits the data into smaller subsets, each processed independently by the same function. This step typically returns a list of results, grouped by a key. Next is the reduce operation, where results for each key are aggregated into a single outcome. The final output is a list of key–value pairs, where the keys come from the map step and the values are the result of aggregating the mapped data in the reduce step.

Reduce

Setting up the reduce chain, which summarizes the summaries from each document chunk, follows a process similar to the map chain but requires a bit more setup. Start by defining the prompt template:

# Reduce
summarize_summaries_prompt_template = """
Write a concise summary of the following text, which joins several summaries, and include the main details.
Text: {{summaries}}
"""

summarize_summaries_prompt = PromptTemplate.from_template(summarize_summaries_prompt_template)
summarize_reduce_chain = (
    RunnableLambda(lambda x: 
        {{
            'summaries': '\n'.join([i['summary'] for i in x]), 
        }})
    | summarize_summaries_prompt 
    | llm 
    | StrOutputParser()
)

The reduce chain includes a lambda function that combines the summaries from the map chain into a single string. This string is then processed by the summarize_summaries_prompt prompt, which generates a final summary of the combined content.

MapReduce Combined Chain

Finally, we combine the document-splitting chain, the map chain, and the reduce chain into a single MapReduce chain:

map_reduce_chain = (
   text_chunks_chain
   | summarize_map_chain.map()
   | summarize_reduce_chain
)

This setup efficiently splits the input document into chunks, summarizes each chunk, and then compiles those summaries into a final summary. The map() function on summarize_map_chain is essential to enable parallel processing of the chunks.

MapReduce Execution

Everything is now set up. Start the MapReduce summarization of the large document with this command (if you’re on the OpenAI free tier, this might fail with a 429 Rate Limit error):

summary = map_reduce_chain.invoke(moby_dick_book)
print(summary)

If you run print(summary), you’ll get output similar to the following:

The introduction to the Project Gutenberg eBook of "Moby-Dick; or The Whale"
by Herman Melville outlines the book's availability and updates, with the
first eBook release in June 2001 and the latest in August 2021. The narrative
begins with Ishmael, the narrator, who seeks solace at sea to escape his
melancholic state, showcasing the ocean's allure compared to city life. He
reflects on his reasons for joining a whaling voyage, driven by a fascination
with whales and a thirst for adventure. After arriving in New Bedford,
Ishmael faces challenges finding lodging, ultimately settling at "The Spouter
Inn," where he encounters a chaotic environment and a mysterious harpooneer
named Queequeg.

As Ishmael shares a bed with Queequeg, whom he initially fears to be a
cannibal, he gradually overcomes his apprehensions. The morning after their
first night together highlights their strange yet developing bond, as Ishmael
observes Queequeg's unique customs and politeness, emphasizing themes of
fate, choice, and the allure of the unknown in the whaling industry.

WARNING: As previously mentioned, running map_reduce_chain will incur costs; the longer the text you want to summarize, the higher the cost. Therefore, consider further shortening the input text (in our case, the Moby-Dick.txt e-book file) if you want to limit expenses. Additionally, ensure your OpenAI API credit balance remains positive to avoid errors such as the following: RateLimitError: Error code: 429 - {'error': {'message': 'You exceeded your current quota, please check your plan and billing details [...].' If necessary, log in to the OpenAI API page, navigate to Settings > Billing, and set a credit of at least $5.


Previous: ← Part 2: Summarization Overview
Next: Part 2.2: Summarizing Multiple Documents →

— Nguyen Dai, AI Engineer @ PIXTA Vietnam
GitHub · LinkedIn

Trong Phần 1, chúng ta đã khám phá ba loại ứng dụng LLM chính: summarization engines, chatbots và AI agents. Trong phần này, bạn sẽ bắt đầu xây dựng các summarization chains thực tế sử dụng LangChain, với trọng tâm đặc biệt vào LangChain Expression Language (LCEL) để xử lý các tình huống thực tế khác nhau. Một chain là một chuỗi các thao tác được kết nối với nhau trong đó đầu ra của một bước trở thành đầu vào cho bước tiếp theo—lý tưởng để tự động hóa các tác vụ như tóm tắt. Công việc này đặt nền móng cho việc xây dựng một summarization engine nâng cao hơn trong phần tiếp theo.

Summarization engines rất cần thiết để tự động hóa việc tóm tắt khối lượng lớn tài liệu, một nhiệm vụ không thực tế và tốn kém nếu xử lý thủ công, ngay cả với các công cụ như ChatGPT. Bắt đầu với summarization engine là điểm khởi đầu thực tế để phát triển ứng dụng LLM, cung cấp nền tảng vững chắc cho các dự án phức tạp hơn và thể hiện khả năng của LangChain, mà chúng ta sẽ khám phá thêm trong các phần sau.

Trước khi bắt đầu xây dựng, chúng ta sẽ xem xét các kỹ thuật tóm tắt khác nhau, mỗi kỹ thuật phù hợp với các tình huống cụ thể, bao gồm tài liệu lớn, hợp nhất nội dung và xử lý dữ liệu có cấu trúc. Bạn đã làm việc với việc tóm tắt tài liệu nhỏ bằng PromptTemplate trong các ví dụ trước đó, vì vậy chúng ta sẽ bỏ qua phần đó và tập trung vào các tình huống phức tạp hơn.


Tóm Tắt Tài Liệu Lớn Hơn Context Window

Như đã đề cập trong Phần 1, mỗi LLM có kích thước prompt tối đa, còn được gọi là context window. Khi context window cho các LLM phổ biến tiếp tục tăng lên, bạn vẫn có thể gặp phải tình huống mà tài liệu vượt quá giới hạn token của model bạn chọn. Trong những trường hợp này, bạn có thể sử dụng phương pháp MapReduce, như được minh họa trong hình 2.1.

Tóm tắt tài liệu lớn hơn context window của LLM Hình 2.1: Tóm tắt tài liệu lớn hơn context window của LLM bao gồm việc chia tài liệu thành các chunk nhỏ hơn, tóm tắt từng chunk, sau đó tóm tắt các bản tóm tắt chunk đã kết hợp.

ĐỊNH NGHĨA: Context window của LLM đề cập đến lượng văn bản tối đa—bao gồm cả hướng dẫn và ngữ cảnh—có thể được cung cấp cho một language model trong một prompt duy nhất. Các LLM khác nhau hỗ trợ giới hạn token khác nhau cho context window này, trong đó một token xấp xỉ tương ứng với một từ. Ví dụ, GPT-3.5 có thể xử lý tới 16,000 tokens, GPT-4 và Claude 3 hỗ trợ tới 100,000 tokens, và các model mới hơn như GPT-5 và Gemini có thể xử lý hơn 1 triệu tokens.

Khi làm việc với các tài liệu vượt quá context window của LLM, một chiến lược phổ biến là chia văn bản thành các chunks có thể quản lý được, tạo bản tóm tắt cho mỗi chunk, sau đó tạo bản tóm tắt cuối cùng từ các kết quả trung gian đó. Để bắt đầu, bạn cần chia văn bản thành các chunks bằng tokenizer. Tokenizer đọc văn bản và chia nó thành các tokens, là các đơn vị văn bản nhỏ nhất, thường là các phần của từ. Sau khi tokenize tài liệu, các tokens được nhóm thành các chunks có kích thước cụ thể. Điều này cho phép bạn kiểm soát kích thước nội dung được xử lý bởi LLM và đảm bảo số lượng token nằm trong giới hạn prompt của LLM.

Ở đây, tôi sẽ chỉ cho bạn cách sử dụng TokenTextSplitter, một phần của package tiktoken, một tokenizer được phát triển bởi OpenAI.

Chia Văn Bản Thành Các Document Objects

Hãy tóm tắt cuốn sách Moby Dick sử dụng file văn bản của nó, Moby-Dick.txt, tải xuống từ trang web Project Gutenberg (www.gutenberg.org). Bạn có thể tìm thấy file Moby-Dick.txt trong subfolder trên trang GitHub của tôi. Đặt file này trong thư mục code của bạn và load văn bản vào một biến:

with open("./Moby-Dick.txt", 'r', encoding='utf-8') as f:
    moby_dick_book = f.read()

LƯU Ý: Hãy nhớ rằng chạy code trên toàn bộ văn bản Moby Dick có thể tốn kém. File Moby-Dick.txt được cung cấp là phiên bản ngắn hơn, chỉ chứa năm phần và khoảng 18,000 tokens. Chạy code vài lần với phiên bản này sẽ không tốn nhiều chi phí. Tuy nhiên, nếu bạn dự định chạy nhiều bài test, bạn có thể muốn giảm kích thước file hơn nữa để tiết kiệm tiền. Mỗi lần bạn thực thi một chain với LLMChain block, bạn sẽ bị tính phí. Nếu ngân sách cho phép, bạn có thể sử dụng phiên bản đầy đủ của cuốn sách, có nhãn Moby-Dick_ORIGINAL_EXPENSIVE.txt. Toàn bộ văn bản Moby Dick có khoảng 300,000 từ, hoặc khoảng 350,000 tokens. Sử dụng GPT-5, có giá $1.25 mỗi triệu tokens, xử lý toàn bộ văn bản sẽ có giá khoảng $0.37. Chạy nó nhiều lần sẽ tăng lên. Nếu bạn chuyển sang GPT-5-nano, có giá $0.05 mỗi triệu tokens, chi phí giảm xuống khoảng $0.015, rẻ hơn nhiều.

Tôi sẽ đề cập chi tiết về các chiến lược chunking—như chunking theo kích thước và cấu trúc nội dung—trong phần sau. Hiện tại, hãy chia văn bản thành các chunks khoảng 3,000 tokens mỗi cái để mô phỏng context window ngắn hơn so với model GPT-5-nano mà chúng ta sẽ sử dụng.

Split

Để bắt đầu quá trình split, bạn cần thực hiện một chút công việc chuẩn bị. Đầu tiên, import các thư viện cần thiết:

from langchain_openai import ChatOpenAI
from langchain_text_splitters import TokenTextSplitter
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableLambda, RunnableParallel
import getpass

Tiếp theo, lấy OpenAI API key của bạn sử dụng hàm getpass:

OPENAI_API_KEY = getpass.getpass('Enter your OPENAI_API_KEY')

Bạn sẽ được nhắc nhập OPENAI_API_KEY của mình. Sau đó, khởi tạo OpenAI model trong một notebook cell mới:

llm = ChatOpenAI(openai_api_key=OPENAI_API_KEY, model_name="gpt-5-nano")

Bây giờ bạn đã sẵn sàng thiết lập chain đầu tiên, sẽ chia tài liệu thành các chunks có kích thước được chỉ định. Trong phần này, bạn sẽ học các kiến thức cơ bản về LCEL, với khám phá chi tiết hơn trong phần tiếp theo:

# Split
text_chunks_chain = (
    RunnableLambda(lambda x: 
        [
            {{
                'chunk': text_chunk, 
            }}
            for text_chunk in 
               TokenTextSplitter(chunk_size=3000, chunk_overlap=100).split_text(x)
        ]
    )
)

LƯU Ý: LangChain chains được tạo thành từ các components implement Runnable interface, một abstract Python class định nghĩa cách một component nhận input, xử lý nó và trả về output. Bất kỳ class nào implement Runnable đều có thể là một phần của chain. RunnableLambda cho phép bạn biến bất kỳ Python callable nào thành Runnable, giúp dễ dàng bao gồm các custom functions trong LangChain chain. Nó tương tự như lambda expression của Python, nơi bạn chạy code với một parameter và tùy chọn trả về output mà không cần định nghĩa một function đầy đủ. Với RunnableLambda, bạn có thể tạo một chain component mà không cần viết một class riêng để implement Runnable interface. Trong ví dụ này, code được wrapped bởi RunnableLambda nhận input text dưới dạng string thông qua parameter x và truyền nó đến hàm split_text(), hàm này chia văn bản thành các chunks.

Map

Bước tiếp theo là thiết lập map chain, sẽ chạy một summarization prompt cho mỗi document chunk và đảm bảo rằng mọi phần của văn bản được xử lý độc lập trước khi được kết hợp sau:

# Map
summarize_chunk_prompt_template = """
Write a concise summary of the following text, and include the main details.
Text: {{chunk}}
"""

summarize_chunk_prompt = PromptTemplate.from_template(summarize_chunk_prompt_template)
summarize_chunk_chain = summarize_chunk_prompt | llm

summarize_map_chain = (
    RunnableParallel (
        {{
            'summary': summarize_chunk_chain | StrOutputParser()        
        }}
    )
)

Trong code này, toán tử pipe (|) được sử dụng để chain các components lại với nhau, truyền output của một object làm input cho object tiếp theo. Ví dụ, summarize_chunk_prompt được pipe vào llm, có nghĩa là prompt được tạo ra được gửi trực tiếp đến model. Tương tự, output của model được pipe vào StrOutputParser(), chuyển đổi response của model thành một chuỗi văn bản sạch.

LƯU Ý: Trong chain này, tôi đã sử dụng RunnableParallel, tương tự như RunnableLambda, nhưng nó hoạt động trên một sequence, xử lý từng phần tử song song. Trong trường hợp này, chúng ta sẽ feed sequence của các text chunks vào summarize_map_chain, và mỗi chunk sẽ được tóm tắt song song bởi summarize_chunk_chain bên trong.

MapReduce: MapReduce là một programming model để xử lý các datasets lớn theo hai bước. Đầu tiên, map operation chia dữ liệu thành các subsets nhỏ hơn, mỗi subset được xử lý độc lập bởi cùng một function. Bước này thường trả về một list kết quả, được nhóm theo một key. Tiếp theo là reduce operation, nơi kết quả cho mỗi key được tổng hợp thành một outcome duy nhất. Output cuối cùng là một list các cặp key–value, trong đó các keys đến từ bước map và các values là kết quả của việc tổng hợp dữ liệu được map trong bước reduce.

Reduce

Thiết lập reduce chain, tóm tắt các bản tóm tắt từ mỗi document chunk, tuân theo một quy trình tương tự như map chain nhưng yêu cầu thêm một chút thiết lập. Bắt đầu bằng cách định nghĩa prompt template:

# Reduce
summarize_summaries_prompt_template = """
Write a concise summary of the following text, which joins several summaries, and include the main details.
Text: {{summaries}}
"""

summarize_summaries_prompt = PromptTemplate.from_template(summarize_summaries_prompt_template)
summarize_reduce_chain = (
    RunnableLambda(lambda x: 
        {{
            'summaries': '\n'.join([i['summary'] for i in x]), 
        }})
    | summarize_summaries_prompt 
    | llm 
    | StrOutputParser()
)

Reduce chain bao gồm một lambda function kết hợp các bản tóm tắt từ map chain thành một chuỗi duy nhất. Chuỗi này sau đó được xử lý bởi summarize_summaries_prompt prompt, tạo ra một bản tóm tắt cuối cùng của nội dung đã kết hợp.

MapReduce Combined Chain

Cuối cùng, chúng ta kết hợp document-splitting chain, map chain và reduce chain thành một MapReduce chain duy nhất:

map_reduce_chain = (
   text_chunks_chain
   | summarize_map_chain.map()
   | summarize_reduce_chain
)

Thiết lập này hiệu quả chia input document thành các chunks, tóm tắt mỗi chunk, sau đó biên soạn các bản tóm tắt đó thành một bản tóm tắt cuối cùng. Hàm map() trên summarize_map_chain rất cần thiết để cho phép xử lý song song các chunks.

Thực Thi MapReduce

Mọi thứ giờ đã được thiết lập. Bắt đầu MapReduce summarization của tài liệu lớn với lệnh này (nếu bạn đang ở free tier của OpenAI, điều này có thể thất bại với lỗi 429 Rate Limit):

summary = map_reduce_chain.invoke(moby_dick_book)
print(summary)

Nếu bạn chạy print(summary), bạn sẽ nhận được output tương tự như sau:

The introduction to the Project Gutenberg eBook of "Moby-Dick; or The Whale"
by Herman Melville outlines the book's availability and updates, with the
first eBook release in June 2001 and the latest in August 2021. The narrative
begins with Ishmael, the narrator, who seeks solace at sea to escape his
melancholic state, showcasing the ocean's allure compared to city life. He
reflects on his reasons for joining a whaling voyage, driven by a fascination
with whales and a thirst for adventure. After arriving in New Bedford,
Ishmael faces challenges finding lodging, ultimately settling at "The Spouter
Inn," where he encounters a chaotic environment and a mysterious harpooneer
named Queequeg.

As Ishmael shares a bed with Queequeg, whom he initially fears to be a
cannibal, he gradually overcomes his apprehensions. The morning after their
first night together highlights their strange yet developing bond, as Ishmael
observes Queequeg's unique customs and politeness, emphasizing themes of
fate, choice, and the allure of the unknown in the whaling industry.

CẢNH BÁO: Như đã đề cập trước đó, chạy map_reduce_chain sẽ phát sinh chi phí; văn bản càng dài bạn muốn tóm tắt, chi phí càng cao. Do đó, hãy xem xét việc rút ngắn thêm văn bản input (trong trường hợp của chúng ta là file e-book Moby-Dick.txt) nếu bạn muốn giới hạn chi phí. Ngoài ra, đảm bảo số dư credit API OpenAI của bạn vẫn còn dương để tránh các lỗi như sau: RateLimitError: Error code: 429 - {'error': {'message': 'You exceeded your current quota, please check your plan and billing details [...].' Nếu cần thiết, đăng nhập vào trang OpenAI API, điều hướng đến Settings > Billing, và đặt một credit ít nhất $5.


Trước đó: ← Part 2: Tổng Quan Summarization
Tiếp theo: Part 2.2: Tóm Tắt Nhiều Tài Liệu →

— Nguyễn Đài, AI Engineer @ PIXTA Vietnam
GitHub · LinkedIn