想象一下,你需要从大量的项目文档中快速找到关于“Project ATLAS”的关键决策。翻箱倒柜,在会议记录、PDF报告和Wiki页面之间来回切换,最终也只是拼凑出一些零散的信息。如果有一个聊天机器人,能够直接从这些文档中提取信息并回答你的问题,那该有多好?这就是检索增强生成(RAG)技术能够解决的问题。本文将深入探讨如何使用LangChainOllama,构建一个本地化的RAG聊天机器人,让你的数据驱动智能对话。

RAG:连接LLM与你的知识库

检索增强生成(RAG),是一种结合了信息检索和文本生成的技术。它的核心思想是,当大型语言模型(LLM)需要生成文本时,首先从一个外部知识库中检索相关信息,然后将这些信息作为上下文输入到LLM中,从而提高生成文本的质量和准确性。RAG技术弥补了LLM自身知识的不足,使其能够基于最新的、特定的数据进行回答,避免了模型“幻觉”的问题。

核心工具:Ollama、FAISS、Streamlit和LangChain

构建一个强大的RAG聊天机器人,需要几个关键工具的协同工作:

  • Ollama: 提供了一个本地接口,可以轻松运行开源大型语言模型。例如,文章中使用了IBM的Granite 3.3模型,这是一个专为企业设计的、具有强大推理能力的开源模型。Ollama的价值在于,它让开发者可以在本地环境中便捷地尝试和部署各种LLM,而无需依赖云服务。
  • FAISS (Facebook AI Similarity Search): 这是一个高性能的向量数据库,用于存储和检索文档的向量嵌入。FAISS的优势在于其快速的相似度搜索能力,使得聊天机器人能够迅速找到与用户问题相关的文档片段。
  • Streamlit: 这是一个Python库,可以快速构建交互式的Web应用程序。在这里,Streamlit用于创建一个简洁的用户界面,让用户可以上传文档并与聊天机器人进行交互。
  • LangChain: 这是一个流行的Python框架,它将所有组件连接在一起,包括嵌入创建、文档检索、提示词格式化和LLM编排。LangChain的作用就像胶水,简化了用户输入、上下文检索和提示词管理之间的复杂逻辑。

LangChain:RAG系统的核心编排者

LangChain 在整个RAG系统中扮演着至关重要的角色。它负责将用户输入、上下文检索和提示词管理结合在一起。更具体地说,LangChain 处理以下任务:

  • 上下文检索: 根据用户的问题,从FAISS向量数据库中检索相关的文档片段。
  • 提示词构建: 将检索到的上下文、用户问题和聊天历史组合成一个精心设计的提示词,以便LLM能够理解并生成合适的答案。
  • LLM调用: 调用本地运行的LLM (如Granite 3.3) ,并将提示词传递给它。
  • 聊天历史管理: 维护聊天历史,以便LLM能够理解上下文并生成更连贯的回复。

没有LangChain,构建一个RAG系统将需要大量的手动编码,并且难以维护。LangChain 提供的抽象和工具极大地简化了开发过程,使开发者能够专注于构建更智能、更高效的聊天机器人。

RAG聊天机器人的架构:模块化设计

这个RAG聊天机器人采用模块化设计,由五个核心组件组成:

  1. ChatUI: 使用Streamlit构建的用户界面,提供上传文档和实时交互的功能。
  2. LLMRAGHandler: 这是核心组件,使用LangChain实现,负责管理对话流程、检索相关上下文、格式化提示词和调用LLM。
  3. Vector Store: 文档以向量嵌入的形式存储在FAISS中,实现基于语义相似度的快速搜索。
  4. LLM: 在本地使用Ollama运行Granite 3.3模型,确保数据安全和控制。
  5. Conversation Store: 将对话历史存储在本地文件中,实现聊天状态的持久化。

构建向量数据库:理解你的文档

聊天机器人能够回答问题之前,它需要“理解”你的文档。这涉及将文档转换为向量嵌入并存储在向量数据库中。以下是构建向量数据库的关键步骤:

  1. 初始化向量数据库: 使用LangChain的Ollama集成创建嵌入模型,并设置分块大小和重叠参数。
  2. 设置FAISS索引: 创建FAISS索引,并将其与一个内存文档存储关联。
  3. 将文档添加到向量数据库: 加载文档,将其分割成块,嵌入,然后存储在FAISS数据库中。
  4. 文档加载和分块: 使用PyPDF库加载PDF文件,并使用LangChain的RecursiveCharacterTextSplitter将文档分割成块。

文档分块是一个关键步骤,因为它直接影响了LLM的性能。如果块太小,LLM可能无法获得足够的上下文来回答问题。如果块太大,LLM可能会超出其上下文窗口的限制。RecursiveCharacterTextSplitter通过优先保持段落完整来优化分块,然后在需要时进一步分割,从而确保为LLM提供连贯的块。

RAG Pipeline的实现:检索、增强和生成

一旦文档存储为向量嵌入,就可以实现RAG pipeline。这涉及检索相关内容,构建包含上下文的提示词,并使用本地LLM生成响应。RAG pipeline在LLMRAGHandler类中实现,并使用LangChain的链式接口实现模块化和灵活性。具体步骤如下:

  1. 提示词工程: 定义一个自定义的系统消息和一个提示词模板,指示LLM如何使用检索到的上下文。模板包含占位符,如{chat_history}{input}{context},这些占位符在每个用户消息中都会被动态填充。
  2. 构建检索链: 使用LangChaincreate_retrieval_chain()函数将检索器(基于FAISS向量数据库)和LLM链连接在一起。| (管道) 运算符是LangChain的Runnable接口的一部分,允许以声明方式将组件链接在一起。
  3. 生成响应: 当用户发送新消息时,检索相关上下文并运行RAG链。检索器使用FAISS向量数据库的相似度搜索来获取相关的文档块。

Streamlit构建聊天UI:用户友好的交互界面

最后一步是提供一个简单且交互式的界面,供用户上传文档和提问。Streamlit是一个理想的选择,因为它是一个开源的Python框架,非常适合构建快速UI,而无需大量的前端代码。

  • 聊天界面: Streamlit的chat_message() API提供了一个开箱即用的聊天布局。
  • 上传PDF: 允许用户使用侧边栏文件上传器上传文档。上传的文件被解析、嵌入并添加到FAISS向量存储中,以便可以立即查询。
  • 处理用户输入: 用户可以通过聊天UI发送问题。
  • 持久化状态和历史记录: 使用Streamlit的session_state管理聊天记忆。

挑战与经验教训:优化RAG系统

在构建本地RAG聊天机器人的过程中,会遇到一些挑战:

  • 提示词设计至关重要: 系统提示和用户提示模板的结构对模型行为有很大的影响。措辞不当的系统消息可能导致幻觉或不相关的答案。明确地指示模型在可用时使用上下文,否则承认不确定性,可以大大提高质量。
  • 推理延迟: 端到端推理pipeline包括嵌入生成、向量搜索和LLM推理,这可能导致交互式UI中的延迟问题,甚至冻结UI。可以考虑使用轻量级模型(如TinyLlama)进行更快的迭代,或缓存频繁查询的响应。
  • 评估困难: 评估聊天机器人响应的“好坏”非常困难,尤其是在涉及多个文档且没有单一的正确答案时。人工评估目前效果最好。
  • 处理大型文档: 解析和嵌入大型文档可能需要时间。如果可能,可以提前(离线)嵌入文档,并在运行时简单地加载向量存储。

总结与未来展望:RAG技术的无限可能

本文介绍了如何构建一个本地的检索增强生成聊天机器人,它可以基于你自己的PDF文档和文档回答问题。该系统使用Ollama在本地运行开源LLM,使用FAISS作为向量数据库,使用LangChain来编排检索和提示,并使用Streamlit作为文档上传和聊天交互的UI。

RAG技术的未来发展潜力巨大。以下是一些可以进一步探索的方向:

  • Agentic RAG: 让LLM决定何时检索上下文或使用哪个工具。例如,如果用户的问题涉及到多个主题,LLM可以自动检索每个主题的相关文档,并将它们组合成一个全面的答案。
  • 工具调用/插件: 使模型能够获取实时数据(例如,通过搜索或API)。例如,聊天机器人可以连接到天气API,以便回答有关特定位置的天气的问题。
  • 其他数据源: 与Google Drive、Notion或内部API集成。例如,聊天机器人可以连接到公司的知识库,以便回答有关内部政策和程序的问题。

RAG 代表着LLM应用的一个重要方向,它将强大的生成能力与外部知识库相结合,为构建更智能、更可靠的AI系统开辟了新的可能性。随着LangChain等工具的不断发展,相信RAG技术将在未来得到更广泛的应用。

实现细节补充

1.向量存储的具体代码实现

import faiss
from langchain.vectorstores import FAISS
from langchain.docstore import InMemoryDocstore
from langchain.embeddings import OllamaEmbeddings
from langchain.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from typing import List
from langchain.document import Document
from pathlib import Path

class VectorStore:
    def __init__(self, vector_store_path, llm_model="granite3.3",
                 chunk_size=500, chunk_overlap=100, index_path="faiss_index"):
        self.vector_store_path = vector_store_path
        self.llm_model = llm_model
        self.embeddings_model = OllamaEmbeddings(model=llm_model)
        self.chunk_size = chunk_size
        self.chunk_overlap = chunk_overlap
        self.index_path = index_path

        self._setup_vector_store()

    def _setup_vector_store(self):
        self.embedding_dim = len(self.embeddings_model.embed_query("hello world"))
        self.index = faiss.IndexFlatL2(self.embedding_dim)

        self.vector_store = FAISS(
            embedding_function=self.embeddings_model,
            index=self.index,
            docstore=InMemoryDocstore(),
            index_to_docstore_id={},
        )

    def add_all_documents(self, data_path: str = "data") -> List[Document]:
        # Collect Data
        documents = self.load_documents(data_path)
        # Split Data / Chunking
        splitted_docs = self.chunk_documents(documents=documents)
        # The following method provided by langchain combines the two steps
        # Generate Embeddings & Store in Data Base
        self.vector_store.add_documents(splitted_docs)
        # Store updates to the vector store on file system
        self.vector_store.save_local(self.index_path)
        return documents

    def load_documents(self, data_path="data") -> List[Document]:
        documents = []
        for pdf_path in Path(data_path).glob("*.pdf"):
            loader = PyPDFLoader(str(pdf_path))
            docs = loader.load()
            documents.extend(docs)
        return documents

    def chunk_documents(self, documents: List[Document]) -> List[Document]:
        text_splitter = RecursiveCharacterTextSplitter(
            chunk_size=self.chunk_size,
            chunk_overlap=self.chunk_overlap
        )
        return text_splitter.split_documents(documents)

2.RAG Pipeline的关键代码

from langchain.prompts import PromptTemplate
from langchain.chains import create_retrieval_chain
from langchain.llms import Ollama
from langchain.schema.runnable import RunnablePassthrough
from langchain.schema import StrOutputParser, HumanMessage, AIMessage

class LLMRAGHandler:
    def __init__(self, vector_store: VectorStore, llm_model="granite3.3"):
        self.vector_store = vector_store
        self.llm = Ollama(model=llm_model)
        self.history = []

        self.system_prompt = (
            "You are an assistant for question-answering tasks. "
            "Use the following pieces of retrieved context to answer the question. "
            "If you don't know the answer, try to answer the question without context but mention that "
            "the context does not provide enough information. "
            "Use three sentences maximum and keep the answer concise.")

        self.rag_prompt = PromptTemplate.from_template(
            "Previous conversation: {chat_history}\n"
            "Question: {input}\n"
            "Context: {context}\n"
            "Answer:")

        self.llm_chain = self.rag_prompt | self.llm | StrOutputParser()
        self.rag_chain = create_retrieval_chain(self.vector_store.as_retriever(), self.llm_chain)

    def generate_response(self, human_message: str):
        context_docs = self.retrieve(human_message)
        response = self.rag_chain.invoke({
            "input": human_message,
            "context": context_docs,
            "chat_history": self.history
        })

        self.history.append(HumanMessage(content=human_message))
        self.history.append(AIMessage(content=response["answer"]))

        return response["answer"]

    def retrieve(self, question: str, k: int = 4):
        return self.vector_store.vector_store.similarity_search(question, k=k) #修正了此处,使用vector_store.vector_store

3. Streamlit UI的代码片段

import streamlit as st
from llm_rag_handler import LLMRAGHandler
from vector_store import VectorStore
from pathlib import Path

st.set_page_config(page_title="Local RAG Chatbot")
st.title("📄 Chat with Your Documents")

# Store handler in session_state
if "rag_handler" not in st.session_state:
    #先初始化向量数据库
    vector_store = VectorStore(vector_store_path="vector_store")
    st.session_state["rag_handler"] = LLMRAGHandler(vector_store=vector_store) # 初始化时传入向量数据库
handler = st.session_state["rag_handler"]


st.sidebar.header("Upload a PDF")
uploaded_file = st.sidebar.file_uploader("Choose a PDF file", type=["pdf"])

if uploaded_file is not None:
    with open("data/uploaded.pdf", "wb") as f:
        f.write(uploaded_file.read())
    handler.vector_store.add_all_documents("data") # 修改为使用向量数据库的add_all_documents函数
    st.sidebar.success("File uploaded and processed!")


user_input = st.chat_input("Ask something about your document...")

if user_input:
    with st.chat_message("user"):
        st.write(user_input)

    with st.chat_message("assistant"):
        response = handler.generate_response(user_input)
        st.write(response)

这些代码片段可以帮助你更好地理解如何构建一个本地RAG聊天机器人。请注意,这些只是代码示例,你需要根据自己的需求进行修改和调整。

注意: 以上代码仅为示例,实际应用中可能需要进行错误处理、依赖管理、配置管理等方面的完善。特别注意LLMRAGHandlerself.retrieve函数的修正,确保正确调用了 FAISS 的相似度搜索功能。 同时,Streamlit 应用需要向量数据库实例来初始化LLMRAGHandler

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注