Published on

RAG检索增强生成(一)

Authors
  • avatar
    Name
    游戏人生
    Twitter

RAG 原理及流程

RAG 原理

RAG 通过尽可能提供与答案相关的上下文,来提升搜索结果的准确性,用于解决 LLM 基于概率性的底层逻辑。

RAG 流程

  • 加载数据: 加载不同数据源的数据,例如 pdf、code、现存数据库、云数据库等。
  • 切分数据: 对数据集进行语意化的切分,根据内容的特点和目标大模型的特点、上下文窗口等,对数据源进行合适的切分。
  • 嵌入(embedding): 将切分后的每一个文档块使用 embedding 算法转换成一个向量,存储到向量数据库中,这样每一个原始数据都有一个对应的向量用来检索。
  • 检索数据: 当所有需要的数据都存储到向量数据库中后,就把用户的提问也 embedding 成向量,用这个向量去向量数据库中进行检索,找到相似性最高的几个文档块。
  • 增强 prompt: 根据文档块去构建 prompt
  • 生成结果

相关概念及API

prompt

PromptTemplate 可以帮助我们定义一个包含变量的字符串模版,可以通过向该类的对象输入不同的变量值来生成模版渲染的结果。

基础 prompt

无变量

  import { PromptTemplate } from "@langchain/core/prompts";

  const prompt1 = new PromptTemplate({
    inputVariables: [],
    template: "你是谁",
  });
  const msg = await prompt1.format();

  console.log(msg);
  // 你是谁

带变量

  const paramPrompt = new PromptTemplate({
    inputVariables: ["name"],
    template: "我是:{name}",
  });
  const model = await paramPrompt.format({
    name: "小明",
  });

  console.log(model);
  // 我是:小明

多个变量

  const paramPrompt = new PromptTemplate({
    inputVariables: ["name", "age", "sex"],
    template: "我是:{name},今年{age}岁,{{{sex}}}",
  });
  const model = await paramPrompt.format({
      name: "小明",
      age: 18,
      sex: "男",
  });

  console.log(model);
  // 我是:小明,今年18岁,{男}

简写

  const simpleTemp = PromptTemplate.fromTemplate("我是 {name},今年{age}岁,{{{sex}}}");
  console.log(simpleTemp.inputVariables);
  // [ "name", "age", "sex" ]

  const modal = await simpleTemp.format({
    name: "小明",
    age: 18,
    sex: "男",
  });
  console.log(modal);
  // 我是 小明,今年18岁,{男}

分步传参

  const initialPrompt = new PromptTemplate({
    inputVariables: ["name", "age"],
    template: "我是:{name},今年{age}岁",
  });

  const partialedPrompt = await initialPrompt.partial({
    name: "小明",
  });

  const format1 = await partialedPrompt.format({
    age: 23,
  });
  console.log(format1);
  // 我是:小明,今年23岁

  const format2 = await partialedPrompt.format({
    age: 16,
  });
  console.log(format2);
  // 我是:小明,今年16岁

动态参数

  const getCurrentTime = () => {
    return new Date().toLocaleDateString();
  };

  const prompt1 = new PromptTemplate({
    template: "现在是{date},{activity}。",
    inputVariables: ["date", "activity"],
  });

  const partialedPt = await prompt1.partial({
    date: getCurrentTime,
  });

  const formatVal = await partialedPt.format({
    activity: "该吃饭了",
  });

  console.log(formatVal);
  // 现在是2024/5/23,该吃饭了。

函数 getCurrentTime 在 format 被调用的时候实时执行,也就是可以在被渲染成字符串时获取到最新的外部信息。 目前不支持传入参数,如果需要参数,可以用 js 的闭包进行参数的传递。

chat prompt

chatPrompt 主要包括以下几种,分别对应一段 ChatMessage 不同的角色:

  • ChatPromptTemplate : 描述了用户和 LLM 的对话,包括系统消息、用户消息和 LLM 消息
  • SystemMessagePromptTemplate: 系统消息,通常用于设置对话的上下文或指定模型采取特定的行为模式
  • AIMessagePromptTemplate: 用户消息,代表真实用户在对话中的发言
  • HumanMessagePromptTemplate: AI模型的回复,这些消息是模型根据 system 的指示和 user 的输入生成的

具体使用:

1、构建系统 message

  import { SystemMessagePromptTemplate } from "@langchain/core/prompts";

  const sysTemplate = SystemMessagePromptTemplate.fromTemplate(`你是一个专
  业的翻译员,你的任务是将文本从{source_lang}翻译成{target_lang}。`);

2、构建用户输入 message

  import { HumanMessagePromptTemplate } from "@langchain/core/prompts";

  const userTemplate = HumanMessagePromptTemplate.fromTemplate("请翻译这句话:{text}");

3、将sysTemplate 和 userTemplate 组合起来,构建一个 PromptTemplate 对象

  import { ChatPromptTemplate } from "@langchain/core/prompts";

  const chatPrompt = ChatPromptTemplate.fromMessages([
    sysTemplate,
    userTemplate,
  ]);

4、使用 formatMessages 格式化对话信息

  const formatPt = await chatPrompt.formatMessages({
    source_lang: "中文",
    target_lang: " 日语",
    text: "你好,小明同学",
  });

  console.log(formatPt);

5、简写写法如下

  import { ChatPromptTemplate } from "@langchain/core/prompts";
  
  const systemTemplate = "你是一个专业的翻译员,你的任务是将文本从{source_lang}翻译成{target_lang}。";
  const humanTemplate = "请翻译这句话:{text}";

  const chatPrompt = ChatPromptTemplate.fromMessages([
    ["system", systemTemplate],
    ["human", humanTemplate],
  ]);

6、测试

  import { ChatAlibabaTongyi } from "@langchain/community/chat_models/alibaba_tongyi";
  import { HumanMessage } from "@langchain/core/messages";
  import { StringOutputParser } from "@langchain/core/output_parsers";

  const model = new ChatAlibabaTongyi({
    model: "qwen-turbo", // Available models: qwen-turbo, qwen-plus, qwen-max
    temperature: 1,
  });

  const outputPrase = new StringOutputParser();
  const simpleChain = chatPrompt.pipe(model).pipe(outputPrase);

  const stream = await simpleChain.invoke({
      source_lang: "中文",
      target_lang: "日语",
      text: "你好,小明同学",
  });

  console.log(stream);
  // こんにちは、小明さん。

组合多个 prompt

PipelinePromptTemplate 可以将多个独立的 template 构建成一个完整且复杂的 prompt,以此提高独立 prompt 的复用性,进一步增强模块化带来的优势。

PipelinePromptTemplate 两个核心概念:

  • pipelinePrompts 一组 object,每个 object 表示 prompt 运行后赋值给 name 变量
  • finalPrompt 表示最终输出的 prompt

示例如下:

  import {
    PromptTemplate,
    PipelinePromptTemplate,
  } from "@langchain/core/prompts";

  const getCurrentTime = () => {
    return new Date().toLocaleDateString();
  };

  const fullPt = PromptTemplate.fromTemplate(`
  你是一个智能管家,今天是 {date},你的主人的信息是{info}, 
  根据上下文,完成主人的需求
  {task}`);

  const datePt = PromptTemplate.fromTemplate("{date},现在是 {period}");
  const periodPt = await datePt.partial({
    date: getCurrentTime,
  });

  const infoPt =  PromptTemplate.fromTemplate("姓名是 {name}, 性别是 {sex}");

  const taskPt = PromptTemplate.fromTemplate(`
  我想吃 {period} 的 {food}。 
  再重复一遍我的信息 {info}`);

  const composedPrompt = new PipelinePromptTemplate({
    pipelinePrompts: [
      {
        name: "date",
        prompt: periodPt,
      },
      {
        name: "info",
        prompt: infoPt,
      },
      {
        name: "task",
        prompt: taskPt,
      },
    ],
    finalPrompt: fullPt,
  });

  const formatPt = await composedPrompt.format({
      period: "早上",
      name: "小明",
      sex: "男",
      food: "煎饼",
  });

  console.log(formatPt);
  // 你是一个智能管家,今天是 2024/5/23,现在是 早上,你的主人的信息是姓名是 小明, 性别是 男, 
  // 根据上下文,完成主人的需求

  // 我想吃 早上 的 煎饼。 
  // 再重复一遍我的信息 姓名是 小明, 性别是 男

注意

  • 一个变量可以多次复用,例如外界输入的 period 在 periodPt 和 taskPt 都被使用了
  • pipelinePrompts 中的变量可以被引用,比如在 taskPt 使用了 infoPt 的运行结果
  • 支持动态自定义和 partial,示例中也涉及到了这两种特殊的 template
  • langchain 会自动分析 pipeline 之间的依赖关系,尽可能的进行并行化来提高运行速度

OutputParser

OutputParser 用于解析模型输出,将模型输出转换为我们需要的格式。

StringOutputParser

最简单的 Parser,返回 API 中的文本数据,即 content 部分。langchain 内部有错误处理和 stream 等的支持。

  import { ChatBaiduWenxin } from "@langchain/community/chat_models/baiduwenxin";
  import { HumanMessage } from "@langchain/core/messages";
  import { StringOutputParser } from "@langchain/core/output_parsers";

  const model = new ChatBaiduWenxin({
    model: "ERNIE-Speed-8K", // ERNIE-Lite-8K、ERNIE-Lite-8K-0922、ERNIE-Tiny-8K、ERNIE-Speed-128K、ERNIE-Speed-8K、ERNIE Speed-AppBuilder
    temperature: 1,
  });

  const outputPrase = new StringOutputParser();
  const simpleChain = model.pipe(outputPrase);

  const stream = await simpleChain.invoke([
      new HumanMessage("你是谁"),
  ]);

  console.log(stream);

StructuredOutputParser

引导模型引以需要的格式进行输出。

  import { StructuredOutputParser } from "langchain/output_parsers";
  import { PromptTemplate } from "@langchain/core/prompts";

  const parser = StructuredOutputParser.fromNamesAndDescriptions({
    answer: "用户问题的答案",
    evidence: "你回答用户问题所依据的答案",
    confidence: "问题答案的可信度评分,格式是百分数",
  });

  const prompt = PromptTemplate.fromTemplate("尽可能的回答用的问题 {instructions} {question}")
  const model = new ChatBaiduWenxin({
    model: "ERNIE-Speed-8K",
    temperature: 1,
  });

  const chain = prompt.pipe(model).pipe(parser);

  const res = await chain.invoke({
      question: "蒙娜丽莎的作者是谁?是什么时候绘制的",
      instructions: parser.getFormatInstructions(),
  });

  console.log(res);

输出格式为:

  {
    answer: "蒙娜丽莎的作者是列奥纳多·达·芬奇,大约是在15世纪末期绘制的。",
    evidence: "这一信息来源于艺术史和相关的文化资料,蒙娜丽莎是一幅世界著名的画作,其作者和创作时间都是公众熟知的事实。",
    confidence: "95%"
  }

CommaSeparatedListOutputParser

CommaSeparatedListOutputParser 是一个输出解析器,用于解析成逗号分隔的列表。

  import { CommaSeparatedListOutputParser } from "@langchain/core/output_parsers";

  const parser = new CommaSeparatedListOutputParser();
  const model = new ChatBaiduWenxin({
    model: "ERNIE-Speed-8K",
    temperature: 1,
  });

  const prompt = PromptTemplate.fromTemplate("列出3个 {country} 的著名的互联网公司. {instructions}")
    
  const chain = prompt.pipe(model).pipe(parser)

  const res = await chain.invoke({
    country: "中国",
    instructions: parser.getFormatInstructions(),
  });
  console.log(res);

输出内容如下:

  [ "百度,阿里巴巴,腾讯。" ]

Auto Fix Parser

首先使用 zod 定义一个需要的类型:

  import { z } from "zod";

  const schema = z.object({
    answer:  z.string().describe("用户问题的答案"),
    confidence: z.number().min(0).max(100).describe("问题答案的可信度评分,满分 100")
  });

使用正常的方式,使用 zod 来创建一个 StructuredOutputParser

  import { PromptTemplate } from "@langchain/core/prompts";
  import { StructuredOutputParser } from "langchain/output_parsers";
  import { ChatBaiduWenxin } from "@langchain/community/chat_models/baiduwenxin";

  const parser = StructuredOutputParser.fromZodSchema(schema);
  const prompt = PromptTemplate.fromTemplate("尽可能的回答用的问题 \n{instructions} \n{question}")

  const model = new ChatBaiduWenxin({
    model: "ERNIE-Speed-8K",
    temperature: 1,
  });

  const chain = prompt.pipe(model).pipe(parser);
  const res = await chain.invoke({
      question: "蒙娜丽莎的作者是谁?是什么时候绘制的",
      instructions: parser.getFormatInstructions(),
  });
                                
  console.log(res);

输出内容如下:

  { answer: "蒙娜丽莎的作者是列奥纳多·达·芬奇,大约是在15世纪末期绘制的。", confidence: 100 }

构造一个可以根据 zod 定义以及错误的输出,来自动修复的 parser:

  import { OutputFixingParser } from "langchain/output_parsers";

  const fixParser = OutputFixingParser.fromLLM(model, parser);

修复一个错误类型的输出:

  const wrongOutput = {
    "answer": "蒙娜丽莎的作者是达芬奇,大约在16世纪初期(1503年至1506年之间)开始绘制。",
    "sources": "90%",
  };

  const fixParser = OutputFixingParser.fromLLM(model, parser);
  const output = await fixParser.parse(JSON.stringify(wrongOutput));
  console.log(output);

输出内容如下:

  { answer: "蒙娜丽莎的作者是达芬奇,大约在15世纪末期至16世纪初期开始绘制。", confidence: 90 }

定义一个,数值超出限制的错误:

  const wrongOutput = {
    "answer": "蒙娜丽莎的作者是达芬奇,大约在16世纪初期(1503年至1506年之间)开始绘制。",
    "sources": "-1" 
  };

  const fixParser = OutputFixingParser.fromLLM(model, parser);
  const output = await fixParser.parse(JSON.stringify(wrongOutput));

  console.log(output);

输出内容如下:

  { answer: "蒙娜丽莎的作者是达芬奇,大约在15世纪末期至16世纪初期开始绘制。", confidence: 95 }

Embedding 多数据源加载

RAG 的本质是给 chat bot 外挂数据源,数据源的形式多种多样,可以是文件/数据库/网络数据/代码 等等。

TextLoader

用于对文件所在的路径进行加载。

  import { TextLoader } from "langchain/document_loaders/fs/text";
  const loader = new TextLoader("data/2.txt");

  const docs = await loader.load();
  console.log(docs);

输出内容如下:

  [
    Document {
      pageContent: "鲁镇的酒店的格局,是和别处不同的:都是当街一个曲尺形的大柜台,柜里面预备着热水,可以随时温酒。做工的人,傍午傍晚散了工,每每花四文铜钱,买一碗酒,——这是二十多年前的事,现在每碗要涨到十文,——靠柜外"... 2150 more characters,
      metadata: { source: "data/2.txt" }
    }
  ]

返回对象就是一个 Document 对象的实例,其中 pageContent 是文本的原始内容,而在 metadata 中是跟这个对象相关的一些元数据, 这里就是加载原始文件的文件名。

PDFLoader

加载 PDF 文件作为外挂数据库。

在 Deno 环境下使用 PDFLoader 会有 bug,报错找不到 pdf 文件。 解决方法有两个,第一个是把这个文件放在项目根目录里。第二个是修改 deno.json 中 pdf-parser 的别名。

  "pdf-parse": "npm:/pdf-parse/lib/pdf-parse.js"

加载 pdf 文件:

  import * as pdfParse from "pdf-parse";
  import { PDFLoader } from "langchain/document_loaders/fs/pdf";

  const loader = new PDFLoader("./data/webpack.pdf");
  const pdfs = await loader.load()
  console.log(pdfs);

输出内容如下:

  [
    Document {
      pageContent: "什么是前端工程化\n" +
        "前端工程化: 在企业级的前端项目开发中,把前端开发所需要的工具,技术,流程,经验等进行规范化,标准化。\n" +
        "前端工程化的解决方案\n" +
        "早期的前端工程化解决方案:grunt ,gulp\n" +
        "目前主"... 8041 more characters,
      metadata: {
        source: "./data/webpack.pdf",
        pdf: {
          version: "1.10.100",
          info: {
            PDFFormatVersion: "1.3",
            IsAcroFormPresent: false,
            IsXFAPresent: false,
            Producer: "macOS 版本12.3.1(版号21E258) Quartz PDFContext",
            CreationDate: "D:20220430083114Z00'00'",
            ModDate: "D:20220430083114Z00'00'"
          },
          metadata: null,
          totalPages: 1
        },
        loc: { pageNumber: 1 }
      }
    }
  ]

打印出来 pdfs是一个 Document 数组,每一个 Document 对象即 pdf 中的一页,这是 PDFLoader 的默认行为。 关闭这个特性:

  const loader = new PDFLoader("./data/webpack.pdf");
  const pdf = await loader.load();

DirectoryLoader

用于加载一个文件夹下多种格式的文件。

  import { DirectoryLoader } from "langchain/document_loaders/fs/directory";

  const loader = new DirectoryLoader(
    "./data",
    {
      ".pdf": (path) => new PDFLoader(path, { splitPages: false }),
      ".txt": (path) => new TextLoader(path),
    }
  );
  const docs = await loader.load();
  console.log(docs);

返回内容的每个 Document 都对应一篇文档。

Github loader

获取 Github 上某个仓库的代码,并构建数据库,然后根据用户提问寻找与此相关的代码片段回答用户问题

  import { GithubRepoLoader } from "langchain/document_loaders/web/github";
  import ignore from "ignore";

  const loader = new GithubRepoLoader(
    "https://github.com/qingxiang1/qingxiang1.github.io",
    { 
        branch: "master",
        recursive: false, 
        unknown: "warn", 
        ignorePaths: ["*.md", "yarn.lock", "*.json"],
        accessToken: env["GITHUB_TOKEN"]
    }
  );

相关属性如下:

  • branch 分支
  • recursive 是否递归访问文件夹内部的内容,请求量比较大,等待比较久
  • ignorePaths 使用的 git ignore 的语法,忽略掉一些特定格式的文件
  • accessToken github API 的 accessToken,在没有设置的情况也可以访问,但有频率限制

设置 Github Token

WebLoader

一般使用 Cheerio 来提取网页中的静态内容,类似于 python 中的 BeautifulSoup。

  import "cheerio";
  import { CheerioWebBaseLoader } from "langchain/document_loaders/web/cheerio";

  const loader = new CheerioWebBaseLoader(
    "https://blog.qxmall.store/blog/%E6%B5%8F%E8%A7%88%E5%99%A8%E5%9F%BA%E7%A1%80"
  );

  const docs = await loader.load();
  console.log(docs);

输出内容为:

  [
    Document {
      pageContent: "!function(){try{var d=document.documentElement,c=d.classList;c.remove('light','dark');var e=localSto"... 105007 more characters,
      metadata: {
        source: "https://blog.qxmall.store/blog/ai-model/%E5%A4%A7%E6%A8%A1%E5%9E%8B%E5%9F%BA%E7%A1%80"
      }
    }
  ]

可以用类似 jQuery 的语法对 html 中的元素进行选择和过滤:

  const loader = new CheerioWebBaseLoader(
    "https://blog.qxmall.store/blog/ai-model/%E5%A4%A7%E6%A8%A1%E5%9E%8B%E5%9F%BA%E7%A1%80",
    {
      selector: "h2",
    }
  );

Search API

给 chatbot 接入网络支持最重要的 API,在 langchain.js 中,常用的是 SearchApiLoader 和 SerpAPILoader,这两个提供的都是接入搜索的能力,免费计划都是每个月 100 次 search 能力,除了 google 外,也支持 baidu/bing 等常用的搜索引擎。

  import { SerpAPILoader } from "langchain/document_loaders/web/serpapi";
  // import { SerpAPILoader } from "@langchain/community/document_loaders/web/serpapi";

  const apiKey = env["SERP_KEY"]
  const question = "什么是 github"
  const loader = new SerpAPILoader({ q: question, apiKey });
  const docs = await loader.load();
  console.log(docs);

申请 Search Key

输出内容如下:

  [
    Document {
      pageContent: '{"title":"GitHub","type":"Software company","entity_type":"companies, company","kgmid":"/m/0ryppmg",'... 11896 more characters,
      metadata: { source: "SerpAPI", responseType: "knowledge_graph" }
    },
    Document {
      pageContent: '{"position":1,"title":"GitHub - 维基百科,自由的百科全书","link":"https://zh.wikipedia.org/zh-hans/GitHub","redi'... 767 more characters,
      metadata: { source: "SerpAPI", responseType: "organic_results" }
    },
    Document {
      pageContent: '{"position":2,"title":"Github_百度百科","link":"https://baike.baidu.com/item/Github/10145341","redirect_'... 548 more characters,
      metadata: { source: "SerpAPI", responseType: "organic_results" }
    },
    Document {
      pageContent: '{"position":3,"title":"Github是什么?看完你就了解一些了","link":"https://www.cnblogs.com/jiqing9006/p/5584848.htm'... 564 more characters,
      metadata: { source: "SerpAPI", responseType: "organic_results" }
    },
    ...
  ]