注意:本篇文章中的部分代码运行在服务器端,我使用的node
1. 需求和效果
在很多软件中,经常有初始教程,就像这样: 这种功能可以很直观得告诉用户这个功能该怎么用。一般会在用户第一次进入这个模块的时候显示。
如果在一个功能很多的系统中,用户根本不知道需要完成的任务对应哪一个功能,自然不会点进去也就不知道该如何使用。所以我们可以利用LLM
的语言理解能力,理解用户的需求,找到对应的功能并展示引导。
本文最后实现的效果就是这样:
有点像智能客服:
所以我们不能让LLM
模型什么问题都回答,回答的范围也必须限制在系统功能上。
而系统功能并不是LLM
模型学习过的知识,只有开发者才知道有哪些功能。这个时候可以使用langchain
的 Retrieval
模块。
简单来说,就是根据你提供的文档资料,形成一个搜索库,根据用户的问题寻找对应的文档片段。
3. 前置知识
3.1.Retrieval
模块
Retrieval
就是检索的意思,在langchain
中一个检索流程由下面几个模块组成:
source
:数据源,就是要被检索的文档数据load
:文档加载器,因为数据的格式千变万化,需要一个加载器统一处理transform
:将文档按照一定规则分割,搜索不可能全文返回,肯定是分块搜索效率高。embed
:将文档映射(嵌入)到向量空间(embeddings
),因为机器并不真的能读懂文字语言,只是单纯的数学计算。向量的理解store
:向量数据库,直接暴力对比相似度效率肯定不高。借用专门的向量数据库可以快速的检索答案。retrieve
:使用适当的检索策略并返回检索结果对应的文档,比如增加上下文的联系、根据元信息搜索等等。
在langchain
的文档中这几个部分都有详细的介绍。
简单来说,就是利用LLM
模型将用户的问题转化为向量,再利用向量数据库找出相似文档。
3.2. 加载文档
内置的有csv
、json
、pdf
等格式的加载器,本质上就是把文件中的字符串提取出来。 langchain源码里其实有很多加载器,感兴趣的可以去看一下。
注意:内置的文件加载器都继承与TextLoader
,这个类使用了node
的fs
模块,所以在浏览器中是无法运行的。
如果想在浏览器里运行,你可以选择直接使用字符串数据或者自定义解析器,文档有详细介绍。
所以下面代码我将分为服务器端 和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);
}
}
这里的检索器内部已经完成了文档分割 、向量化 、向量存储,所以不用再写额外的代码。
我使用了ParentDocumentRetriever
和MultiQueryRetriever
组合,目的是提高命中率
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
帮助你从而达到你的目的。这里说几个我想到的一些功能点,感兴趣的可以自己实现一下:
连续对话功能
:用户问了一次之后可以提供几个备选项,让用户确认选择再后续处理关联功能指引
:有些流程需要几个功能一起协作,或许可以调整检索策略实现整个流程的指引代码向量化
:直接把页面的代码向量化,这样可以更加自由的控制页面。优化数据结构
:可以想个更通用方便的数据格式定义指引数据。- ......
总之有了大模型的加持,可以扩展更多的想象空间,完成更加智能自然的交互。