一、背景
二、简介
1. 什么是RAG
2. RAG应用的可落地场景
3. RAG应用的主要组成部分
4. RAG应用的核心流程
三、实现目标
四、整体流程
1. 技术选型
2. 准确性思考
3. 用户提问结构化
4. 数据预处理与向量库的准备工作
5. CO-STAR结构
6. 相似性搜索
7. 用户提问解答
8. Runnable的结合
9. 流程串联
五、应用测试
六、未来展望
得物开放平台是一个把得物能力进行开放,同时提供给开发者提供 公告、应用控制台、权限包申请、业务文档等功能的平台。
-
面向商家:通过接入商家自研系统。可以实现自动化库存、订单、对账等管理。
-
面向ISV :接入得物开放平台,能为其产品提供更完善的全平台支持。
-
面向内部应用:提供安全、可控的、快速支持的跨主体通讯。
得物开放平台目前提供了一系列的文档以及工具去辅助开发者在实际调用API之前进行基础的引导和查询。
但目前的文档搜索功能仅可以按照接口路径,接口名称去搜索,至于涉及到实际开发中遇到的接口前置检查,部分字段描述不清等实际问题,且由于信息的离散性,用户想要获得一个问题的答案需要在多个页面来回检索,造成用户焦虑,进而增大TS的答疑可能性。
随着这几年AI大模型的发展,针对离散信息进行聚合分析且精准回答的能力变成了可能。而RAG应用的出现,解决了基础问答类AI应用容易产生
幻觉现象的问题,达到了可以解决实际应用内问题的目标。
RAG(检索增强生成)指Retrieval Augmented Generation。
这是一种通过从外部来源获取知识来提高生成性人工智能模型准确性和可靠性的技术。通过RAG,用户实际上可以与任何数据存储库进行对话,这种对话可视为“开卷考试”,即让大模型在回答问题之前先检索相关信息。
RAG应用的根本是依赖一份可靠的外部数据,根据提问检索并交给大模型回答,任何基于可靠外部数据的场景均是RAG的发力点。
-
外部知识库:问题对应的相关领域知识,该知识库的质量将直接影响最终回答的效果。
-
Embedding模型:用于将外部文档和用户的提问转换成Embedding向量。
-
向量数据库:将外部信息转化为Embedding向量后进行存储。
-
检索器:该组件负责从向量数据库中识别最相关的信息。检索器将用户问题转换为Embedding向量后执行相似性检索,以找到与用户查询相关的Top-K文档(最相似的K个文档)。
-
生成器(大语言模型LLM):一旦检索到相关文档,生成器将用户查询和检索到的文档结合起来,生成连贯且相关的响应。
-
提示词工程(Prompt Engineering):这项技术用于将用户的问题与检索到的上下文有效组合,形成大模型的输入。
以下为一个标准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>
{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: {
serviceId: data.service.id,
apiId: data.apiId!,
apiName: data.apiName!,
apiUrl: data.apiUrl!,
pageUrl: data.pageUrl!,
},
pageContent: content!,
});
});
};
通过建立定时任务(DJOB),使用MILVUS sdk将以上拆分后的文档导入对应数据集中。
在上文中的Prompt,使用了一种名为CO-STAR的结构化模板,该框架由新加坡政府科技局的数据科学与AI团队创立。
CO-STAR框架是一种用于设计Prompt的结构化模板,旨在提高大型语言模型(LLM)响应的相关性和有效性,考虑了多种影响LLM输出的关键因素。
结构: