你好,欢迎来到本系列的最后一部分,我们将用Langchain、Streamlit和PubMed构建一个生物医学聊天Agents!
在前一部分中,我们使用vectorstore构建了数据持久化和RAG工作流。现在,是时候将我们之前构建的所有功能整合起来,创建一个聊天Agents用户界面,这个界面将使用我们已经开发的后端功能,帮助使用人员解答他们的问题!
为了提醒一下,这就是我们在整个系列中逐步构建的完整解决方案:
应用演示
让我们先来看一下应用最终的效果图!
构建过程
已完成的步骤概述
如果你还没有完成第一部分、第二部分和第三部分的内容,我们先回顾一下,因为我们将在这些基础上继续进行。到最后一部分结束时,我们的项目结构已经如下所示:
.
├── app
│ ├── app.py
│ ├── backend
│ │ ├── abstract_retrieval
│ │ │ ├── interface.py
│ │ │ ├── pubmed_retriever.py
│ │ │ └── pubmed_query_simplification.py
│ │ ├── data_repository
│ │ │ ├── interface.py
│ │ │ ├── local_data_store.py
│ │ │ └── models.py
│ │ └── rag_pipeline
│ │ ├── interface.py
│ │ ├── chromadb_rag.py
│ │ └── embeddings.py
│ ├── components
│ │ ├── chat_utils.py
│ │ ├── llm.py
│ │ └── prompts.py
│ └── tests
│ └── test_chat_utils.py
├── assets
│ └── pubmed-screener-logo.jpg
└── environment
└── requirements.txt
在本系列的最后一部分,我们将重点讲解定义我们Streamlit用户界面的代码部分——app/app.py和app/components模块。
修改 chat_utils.py 以包含 RAG 逻辑
在第一部分中,我们构建了一个初步版本的chat_utils.py,其中包含了一个简单的问答聊天机器人实现(没有RAG)。现在,我们将深入研究并将其转换为一个上下文感知的问答聊天机器人,能够根据用户问题构建答案,并通过相似性搜索从我们的向量索引中检索相关的上下文(如摘要)。
我们将使用第三部分中构建的所有后端功能来实现这个目标。
app/components/chat_utils.py
from typing import List
import streamlit as st
from langchain_core.documents.base import Document
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_core.runnables.base import Runnable
from langchain_core.runnables.utils import Output
from langchain_community.chat_message_histories import StreamlitChatMessageHistory
from langchain_core.prompts import ChatPromptTemplate
from langchain.vectorstores import VectorStore
class ChatAgent:
def __init__(self, prompt: ChatPromptTemplate, llm: Runnable):
"""
初始化 ChatAgent。
参数:
- prompt (ChatPromptTemplate): 聊天提示模板。
- llm (Runnable): 语言模型可运行实例。
"""
self.history = StreamlitChatMessageHistory(key="chat_history")
self.llm = llm
self.prompt = prompt
self.chain = self.setup_chain()
def reset_history(self) -> None:
"""
清除聊天历史记录,开始新的聊天会话。
"""
self.history.clear()
def setup_chain(self) -> RunnableWithMessageHistory:
"""
设置 ChatAgent 的链条。
返回:
- RunnableWithMessageHistory: 配置好的链条,包含消息历史记录。
"""
chain = self.prompt | self.llm
return RunnableWithMessageHistory(
chain,
lambda session_id: self.history,
input_messages_key="question",
history_messages_key="history",
)
def display_messages(self, selected_query: str) -> None:
"""
在聊天界面展示消息。
如果没有历史消息,添加默认的 AI 消息。
"""
if len(self.history.messages) == 0:
self.history.add_ai_message(f"Let's chat about your query: {selected_query}")
for msg in self.history.messages:
st.chat_message(msg.type).write(msg.content)
def format_retreieved_abstracts_for_prompt(self, documents: List[Document]) -> str:
"""
格式化检索到的文档为字符串,传递给 LLM。
"""
formatted_strings = []
for doc in documents:
formatted_str = f"ABSTRACT TITLE: {doc.metadata['title']}, ABSTRACT CONTENT: {doc.page_content}, ABSTRACT DOI: {doc.metadata['source'] if 'source' in doc.metadata.keys() else 'DOI missing..'}"
formatted_strings.append(formatted_str)
return "; ".join(formatted_strings)
def get_answer_from_llm(self, question: str, retrieved_documents: List[Document]) -> Output:
"""
根据用户问题和检索到的文档,从 LLM 获取响应。
"""
config = {"configurable": {"session_id": "any"}}
return self.chain.invoke(
{
"question": question,
"retrieved_abstracts": retrieved_documents,
}, config
)
def retrieve_documents(self, retriever: VectorStore, question: str, cut_off: int = 5) -> List[Document]:
"""
使用相似度搜索检索文档
cut_off 参数控制检索结果的数量(默认值为 5)。
"""
return retriever.similarity_search(question)[:cut_off]
def start_conversation(self, retriever: VectorStore, selected_query: str) -> None:
"""
在聊天界面开始对话。
显示消息,提示用户输入,并处理 AI 的响应。
"""
self.display_messages(selected_query)
user_question = st.chat_input(placeholder="Ask me anything..")
if user_question:
documents = self.retrieve_documents(retriever, user_question)
retrieved_abstracts = self.format_retreieved_abstracts_for_prompt(documents)
st.chat_message("human").write(user_question)
response = self.get_answer_from_llm(user_question, retrieved_abstracts)
st.chat_message("ai").write(response.content)
有哪些变化:
- 新增了方法retrieve_documents,该方法以向量索引(retriever)作为参数,并调用 retriever 的similarity_search方法,从生物医学文献摘要的向量索引中获取与用户问题最相似的记录。需要注意的是,这里包含一个参数cut_off,用于指定检索的结果数量(默认为 5)。
- 新增了方法format_retreieved_abstracts_for_prompt,用于将通过retrieve_documents方法检索到的文档格式化为适合 LLM 的输入。这在向 LLM 提问时要求其引用相关来源(文章 DOI 和标题)时会非常有用。
- 新增了方法get_answer_from_llm,专门用于调用 LLM 并传递必要的变量,以保持客户端函数start_conversation的代码整洁。
- 修改了start_conversation方法,加入了 RAG 逻辑。
为问答创建聊天提示
- 我们将修改现有的聊天提示,将检索到的文献摘要包含其中,并基于这些摘要构建答案。
- 另外,我们还会添加一个额外的(简单)提示,用于在聊天机器人部分之外快速提供答案,直接在用户界面上显示用户问题的即时答案。
app/components/chat_prompts.py
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder, PromptTemplate
chat_prompt_template = ChatPromptTemplate.from_messages(
[
("system", "You are a knowledgeable expert chatbot in the biomedicine field."),
MessagesPlaceholder(variable_name="history"),
(
"human",
"""
Answer the following scientific question: {question},
using the following context retrieved from scientific articles: {retrieved_abstracts}.
The user might refer to the history of your conversation. Please, use the following history of messages for the context as you see fit.
The abstracts will come formatted in the following way: ABSTRACT TITLE: ; ABSTRACT CONTENT: , ABSTRACT DOI: (the content inside <> will be variable).
In your answer, ALWAYS cite the abstract title and abstract DOI when citing a particular piece of information from that given abstract.
Your example response might look like this:
In the article (here in the brackets goes the contents of ABSTRACT_TITLE), it was discussed, that Cannabis hyperemesis syndrome (CHS) is associated with chronic, heavy cannabis use. The endocannabinoid system (ECS) plays a crucial role in the effects of cannabis on end organs and is central to the pathophysiology of CHS. (here, in the end of the cited chunk, the ABSTRACT_DOI goes)
"""
),
]
)
qa_template = PromptTemplate(
input_variables=['question', 'retrieved_abstracts'],
template="""
Answer the following scientific question: {question},
using the following context retrieved from scientific articles: {retrieved_abstracts}.
The abstracts will come formatted in the following way: ABSTRACT TITLE: ; ABSTRACT CONTENT: , ABSTRACT DOI: (the content inside <> will be variable).
In your answer, ALWAYS cite the abstract title and abstract DOI when citing a particular piece of information from that given abstract.
Your example response might look like this:
In the article (here in the brackets goes the contents of ABSTRACT_TITLE), it was discussed, that Cannabis hyperemesis syndrome (CHS) is associated with chronic, heavy cannabis use. The endocannabinoid system (ECS) plays a crucial role in the effects of cannabis on end organs and is central to the pathophysiology of CHS. (here, in the end of the cited chunk, the ABSTRACT_DOI goes)
"""
)
- 这里要注意了,这两个提示的内容几乎相同,但聊天提示包含了对聊天历史的引用(使用MessagesPlaceholder),并指示 LLM 在对话中根据需要使用聊天历史。
创建新文件app/components/layout_extensions.py
- 该文件将包含一个辅助函数,用于渲染应用界面的一部分,为用户提供示例查询(提示用户如何使用应用)。我决定创建这个扩展文件是为了避免让app.py文件过于复杂,保持其整洁,因为这段代码相对较长,并包含一些自定义样式(这些应用信息会在用户悬停时显示出来):
import streamlit as st
def render_app_info():
st.title("PubMed 筛查器")
st.markdown("""
PubMed 筛查器是一个由 ChatGPT 和 PubMed 提供支持的生物医学摘要洞察生成器。
""")
# 添加自定义HTML和CSS以改善悬停工具提示的显示效果
st.markdown("""
示例问题
示例生物医学问题:
- 如何利用先进的影像技术和生物标志物进行神经退行性疾病的早期诊断和进展监测?
- 干细胞技术和再生医学在神经退行性疾病治疗中的潜在应用有哪些?有哪些挑战?
- 肠道微生物群和肠-脑轴在1型和2型糖尿病发病机制中的作用是什么?如何调节这些相互作用以获得治疗效果?
- 靶向癌症治疗中的耐药机制是什么?如何克服这些耐药机制?
""", unsafe_allow_html=True)
st.text("") # 添加空白行,保持界面整洁
修改app/app.py
- 最后,是时候将我们构建的所有部分整合起来,并将其作为一个 Streamlit 应用程序展示出来了!
import redis
import streamlit as st
from metapub import PubMedFetcher
from components.chat_utils import ChatAgent
from components.chat_prompts import chat_prompt_template, qa_template
from components.llm import llm
from components.layout_extensions import render_app_info
from backend.abstract_retrieval.pubmed_retriever import PubMedAbstractRetriever
from backend.data_repository.local_data_store import LocalJSONStore
from backend.rag_pipeline.chromadb_rag import ChromaDbRag
from backend.rag_pipeline.embeddings import embeddings
# 实例化对象
pubmed_client = PubMedAbstractRetriever(PubMedFetcher())
data_repository = LocalJSONStore(storage_folder_path="backend/data")
rag_client = ChromaDbRag(persist_directory="backend/chromadb_storage", embeddings=embeddings)
chat_agent = ChatAgent(prompt=chat_prompt_template, llm=llm)
# 连接Redis服务器
r = redis.Redis(host='127.0.0.1', port=6379, db=0)
def main():
# 增加访问统计
visit_count = r.incr('visit_count')
# 设置页面配置
st.set_page_config(
page_title="PubMed 筛查器", # 页面标题
page_icon='../assets/favicon32-32.ico', # 页面图标
layout='wide'
)
# 定义页面列布局
column_logo, column_app_info, column_answer = st.columns([1, 4, 4])
with column_logo:
st.image('../assets/m.png')
st.markdown(
f"""
本页面总访问量: {visit_count}
""",
unsafe_allow_html=True
)
with column_app_info:
# 渲染应用信息
render_app_info()
# 生物医学问题输入部分
st.header("请输入您的问题!")
placeholder_text = "在此输入您的问题..."
scientist_question = st.text_input("您的问题是什么?", placeholder_text)
get_articles = st.button('获取文献与回答')
# 处理用户问题,获取数据
if get_articles:
with st.spinner('正在获取摘要,这可能需要一些时间...'):
if scientist_question and scientist_question != placeholder_text:
# 获取摘要数据
retrieved_abstracts = pubmed_client.get_abstract_data(scientist_question)
if not retrieved_abstracts:
st.write('未找到摘要。')
else:
# 保存摘要到存储并创建向量索引
query_id = data_repository.save_dataset(retrieved_abstracts, scientist_question)
documents = data_repository.create_document_list(retrieved_abstracts)
rag_client.create_vector_index_for_user_query(documents, query_id)
# 回答用户问题并直接在界面显示答案
vector_index = rag_client.get_vector_index_by_user_query(query_id)
retrieved_documents = chat_agent.retrieve_documents(vector_index, scientist_question)
chain = qa_template | llm
with column_answer:
st.markdown(f"##### 您的问题 '{scientist_question}' 的答案")
st.write(chain.invoke({
"question": scientist_question,
"retrieved_abstracts": retrieved_documents,
}).content)
# 聊天机器人部分的开始
query_options = data_repository.get_list_of_queries()
if query_options:
st.header("与摘要聊天")
selected_query = st.selectbox('选择一个问题', options=list(query_options.values()), key='selected_query')
if selected_query:
selected_query_id = next(key for key, val in query_options.items() if val == selected_query)
vector_index = rag_client.get_vector_index_by_user_query(selected_query_id)
if 'prev_selected_query' in st.session_state and st.session_state.prev_selected_query != selected_query:
chat_agent.reset_history()
st.session_state.prev_selected_query = selected_query
chat_agent.start_conversation(vector_index, selected_query)
if __name__ == "__main__":
main()
代码包含以下部分:
- 实例化前几部分中构建的所有对象
- 包括:PubMedAbstractRetriever(PubMed 摘要检索器)、LocalJSONStore(本地 JSON 存储)、ChromaDbRag(向量数据库 RAG 客户端)和ChatAgent(聊天、agents)。这些对象将会在应用程序代码中被使用。
- 定义布局
- 用于渲染应用的标题、Logo 和应用信息。
- 定义用户问题输入和提交按钮
- 用户可以在输入框中提出问题,并通过点击按钮提交。这会触发以下逻辑:
- 使用**PubMedAbstractRetriever(pubmed_client)**搜索并获取 PubMed 文章。
- 使用**LocalJSONStore(data_repository)**将文章保存到本地数据存储库中。
- 使用**ChromaDbRag(rag_client)**为文章创建向量索引。
- 直接回答用户的问题
- 在用户界面上显示答案。
- 显示聊天机器人部分
- 在此部分中,用户可以选择一个过去的查询进行深入聊天。如果选择了一个过去的查询,则会加载对应的向量索引,并启动聊天会话(通过chat_agent.start_conversation(…))。现在,用户可以与相关的文献摘要进行交互式对话了!
局限性
到目前为止我们构建了一个生物医学聊天Agents的原型!不过需要说明的是,这个应用程序目前仅限于一个概念验证(PoC)的范围,实际部署到生产环境之前,还有一些需要解决的局限性和问题。
初级 RAG 的局限性和需要考虑的问题
- 检索内容的相关性
- 无法完全确保检索到的内容(即与用户问题最相似的内容)是最相关的信息。有一些高级 RAG 技术(如Hypothetical Questions和Hierarchical Indexing)可以帮助提升内容相关性。
- 检索内容的截断问题
- 很难确保所有相关信息都被成功检索。此外,由于 LLM 的 token 限制,可能无法将所有上下文都放入提示中。在我们的 ChatAgent 的retrieve_documents方法中,默认的截断限制为 5 篇摘要,这显然不足以回答用户提出的广泛问题。
- 适用性的局限性
- 有时用户的问题更倾向于摘要性质,而这类问题可能更适合使用其他技术而不是 RAG。例如,可以构建一个智能代理,根据用户问题判断任务是摘要还是检索。完成评估后,相应的函数将分别执行摘要或检索逻辑。
以上局限性为进一步优化和扩展提供了思考方向,同时也提醒我们,生产级应用需要更加成熟和全面的设计。