介绍
在近几年里,自然语言处理(NLP)的进步为复杂的对话式AI铺平了道路。虽然并不完美,但检索增强生成(RAG)脱颖而出,成为创建信息丰富且上下文感知响应的一种方式。尽管有许多基于RAG的教程和解决方案,但我还是想自己动手做一个。我想要一个完全离线的,同时具备会话记忆功能的系统,这样可以进行自然对话,而不仅仅是提出一系列独立的问题。这篇文章将带你看看我如何在周末的时间内,打造一个叫NydasBot的离线RAG聊天机器人。我们还将看看代码,并展示最终的解决方案。这个项目是在周末完成的,利用了其他已发布的文章和AI的帮助,生成了前端代码。
聊天机器人已经启动了
要为什么在线呢?离线运行AI解决方案有以下几个好处:
- 隐私: 数据在当地处理,确保用户隐私。
- 延迟时间: 消除了对互联网速度的依赖,从而缩短了响应时间。
- 成本: 避免了与基于云的解决方案相关的持续费用。
- 可靠性: 无需稳定的互联网连接即可正常运作。
虽然这可能不适合大规模使用,但我有一个不错的家用系统,想好好利用一下。长期关注我文章的读者可能已经注意到,我大多数与代码相关的文章都是设计成完全离线运行的。我在这里主要想说的是,你可以自己动手试一试,使用你自己的硬件,无需额外支付费用进行尝试。
项目简介,注意:为了这个演示,我只将一个文件加载到了向量存储库中。那份文档是我的简历。现在我已经建好了这个项目,我可能会每天都用它,我计划用它来存放我购买的数据科学、数据治理、数据工程、数据分析和通用管理等方面的教科书。但是,我不想因为使用这些书中的内容而侵犯版权,所以在这篇文章中我不会使用这些书中的内容。下面通过截图更多地了解我吧
这个项目主要分为两个部分:
- 后端: 一个基于Python的服务器,实现了RAG模型,并采用会话存储(本地ChromaDB)。
- 前端: 基于React.js的应用程序,供用户与聊天机器人交互的界面。
这些组件之间的通信是通过WebSockets来管理的,以确保实时互动。
简单的流程图
主要特点- 会话记忆功能: 维护多个消息间的对话上下文,使对话更加自然。
- 向量存储检索功能: 随着时间推移构建并查询数据目录。
- WebSocket通信功能: 实现前端和后端之间的实时消息传输。
聊天机器人的架构被设计成模块化和可扩展的。以下是对系统的一个高层次概述。
后端开发后端是用Python构建的,并使用了几个关键库:
- Langchain: 用于构建 RAG 模型的。
- Websockets: 用于实时通讯。
- ChromaDB: 用于向量数据库管理。
- TOML: 用于配置管理。
核心部分是RAG模型(RAG模型),它将语言模型和向量存储结合,提供基于上下文的答案。
该模型会保留聊天记录,并使用增强型检索生成链来处理用户的查询并给出相关的回复。
为了运行这个,你需要在你的电脑上运行Ollama。
前端开发前端是使用 React.js 构建的应用程序,为用户提供了一个与聊天机器人交互的简单界面。它通过 WebSocket 连接与后端通信,发送用户的查询并实时显示响应。我不是专职的前端开发者,所以请原谅我的用户界面设计,还有我(使用了 AI 的)React 代码。 😬
实现细节 搭建后端:后端设计得可以通过一个参数文件 parameters.toml 轻松配置。它包含了语言模型、向量存储和服务器配置的相关设置:
parameters.toml
你通常不需要更改这些设置,除非你想使用不同型号,或者需要调整日志级别或端口号。
后台实际上分为两部分:
- 导入新文件
- 启动服务器
我不会过多地详细说明如何导入新文件。我为该文件中的唯一函数添加了一个我认为相当全面的docstring。基本上,它会在 to_process
文件夹中查找 PDF 文件,将这些文件转换为 ChromaDB,然后处理完成后,将文件移动到 processed
文件夹。如上所述,我已将我的简历作为此项目的一部分导入,以演示数据检索功能。
要让服务器运行起来其实涉及很多方面,但为了简化,我把它分为三个类别。
- ConfigLoader: 做的就是它名字所表示的:从 TOML 文件中加载所有必要的配置信息,以供使用。
- RAGModel :这是代码的主要部分,也是 RAG 和 LLM 的配置。下面我们将详细讨论这个部分。
- WebSocketServer: 我不仅想构建一个普通的 CLI 接口,而是希望通过网页来与之互动。这意味着我可以在我家的服务器上运行它,并使用 Firefox 从我的笔记本电脑访问它。这个类的主要功能是启动服务器,并通过 WebSocket 发送和接收消息。
或者
RAGModel(注:RAG模型,特定技术术语)我已经确保为所有类中的方法添加了docstrings,所以我希望这应该相对容易跟上。我会逐个解释每个方法,不过为了节省篇幅,我把注释去掉了。
def setup_llm(self):
local_llm = self.config["llm"]["model"]
# 初始化ChatOllama模型,设置模型为local_llm,温度设置为0
return ChatOllama(
model=local_llm,
temperature=0,
)
首先,我们从导入的TOML数据中获取参数信息,这表明我们要用llama3模型。我用ChatOllama来操作这个模型。
def setup_retriever(self):
embedding_model = self.config["llm"]["embedding_model"]
vector_store_path = self.config["rag"]["vector_store_path"]
embedding_function = SentenceTransformerEmbeddings(model_name=embedding_model)
vectorstore = Chroma(
persist_directory=vector_store_path,
embedding_function=embedding_function,
)
return vectorstore.as_retriever()
然后我设置了检索工具。这里我指定要使用Chroma数据库(向量存储),给出其位置,并选择嵌入模型。嵌入模型是将单词或句子转换成可存储在Chroma DB中的向量的模型,用来把词句变成能放进Chroma DB的向量的模型。
def setup_rag_chain(self):
get_standalone_question_system_prompt = """给定聊天历史和最新的用户问题,该问题可能引用了聊天历史中的上下文,将其改写成一个独立的问题,使其可以不依赖聊天历史也能被理解。请勿回答问题,只需要在必要时改写问题,否则返回原问题。"""
standalone_question_prompt = ChatPromptTemplate.from_messages(
[
("system", get_standalone_question_system_prompt),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
]
)
history_aware_retriever = create_history_aware_retriever(
self.llm, self.retriever, standalone_question_prompt
)
q_and_a_system_prompt = """你是一个用于问答任务的助手。使用以下检索到的相关信息来回答问题。如果你不知道答案,就说你不知道。用三个句子以内回答,并保持简洁。\n\n{context}"""
q_and_a_prompt = ChatPromptTemplate.from_messages(
[
("system", q_and_a_system_prompt),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
]
)
question_answer_chain = create_stuff_documents_chain(self.llm, q_and_a_prompt)
return create_retrieval_chain(history_aware_retriever, question_answer_chain)
这里有几个事情需要注意。这里我定义了提示和模板。我首先创建了一个提示,这个提示将一个问题重新格式化,使其能够在单独使用时更有效。这样处理聊天记录时会更方便,比如下面的例子:
我的第一个问题是关于我们要讨论的对象,而第二个问题不需要这样的设定。目的是让第二个问题能够单独理解,大型语言模型可能会这样改写。第二个问题需要重新表述,使其能够独立阅读。
Andy Sawyer实施了几个新数据仓库?
这使它看起来对话好像在原有的对话中继续进行一样。
接下来的任务则需要LLM扮演助手角色,用Chroma DB里的内容来回答问题。
然后我们将检索器和回答链路组合成一个单一的RAG链路。这基本上使我们能够利用对话历史记录和检索到的文档来生成与内容相关的回答。
def 获取会话历史(self, session_id):
if session_id not in self.store:
self.store[session_id] = ChatMessageHistory()
return self.store[session_id]
这里有一个快速简单的方法来获取给定会话的聊天记录(如果有)。如果没有找到该会话,则会开始一个新的会话。
最后,
def get_answer(self, input_text, session_id):
# 获取答案函数,根据输入文本和会话ID返回回答
conversational_rag_chain = RunnableWithMessageHistory(
self.rag_chain,
self.get_session_history,
input_messages_key="input",
history_messages_key="chat_history",
output_messages_key="answer",
)
return conversational_rag_chain.invoke(
{"input": input_text},
config={"configurable": {"session_id": session_id}}
)["answer"]
我们从大型语言模型(LLM)那里得到答案。你可以看到我们使用了RunnableWithMessageHistory
这个工具,定义了我们的输入和问题的历史记录。调用这个将返回LLM对问题的回答。
代码完成后,我就能本地运行服务器了,并通过Postman使用WebSocket连接查询它。
使用Postman访问大型语言模型(LLM)
搭建前端界面我已经有一阵子没用 React 了。所以在这里我要先说一声,‘请别太苛刻地评价这段代码’。我在几个地方卡住了,借助了 AI 的力量。这是一段挺有趣的经历。
首先,我们得连接到WebSocket服务器:
useEffect(() => {
websocket.current = new WebSocket('ws://localhost:8765');
websocket.current.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.answer) {
setMessages((prevMessages) => [...prevMessages, `${data.answer}`]);
}
};
return () => {
if (websocket.current) {
websocket.current.close();
}
};
}, []);
此处的代码实现了一个WebSocket连接,用于接收来自服务器的消息。当接收到的消息包含answer
字段时,会将该字段的内容添加到消息列表中。当组件卸载时,会关闭WebSocket连接以释放资源。
这会创建一个WebSocket连接。这里我将端口固定为硬编码值,所以如果你更改了后端的任何内容,请别忘了在这里更新它。
一旦连接建立好,它也会解析来自 WebSocket 的数据,如果收到带有回答的数据(如我们在后端所定义的),则将其添加到我们的消息列表中。
接下来的部分将讨论如何通过 WebSocket 发送消息。
const sendMessage = () => {
if (input.trim()) {
const message = JSON.stringify({ input });
websocket.current.send(message);
setMessages((prevMessages) => [...prevMessages, `你: ${input.trim()}`]);
setInput('');
}
};
应该是挺容易看懂的吧?
之后,我尝试将数据转换成Markdown格式。因为我经常写代码,基本文本并不总是很好用,Markdown在这里就很有用。
const renderMessageAsMarkdown = (msg) => {
const 是用户消息 = msg.startsWith('You: ') === true;
const 显示消息 = 是用户消息 ? `<b>你</b><br />${msg.substring(5)}` : `<b>Nydas:</b><br />${msg}`;
const 原始标记 = marked.parse(显示消息);
const 清洁标记 = DOMPurify.sanitize(原始标记);
return (
<div
className={是用户消息 ? 'box3 sb13' : 'box3 sb14'}
key={是用户消息 ? `user-${显示消息}` : `server-${显示消息}`}
style={{
margin: 是用户消息 ? '5px 15px' : '5px 15px 5px 0',
alignSelf: 是用户消息 ? 'flex-end' : 'flex-start'
}}
dangerouslySetInnerHTML={{ __html: 清洁标记 }}
/>
);
};
我也在这里添加了一段代码,用于根据消息来源调整布局。
说实话。看到 dangerouslySetInnerHTML
这个东西,真心不太爽。但我不是前端工程师,而且找不到其他解决方法来绕过遇到的问题。如果你知道答案,麻烦在评论区告诉我一声!
最后终于,我带着大部分排版回来了。
{/* 渲染组件 */}
<div id='background_grad' style={{ height: '100vh', display: 'flex' }}>
<div style={{ flex: 1, display: 'flex' }}></div>
<div className="right-section" style={{ flex: '100%', display: 'flex', flexDirection: 'column', margin: '10px', padding: '10px', border: '1px solid #79a0b7' }}>
<div style={{ overflowY: 'auto', flexGrow: 1, padding: '10px', paddingTop: '120px', display: 'flex', flexDirection: 'column-reverse' }}>
{/* 将消息数组反转并渲染为Markdown格式 */}
{[...messages].reverse().map((msg) => renderMessageAsMarkdown(msg))}
</div>
<div style={{ display: 'flex', padding: '10px' }}>
{/* 输入框,用于输入消息 */}
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
style={{ flex: 1, marginRight: '10px', padding: '10px' }}
/>
{/* 发送按钮,点击发送消息 */}
<button onClick={sendMessage} style={{ padding: '10px' }}>
发送消息
</button>
</div>
</div>
</div>
把一切都整合起来
一旦搭建完毕,最后一步就是进行测试。我在Macbook Pro M3 Max上运行,所以知道结果可能因人而异。但我还是做了一个简短的视频,这样如果你不想下载仓库(包括4.7GB的llm模型)并自己尝试运行,也可以看到它的实际运行情况。希望你喜欢这个视频!
结论创建一个离线的RAG聊天机器人是一次令人满意的经历。它让我深入了解了如何结合检索和生成模型的复杂性,有时也会让我感到沮丧。我曾尝试让它逐令牌地显示在屏幕上,而不是等待完整的回复,但感觉像是在原地转圈。总的来说,我对最终的结果感到满意,并打算将其作为我的个人数据仓库使用。希望这篇文章能激发你探索离线AI应用的潜力,并鼓励你构建你自己的创新解决方案。
如果你对代码感兴趣的话,可以在Github上找到整个项目,还有一个README.md
文件会帮助你完成所有设置的步骤。
共同学习,写下你的评论
评论加载中...
作者其他优质文章