一、背景
得物开放平台是一个把得物能力进行开放,同时提供给开发者提供 公告、应用控制台、权限包申请、业务文档等功能的平台。
面向商家:通过接入商家自研系统。可以实现自动化库存、订单、对账等管理。
面向 ISV :接入得物开放平台,能为其产品提供更完善的全平台支持。
面向内部应用:提供安全、可控的、快速支持的跨主体通讯。
得物开放平台目前提供了一系列的文档以及工具去辅助开发者在实际调用 API 之前进行基础的引导和查询。
但目前的文档搜索功能仅可以按照接口路径,接口名称去搜索,至于涉及到实际开发中遇到的接口前置检查,部分字段描述不清等实际问题,且由于信息的离散性,用户想要获得一个问题的答案需要在多个页面来回检索,造成用户焦虑,进而增大 TS 的答疑可能性。
随着这几年 AI 大模型的发展,针对离散信息进行聚合分析且精准回答的能力变成了可能。而 RAG 应用的出现,解决了基础问答类 AI 应用容易产生幻觉现象的问题,达到了可以解决实际应用内问题的目标。
二、简介
什么是 RAG
RAG(检索增强生成)指 Retrieval Augmented Generation。
这是一种通过从外部来源获取知识来提高生成性人工智能模型准确性和可靠性的技术。通过 RAG,用户实际上可以与任何数据存储库进行对话,这种对话可视为“开卷考试”,即让大模型在回答问题之前先检索相关信息。
RAG 应用的可落地场景
RAG 应用的根本是依赖一份可靠的外部数据,根据提问检索并交给大模型回答,任何基于可靠外部数据的场景均是 RAG 的发力点。
RAG 应用的主要组成部分
外部知识库:问题对应的相关领域知识,该知识库的质量将直接影响最终回答的效果。
Embedding 模型:用于将外部文档和用户的提问转换成 Embedding 向量。
向量数据库:将外部信息转化为 Embedding 向量后进行存储。
检索器:该组件负责从向量数据库中识别最相关的信息。检索器将用户问题转换为 Embedding 向量后执行相似性检索,以找到与用户查询相关的 Top-K 文档(最相似的 K 个文档)。
生成器(大语言模型 LLM):一旦检索到相关文档,生成器将用户查询和检索到的文档结合起来,生成连贯且相关的响应。
提示词工程(Prompt Engineering):这项技术用于将用户的问题与检索到的上下文有效组合,形成大模型的输入。
RAG 应用的核心流程
以下为一个标准 RAG 应用的基础流程:
将查询转换为向量
在文档集合中进行语义搜索
将检索到的文档传递给大语言模型生成答案
从生成的文本中提取最终答案
但在实际生产中,为了确保系统的全面性、准确性以及处理效率,还有许多因素需要加以考虑和处理。
下面我将基于答疑助手在开放平台的落地,具体介绍每个步骤的详细流程。
三、实现目标
鉴于目前得物开放平台的人工答疑数量相对较高,用户在开放平台查询未果就会直接进入到人工答疑阶段。正如上文所说,RAG 擅长依赖一份可靠的知识库作出相应回答,构建一个基于开放平台文档知识库的 RAG 应用再合适不过,同时可以一定程度降低用户对于人工答疑的依赖性,做到问题前置解决。
四、整体流程
技术选型
大模型:https://openai.com/index/gpt-4o-mini-advancing-cost-efficient-intelligence/
Embedding 模型:https://platform.openai.com/docs/guides/embeddings
向量数据库:https://milvus.io/
框架:https://js.langchain.com/v0.2/docs/introduction/LangChain.js 是 LangChain 的 JavaScript 版本,专门用于开发 LLM 相关的交互应用程序,其 Runnable 设计在开放平台答疑助手中广泛应用,在拓展性、可移植性上相当强大。
准确性思考
问答的准确性会直接反馈到用户的使用体验,当一个问题的回答是不准确的,会导致用户根据不准确的信息进一步犯错,导致人工客服介入,耐心丧失直至投诉。
所以在实际构建基于开放平台文档的答疑助手之前,首先考虑到的是问答的准确性,主要包括以下 2 点:
首要解决答疑助手针对非开放平台提问的屏蔽
寻找可能导致答非所问的时机以及相应的解决方案
屏蔽非相关问题
为了屏蔽 AI 在回答时可能会回答一些非平台相关问题,我们首先要做的是让 AI 明确我们的目标(即问答上下文),且告诉他什么样的问题可以回答,什么问题不可以回答。
在这一点上,常用的手段为告知其什么是开放平台以及其负责的范畴。
例如:得物的开放平台是一个包含着 API 文档,解决方案文档的平台,商家可以通过这个平台获取到得物的各种接口,以及解决方案,帮助商家更好的使用得物的服务。现在需要做一个智能答疑助手,你是其中的一部分。
在这一段描述中,我们告知了答疑助手,开放平台包含着 API 文档,包含着解决方案,同时包含接口信息,同时会有商家等之类的字眼。大模型在收到这段上下文后,将会对其基础回答进行判断。
同时,我们可以通过让答疑助手二选一的方式进行回答,即平台相关问题与非平台相关问题。我们可以让大模型返回特定的数据枚举,且限定枚举范围,例如:开放平台通用问题、开放平台 API 答疑问题,未知问题。
借助 Json 类型的输出 + JSON Schema,我们可通过 Prompt 描述来限定其返回,从而在进入实际问答前做到事前屏蔽。
寻找可能导致答非所问的时机
当问题被收拢到开放平台这个主题之后,剩余的部分就是将用户提问与上下文进行结合,再交由大模型回答处理。在这过程中,可能存在的答非所问的时机有:不够明确的 Prompt 说明、上下文信息过于碎片化以及上下文信息的连接性不足三种。
不够明确的 Prompt 说明:Prompt 本身描述缺少限定条件,导致大模型回答轻易超出我们给予的要求,从而导致答非所问。
上下文信息过于碎片化:上下文信息可能被分割成 N 多份,这个 N 值过大或者过小,都会导致单个信息过大导致缺乏联想性、单个信息过小导致回答时不够聚焦。
上下文信息连接性不够:若信息之间被随意切割,且缺少相关元数据连接,交给大模型的上下文将会是丧失实际意义的文本片段,导致无法提取出有用信息,从而答非所问。
为了解决以上问题,在设计初期,开放平台答疑助手设定了以下策略来前置解决准确性问题:
用户提问的结构化
向量的分割界限以及元信息处理
CO-STAR Prompt 结构
相似性搜索的 K 值探索
用户提问结构化
目标:通过大模型将用户提问的结构化,将用户提问分类并提取出精确的内容,便于提前引导、终止以及提取相关信息。
例如,用户提问今天天气怎么样,结构化 Runnable 会将用户问题进行初次判断。
一个相对简单的 Prompt 实现如下:
# CONTEXT
得物的开放平台是一个包含着 API 文档,解决方案文档的平台,商家可以通过这个平台获取到得物的各种接口,以及解决方案,帮助商家更好的使用得物的服务。现在需要做一个智能答疑助手,你是其中的一部分。
# OBJECTIVE
你现在扮演一名客服。请将每个客户问题分类到固定的类别中。
你只接受有关开放平台接口的相关问答,不接受其余任何问题。
具体的类别我会在提供给你的JSON Schema中进行说明。
# STYLE
你需要把你的回答以特定的 JSON 格式返回
# TONE
你给我的内容里,只能包含特定 JSON 结构的数据,不可以返回给我任何额外的信息。
# AUDIENCE
你的回答是给机器看的,所以不需要考虑任何人类的感受。
# RESPONSE
你返回的数据结构必须符合我提供的 JSON Schema 规范,我给你的 Schema 将会使用\`<json-schema></json-schema>\`标签包裹.
每个字段的描述,都是你推算出该字段值的依据,请仔细阅读。
<json-schema>
{schema}
</json-schema>
复制代码
Json Schema 的结构通过 zod 描述如下:
const zApiCallMeta = z
.object({
type: z
.enum(['api_call', 'unknown', 'general'])
.describe('当前问题的二级类目, api_call为API调用类问题,unknown为非开放平台相关问题, general为通用类开放平台问题'),
apiName: z
.string()
.describe(
'接口的名称。接口名称为中文,若用户未给出明确的API中文名称,不要随意推测,将当前字段置为空字符串',
),
apiUrl: z.string().describe('接口的具体路径, 一般以/开头'),
requestParam: z.unknown().default({}).describe('接口的请求参数'),
response: z
.object({})
.or(z.null())
.default({})
.describe('接口的返回值,若未提供则返回null'),
error: z
.object({
traceId: z.string(),
})
.optional()
.describe('接口调用的错误信息,若接口调用失败,则提取traceId并返回'),
})
.describe('当二级类目为api_call时,使用这个数据结构');
复制代码
以上结构,将会对用户的问题输入进行结构化解析。同时给出相应 JSON 数据结构。
将以上结构化信息结合,可实现一个基于 LangChain.js 的结构化 Runnable,在代码结构设计上,所有的 Runnable 将会使用 $作为变量前缀,用于区分 Runnable 与普通函数。
import { ChatOpenAI } from '@langchain/openai';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { RunnableSequence, RunnableMap } from '@langchain/core/runnables';
import { $getPrompt } from './$prompt';
import { zSchema, StructuredInputType } from './schema';
import { n } from 'src/utils/llm/gen-runnable-name';
import { getLLMConfig } from 'src/utils/llm/get-llm-config';
import { getStringifiedJsonSchema } from 'src/utils/llm/get-stringified-json-schema';
const b = n('$structured-input');
const $getStructuredInput = () => {
const $model = new ChatOpenAI(getLLMConfig().ChatOpenAIConfig).bind({
response_format: {
type: 'json_object',
},
});
const $input = RunnableMap.from<{ question: string }>({
schema: () => getStringifiedJsonSchema(zSchema),
question: (input) => input.question,
}).bind({ runName: b('map') });
const $prompt = $getPrompt();
const $parser = new StringOutputParser();
return RunnableSequence.from<{ question: string }, string>([
$input.bind({ runName: b('map') }),
$prompt.bind({ runName: b('prompt') }),
$model,
$parser.bind({ runName: b('parser') }),
]).bind({
runName: b('chain'),
});
};
export { $getStructuredInput, type StructuredInputType };
复制代码
鉴于 CO-STAR 以及 JSONSchema 的提供的解析稳定性,此 Runnable 甚至具备了可单测的能力。
import dotenv from 'dotenv';
dotenv.config();
import { describe, expect, it } from 'vitest';
import { zSchema } from '../runnables/$structured-input/schema';
import { $getStructuredInput } from '../runnables/$structured-input';
const call = async (question: string) => {
return zSchema.safeParse(
JSON.parse(await $getStructuredInput().invoke({ question })),
);
};
describe('The LLM should accept user input as string, and output as structured data', () => {
it('should return correct type', { timeout: 10 * 10000 }, async () => {
const r1 = await call('今天天气怎么样');
expect(r1.data?.type).toBe('unknown');
const r2 = await call('1 + 1');
expect(r2.data?.type).toBe('unknown');
const r3 = await call('trace: 1231231231231231313');
expect(r3.data?.type).toBe('api_call');
const r4 = await call('快递面单提示错误');
expect(r4.data?.type).toBe('api_call');
const r5 = await call('发货接口是哪个');
expect(r5.data?.type).toBe('api_call');
const r6 = await call('怎么发货');
expect(r6.data?.type).toBe('general');
const r7 = await call('获取商品详情');
expect(r7.data?.type).toBe('api_call');
const r8 = await call('dop/api/v1/invoice/cancel_pick_up');
expect(r8.data?.type).toBe('api_call');
const r9 = await call('开票处理');
expect(r9.data?.type).toBe('api_call');
const r10 = await call('权限包');
expect(r10.data?.type).toBe('api_call');
});
复制代码
数据预处理与向量库的准备工作
RAG 应用的知识库准备是实施过程中的关键环节,涉及多个步骤和技术。以下是知识库准备的主要过程:
知识库选择:【全面性与质量】数据源的信息准确性在 RAG 应用中最为重要,基于错误的信息将无法获得正确的回答。
知识库收集:【多类目数据】数据收集通常涉及从多个来源提取信息,包括不同的渠道,不同的格式等。如何确保数据最终可以形成统一的结构并被统一消费至关重要。
数据清理:【降低额外干扰】原始数据往往包含不相关的信息或重复内容。
知识库分割:【降低成本与噪音】将文档内容进行分块,以便更好地进行向量化处理。每个文本块应适当大小,并加以关联,以确保在检索时能够提供准确的信息,同时避免生成噪声。
向量化存储:【Embedding 生成】使用 Embedding 模型将文本块转换为向量表示,这些向量随后被存储在向量数据库中,以支持快速检索。
检索接口构建:【提高信息准确性】构建检索模块,使其能够根据用户查询从向量数据库中检索相关文档。
知识库拆分
知识库文档的拆分颗粒度(Split Chunk Size) 是影响 RAG 应用准确性的重要指标:
拆分颗粒度过大可能导致检索到的文本块包含大量不相关信息,从而降低检索的准确性。
拆分颗粒度过小则可能导致必要的上下文信息丢失,使得生成的回答缺乏连贯性和深度。
在实际应用中,需要不断进行实验以确定最佳分块大小。通常情况下,128 字节大小的分块是一个合适的分割大小。
同时还要考虑 LLM 的输入长度带来的成本问题。
下图为得物开放平台【开票取消预约上门取件】接口的接口文档:
开票取消预约上门取件接口信息
拆分逻辑分析(根据理论提供 128 字节大小)
在成功获取到对应文本数据后,我们需要在数据的预处理阶段,将文档根据分类进行切分。这一步将会将一份文档拆分为多份文档。
由上图中信息可见,一个文档的基础结构是由一级、二级标题进行分割分类的。一个基本的接口信息包括:基础信息、请求地址、公共参数、请求入参、请求出参、返回参数以及错误码信息组成。
拆分方式
拆分的实现一般有 2 种,一是根据固定的文档大小进行拆分(128 字节)二是根据实际文档结构自己做原子化拆分。
直接根据文档大小拆分的优点当然是文档的拆分处理逻辑会直接且简单粗暴,缺点就是因为是完全根据字节数进行分割,一段完整的句子或者段落会被拆分成 2 半从而丢失语义(但可通过页码进行链接解决)。
根据文档做结构化拆分的优点是上下文结构容易连接,单个原子文档依旧具备语义化,检索时可以有效提取到信息,缺点是拆分逻辑复杂具备定制性,拆分逻辑难以与其他知识库复用,且多个文档之间缺乏一定的关联性(但可通过元信息关联解决)。
在得物开放平台的场景中,**因为文档数据大多以 json 为主(例如 api 表格中每个字段的名称、默认值、描述等),将这些 json 根据大小做暴力切分丢失了绝大部分的语义,难以让 LLM 理解。**所以,我们选择了第二种拆分方式。
拆分实现
在文档分割层面,Markdown 作为一种 LLM 可识别且可承载文档元信息的文本格式,作为向量数据的基础元子单位最为合适。
基础的文档单元根据大标题进行文档分割,同时提供 frontmatter 作为多个向量之间连接的媒介。
正文层面,开放平台的 API 文档很适合使用 Markdown Table 来做内容承接,且 Table 对于大模型更便于理解。
根据以上这种结构,我们可得到以下拆分流程:
代码实现:
const hbsTemplate = `
---
服务ID (serviceId): {{ service.id }}
接口ID (apiId): {{ apiId }}
接口名称 (apiName): {{ apiName }}
接口地址 (apiUrl): {{ apiUrl }}
页面地址 (pageUrl): {{ pageUrl }}
---
# {{ title }}
{{ paragraph }}
`;
export const processIntoEmbeddings = (data: CombinedApiDoc) => {
const template = baseTemplate(data);
const texts = [
template(requestHeader(data)),
template(requestUrl(data)),
template(publicRequestParam(data)),
template(requestParam(data)),
template(responseParam(data)),
template(errorCodes(data)),
template(authPackage(data)),
].filter(Boolean) as string[][];
return flattenDeep(texts).map((content) => {
return new Document<MetaData>({
// id: toString(data.apiId!),
metadata: {
serviceId: data.service.id,
apiId: data.apiId!,
apiName: data.apiName!,
apiUrl: data.apiUrl!,
pageUrl: data.pageUrl!,
},
pageContent: content!,
});
});
};
复制代码
知识库导入
通过建立定时任务(DJOB),使用 MILVUS sdk 将以上拆分后的文档导入对应数据集中。
CO-STAR 结构
在上文中的 Prompt,使用了一种名为 CO-STAR 的结构化模板,该框架由新加坡政府科技局的数据科学与 AI 团队创立。CO-STAR 框架是一种用于设计 Prompt 的结构化模板,旨在提高大型语言模型(LLM)响应的相关性和有效性,考虑了多种影响 LLM 输出的关键因素。
结构:
上下文(Context):提供与任务相关的背景信息,帮助 LLM 理解讨论的具体场景,确保其响应具有相关性。
目标(Objective):明确你希望 LLM 执行的具体任务。清晰的目标有助于模型聚焦于完成特定的请求,从而提高输出的准确性。
风格(Style):指定希望 LLM 采用的写作风格。这可以是某位名人的风格或特定职业专家的表达方式,甚至要求 LLM 不返回任何语气相关文字,确保输出符合要求。
语气(Tone):设定返回的情感或态度,例如正式、幽默或友善。这一部分确保模型输出在情感上与用户期望相符。
受众(Audience):确定响应的目标受众。根据受众的不同背景和知识水平调整 LLM 的输出,使其更加适合特定人群。
响应(Response):规定输出格式,以确保 LLM 生成符合后续使用需求的数据格式,如列表、JSON 或专业报告等。这有助于在实际应用中更好地处理 LLM 的输出。
在上文结构化的实现中,演示了如何使用 CO-STAR 结构的 Prompt,要求大模型“冰冷的”对用户提问进行的解析,当然 CO-STAR 也适用于直接面向用户的问答,例如:
## Context
我是一名正在寻找酒店信息的旅行者,计划在即将到来的假期前往某个城市。我希望了解关于酒店的设施、价格和预订流程等信息。
## Objective
请提供我所需的酒店信息,包括房间类型、价格范围、可用设施以及如何进行预订。
## Style
请以简洁明了的方式回答,确保信息易于理解。
## Tone
使用友好和热情的语气,给人一种欢迎的感觉。
## Audience
目标受众是普通旅行者,他们可能对酒店行业不太熟悉。
## Response
请以列表形式呈现每个酒店的信息,包括名称、地址、房间类型、价格和联系方式。每个酒店的信息应简短且直接,便于快速浏览。
复制代码
相似性搜索
当我们使用了问题结构化 Runnable 后,非开放平台类问题将会提前终止,告知用户无法解答相关问题,其他有效回答将会进入相似性搜索环节。
相似性搜索基于数据之间的相似性度量,通过计算数据项之间的相似度来实现检索。在答疑助手的相似性实现是通过余弦相似度来进行相似性判断的。
我们将用户的提问,与向量数据库中数据进行余弦相似度匹配。取 K 为 5 获取最相似的五条记录。
注意:此 K 值是经过一系列的推断最终决定的,可根据实际情况调整。
import { Milvus } from '@langchain/community/vectorstores/milvus';
import { OpenAIEmbeddings } from '@langchain/openai';
import { RunnableSequence } from '@langchain/core/runnables';
import { getLLMConfig } from 'src/utils/llm/get-llm-config';
export const $getContext = async () => {
const embeddings = new OpenAIEmbeddings(
getLLMConfig().OpenAIEmbeddingsConfig,
);
const vectorStore = await Milvus.fromExistingCollection(embeddings, {
collectionName: 'open_rag',
});
return RunnableSequence.from([
(input) => {
return input.question;
},
vectorStore.asRetriever(5),
]);
};
复制代码
此 Runnable 会将搜索结果组成一大段可参考数据集,用于后续用户提问。
用户提问解答
用户提问的解答同样通过 Runnable 的方式来承接,通过用户提问、结构化数据、提取的相似性上下文进行结合,最终得到问题的解答。
我们先将上下文进行格式化整理:
import { RunnablePassthrough, RunnablePick } from '@langchain/core/runnables';
import { Document } from 'langchain/document';
import { PromptTemplate } from '@langchain/core/prompts';
import { MetaData } from 'src/types';
const $formatRetrieverOutput = async (documents: Document<MetaData>[]) => {
const strings = documents.map(async (o) => {
const a = await PromptTemplate.fromTemplate(`{pageContent}`).format({
pageContent: o.pageContent,
});
return a;
});
const context = (await Promise.all(strings)).join('\n');
return context;
};
export const $contextAssignRunnable = () => {
return RunnablePassthrough.assign({
context: new RunnablePick('context').pipe($formatRetrieverOutput),
});
};
复制代码
问答整体 Prompt 实现:
export const promptTemplateMarkdown = () => {
return `
# CONTEXT
得物的开放平台是一个包含着 API 文档,解决方案文档的平台,商家可以通过这个平台获取到得物的各种接口,以及解决方案,帮助商家更好的使用得物的服务。
现在得物开放平台的人工答疑率相当高,原因可能是文档的信息藏的较深,我希望做一个人工智能答疑助手,通过分析开放平台的各种文档,来回答用户的问题,最终让用户不进入人工答疑阶段。
我们只讨论[开放平台接口]的相关问题,不要谈及其他内容。
# OBJECTIVE
你需要根据用户的输入,以及提供的得物开放平台的文档上下文,进行答疑。
你只接受有关[开放平台接口]的相关问答,不接受其余任何问题。
## 关于用户的输入:
1. 你会得到一份符合 JSONSchema 结构的结构化数据,这份数据我会使用\`<structured-input></structured-input>\`包裹。
这份结构化数据是通过实际的用户提问进行了二次分析而得出的。结构化数据里也会包含用户的最初始的问题供你参考(最初始的问题会放在 question 字段里)
## 关于上下文
1. 我已经提前准备好了你需要参考的资料,作为你回答问题的上下文,上下文是由许多篇 Markdown 文档组成的。这些 Markdown 的文档大标题代表了这个片段的模块名,例如 \`# 接口入参\`就代表这部分是文档的接口入参部分, \`# 接口返回\`就代表这部分是文档的接口返回部分,
2. 上下文中的主要信息部分我会使用 Markdown Table 的结构提供给你。
3. 每个上下文的开头,我都会给你一些关于这份上下文的元信息(使用 FrontMatter 结构),这个元信息代表了这份文档的基础信息,例如文档的页面地址,接口的名称等等。
以下是我提供的结构化输入,我会使用\`<structured-input></structured-input>\`标签做包裹
<structured-input>
{structuredInput}
</structured-input>
以下是我为你提供的参考资料,我会使用\`<context></context>\`标签包裹起来:
<context>
{context}
</context>
# STYLE
你需要把你的回答以特定的 JSON 格式返回
# TONE
你是一个人工智能答疑助手,你的回答需要温柔甜美,但又不失严谨。对用户充满了敬畏之心,服务态度要好。在你回答问题之前,需要简单介绍一下自己,例如“您好,很高兴为您服务。已经收到您的问题。”
# AUDIENCE
你的用户是得物开放平台的开发者们,他们是你要服务的对象。
# RESPONSE
你返回的数据结构必须符合我提供的 JSON Schema 规范,我给你的 Schema 将会使用\`<structured-output-schema></structured-output-schema>\`标签包裹.
<structured-output-schema>
{strcuturedOutputSchema}
</structured-output-schema>
`;
};
复制代码
以上问答通过 CO-STAR 结构,从 6 个方面完全限定了答疑助手的回答腔调以及问答范畴,我们现在只需要准备相应的数据结构提供给这份 Prompt 模板。
问答结果结构化
在开放平台答疑助手的场景下,我们不仅要正面回答用户的问题,同时还需要给出相应的可阅读链接。结构如下:
import { z } from 'zod';
const zOutputSchema = z
.object({
question: z
.string()
.describe(
'提炼后的用户提问。此处的问题指的是除去用户提供的接口信息外的问题。尽量多的引用用户的提问',
),
introduction: z
.string()
.describe('开放平台智能答疑助手对用户的问候以及自我介绍'),
answer: z
.array(z.string())
.describe(
'开放平台智能答疑助手的回答,需将问题按步骤拆分,形成数组结构,回答拆分尽量步骤越少越好。如果回答的问题涉及到具体的页面地址引用,则将页面地址放在relatedUrl字段里。不需要在answer里给出具体的页面地址',
),
relatedUrl: z
.array(z.string())
.describe(
'页面的链接地址,取自上下文的pageUrl字段,若涉及多个文档,则给出所有的pageUrl,若没有pageUrl,则不要返回',
)
.optional(),
})
.required({
question: true,
introduction: true,
answer: true,
});
type OpenRagOutputType = z.infer<typeof zOutputSchema>;
export { zOutputSchema, type OpenRagOutputType };
复制代码
在我们之前的设计中,我们的每一份向量数据的头部,均带有相应的文档 meta 信息,通过这种向量设计,我们可以很容易的推算出可阅读链接。同时,我们在这份 zod schema 中提供了很详细的 description,来限定机器人的回答可以有效的提取相应信息。
Runnable 的结合
在用户提问解答这个 Runnable 中,我们需要结合 Retriever, 上下文,用户提问,用户输出限定这几部分进行组合。
import { ChatOpenAI } from '@langchain/openai';
import { $getPrompt } from './prompt/index';
import { JsonOutputParser } from '@langchain/core/output_parsers';
import { RunnableSequence, RunnableMap } from '@langchain/core/runnables';
import { zOutputSchema } from './schema';
import { $getContext } from './retriever/index';
import { getLLMConfig } from 'src/utils/llm/get-llm-config';
import { getStringifiedJsonSchema } from 'src/utils/llm/get-stringified-json-schema';
import { n } from 'src/utils/llm/gen-runnable-name';
const b = n('$open-rag');
type OpenRagInput = {
structuredInput: string;
question: string;
};
const $getOpenRag = async () => {
const $model = new ChatOpenAI(getLLMConfig().ChatOpenAIConfig).bind({
response_format: {
type: 'json_object',
},
});
const chain = RunnableSequence.from([
RunnableMap.from<OpenRagInput>({
// 问答上下文
context: await $getContext(),
// 结构化输入
structuredInput: (input) => input.structuredInput,
// 用户提问
question: (input) => input.question,
// 输出结构
strcuturedOutputSchema: () => getStringifiedJsonSchema(zOutputSchema),
}).bind({ runName: b('runnable-map') }),
$getPrompt().bind({ runName: b('prompt') }),
$model,
new JsonOutputParser(),
]).bind({ runName: b('chain') });
return chain;
};
export { $getOpenRag };
复制代码
流程串联
通过上文的几大部分,我们已经将 用户提问、结构化解析、相似性搜索、问答上下文,问答 Prompt 模板以及结构化输出这几部分实现完成。
现在可以通过 LangChain 的 Runnable 能力进行流程串联。
const structure = safeJsonParse<StructuredInputType>(
await $getStructuredInput().invoke({ question }),
null,
)
const mainChain = RunnableSequence.from<{ question: string }, OpenRagOutputType>([
RunnablePassthrough.assign({
structuredInput: () => structure,
}),
await $getOpenRag(),
]);
const response = await mainChain.invoke({
question: question,
});
复制代码
串联流程如下:
返回结果示例:
输入:
{
"question": "正式环境调用:直发订单获取打印面单v2【品牌直发/众筹直发/拍卖直发】接口返回报错(https://openapi.dewu.com/dop/api/v2/order/brand_deliver/express_sheet)。请求参数:{\"order_no\":\"110152014363632294\",\"app_key\":\"953a43bec40943c98a5cdbc63333242f;\",\"sign\":\"AD968ED6A359D144B61F1FEF6BB380ED\",\"timestamp\":1720430064169}"
}
复制代码
输出:
{
"question": "我的问题是:正式环境调用:直发订单获取打印面单v2【品牌直发/众筹直发/拍卖直发】接口返回报错。",
"introduction": "您好,很高兴为您服务。已经收到您的问题。",
"answer": [
"根据您提供的请求参数信息,出现错误可能是由于接口调用参数不正确导致的,接下来我们一步步排查问题。",
"首先,您提供的请求参数中的订单号为\"110152014363632294\",请确认该订单号的状态是否为待物流揽收或待买家收货状态。",
"其次,检查您所使用的app_key是否正确,确保该app_key授权了调用该接口的权限。",
"再次,请确认传入的签名(sign)是否正确,可能需要重新生成签名以保证准确性。",
"最后,检查时间戳(timestamp)是否正确,并且处于合理的时间范围内。",
"如果以上步骤都没有解决问题,建议您查看详细的接口文档以进一步调试和解决问题。"
],
"relatedUrl": [
"https://open.dewu.com/#/api?apiId=1174"
]
}
复制代码
五、应用调试
基于大模型应用可能设计到多个 Runnable 的多次调用,借用 LangSmith 的 trace 功能,我们可以对每一个 Runnable 进行出入参的 debug。
关于 LangSmith 的接入:
六、未来展望
RAG 在减少模型幻觉,无需模型训练就可享受内容时效性的特点在此类答疑应用中展露无遗,RAG 应用开放平台落地从一定程度上验证了依赖可靠知识库的答疑场景具备可执行性,还为内部系统的应用提供了有力的参考。在实际应用中,除了直接解决用户的提问外,通过回放用户提问的过程,可以为产品和业务的发展提供重要的洞察。
面向未来,是否可以尝试将答疑助手的形式在内部系统落地,在内部建立知识库体系,将部分问题前置给大模型处理,降低 TS 和开发介入答疑的成本。
文 / 惑普
关注得物技术,每周更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。
评论