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.参考

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

向量与向量数据库

相关推荐
慧一居士28 分钟前
flex 布局完整功能介绍和示例演示
前端
DoraBigHead30 分钟前
小哆啦解题记——两数失踪事件
前端·算法·面试
一斤代码6 小时前
vue3 下载图片(标签内容可转图)
前端·javascript·vue
中微子6 小时前
React Router 源码深度剖析解决面试中的深层次问题
前端·react.js
光影少年6 小时前
从前端转go开发的学习路线
前端·学习·golang
中微子7 小时前
React Router 面试指南:从基础到实战
前端·react.js·前端框架
3Katrina7 小时前
深入理解 useLayoutEffect:解决 UI "闪烁"问题的利器
前端·javascript·面试
前端_学习之路8 小时前
React--Fiber 架构
前端·react.js·架构
伍哥的传说8 小时前
React 实现五子棋人机对战小游戏
前端·javascript·react.js·前端框架·node.js·ecmascript·js
qq_424409198 小时前
uniapp的app项目,某个页面长时间无操作,返回首页
前端·vue.js·uni-app