LangChain搭建问答系统分析

247 阅读9分钟

Intro

开发框架

  • image-20241107153342142

  • 整个框架分为三部分

    • 数据源

      • 非结构化数据
      • 结构化数据
    • LLM应用

    • 用户层 -- Client or Browser

核心实现机制:

  • image-20241107154147808

5步核心流程如下:

    1. Loading(加载) 首先,需要将文档加载到系统中。文档可能来自各种来源,例如URL、PDF文件、数据库等。LangChain提供了多种加载器(Loader)可以处理不同格式的文档,将它们转化为模型能够处理的格式。此步骤的目标是收集并读取所有相关文档,并将它们转换为标准化的文本格式,便于后续处理。
    1. Splitting(切分) 在这一阶段,系统会将加载的文档进行切分。通常,文档会被分成更小的片段(称为“文档块”或“文档切片”)。切分的粒度取决于具体应用需求,可以是段落、句子,甚至是更小的文本块。分割的目的是为了便于后续的向量化处理,同时在检索时可以更精准地找到与查询相关的小段文本。
    1. Storage(存储) 将分割好的文档切片转换为向量(Embedding)并存储到向量数据库(Vector DB)中。向量化的过程是将每个切片用模型转化为一个高维向量,表示该文本的语义信息。通过存储向量表示,可以为后续的检索步骤提供支持,以实现高效的相似度查询。
    1. Retrieval(检索) 当用户输入查询(Query)后,系统会在向量数据库中检索与查询最相似的文档切片。检索是通过计算查询向量与存储向量之间的相似度(如余弦相似度或欧氏距离)来实现的。找到与查询最相关的切片后,系统将这些切片作为上下文,提供给语言模型,用以生成回答。
    1. Output(输出) 在最后一步,系统将用户的问题和检索到的相关文档切片作为提示(Prompt),输入到大型语言模型(LLM)中。LLM结合这些上下文信息生成回答,并输出给用户。这样,系统能够给出一个与查询相关的、基于文档信息的回答。

Token and Character

在 NLP 领域,字符(character)token(标记) 是两个不同的概念,尤其在大型语言模型(LLM)中,理解它们的区别非常重要。

  1. 字符(Character)

    • 定义:字符是文本的最小组成单位,例如字母、数字、符号等。在英文中,像 abc1@ 等都是字符;在中文中,每个汉字(如 "你"、"好")都是一个字符。
    • 使用:字符的计算通常用于一些基础的文本操作(如限制字符长度)和编码操作(如 Unicode)。在许多文本分割工具中,参数 chunk_size 使用的是字符数。
  2. Token(标记)

  • 定义:Token 是 NLP 模型在处理文本时的基本单位。根据分词方法的不同,一个 Token 可以是一个单词、一个词根、甚至是部分单词或字符的组合。 比如在英文中,hello 是一个 Token,但在一些模型中,它也可能被分为多个部分(如 hello),尤其是使用 BPE(Byte-Pair Encoding)或 WordPiece 分词技术的模型。在中文中,每个汉字也常常被视为一个独立的 Token。
  • 在 LLM 中的使用:大型语言模型(如 GPT-3)在处理文本时会将文本转化为 Token 序列。模型的输入限制通常是 Token 数,而不是字符数。例如,GPT-3 的输入限制是 4096 Tokens,而不是 4096 字符。

字符和 Token 的区别

  1. 数量关系

    • 在英文中,Token 通常比字符少,因为一个单词可以包含多个字符,但可能是一个 Token。
    • 在中文中,由于每个汉字通常是一个 Token,因此字符数和 Token 数大致相同,但仍有例外,特别是在处理混合语言时(如中英文混合文本)。
  2. 模型的输入限制

    • 语言模型通常限制的是 Token 数,而不是字符数。比如,假设一个模型的最大输入限制是 2000 Tokens,那么无论字符数如何,只要不超过 2000 个 Token 都可以作为输入。
  3. 分词方式的差异

    • 不同的模型和分词方式会对 Token 的定义有所不同。例如,GPT-3 使用 BPE 分词,因此一些常见词会作为一个 Token,但不常见的词会被分成多个 Token。
    • 字符是固定的,分词与字符数无关。

举例说明

假设句子为 "Hello, 你好",可以看到字符数和 Token 数的差异:

  • 字符数Hello 5 个字符,逗号 1 个字符,空格 1 个字符,你好 2 个字符,总共 9 个字符。
  • Token 数(假设使用 BPE 分词):在 GPT-3 中,Hello 可能是一个 Token,而 你好 中每个汉字是一个 Token,总共 4 个 Token。

在 LLM 中,字符和 Token 的区别在于:

  • 字符是文本的最小单位,而Token是模型分词后的基本处理单元。
  • 模型的输入限制基于 Token 数,而不是字符数,因此在使用模型时,我们通常需要关注 Token 数,而不是字符数。

Example

1 Load

  • langchain_community.document_loaders 中导入对应文档格式的Loader
  • 再创建以 对应文档的Loader对象
  • 最后通过 Loader对象 进行加载, 加载的是以page为单位的列表
 import os
 ​
 # 1.Load 导入Document Loaders
 from langchain_community.document_loaders import PyPDFLoader
 from langchain_community.document_loaders import Docx2txtLoader
 from langchain_community.document_loaders import TextLoader
 ​
 # 加载Documents
 base_dir = r'./oneFlower'  # 文档的存放目录
 documents = []
 for file in os.listdir(base_dir): 
     # 构建完整的文件路径
     file_path = os.path.join(base_dir, file)
     if file.endswith('.pdf'):
         loader = PyPDFLoader(file_path)
         documents.extend(loader.load())
     elif file.endswith('.docx'): 
         loader = Docx2txtLoader(file_path)
         documents.extend(loader.load())
     elif file.endswith('.txt'):
         loader = TextLoader(file_path)
         documents.extend(loader.load())
 ​
 print(documents[1].page_content[10:])
 ​
  • Result

    • image-20241107161849413

    • 以 page 为单位存储

      • 例如,”员工手册.pdf“的页数为32,那么就有32个page_content
    • image-20241107162046170

2 Split

RecursiveCharacterTextSplitter 是 LangChain 中用于将文本分割成更小块的工具,便于后续的嵌入(Embedding)和向量存储。以下是对这个函数的详细解释,包括其作用、参数和分块的原理。

作用

在许多自然语言处理(NLP)任务中,处理较长的文档会超出模型的输入限制,因此需要将文档拆分成更小的文本块。RecursiveCharacterTextSplitter 的作用就是将文本递归地分割成小块,以确保每个文本块的长度不超过指定的大小,同时保留一定的上下文信息(通过块之间的重叠)以便后续的查询匹配更准确。

分块原理

RecursiveCharacterTextSplitter 通过递归方式来分割文本。它会首先尝试按照自然的分隔符(如段落、句子等)进行分割。如果一个段落或句子超过了指定的 chunk_size,则会进一步分割,直到符合长度要求。这种递归的分割方式使得文本块尽可能保持语义连贯,有助于提高问答系统的准确性。

参数

RecursiveCharacterTextSplitter 的初始化包含一些关键参数:

  1. chunk_size

    • 含义:指定每个分块的最大字符数
    • 作用:定义了分块的目标大小,保证每个分块不会过长,从而避免模型输入超长的问题。
    • 示例:如果 chunk_size=200,每个分块的字符数会尽量接近 200,但不会超过 200。
  2. chunk_overlap

    • 含义:指定相邻文本块之间的重叠字符数
    • 作用:提供上下文信息。通过使相邻的块部分重叠,模型可以更好地理解文本之间的联系。特别是在分块过程中,如果一块的内容在下一块中部分重复,那么查询时可以获得更连贯的答案。
    • 示例:如果 chunk_overlap=10,则每个块的最后 10 个字符会出现在下一个块的开头。

使用示例

 text_splitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=10)
 chunked_documents = text_splitter.split_documents(documents)
  • text_splitter.split_documents(documents)

    • split_documents 方法用于分割传入的文档列表 documents。每个文档会被分割成多个小块,并存储在 chunked_documents 中。
    • 返回值 chunked_documents 是一个分块后的文档列表,每个元素都是一个独立的文本块,符合 chunk_sizechunk_overlap 的要求。

Result

  • image-20241107164846413
  • 同一个 page 被split为多个chunk。 同时,某些上下文chunk会有部分overlap

3 Storage

  • pip 相应的SDK
  • Embedding使用相应的 Embedding 模型,而不是Chat模型
 # 3.Store 将分割嵌入并存储在矢量数据库Qdrant中
 from langchain_community.vectorstores import Qdrant
 from typing import Dict, List, Any
 from langchain.embeddings.base import Embeddings
 # from langchain.pydantic_v1 import BaseModel
 from pydantic import BaseModel
 from volcenginesdkarkruntime import Ark
 class DoubaoEmbeddings(BaseModel, Embeddings):
     client: Ark = None
     api_key: str = 'xxx',
     model: str ='xxxx'
 ​
     def __init__(self, **data: Any):
         super().__init__(**data)
         self.client = Ark(
             base_url='xxx',
             api_key='xxx'
         )
 ​
     def embed_query(self, text: str) -> List[float]:
         """
         生成输入文本的 embedding.
         Args:
             texts (str): 要生成 embedding 的文本.
         Return:
             embeddings (List[float]): 输入文本的 embedding,一个浮点数值列表.
         """
         embeddings = self.client.embeddings.create(model=self.model, input=text)
         return embeddings.data[0].embedding
 ​
     def embed_documents(self, texts: List[str]) -> List[List[float]]:
         return [self.embed_query(text) for text in texts]
 ​
     class Config:
         arbitrary_types_allowed = True
 ​
 ​
 vectorstore = Qdrant.from_documents(
     documents=chunked_documents,  # 以分块的文档
     embedding=DoubaoEmbeddings(
     ),  
     location=":memory:",  # in-memory 存储
     collection_name="my_documents",
 )  # 指定collection_name

4 检索

  • 多询问检索器 MultiQueryRetriever

  • 检索QA RetrievalQA

  • 检索器和QA链都需要大模型的辅助

    • 检索器可利用大模型 生成多个问题变体,提高检索结果的相关性
    • QA链利用 检索器检索到的上下文, 与问题一同输入LLM,模型会基于context和Problem进行回答
 from langchain_openai import ChatOpenAI
 from langchain.retrievers.multi_query import MultiQueryRetriever # MultiQueryRetriever工具
 from langchain.chains import RetrievalQA # RetrievalQA链
 ​
 # 实例化一个MultiQueryRetriever
 retriever_from_llm = MultiQueryRetriever.from_llm(retriever=vectorstore.as_retriever(), llm=llm)
 ​
 # 实例化一个RetrievalQA链
 qa_chain = RetrievalQA.from_chain_type(llm,retriever=retriever_from_llm)
 ​
 # 实例化一个大模型工具 - OpenAI的GPT-3.5
 llm = ChatOpenAI(
     api_key='xxxx',
     model='xxx',
     base_url='xxx')
 ​
 # 实例化一个MultiQueryRetriever
 retriever_from_llm = MultiQueryRetriever.from_llm(retriever=vectorstore.as_retriever(), llm=llm)
 ​
 # 实例化一个RetrievalQA链
 qa_chain = RetrievalQA.from_chain_type(llm,retriever=retriever_from_llm)
 ​
 # 5. Output 问答系统的UI实现
 ​
 question = "袜子要求?"
 # RetrievalQA链 - 读入问题,生成答案
 result = qa_chain.invoke({"query": question})
 print(result.result)

logging:用于设置日志输出,以便监控和调试程序执行的状态。

ChatOpenAI:这是 LangChain 提供的接口,用于调用 OpenAI 的聊天模型(如 gpt-3.5-turbo)。

MultiQueryRetriever:这是一个多查询检索器,可以基于多个不同的问题格式对向量数据库执行检索,以获取更相关的内容。

RetrievalQA:LangChain 提供的问答链,它通过指定检索器和语言模型,构建一个用于问答的完整链条。

MultiQueryRetriever.from_llm(...) :使用语言模型生成多个不同的问题变体,并基于这些变体执行多次检索。这样可以提高检索结果的相关性,确保返回的内容更符合查询意图。

RetrievalQA.from_chain_type(...) :创建一个问答链,将检索器和语言模型组合在一起。

image-20241107203243069