在这篇教程中,我们将学习如何使用 langgraph 构建一个智能文档检索系统。该系统能够从网页中提取信息,进行智能分段,并通过查询分析、向量检索实现精准的问答功能。
1.安装依赖 pip install beautifulsoup4
2. 导入必要的库 1 2 3 4 5 6 7 8 9 10 import bs4from typing import Literal from typing_extensions import List , TypedDict, Annotatedfrom langchain_openai import ChatOpenAI, OpenAIEmbeddingsfrom langchain_core.vectorstores import InMemoryVectorStorefrom langchain_community.document_loaders import WebBaseLoaderfrom langchain_core.documents import Documentfrom langchain_text_splitters import RecursiveCharacterTextSplitterfrom langgraph.graph import START, StateGraphfrom langchain_core.prompts import PromptTemplate
3. 网页内容加载 WebBaseLoader 是 LangChain 提供的一个强大的网页内容加载器,它的工作流程如下:
URL 获取 :使用 urllib 库从指定的 URL 获取原始 HTML 内容
HTML 解析 :使用 BeautifulSoup4 库解析 HTML 内容
内容过滤 :通过 bs_kwargs
参数可以自定义解析规则
在我们的例子中,使用 SoupStrainer("li")
只提取列表项内容
这样可以有效过滤掉网页中的导航栏、页脚等无关内容
1 2 3 4 5 6 7 loader = WebBaseLoader( web_paths=("https://github.com/jobbole/awesome-python-cn/blob/master/README.md" ,), bs_kwargs=dict ( parse_only=bs4.SoupStrainer("li" ) ), ) docs = loader.load()
4. 文档智能分割 文本分割器采用递归策略,具体步骤如下:
初始分割 :首先尝试使用最高级别的分隔符(如换行符、段落符号)
递归处理 :如果分割后的块仍然过大,则使用次级分隔符(如句号、分号)继续分割
重叠处理 :
chunk_overlap=200 表示每个相邻块之间共享200个字符
这种重叠设计确保了上下文的连续性,防止句子被生硬切断
例如,如果一个重要概念横跨两个块,通过重叠可以在检索时完整捕获这个概念
1 2 text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000 , chunk_overlap=200 ) all_splits = text_splitter.split_documents(docs)
5. 元数据增强 为了实现更智能的检索,我们为文档添加位置相关的元数据。这种元数据可以帮助我们在检索时进行更精确的过滤:
1 2 3 4 5 6 7 8 9 total_documents = len (all_splits) third = total_documents // 3 for i, document in enumerate (all_splits): if i < third: document.metadata["section" ] = "beginning" elif i < 2 * third: document.metadata["section" ] = "middle" else : document.metadata["section" ] = "end"
通过添加 section 元数据,我们可以:
在检索时进行定向搜索
只搜索文档开头、中间或结尾部分的内容
提高检索的精确度
6. 定义查询模式 使用 TypedDict 定义查询的数据结构,确保查询的规范性和可维护性:
1 2 3 4 5 6 7 8 class Search (TypedDict ): """Search query.""" query: Annotated[str , ..., "Search query to run." ] section: Annotated[ Literal ["beginning" , "middle" , "end" ], ..., "Section to query." , ]
7. 向量存储设置 InMemoryVectorStore 提供了高效的向量存储和检索功能:
使用 OpenAI 的 text-embedding-3-large 模型将文本转换为高维向量
每个文档块都会被转换为一个独特的向量表示
1 2 3 embeddings = OpenAIEmbeddings(model="text-embedding-3-large" ) vector_store = InMemoryVectorStore(embeddings) _ = vector_store.add_documents(documents=all_splits)
8. 设置语言模型和提示模板 提示词模板的设计考虑了以下几个关键点:
上下文注入 :
将检索到的文档内容作为上下文提供给语言模型
使用 {context} 和 {question} 占位符动态插入内容
回答约束 :
限制回答最多三个句子,保持简洁
明确指示在不确定时承认不知道,避免编造答案
添加固定的结束语”thanks for asking!”,保持一致的交互风格
1 2 3 4 5 6 7 8 9 10 11 12 13 llm = ChatOpenAI(model="gpt-4o-mini" ) template = """Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know, don't try to make up an answer. Use three sentences maximum and keep the answer as concise as possible. Always say "thanks for asking!" at the end of the answer. {context} Question: {question} Helpful Answer:""" prompt = PromptTemplate.from_template(template)
9. 构建处理流程 LangGraph 提供了一个灵活的工作流程管理系统,它允许我们将复杂的处理流程分解为多个独立的步骤,并通过状态管理来协调这些步骤之间的数据流转。
9.1 状态管理 首先,我们定义了一个 TypedDict 来管理整个处理流程中的状态:
1 2 3 4 5 class State (TypedDict ): question: str query: Search context: List [Document] answer: str
这个状态字典包含了处理流程中的所有关键数据:
question:存储用户输入的原始问题
query:存储经过分析后的结构化查询(使用之前定义的 Search 类型)
context:存储从向量数据库检索到的相关文档
answer:存储最终生成的答案
9.2 处理步骤 处理流程被分解为三个主要步骤,每个步骤都是一个独立的函数,接收当前状态并返回更新后的状态部分:
查询分析(analyze_query) :1 2 3 4 5 6 7 def analyze_query (state: State ): structured_llm = llm.with_structured_output(Search) query = structured_llm.invoke(state["question" ]) return {"query" : query}
这个步骤的作用是:
接收用户的自然语言问题
使用 LLM 分析问题并生成结构化查询
确定查询应该在文档的哪个部分(开始、中间、结尾)进行搜索
文档检索(retrieve) :1 2 3 4 5 6 7 8 9 def retrieve (state: State ): query = state["query" ] retrieved_docs = vector_store.similarity_search( query["query" ], filter =lambda doc: doc.metadata.get("section" ) == query["section" ], ) return {"context" : retrieved_docs}
这个步骤的功能包括:
从状态中获取结构化查询
使用查询文本在向量存储中搜索相似文档
使用 section 元数据过滤文档
返回最相关的文档列表
答案生成(generate) :1 2 3 4 5 6 7 8 9 10 11 12 def generate (state: State ): docs_content = "\n\n" .join(doc.page_content for doc in state["context" ]) messages = prompt.invoke({ "question" : state["question" ], "context" : docs_content }) response = llm.invoke(messages) return {"answer" : response.content}
这个步骤的处理流程是:
将所有检索到的文档内容合并成一个文本
使用提示模板构造包含上下文和问题的输入
调用 LLM 生成最终答案
返回生成的答案文本
10. 组装处理图 使用 LangGraph 将各个处理步骤串联成有向无环图:
每个步骤的输出会自动更新状态,供下一步使用
支持条件分支和并行处理(本例中使用简单的线性流程)
1 2 3 graph_builder = StateGraph(State).add_sequence([analyze_query, retrieve, generate]) graph_builder.add_edge(START, "analyze_query" ) graph = graph_builder.compile ()
11.使用示例 1 2 3 4 5 for message, metadata in graph.stream( {"question" : "请列出文章末尾部分推荐的Python库有哪些?" }, stream_mode="messages" ): print (message.content, end="" )
12.总结 这个项目展示了如何使用 langgraph 构建一个完整的智能文档检索系统。系统的主要特点包括:
智能网页内容提取
文档的智能分割和元数据增强
向量化存储和相似度检索
基于 LLM 的智能问答
流程化的处理架构
通过这个系统,我们可以轻松地实现对大型文档的智能检索和问答功能。这种架构不仅适用于网页内容,还可以扩展到其他类型的文档处理场景。
13.完整代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 import bs4from typing import Literal from typing_extensions import List , TypedDict, Annotatedfrom langchain_openai import ChatOpenAIfrom langchain_openai import OpenAIEmbeddingsfrom langchain_core.vectorstores import InMemoryVectorStorefrom langchain import hubfrom langchain_community.document_loaders import WebBaseLoaderfrom langchain_core.documents import Documentfrom langchain_text_splitters import RecursiveCharacterTextSplitterfrom langgraph.graph import START, StateGraphfrom langchain_core.prompts import PromptTemplateloader = WebBaseLoader( web_paths=("https://github.com/jobbole/awesome-python-cn/blob/master/README.md" ,), bs_kwargs=dict ( parse_only=bs4.SoupStrainer("li" ) ), ) docs = loader.load() text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000 , chunk_overlap=200 ) all_splits = text_splitter.split_documents(docs) total_documents = len (all_splits) third = total_documents // 3 for i, document in enumerate (all_splits): if i < third: document.metadata["section" ] = "beginning" elif i < 2 * third: document.metadata["section" ] = "middle" else : document.metadata["section" ] = "end" class Search (TypedDict ): """Search query.""" query: Annotated[str , ..., "Search query to run." ] section: Annotated[ Literal ["beginning" , "middle" , "end" ], ..., "Section to query." , ] embeddings = OpenAIEmbeddings(model="text-embedding-3-large" ) vector_store = InMemoryVectorStore(embeddings) _ = vector_store.add_documents(documents=all_splits) llm = ChatOpenAI(model="gpt-4o-mini" ) template = """Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know, don't try to make up an answer. Use three sentences maximum and keep the answer as concise as possible. Always say "thanks for asking!" at the end of the answer. {context} Question: {question} Helpful Answer:""" prompt = PromptTemplate.from_template(template) class State (TypedDict ): question: str query: Search context: List [Document] answer: str def analyze_query (state: State ): structured_llm = llm.with_structured_output(Search) query = structured_llm.invoke(state["question" ]) return {"query" : query} def retrieve (state: State ): query = state["query" ] retrieved_docs = vector_store.similarity_search( query["query" ], filter =lambda doc: doc.metadata.get("section" ) == query["section" ], ) return {"context" : retrieved_docs} def generate (state: State ): docs_content = "\n\n" .join(doc.page_content for doc in state["context" ]) messages = prompt.invoke({"question" : state["question" ], "context" : docs_content}) response = llm.invoke(messages) return {"answer" : response.content} graph_builder = StateGraph(State).add_sequence([analyze_query, retrieve, generate]) graph_builder.add_edge(START, "analyze_query" ) graph = graph_builder.compile () for message, metadata in graph.stream( {"question" : "请列出文章末尾部分推荐的Python库有哪些?" }, stream_mode="messages" ): print (message.content, end="" )