RAG 入门实践:从文档拆分到向量数据库与问答构建

打印 上一主题 下一主题

主题 907|帖子 907|积分 2721

本文将使用 Transformers 和 LangChain,选择在 Retrieval -> Chinese 中表现较好的编码模型进行演示,即 chuxin-llm/Chuxin-Embedding。
  

  你还将了解 RecursiveCharacterTextSplitter 的递归工作原理。
  一份值得关注的基准测试榜单:MTEB (Massive Text Embedding Benchmark) Leaderboard。
  代码文件下载
  
  
前言

RAG(Retrieval-Augmented Generation,检索增强天生)并不是一项高深的技能,你可以将其拆分为 RA(检索增强)和 G(天生)来理解。
RA 的通俗解释是:在询问模型之前,通过相似度匹配从文档中检索相关内容,将其与当前问题结合。
举个例子。
假设你正在负责图书答疑,有读者询问某本书中特定主题的问题:

  • 情景 1:读者直接向你提问,但你并不知道他所说的是哪本书,不过,依附丰富的知识储备,你还是给出了一个回复。

  • 情景 2:读者先向你的助理提问,助理从书架上找出了相关的册本,检索到了他以为相关的段落,并将这些段落和问题一起交给你,基于这些具体的信息,你提供了一个更加准确、相关且具体的回复。

情景 2 就是 RAG 的工作方式:在模型回复之前,先检索相关的信息提供给模型,以增强其回复的准确性和相关性。
因此,RA 更像是一种工程上的操作,或者说是对 Prompt 的增强,并不会影响模型本身的参数。通过在 Prompt 中加入检索到的相关信息,模型可以在回复特定文档的问题时表现得更好。有点像将 Zero-shot Prompting 扩充为 Few-shot Prompting,以是在特定文档的问答中会有提升。
而 G 就是大家熟悉的文本天生,或者说天生式模型的调用(本文不会涉及模型训练)。
环境配置

  1. pip install langchain langchain-community langchain-huggingface unstructured
  2. pip install pandas
  3. pip install transformers sentence-transformers accelerate
  4. pip install "numpy<2.0"
  5. # 处理图片,tesseract 进行 OCR(以下为可选下载)
  6. #sudo apt-get update
  7. #sudo apt-get install python3-pil tesseract-ocr libtesseract-dev tesseract-ocr-eng tesseract-ocr-script-latn
  8. #pip install "unstructured[image]" tesseract tesseract-ocr
复制代码
执行以下代码避免后续 documents = loader.load() 大概的 LookupError 报错。
  1. import nltk
  2. nltk.download('punkt')  # 下载 punkt 分词器
  3. nltk.download('punkt_tab')  # 下载 punkt_tab 分词器数据
  4. nltk.download('averaged_perceptron_tagger')  # 下载词性标注器
  5. nltk.download('averaged_perceptron_tagger_eng')  # 下载英文词性标注器
复制代码
RA

在实际实现中,遵循的步骤大抵如下:

  • 使用预训练的编码器模型将「文档」内容编码为向量表现(embedding),然后建立一个向量数据库。
  • 在检索阶段,针对用户的「问题」,同样使用编码器将其编码为向量,然后在向量数据库中寻找与之相似的文档片断。
文档导入

以当前项目 Guide 文件夹下的指导文章为例,发起修改 DOC_PATH,替换成你想要处置处罚的文件夹。
起首,设置路径并使用 DirectoryLoader 加载文档:
  1. from langchain.document_loaders import DirectoryLoader
  2. # 定义文件所在的路径,修改它,或者克隆仓库
  3. # git clone https://github.com/Hoper-J/AI-Guide-and-Demos-zh_CN && cd Demos
  4. DOC_PATH = "../Guide"
  5. # 使用 DirectoryLoader 从指定路径加载文件。"*.md" 表示加载所有 .md 格式的文件,这里仅导入文章 10(避免当前文章的演示内容对结果的影响,注意,如果修改成了自己的DOC_PATH,务必修改 glob 进行匹配)
  6. loader = DirectoryLoader(DOC_PATH, glob="10*.md")
  7. # 加载目录中的指定的 .md 文件并将其转换为文档对象列表
  8. documents = loader.load()
  9. # 打印查看加载的文档内容
  10. print(documents[0].page_content[:200])
复制代码
  可以修改参数 glob 来指定想处置处罚的文件,也可以去除这个参数以处置处罚全部文件。
  输出
  1. 什么是 Top-K 和 Top-P 采样?Temperature 如何影响生成结果?
  2. 在之前的文章中我们探讨了 Beam Search 和 Greedy Search。
  3. 现在来聊聊 model.generate() 中常见的三个参数: top-k, top-p 和 temperature。
  4. 代码文件下载
  5. 目录
  6. 采样方法概述
  7. Top-K 采样详解
  8. ...
复制代码
  细致观察输出格式,想想有什么地方与原文本格式差别。
  「Markdown 的结构化标志被去除了」
  文本处置处罚

   或许你对 chunk 会有一点印象,在 15. 用 API 实现 AI 视频择要:动手制作属于你的 AI 视频助手中,我们使用了非常简单的分块方法(直接截断)。
  LangChain 提供了多种文本分块方式,比方 RecursiveCharacterTextSplitter、HTMLSectionSplitter、MarkdownTextSplitter 等,可以根据需求选择。本文将演示 RecursiveCharacterTextSplitter。
  不过,在使用 split_documents() 处置处罚文档之前,我们先使用 split_text() 来看看它究竟是怎么进行分块的。摘取一段长隆万圣节的文本介绍:
  1. text = """长隆广州世界嘉年华系列活动的长隆欢乐世界潮牌玩圣节隆重登场,在揭幕的第一天就吸引了大批年轻人前往打卡。据悉,这是长隆欢乐世界重金引进来自欧洲的12种巨型花车重磅出巡,让人宛若进入五彩缤纷的巨人国;全新的超级演艺广场每晚开启狂热的电音趴,将整个狂欢氛围推向高点。
  2. 记者在现场看到,明日之城、异次元界、南瓜欢乐小镇、暗黑城、魔域,五大风格迥异的“鬼”域在夜晚正式开启,全新重磅升级的十大“鬼”屋恭候着各位的到来,各式各样的“鬼”开始神出“鬼”没:明日之城中丧尸成群出行,寻找新鲜的“血肉”。异次元界异形生物游走,美丽冷艳之下暗藏危机。暗黑城亡灵出没,诅咒降临。魔域异“鬼”横行,上演“血腥恐怖”。南瓜欢乐小镇小丑当家,滑稽温馨带来欢笑。五大“鬼”域以灯光音效科技情景+氛围营造360°沉浸式异域次元界探险模式为前来狂欢的“鬼”友们献上“惊奇、恐怖、搞怪、欢乐”的玩圣体验。持续23天的长隆欢乐玩圣节将挑战游客的认知极限,让你大开眼界!
  3. 据介绍,今年长隆玩圣节与以往相比更为隆重,沉浸式场景营造惊悚氛围,两大新“鬼”王隆重登场,盛大的“鬼”王出巡仪式、数十种集声光乐和高科技于一体的街头表演、死亡巴士酷跑、南瓜欢乐小镇欢乐电音、暗黑城黑暗朋克、魔术舞台双煞魔舞、异形魔幻等一系列精彩节目无不让人拍手称奇、惊叹不止的“玩圣”盛宴让 “鬼”友们身临其境,过足“戏”瘾!
  4. """
  5. print(len(text))
复制代码
这段文本长度为 581。接下来看看效果如何:
  1. from langchain.text_splitter import RecursiveCharacterTextSplitter
  2. # 创建一个文本分割器。
  3. text_splitter = RecursiveCharacterTextSplitter(
  4.     chunk_size=100,   # 每个文本块的最大长度
  5.     chunk_overlap=20  # 文本块之间的字符重叠数量
  6. )
  7. # 将文本分割成多个块
  8. texts = text_splitter.split_text(text)
  9. # 打印分割后的文本块数量
  10. print(len(texts))
  11. # 打印第一个文本块的长度
  12. print(len(texts[0]))
  13. # 打印第一个文本块的最后 20 个字符
  14. print(texts[0][80:])
  15. # 打印第二个文本块的前 20 个字符
  16. print(texts[1][:20])
复制代码
输出
  1. 9
  2. 100
  3. 出巡,让人宛若进入五彩缤纷的巨人国;全新
  4. 出巡,让人宛若进入五彩缤纷的巨人国;全新
复制代码
很好,text 被分为了 9 段,而且可以看到第一段文本确实以 100 个字符进行分割,而且 overlap 符合预期。
   你可以通过下图来理解 overlap:
  

  到目前为止,RecursiveCharacterTextSplitter 的表现就像是一个简单的文本截断,没有什么特别之处。但是,让我们观察 len(text) 和 len(texts):原文本长度为 581,分割后的段落数为 9,问题出现了。按照直接截断的假设,前 8 段应为 100 个字符,即便去除 overlap,总长度仍应超过 600,这与原始文本的长度不符。说明文本分割过程中肯定执行了其他操作,而不仅仅是直接截断。
实际上,RecursiveCharacterTextSplitter() 的关键在于 RecursiveCharacter,即递归地按照指定的分隔符(默以为 ["\n\n", "\n", " ", ""])进行文本拆分。也就是说,在文本拆分的时间,它会尝试使用较大的分隔符来拆分文本,如果长度仍超过 chunk_size,则逐步使用更小的分隔符,直到长度满意或终极进行截断,也就是出现第一次分块当中的效果。以是说,第一次的分块实际上是一个“妥协”。
为了更好的进行理解,现在将 chunk_overlap 设置为 0,并打印输出:
  1. from langchain.text_splitter import RecursiveCharacterTextSplitter
  2. text_splitter = RecursiveCharacterTextSplitter(
  3.     chunk_size=100,
  4.     chunk_overlap=0  # 不重叠
  5. )
  6. texts = text_splitter.split_text(text)
  7. # 输出每个片段的长度和内容
  8. for i, t in enumerate(texts):
  9.     print(f"Chunk {i+1} length: {len(t)}")
  10.     print(t)
  11.     print("-" * 50)
复制代码
输出
  1. Chunk 1 length: 100
  2. 长隆广州世界嘉年华系列活动的长隆欢乐世界潮牌玩圣节隆重登场,在揭幕的第一天就吸引了大批年轻人前往打卡。据悉,这是长隆欢乐世界重金引进来自欧洲的12种巨型花车重磅出巡,让人宛若进入五彩缤纷的巨人国;全新
  3. --------------------------------------------------
  4. Chunk 2 length: 30
  5. 的超级演艺广场每晚开启狂热的电音趴,将整个狂欢氛围推向高点。
  6. --------------------------------------------------
  7. Chunk 3 length: 99
  8. 记者在现场看到,明日之城、异次元界、南瓜欢乐小镇、暗黑城、魔域,五大风格迥异的“鬼”域在夜晚正式开启,全新重磅升级的十大“鬼”屋恭候着各位的到来,各式各样的“鬼”开始神出“鬼”没:明日之城中丧尸成群
  9. --------------------------------------------------
  10. Chunk 4 length: 100
  11. 出行,寻找新鲜的“血肉”。异次元界异形生物游走,美丽冷艳之下暗藏危机。暗黑城亡灵出没,诅咒降临。魔域异“鬼”横行,上演“血腥恐怖”。南瓜欢乐小镇小丑当家,滑稽温馨带来欢笑。五大“鬼”域以灯光音效科技情
  12. --------------------------------------------------
  13. ...
复制代码
可以看到,文本在 全新 和 的 之间被截断,因为达到了 100 个字符的限制,这符合直觉。然而,接下来的 chunk 2 只有 30 个字符,这是因为 RecursiveCharacterTextSplitter 并不是逐「段」分割,而是逐「分隔符」分割。
递归拆分的过程

以下是 RecursiveCharacterTextSplitter 的递归拆分流程:

  • 尝试使用第一个分隔符 \n\n:如果文本长度超过 chunk_size,就以 \n\n 为分隔符拆分文本,以当前文本为例:
    1. Chunk 1 length: 130
    2. 长隆广州世界嘉年华...全新的超级演艺广场每晚开启狂热的电音趴,将整个狂欢氛围推向高点。
    3. --------------------------------------------------
    4. Chunk 2 length: 451
    5. 记者在现场看到,...让你大开眼界!
    6. 据介绍,...惊叹不止的“玩圣”盛宴让 “鬼”友们身临其境,过足“戏”瘾!
    7. --------------------------------------------------
    复制代码
    留意,如果两段被拆分的文本加起来不超过 chunk_size,它们会被合并(不然的话对于英文文本,使用" "空格分割就全拆成单词了)。
  • 查抄拆分后的子文本长度:如果子文本长度仍旧超过 chunk_size,就对每个子文本递归地使用下一个分隔符 \n 进行拆分。
    1. Chunk 1 length: 130
    2. ...(不变)
    3. --------------------------------------------------
    4. Chunk 2 length: 285
    5. 记者在现场看到,...让你大开眼界!
    6. --------------------------------------------------
    7. Chunk 3 length: 164(Chunk 2 3 由之前的 Chunk 2 分割得来)
    8. 据介绍,...惊叹不止的“玩圣”盛宴让 “鬼”友们身临其境,过足“戏”瘾!
    9. --------------------------------------------------
    复制代码
  • 查抄拆分后的子文本长度(和之前一样):如果子文本长度仍旧超过 chunk_size,就对每个子文本递归地使用下一个分隔符  (空格) 进行拆分,留意到,chunk 3 的“让”字后面有一个空格:
    1. Chunk 1 length: 130
    2. ...(不变)
    3. --------------------------------------------------
    4. Chunk 2 length: 285
    5. ...(不变)
    6. --------------------------------------------------
    7. Chunk 3 length: 146
    8. 据介绍,...惊叹不止的“玩圣”盛宴让
    9. --------------------------------------------------
    10. Chunk 4 length: 17(Chunk 3 4 由之前的 Chunk 3 分割得来)
    11. “鬼”友们身临其境,过足“戏”瘾!
    12. --------------------------------------------------
    复制代码
    还需要留意的是 146+17=163<164,这说明当前分隔符不被继承到新的 chunk 中。
  • 重复上述过程(如果还有其他分隔符的话):直到使用最小的分隔符 ""(即逐字符)进行拆分,这一步将会直接截断:
    1. Chunk 1 length: 100
    2. 长隆广州世界嘉年华...全新
    3. --------------------------------------------------
    4. Chunk 2 length: 30(Chunk 1 2 由之前的 Chunk 1 截断得来)
    5. 的超级...
    6. --------------------------------------------------
    7. Chunk 3 length: 99
    8. 记者在现场看到...
    9. --------------------------------------------------
    10. Chunk 4 length: 100
    11. 出行...科技情
    12. --------------------------------------------------
    13. Chunk 5 length: 85(Chunk 2 3 4 由之前的 Chunk 2 截断得来)
    14. 景+氛围...让你大开眼界!
    15. --------------------------------------------------
    16. Chunk 6 length: 99
    17. 据介绍,...暗黑城黑
    18. --------------------------------------------------
    19. Chunk 7 length: 46(Chunk 6 7 由之前的 Chunk 3 截断得来)
    20. 暗朋克...惊叹不止的“玩圣”盛宴让
    21. --------------------------------------------------
    22. Chunk 8 length: 17(Chunk 8 就是之前的 Chunk 4,一段 good_split)
    23. “鬼”友们身临其境,过足“戏”瘾!
    24. --------------------------------------------------
    复制代码
关于拆分文本的 RecursiveCharacterTextSplitter._split_text() 的源码位于附录部门。
   回看对于 \n\n 的处置处罚,chunk 均大于 100,你大概会说:“这是因为演示,代码增长了 chunk_size”,实际并不是云云,\n\n 对应的代码如下:
  1. text_splitter = RecursiveCharacterTextSplitter(
  2. chunk_size=100,
  3. chunk_overlap=0,
  4. separators=["\n\n"]
  5. )
  6. texts = text_splitter.split_text(text)
  7. # 输出每个片段的长度和内容
  8. for i, t in enumerate(texts):
  9. print(f"Chunk {i+1} length: {len(t)}")
  10. print(t)
  11. print("-" * 50)
复制代码
思索一下为什么没有正确截断?
  修改为 separators=["\n\n", ""],再查看其输出。
  中文的句号更偏向表达于一段叙述的结束,以是我们可以试着增长这个符号来修改预期行为:
  1. text_splitter = RecursiveCharacterTextSplitter(
  2.     chunk_size=100,
  3.     chunk_overlap=0,
  4.     separators=["\n\n", "\n", " ", "。", ""]
  5. )
  6. texts = text_splitter.split_text(text)
  7. # 输出每个片段的长度和内容
  8. for i, t in enumerate(texts):
  9.     print(f"Chunk {i+1} length: {len(t)}")
  10.     print(t)
  11.     print("-" * 50)
复制代码
输出
  1. Chunk 1 length: 50
  2. 长隆广州世界嘉年华系列活动的长隆欢乐世界潮牌玩圣节隆重登场,在揭幕的第一天就吸引了大批年轻人前往打卡
  3. --------------------------------------------------
  4. Chunk 2 length: 80
  5. 。据悉,这是长隆欢乐世界重金引进来自欧洲的12种巨型花车重磅出巡,让人宛若进入五彩缤纷的巨人国;全新的超级演艺广场每晚开启狂热的电音趴,将整个狂欢氛围推向高点。
  6. --------------------------------------------------
  7. ...
复制代码
现在,RecursiveCharacterTextSplitter() 还有最后一个参数没有进行讲解:length_function,这是在 split 时盘算长度是否达标的紧张参数:
  1. if self._length_function(s) < self._chunk_size:
  2.     _good_splits.append(s)
复制代码
以是一样平常指定为 len。
❌ length_function 错误示范:
  1. text_splitter = RecursiveCharacterTextSplitter(
  2.     chunk_size=100,
  3.     chunk_overlap=0,
  4.     length_function=lambda x: 1,
  5. )
  6. texts = text_splitter.split_text(text)
  7. # 输出每个片段的长度和内容
  8. for i, t in enumerate(texts):
  9.     print(f"Chunk {i+1} length: {len(t)}")
  10. print(text_splitter._length_function("Hello"))
复制代码
输出
  1. Chunk 1 length: 580
  2. 1
复制代码
此时无论多长,text_splitter._length_function() 都返回为1,以是对任意文本来说,都是一个 _good_splits,导致直接返回不进行分割。
回归正题,处置处罚文档:
  1. from langchain.text_splitter import RecursiveCharacterTextSplitter
  2. text_splitter = RecursiveCharacterTextSplitter(
  3.     chunk_size=500,  # 尝试调整它
  4.     chunk_overlap=100,  # 尝试调整它
  5.     #length_function=len,  # 可以省略
  6.     #separators=["\n\n", "\n", " ", "。", ""]  # 可以省略
  7. )
  8. docs = text_splitter.split_documents(documents)
  9. print(len(docs))
复制代码
加载编码模型

接下来,使用 HuggingFaceEmbeddings 加载 Hugging Face 上的预训练模型:
  1. from langchain_huggingface import HuggingFaceEmbeddings
  2. # 指定要加载的预训练模型的名称,参考排行榜:https://huggingface.co/spaces/mteb/leaderboard
  3. model_name = "chuxin-llm/Chuxin-Embedding"
  4. # 创建 Hugging Face 的嵌入模型实例,这个模型将用于将文本转换为向量表示(embedding)
  5. embedding_model = HuggingFaceEmbeddings(model_name=model_name)
  6. # 打印嵌入模型的配置信息,显示模型结构和其他相关参数
  7. print(embedding_model)
  8. # embed_query() 方法会将文本转换为嵌入的向量
  9. query_embedding = embedding_model.embed_query("Hello")
  10. # 打印生成的嵌入向量的长度,向量长度应与模型的输出维度一致(这里是 1024),你也可以选择打印向量看看
  11. print(f"嵌入向量的维度为: {len(query_embedding)}")
复制代码
输出
  1. client=SentenceTransformer(
  2.   (0): Transformer({'max_seq_length': 8192, 'do_lower_case': False}) with Transformer model: XLMRobertaModel
  3.   (1): Pooling({'word_embedding_dimension': 1024, 'pooling_mode_cls_token': True, 'pooling_mode_mean_tokens': False, 'pooling_mode_max_tokens': False, 'pooling_mode_mean_sqrt_len_tokens': False, 'pooling_mode_weightedmean_tokens': False, 'pooling_mode_lasttoken': False, 'include_prompt': True})
  4.   (2): Normalize()
  5. ) model_name='chuxin-llm/Chuxin-Embedding' cache_folder=None model_kwargs={} encode_kwargs={} multi_process=False show_progress=False
  6. 嵌入向量的维度为: 1024
复制代码
可以看到当前编码模型终极输出的维度是 1024。
   留意:输出效果表现的是模型的配置信息,而不是具体的向量嵌入。向量嵌入将在后续步骤中天生。
  建立向量数据库

现在,使用预训练嵌入模型对文本片断天生实际的向量表现,然后建立向量数据库来存储和检索这些向量。这里使用 FAISS(Facebook AI Similarity Search):
  1. from langchain.vectorstores import FAISS
  2. # 使用预训练嵌入模型生成向量并创建向量数据库
  3. vectorstore = FAISS.from_documents(docs, embedding_model)
复制代码
FAISS.from_documents() 方法会调用 embedding_model 对 docs 中的每个文本片断天生相应的向量表现。
生存和加载向量数据库(可选)

为了避免每次运行程序都重新盘算向量表现,可以将向量数据库生存到本地,以便下次直接加载:
  1. from langchain.vectorstores import FAISS
  2. # 保存向量数据库
  3. vectorstore.save_local("faiss_index")
  4. # 加载向量数据库
  5. # 注意参数 allow_dangerous_deserialization,确保你完全信任需要加载的数据库(当然,自己生成的不需要考虑这一点)
  6. vectorstore = FAISS.load_local("faiss_index", embedding_model, allow_dangerous_deserialization=True)
复制代码
创建检索器

现在,我们需要创建一个检索器,用于在用户提出问题时,从向量数据库中检索相关的文本片断。
  1. retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
复制代码
k=3 表现每次检索返回最相似的 3 个文档片断,k 的巨细可以根据需要调解,较大的 k 值会返回更多的文档片断,但大概会包罗较多无关信息,也可以通过 score 的巨细进行初筛。
试着检索一下:
  1. query = "Top-K 和 Top-P 的区别是什么?"
  2. # 检索与 query 相关的文档片段
  3. retrieved_docs = retriever.invoke(query)
  4. # 打印检索到的文档片段
  5. for i, doc in enumerate(retrieved_docs):
  6.     print(f"Document {i+1}:")
  7.     print(f"Content: {doc.page_content}\n")
复制代码
输出(省略部门内容)
  1. Document 1:
  2. Content: 什么是 Top-K 和 Top-P 采样?Temperature 如何影响生成结果?
  3. ...
  4. 代码文件下载
  5. 目录
  6. 采样方法概述
  7. Top-K 采样详解
  8. ...
  9. Top-P 采样详解
  10. ...
  11. Document 2:
  12. Content: 输出:
  13. ...
  14. Top-P 采样(又称 Nucleus Sampling)是一种动态选择候选词汇的方法。与 Top-K 采样不同,Top-P 采样不是固定选择 K 个词汇,而是选择一组累计概率达到 P 的词汇集合(即从高到低加起来的概率)。这意味着 Top-P 采样可以根据当前的概率分布动态调整候选词汇的数量,从而更好地平衡生成的多样性和质量。
  15. ...
  16. Document 3:
  17. Content: top_p=0.5: 在这 10 个词汇中,从高到低,选择累积概率达到 0.5 的词汇归一化后进行采样。
  18. temperature=0.8: 控制生成的随机性,较低的温度使模型更倾向于高概率词汇。
  19. ...
复制代码
你需要留意到的是,即便是在当前项目中进行简单的文档检索,也会出现一个问题,观察 Document 1:由于文章在引言和目录部门一样平常会精粹总体的信息,以是 retriever 非常有大概捕捉到它,而这些部门通常无法有效回复具体技能细节。通过以下代码,我们可以查看各部门的得分环境:
  1. # 使用 FAISS 数据库进行相似性搜索,返回最相关的文档片段
  2. retrieved_docs = vectorstore.similarity_search_with_score(query, k=3)
  3. # 现在的 retrieved_docs 包含 (Document, score)
  4. for doc, score in retrieved_docs:
  5.     print(f"Score: {score}")
  6.     print(f"Content: {doc.page_content}\n")
复制代码
输出
  1. Document 1:
  2. Score: 0.8947205543518066
  3. Content: 什么是 Top-K 和 Top-P 采样?Temperature 如何影响生成结果?
  4. ...
  5. 目录
  6. ...
  7. Document 2:
  8. Score: 0.9108018279075623
  9. Content: 输出:
  10. ...
  11. Document 3:
  12. Score: 0.9529485702514648
  13. Content: top_p=0.5: 在这 10 个词汇中,从高到低,选择累积概率达到 0.5 的词汇归一化后进行采样。
复制代码
需要留意的是,在这里,得分越低表现相似度越高,参见源码:
  1. """
  2. Returns:
  3.         List of documents most similar to the query text and L2 distance in float for each. Lower score represents more similarity.
  4.         返回一个包含与查询文本最相似的文档列表,以及每个文档对应的 L2 距离(浮点数)。得分越低,表示相似度越高。
  5. """
复制代码
如果查询的问题都是关于具体细节的,那么目录部门的得分大概没有实质意义。以是根据需求,可以在文件预处置处罚时简单地过滤掉目录内容。
G

通过 Transformers 以及 LangChain 的 HuggingFacePipeline,完成文本天生任务。
加载文本天生模型

这里我们选择 19a 所使用的量化模型,当然,你可以替换它:
  1. from transformers import AutoTokenizer, AutoModelForCausalLM
  2. # 以下二选一,也可以进行替换
  3. # 本地
  4. model_path = './Mistral-7B-Instruct-v0.3-GPTQ-4bit'
  5. # 远程
  6. model_path = 'neuralmagic/Mistral-7B-Instruct-v0.3-GPTQ-4bit'
  7. # 加载
  8. tokenizer = AutoTokenizer.from_pretrained(model_path)
  9. model = AutoModelForCausalLM.from_pretrained(
  10.     model_path,
  11.     torch_dtype="auto",  # 自动选择模型的权重数据类型
  12.     device_map="auto",   # 自动选择可用的设备(CPU/GPU)
  13. )
复制代码
创建管道

使用 Transformers 的 pipeline 创建一个文本天生器:
  1. from transformers import pipeline
  2. generator = pipeline(
  3.     "text-generation",  # 指定任务类型为文本生成
  4.     model=model,
  5.     tokenizer=tokenizer,
  6.     max_length=4096,    # 指定生成文本的最大长度
  7.     pad_token_id=tokenizer.eos_token_id
  8. )
复制代码
  pipeline() 的第一个参数 task 并不是可以随意自定义的名称,而是特定任务的标识。比方,“text-generation” 对应于构造一个 TextGenerationPipeline,用于天生文本。
  集成到 LangChain

使用 LangChain 的 HuggingFacePipeline 将天生器包装为 LLM 接口:
  1. from langchain_huggingface import HuggingFacePipeline
  2. llm = HuggingFacePipeline(pipeline=generator)
复制代码
定义提示词模版

  1. from langchain.prompts import PromptTemplate
  2. custom_prompt = PromptTemplate(
  3.     template="""Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know, don't try to make up an answer.
  4. {context}
  5. Question: {question}
  6. Answer:""",
  7.     input_variables=["context", "question"]
  8. )
复制代码
构建问答链

使用检索器和 LLM 创建问答链:
  1. from langchain.chains import RetrievalQA
  2. qa_chain = RetrievalQA.from_chain_type(
  3.     llm=llm,
  4.     chain_type="stuff",   # 直接堆叠所有检索到的文档
  5.     retriever=retriever,  # 使用先前定义的检索器来获取相关文档
  6.     # chain_type_kwargs={"prompt": custom_prompt}  # 可以选择传入自定义提示模板(传入的话记得取消注释),如果不需要可以删除这个参数
  7. )
复制代码


  • chain_type 参数说明:

    • stuff
      将全部检索到的文档片断直接与问题“堆叠”在一起,通报给 LLM。这种方式简单直接,但当文档数量较多时,大概会超过模型的上下文长度限制。
    • map_reduce
      对每个文档片断分别天生回复(map 阶段),然后将全部回复汇总为终极答案(reduce 阶段)。
    • refine
      先对第一个文档片断天生初始回复,然后依次读取后续文档,对答案进行逐步细化和美满。
    • map_rerank
      对每个文档片断分别天生回复,并为每个回复打分,终极选择得分最高的回复作为答案。

   map_reduce 和 refine 在用 API 实现 AI 视频择要一文中有简单的概念解释。
  进行 QA

  1. # 提出问题
  2. query = "Top-K 和 Top-P 的区别是什么?"
  3. # 获取答案
  4. answer = qa_chain.run(query)
  5. print(answer)
复制代码
输出
  1. Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know, don't try to make up an answer.
  2. 什么是 Top-K 和 Top-P 采样?Temperature 如何影响生成结果?
  3. ...
  4. Question: Top-K 和 Top-P 的区别是什么?
  5. Answer: Top-K 采样是固定选择 K 个词汇,而 Top-P 采样是选择一组累计概率达到 P 的词汇集合。Top-P 采样可以根据当前的概率分布动态调整候选词汇的数量,从而更好地平衡生成的多样性和质量。
复制代码
至此,我们完成了一个简单的 RAG 流程。留意,在实际应用中,许多的参数都需要根据具体环境来调解。
实际上,使用 LangChain 并非必需。你可以观察到,代码对于模型的使用完全可以基于 Transformers,文档的递归分割实际上也可以自己构造函数来实现,使用 LangChain 只是为了将其引入我们的视野。
参考链接

LangChain - Docs
Understanding LangChain’s RecursiveCharacterTextSplitter
附录

完整代码

  1. from langchain.document_loaders import DirectoryLoaderfrom langchain.text_splitter import RecursiveCharacterTextSplitterfrom langchain.prompts import PromptTemplatefrom langchain.vectorstores import FAISSfrom transformers import AutoTokenizer, AutoModelForCausalLM, pipelinefrom langchain.chains import RetrievalQAfrom langchain_huggingface import HuggingFaceEmbeddings, HuggingFacePipeline# 定义文件地点的路径DOC_PATH = "../Guide"# 使用 DirectoryLoader 从指定路径加载文件。"*.md" 表现加载全部 .md 格式的文件,这里仅导入文章 10(避免文章 20 的演示内容对效果的影响)loader = DirectoryLoader(DOC_PATH, glob="10*.md")# 加载目录中的指定的 .md 文件并将其转换为文档对象列表documents = loader.load()# 文本处置处罚text_splitter = RecursiveCharacterTextSplitter(    chunk_size=500,  # 尝试调解它    chunk_overlap=100,  # 尝试调解它    #length_function=len,  # 可以省略    #separators=["\n\n", "\n", " ", "。", ""]  # 可以省略)docs = text_splitter.split_documents(documents)# 天生嵌入(使用 Hugging Face 模型)# 指定要加载的预训练模型的名称,参考排行榜:https://huggingface.co/spaces/mteb/leaderboardmodel_name = "chuxin-llm/Chuxin-Embedding"# 创建 Hugging Face 的嵌入模型实例,这个模型将用于将文本转换为向量表现(embedding)embedding_model = HuggingFaceEmbeddings(model_name=model_name)# 建立向量数据库vectorstore = FAISS.from_documents(docs, embedding_model)# 生存向量数据库(可选)#vectorstore.save_local("faiss_index")# 加载向量数据库(可选)# 留意参数 allow_dangerous_deserialization,确保你完全信任需要加载的数据库(当然,自己天生的不需要考虑这一点)#vectorstore = FAISS.load_local("faiss_index", embedding_model, allow_dangerous_deserialization=True)# 创建检索器retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
  2. # 加载文本天生模型# 本地model_path = './Mistral-7B-Instruct-v0.3-GPTQ-4bit'# 长途#model_path = 'neuralmagic/Mistral-7B-Instruct-v0.3-GPTQ-4bit'# 加载tokenizer = AutoTokenizer.from_pretrained(model_path)model = AutoModelForCausalLM.from_pretrained(    model_path,    torch_dtype="auto",  # 主动选择模型的权重数据类型    device_map="auto",   # 主动选择可用的设备(CPU/GPU))# 创建文本天生管道generator = pipeline(    "text-generation",  # 指定任务类型为文本天生    model=model,    tokenizer=tokenizer,    max_length=4096,    # 指定天生文本的最大长度    pad_token_id=tokenizer.eos_token_id)# 包装为 LangChain 的 LLM 接口llm = HuggingFacePipeline(pipeline=generator)custom_prompt = PromptTemplate(    template="""Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know, don't try to make up an answer.{context}Question: {question}Answer:""",    input_variables=["context", "question"])# 构建问答链qa_chain = RetrievalQA.from_chain_type(    llm=llm,    chain_type="stuff",   # 直接堆叠全部检索到的文档    retriever=retriever,  # 使用先前定义的检索器来获取相关文档    # chain_type_kwargs={"prompt": custom_prompt}  # 可以选择传入自定义提示模板(传入的话记得取消注释),如果不需要可以删除这个参数)# 提出问题
  3. query = "Top-K 和 Top-P 的区别是什么?"
  4. # 获取答案
  5. answer = qa_chain.run(query)
  6. print(answer)
复制代码
RecursiveCharacterTextSplitter 源码

   https://api.python.langchain.com/en/latest/_modules/langchain_text_splitters/character.html#RecursiveCharacterTextSplitter
  1. class RecursiveCharacterTextSplitter(TextSplitter):
  2.     ...
  3.     def _split_text(self, text: str, separators: List[str]) -> List[str]:
  4.         """Split incoming text and return chunks."""
  5.         final_chunks = []
  6.         # 获取要使用的分隔符
  7.         separator = separators[-1]
  8.         new_separators = []
  9.         for i, _s in enumerate(separators):
  10.             _separator = _s if self._is_separator_regex else re.escape(_s)
  11.             if _s == "":
  12.                 separator = _s
  13.                 break
  14.             if re.search(_separator, text):
  15.                 separator = _s
  16.                 new_separators = separators[i + 1 :]
  17.                 break
  18.         _separator = separator if self._is_separator_regex else re.escape(separator)
  19.         splits = _split_text_with_regex(text, _separator, self._keep_separator)
  20.         # 现在开始合并文本,并递归拆分较长的文本片段
  21.         _good_splits = []
  22.         _separator = "" if self._keep_separator else separator
  23.         for s in splits:
  24.             if self._length_function(s) < self._chunk_size:
  25.                 _good_splits.append(s)
  26.             else:
  27.                 if _good_splits:
  28.                     merged_text = self._merge_splits(_good_splits, _separator)
  29.                     final_chunks.extend(merged_text)
  30.                     _good_splits = []
  31.                 if not new_separators:
  32.                     final_chunks.append(s)
  33.                 else:
  34.                     # **递归调用自身,使用剩余的分隔符继续拆分**
  35.                     other_info = self._split_text(s, new_separators)
  36.                     final_chunks.extend(other_info)
  37.         if _good_splits:
  38.             merged_text = self._merge_splits(_good_splits, _separator)
  39.             final_chunks.extend(merged_text)
  40.         return final_chunks
复制代码
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

反转基因福娃

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表