在实现 RAG(Retrieval Augmented Generation,检索增强生成)
系统的时候,在检索(retrieve)知识库时通常一个参数。
本文将演练更复杂一点的情况:通过两个参数进行知识库的检索。为此会在 langgraph
链 中增加一个 analyze_query
节点,它用来基于用户问题推理检索的参数。
本次构建的 LangGraph
链结构如下图:
使用
qwen2.5
、deepseek
以及llama3.1
做实验,用shaw/dmeta-embedding-zh
做中文嵌入和检索。
准备
在正式开始撸代码之前,需要准备一下编程环境。
-
计算机
本文涉及的所有代码可以在没有显存的环境中执行。 我使用的机器配置为:
- CPU: Intel i5-8400 2.80GHz
- 内存: 16GB
-
Visual Studio Code 和 venv 这是很受欢迎的开发工具,相关文章的代码可以在
Visual Studio Code
中开发和调试。 我们用python
的venv
创建虚拟环境, 详见:
在Visual Studio Code中配置venv。 -
Ollama 在
Ollama
平台上部署本地大模型非常方便,基于此平台,我们可以让langchain
使用llama3.1
、qwen2.5
、deepseek
等各种本地大模型。详见:
在langchian中使用本地部署的llama3.1大模型 。
准备矢量数据库
这次我们使用网上的一篇博客做数据源,在使用 WebBaseLoader
加载文档时,用 bs4
只将网页中 css样式为"post-header","post-content"的内容解析出来,最后把数据存储在本地备用。
python
def create_db(model_name,url):
"""生成本地矢量数据库"""
persist_directory = get_persist_directory(model_name)
# 判断矢量数据库是否存在,如果存在则不再做索引,方便反复测试
if os.path.exists(persist_directory):
return
embedding = OllamaEmbeddings(model=model_name)
vectordb = Chroma(persist_directory=persist_directory,embedding_function=embedding)
# 加载并分块博客内容
loader = WebBaseLoader(
web_paths=(url,),
bs_kwargs=dict(
parse_only=bs4.SoupStrainer(
class_=("post-header","post-content") # 指解析css class 为post-header和post-content 的内容
)
),
)
docs = loader.load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
all_splits = text_splitter.split_documents(docs)
# 在文档的 元数据(metadata) 中添加 section 标签
total_documents = len(all_splits)
third = total_documents // 3
for i, document in enumerate(all_splits):
if i < third:
document.metadata["section"] = "开头"
elif i < 2 * third:
document.metadata["section"] = "中间"
else:
document.metadata["section"] = "结尾"
print(f'Metadata: {all_splits[0].metadata}')
for i in tqdm(range(0, len(all_splits), batch_size), desc="嵌入进度"):
batch = all_splits[i:i + batch_size]
# 从文本块生成嵌入,并将嵌入存储在本地磁盘。
vectordb.add_documents(batch)
create_db(embed_model_name,src_url)
vector_store = Chroma(persist_directory=get_persist_directory(embed_model_name),embedding_function=OllamaEmbeddings(model=embed_model_name))
这次我们把分割后的文档分为三部分,并记录在 metadata 中。
嵌入操作可能很耗时,分批嵌入文档并使用
tqdm
显示进度可以缓解焦虑。
检索和生成回答
之前我们都是使用原始输入执行检索。我们也可以让 LLM
生成用于检索目的的查询条件,它有一些优势。例如: 可以将用户的自然语言重写为多条件的查询。
查询参数
我们为搜索查询定义一个结构。当然,在实际应用中,您也可以随意定义。
python
class Search(TypedDict):
"""查询检索的参数"""
query: Annotated[str, ..., "查询的关键词"]
section: Annotated[str, ..., "要查询的部分,必须是'开头、中间、结尾'之一。"]
State(状态)
我们明确定义一个 State
,用它在 langgraph
链的不同节点间保存和传递数据。
python
class State(TypedDict):
question: str
query: Search
context: List[Document]
answer: str
检索节点
定义检索节点,这里增加了过滤条件。
python
def retrieve(state: State):
"""检索"""
query = state["query"]
retrieved_docs = vector_store.similarity_search(
query["query"],
filter={"section": query["section"]},
)
return {"context": retrieved_docs}
用 langgraph
创建链
在创建链的过程中,我们将定义查询分析方法,还将定义生成方法:
python
def create_graph(llm_model_name):
"""创建langgraph"""
llm = ChatOllama(model=llm_model_name,temperature=0,verbose=True)
def analyze_query(state: State):
"""分析查询,推理出查询参数"""
structured_llm = llm.with_structured_output(Search)
query = structured_llm.invoke(state["question"])
return {"query": query}
def generate(state: State):
"""生成回答"""
prompt = ChatPromptTemplate.from_messages([
("human", """你是问答任务的助手。
请使用以下检索到的**上下文**来回答问题。
如果你不知道答案,就说你不知道。最多使用三句话,并保持答案简洁。
问题: {question}
上下文: {context}
回答:"""),
])
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()
return graph
通过以上代码,我们可以清晰的看到整个执行过程:
analyze_query
将用户的请求转换成查询参数,将推理出的查询条件放在state
的query
键中;retrieve
根据state
的query
中的查询条件检索矢量数据库,并将检索结果放在state
的context
键中;generate
基于state
中context
和question
填充提示词,提交给LLM(大语言模型)
处理。
现在一般的大语言模型处理聊天都很好了,针对这种
RAG
场景,使用analyze_query
推理出查询参数的能力很关键。
见证效果
先定义一个问答方法:
python
def ask(llm_model_name,question):
"""问答"""
graph = create_graph(llm_model_name)
for step in graph.stream(
{"question": question},
stream_mode="updates",
):
print(f"{step}\n\n----------------\n")
再准备一个问题:
python
q = "文章的结尾讲了langgraph的哪些优点?"
来看看各个大模型的表现吧。
qwen2.5
text
{'analyze_query': {'query': {'query': 'langgraph 的优点 结尾', 'section': '结尾'}}}
----------------
{'retrieve': ...}
----------------
{'generate': {'answer': 'langgraph 的优点包括可视化程度高、兼容性强以及易定制。这些特点使得代码虽然多一些,但更容易理解和跟踪执行细节,并且可以顺畅运行多种大模型,同时也方便修改和增删节点。'}}
----------------
显然,qwen2.5
比较好的推理出了查询参数,也获得了妥帖的结果。
完美!
llama3.1
text
{'analyze_query': {'query': {'query': 'langgraph的优点', 'section': '给安'}}}
----------------
...
不知道为什么推理出的 query
内容没问题,但是 section
显然不对了。
MFDoom/deepseek-r1-tool-calling:7b
text
{'analyze_query': {'query': {'query': 'langgraph的优点', 'section': '结尾'}}}
----------------
{'retrieve': ...}
----------------
{'generate': {'answer': '大象的学名是Elephas spp.。它们具有极高的智商和良好的记忆能力,并在文化中被视为神圣动物,曾用于古代战争、运输和旅游业。'}}
完美!
总结
这次我们创建的 RAG
系统的检索步骤增加了过滤条件,相当于可以用两个参数进行检索。实际上我们完全可以定义很多条件,由大语言模型推理出查询参数。
在此次的演练中,qwen2.5
和 deepseek
都不错。
如果您想自己实现一个包含前端和后端的
RAG
系统,从零搭建langchain+本地大模型+本地矢量数据库的RAG系统 可能对您入门有帮助。
代码
本文涉及的所有代码以及相关资源都已经共享,参见:
为便于找到代码,程序文件名称最前面的编号与本系列文章的文档编号相同。
参考
🪐感谢您观看,祝好运🪐