3. 使用langchain实现web前端智能增强引导

注意:本篇文章中的部分代码运行在服务器端,我使用的node

1. 需求和效果

在很多软件中,经常有初始教程,就像这样: 这种功能可以很直观得告诉用户这个功能该怎么用。一般会在用户第一次进入这个模块的时候显示。

如果在一个功能很多的系统中,用户根本不知道需要完成的任务对应哪一个功能,自然不会点进去也就不知道该如何使用。所以我们可以利用LLM的语言理解能力,理解用户的需求,找到对应的功能并展示引导。

本文最后实现的效果就是这样:

有点像智能客服

所以我们不能让LLM模型什么问题都回答,回答的范围也必须限制在系统功能上。

而系统功能并不是LLM模型学习过的知识,只有开发者才知道有哪些功能。这个时候可以使用langchain Retrieval模块

简单来说,就是根据你提供的文档资料,形成一个搜索库,根据用户的问题寻找对应的文档片段。

3. 前置知识

3.1.Retrieval模块

Retrieval就是检索的意思,在langchain中一个检索流程由下面几个模块组成:

  • source:数据源,就是要被检索的文档数据
  • load:文档加载器,因为数据的格式千变万化,需要一个加载器统一处理
  • transform:将文档按照一定规则分割,搜索不可能全文返回,肯定是分块搜索效率高。
  • embed:将文档映射(嵌入)到向量空间(embeddings),因为机器并不真的能读懂文字语言,只是单纯的数学计算。向量的理解
  • store:向量数据库,直接暴力对比相似度效率肯定不高。借用专门的向量数据库可以快速的检索答案。
  • retrieve:使用适当的检索策略并返回检索结果对应的文档,比如增加上下文的联系、根据元信息搜索等等。

langchain的文档中这几个部分都有详细的介绍。

简单来说,就是利用LLM模型将用户的问题转化为向量,再利用向量数据库找出相似文档。

3.2. 加载文档

内置的有csvjsonpdf等格式的加载器,本质上就是把文件中的字符串提取出来。 langchain源码里其实有很多加载器,感兴趣的可以去看一下。

注意:内置的文件加载器都继承与TextLoader,这个类使用了nodefs模块,所以在浏览器中是无法运行的。

如果想在浏览器里运行,你可以选择直接使用字符串数据或者自定义解析器,文档有详细介绍。

所以下面代码我将分为服务器端web前端 ,服务器端主要是运行langchain代码,前端就只是传递用户输入的问题,然后执行响应操作。

ts 复制代码
// node中,express搭了一个建议服务
// 接收前端的参数prompt,也就是用户的问题
app.post('/llm', async (req, res) => {
  const { prompt } = req.body;
   // 逻辑操作
  res.send({ message: response });
});
json 复制代码
// 测试json
{
  "test": "aaaa",
  "test2": [
    "bbb",
    "ccc"
  ]
}
ts 复制代码
// node
import { JSONLoader } from 'langchain/document_loaders/fs/json';
const loader = new JSONLoader('server/src/testData/1.json');
const docs = await loader.load();

可以看到内置的加载器实际上就是把所有内容字符串解析出来了

3.3. 文档分割

ts 复制代码
  const text = `我叫孙金明,来自P城。1300年1月份以专业考试得分第一的好成绩毕业于普通大学的兽医专。毕业之前,我曾在宇宙第一公司实习过,宇宙第一公司和贵公司是同类行业。
  本人性格开朗,善于微笑,长于交际,会简单日语及芭蕾舞。我相信,这一切将成为我工作最大的财富。我在很久就注意到贵公司,贵公司无疑是宠物行业中的姣姣者(将你所了解的公司荣誉或成果填上)。同时我又了解到,这又是一支年轻而又富有活力的队伍。本人非常渴望能够在为其中的一员。
  如果有幸获聘,本人将以为公司创造最大利益为自己最大的利益,不讲价钱。真诚做好每一件事,和同事们团结奋斗。勤奋工作,加强学习,不断进步!谢谢!`;
  // 创建递归字符分割器
  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 10,
    chunkOverlap: 1,
    separators: [':', ',', '。', '!', '?'],
  });
  // 分割文档
  const docOutput = await splitter.splitDocuments([new Document({ pageContent: text })]);

langchain中大概就两种分割器:

  • 按照规定字符分割文档:上面代码就是一种字符分割器,按照规定的separators分割文档。
  • 按照token分割文档:将文本转为BPE token,这块不深入涉及,感兴趣的可以自行学习。

分割完成后:

这里有一点注意separators参数中的字符顺序会影响分割结果。

3.4. 词句向量化

ts 复制代码
import { OpenAIEmbeddings } from "@langchain/openai";  
  
/* Create instance */  
const embeddings = new OpenAIEmbeddings();  
  
/* Embed queries */  
const res = await embeddings.embedQuery("Hello world");  
// 实际上就是把词句按照一定算法转化为向量,方便计算机计算
[  
-0.004845875, 0.004899438, -0.016358767, -0.024475135, -0.017341806,  
... 1436 more items  
]  

一般情况下不需要单独调用这个方法,因为后面用到的检索策略类已经实现了这一步骤。

3.5. 向量存储(数据库、知识库)

如何选择合适的存储库

ts 复制代码
mport { MemoryVectorStore } from "langchain/vectorstores/memory";  
import { OpenAIEmbeddings } from "@langchain/openai";  
import { TextLoader } from "langchain/document_loaders/fs/text";  
  
// Create docs with a loader  
const loader = new TextLoader("src/document_loaders/example_data/example.txt");  
const docs = await loader.load();  
  
// Load the docs into the vector store  
const vectorStore = await MemoryVectorStore.fromDocuments(  
docs,  
new OpenAIEmbeddings()  
);  
  
// Search for the most similar document  
const resultOne = await vectorStore.similaritySearch("hello world", 1);  
  
console.log(resultOne);  
  
/*  
[  
    Document {  
        pageContent: "Hello world",  
        metadata: { id: 2 }  
    }  
]  
*/

你就把向量数据库看做普通的数据库,只不过有专门的搜索算法。

一般来说也不用直接使用这里的方法,后面的检索器中已经集成了。

3.6. 检索策略

文档写的Retrievers,应该是检索器的意思,但是我觉得更像是各种检索策略。文档中列举了很多策略,比如:

  • contextual compression:根据上线文压缩搜索结果,只提取关键信息
  • multi query retriever:多问题检索,用LLM生成几个相似的问题一起查询
  • self query:自查询,查询过程考虑一些元信息

等等,具体可以看文档,怎么使用不再赘述,基本上都封装好了直接调用就行了。

3.7. 简单实现

ts 复制代码
// 这是官网的例子
// 可以大概看出组成部分
import * as fs from "fs";  
  
import { OpenAI, OpenAIEmbeddings } from "@langchain/openai";  
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";  
import { HNSWLib } from "@langchain/community/vectorstores/hnswlib";  
import { ContextualCompressionRetriever } from "langchain/retrievers/contextual_compression";  
import { LLMChainExtractor } from "langchain/retrievers/document_compressors/chain_extract";  
  
const model = new OpenAI({  
modelName: "gpt-3.5-turbo-instruct",  
});  
const baseCompressor = LLMChainExtractor.fromLLM(model);  
  
// 1. 读取文档  
const text = fs.readFileSync("state_of_the_union.txt", "utf8");  
// 2. 分割文档
const textSplitter = new RecursiveCharacterTextSplitter({ chunkSize: 1000 });  
const docs = await textSplitter.createDocuments([text]);  
// 3. 向量化文档,并存入指定向量存储库中
const vectorStore = await HNSWLib.fromDocuments(docs, new OpenAIEmbeddings());  
// 4. 使用指定的检索策略
const retriever = new ContextualCompressionRetriever({  
    baseCompressor,  
    baseRetriever: vectorStore.asRetriever(),  
});  
// 5. 题检索
const retrievedDocs = await retriever.getRelevantDocuments(  
    "What did the speaker say about Justice Breyer?"  
);  
// 6. 查询关联文档
console.log({ retrievedDocs });  
  
/*  
{  
    retrievedDocs: [  
        Document {  
            pageContent: 'One of our nation's top legal minds, who will continue Justice Breyer's legacy of excellence.',  
            metadata: [Object]  
        },  
        Document {  
            pageContent: '"Tonight, I'd like to honor someone who has dedicated his life to serve this country: Justice Stephen Breyer---an Army veteran, Constitutional scholar, and retiring Justice of the United States Supreme Court. Justice Breyer, thank you for your service."',  
            metadata: [Object]  
        },  
        Document {  
            pageContent: 'The onslaught of state laws targeting transgender Americans and their families is wrong.',  
            metadata: [Object]  
        }  
    ]  
}  
*/

4. 实现

有了基本的前置知识,我们就可以开始实现。

4.1 自定义文档解析

自带的文档解析器这是简单的把文字提取,光靠这些信息无法为前端程序提供提示下一步应该做什么,比如跳转到哪个路由聚焦到哪一个按钮等等。

为了满足需求,我们可以自定义文档解析类。你可以把额外的信息解析到pageContent中也可以放到metadata里,其实都无所谓。主要看你想要怎么处理数据。

这里我选择放入metaData中:

json 复制代码
// 假设我们的引导设置文件
// 里面的配置我随造的,并不能实现完整的逻辑
{
  "version": "1.0",
  "tours": [
    {
      "name": "首页统计",
      "description": "进入首页,查询各种统计信息",
      "actions": [
        {
          "type": "navigate",
          "parameters": {
            "to": "/"
          }
        }
      ]
    },
    {
      "name": "新增员工",
      "description": "进入员工管理页面,点击新增员工按钮",
      "actions": [
        {
          "type": "navigate",
          "parameters": {
            "to": "/employee"
          }
        }
      ]
    },
    {
      "name": "编辑部门",
      "description": "进入部门管理,选择要编辑的部门",
      "actions": [
        {
          "type": "navigate",
          "parameters": {
            "to": "/dep"
          }
        }
      ]
    }
  ]
}
ts 复制代码
class TourFileLoad extends TextLoader {
  constructor(filePathOrBlob: string | Blob) {
    super(filePathOrBlob);
  }
  async load(): Promise<Document<Tour>[]> {
    let text: string;
    let metadata: Record<string, string>;
    // 读取文件
    if (typeof this.filePathOrBlob === 'string') {
      const { readFile } = await TextLoader.imports();
      text = await readFile(this.filePathOrBlob, 'utf8');
      metadata = { source: this.filePathOrBlob };
    } else {
      text = await this.filePathOrBlob.text();
      metadata = { source: 'blob', blobType: this.filePathOrBlob.type };
    }
    // 调用parse,默认的parse就是返回字符串
    const [parsedString] = await this.parse(text);
    const json: ToursConfig = JSON.parse(parsedString.trim());
    // 将文档内容存入Document
    return json.tours.map(
      t =>
        new Document({
          pageContent: t.description,
          metadata: t,
        }),
    );
  }
}

// 使用
const loader = new TourFileLoad(path.resolve('./testData.json'));
const docs = await loader.load();

4.2. 自定义检索器

为了达到更好的搜索效果,我想把多种检索策略放在一起,所以我自定义了一个

ts 复制代码
// 继承了 BaseRetriever
class TourRetriever extends BaseRetriever {
  lc_namespace = ['my', 'retrievers', 'TourRetriever'];
  llm: TourRetrieverInput['llm'];
  vectorStore: TourRetrieverInput['vectorStore'];
  docStore: BaseStore<string, Document>;
  parentDocumentRetriever: ParentDocumentRetriever;
  retriever: MultiQueryRetriever;
  static lc_name() {
    return 'TourRetriever';
  }
  constructor(options: TourRetrieverInput) {
    super(options);
    this.llm = options.llm;
    this.vectorStore = options.vectorStore;
    this.docStore = new InMemoryStore();
    // 创建一个ParentDocumentRetriever
    this.parentDocumentRetriever = new ParentDocumentRetriever({
      vectorstore: this.vectorStore,
      docstore: this.docStore,
      parentSplitter: new RecursiveCharacterTextSplitter({
        chunkOverlap: 0,
        chunkSize: 200,
        // 真对中文分隔符单独设置
        separators: [':', ',', ',', '。', '!', '?'],
      }),
      childSplitter: new RecursiveCharacterTextSplitter({
        chunkOverlap: 1,
        chunkSize: 10,
        separators: [':', ',', ',', '。', '!', '?'],
      }),
      childK: 20,
      parentK: 3,
    });
    // 创建一个MultiQueryRetriever
    // 把上面的parentDocumentRetriever当做这里的retriever
    this.retriever = MultiQueryRetriever.fromLLM({
      llm: this.llm,
      retriever: this.parentDocumentRetriever,
      verbose: true,
    });
  }
  // 实现_getRelevantDocuments方法
  async _getRelevantDocuments(
    _query: string,
    _callbacks?: CallbackManagerForRetrieverRun | undefined,
  ): Promise<DocumentInterface<Record<string, any>>[]> {
      // 执行搜索文档
    return await this.retriever.getRelevantDocuments(_query);
  }
  // 为了调用parentDocumentRetriever的addDocuments方法
  // 这个方法是为了临时存储父级文档
  async addDocuments(
    docs: Document[],
    config?: {
      ids?: string[];
      addToDocstore?: boolean;
    },
  ): Promise<void> {
    return await this.parentDocumentRetriever.addDocuments(docs, config);
  }
}

这里的检索器内部已经完成了文档分割向量化向量存储,所以不用再写额外的代码。

我使用了ParentDocumentRetrieverMultiQueryRetriever组合,目的是提高命中率

4.3. 使用

ts 复制代码
// node express
import 'dotenv/config';
import express from 'express';
import ViteExpress from 'vite-express';
import { OpenAIEmbeddings, OpenAI } from '@langchain/openai';
import { Chroma } from '@langchain/community/vectorstores/chroma';
import { ChromaClient } from 'chromadb';
import path from 'path';
import TourFileLoad from './tourFileLoad.js';
import TourRetriever from './tourRetriever.js';
// 加载文档
const loader = new TourFileLoad(path.resolve('testData.json'));
const docs = await loader.load();
// 创建向量引擎
const embeddings = new OpenAIEmbeddings({
  openAIApiKey: process.env.VITE_OPENAI_KEY,
});
// 创建llm模型
const llm = new OpenAI({
  openAIApiKey: process.env.VITE_OPENAI_KEY,
});
// 创建Chroma数据库客户端
// 目的是自定义数据库的操作,防止重复插入数据
// 默认情况下langchain内部每次都会插入新数据,没有判断是否已经创建
// chroma数据库相关的东西自行官网查看
const chromaClient = new ChromaClient({ path: 'http://localhost:3492' });
// 数据库创建表
const collectionName = 'tours';
await chromaClient.getOrCreateCollection({
  name: collectionName,
});
// 创建vectorStore对象
const vectorStore = await Chroma.fromExistingCollection(embeddings, {
  collectionName,
  index: chromaClient,
});

// 前端请求后
app.post('/llm', async (req, res) => {
  const { prompt } = req.body;
  // 创建自定义检索器
  const tourRetriever = new TourRetriever({
    llm,
    vectorStore,
  });
  // 添加父文档
  await tourRetriever.addDocuments(docs);
  // 检索文档
  const query = await tourRetriever.getRelevantDocuments(prompt);
  res.send({ data: query });
});

4.4. 效果

简单写了个页面,由首页、部门管理、员工管理组成。右上角是输入框,可以提出问题。

当我提出一个问题"怎么新加一个人",后端响应出了三个文档,默认是按照匹配度排序。所以第一个就是最匹配的文档。

我故意修改了提问方式,没有直接问"怎么增加员工",替换了几个同义词。可以看到系统仍然可以识别我的意图。

后面的操作就不赘述了,就是前端根据metaData里面定义的动作去执行就行了。

langchain可以输出执行过程,上面的查询过程大概是这样:

  • MultiQuery检索器,基于我的问题,使用LLM生成了多个相似的问题:
  • 将上面生成的问题依次使用Parent Document Retriever检索文档:
  • 最后MultiQuery将所有答案和在一起,去除重复答案,取最接近的:

5. 改进思路

上面只是一个简单的实现思路,由于LLM模型对于自然语言的理解正在飞速进步,你完全可以把它当做一个助手,你可以用千万种方法让ai帮助你从而达到你的目的。这里说几个我想到的一些功能点,感兴趣的可以自己实现一下:

  • 连续对话功能:用户问了一次之后可以提供几个备选项,让用户确认选择再后续处理
  • 关联功能指引:有些流程需要几个功能一起协作,或许可以调整检索策略实现整个流程的指引
  • 代码向量化:直接把页面的代码向量化,这样可以更加自由的控制页面。
  • 优化数据结构:可以想个更通用方便的数据格式定义指引数据。
  • ......

总之有了大模型的加持,可以扩展更多的想象空间,完成更加智能自然的交互。

6.参考

得物大模型平台接入最佳实践

向量与向量数据库

相关推荐
浮华似水12 分钟前
简洁之道 - React Hook Form
前端
正小安2 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch4 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光4 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   4 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   4 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web4 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
安冬的码畜日常4 小时前
【CSS in Depth 2 精译_044】第七章 响应式设计概述
前端·css·css3·html5·响应式设计·响应式
莹雨潇潇5 小时前
Docker 快速入门(Ubuntu版)
java·前端·docker·容器
Jiaberrr5 小时前
Element UI教程:如何将Radio单选框的圆框改为方框
前端·javascript·vue.js·ui·elementui