RAG应用在得物开放平台的智能答疑的探索

一、背景

得物开放平台是一个把得物能力进行开放,同时提供给开发者提供 公告、应用控制台、权限包申请、业务文档等功能的平台。

  1. 面向商家:通过接入商家自研系统。可以实现自动化库存、订单、对账等管理。
  2. 面向ISV :接入得物开放平台,能为其产品提供更完善的全平台支持。
  3. 面向内部应用:提供安全、可控的、快速支持的跨主体通讯。

得物开放平台目前提供了一系列的文档以及工具去辅助开发者在实际调用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应用的基础流程:

  1. 将查询转换为向量
  2. 在文档集合中进行语义搜索
  3. 将检索到的文档传递给大语言模型生成答案
  4. 从生成的文本中提取最终答案

但在实际生产中,为了确保系统的全面性、准确性以及处理效率,还有许多因素需要加以考虑和处理。

下面我将基于答疑助手在开放平台的落地,具体介绍每个步骤的详细流程。

三、实现目标

鉴于目前得物开放平台的人工答疑数量相对较高,用户在开放平台查询未果就会直接进入到人工答疑阶段。正如上文所说,RAG擅长依赖一份可靠的知识库作出相应回答,构建一个基于开放平台文档知识库的RAG应用再合适不过,同时可以一定程度降低用户对于人工答疑的依赖性,做到问题前置解决。

四、整体流程

技术选型

准确性思考

问答的准确性会直接反馈到用户的使用体验,当一个问题的回答是不准确的,会导致用户根据不准确的信息进一步犯错,导致人工客服介入,耐心丧失直至投诉。

所以在实际构建基于开放平台文档的答疑助手之前,首先考虑到的是问答的准确性,主要包括以下2点:

  1. 首要解决答疑助手针对非开放平台提问的屏蔽
  2. 寻找可能导致答非所问的时机以及相应的解决方案

屏蔽非相关问题

为了屏蔽AI在回答时可能会回答一些非平台相关问题,我们首先要做的是让AI明确我们的目标(即问答上下文),且告诉他什么样的问题可以回答,什么问题不可以回答。

在这一点上,常用的手段为告知其什么是开放平台以及其负责的范畴。

例如:得物的开放平台是一个包含着 API 文档,解决方案文档的平台,商家可以通过这个平台获取到得物的各种接口,以及解决方案,帮助商家更好的使用得物的服务。现在需要做一个智能答疑助手,你是其中的一部分。

在这一段描述中,我们告知了答疑助手,开放平台包含着API文档,包含着解决方案,同时包含接口信息,同时会有商家等之类的字眼。大模型在收到这段上下文后,将会对其基础回答进行判断。

同时,我们可以通过让答疑助手二选一的方式进行回答,即平台相关问题与非平台相关问题。我们可以让大模型返回特定的数据枚举,且限定枚举范围,例如:开放平台通用问题、开放平台API答疑问题,未知问题。

借助Json类型的输出 + JSON Schema,我们可通过Prompt描述来限定其返回,从而在进入实际问答前做到事前屏蔽。

寻找可能导致答非所问的时机

当问题被收拢到开放平台这个主题之后,剩余的部分就是将用户提问与上下文进行结合,再交由大模型回答处理。在这过程中,可能存在的答非所问的时机有:不够明确的Prompt说明、上下文信息过于碎片化以及上下文信息的连接性不足三种。

  • 不够明确的Prompt说明:Prompt本身描述缺少限定条件,导致大模型回答轻易超出我们给予的要求,从而导致答非所问。
  • 上下文信息过于碎片化:上下文信息可能被分割成N多份,这个N值过大或者过小,都会导致单个信息过大导致缺乏联想性、单个信息过小导致回答时不够聚焦。
  • 上下文信息连接性不够:若信息之间被随意切割,且缺少相关元数据连接,交给大模型的上下文将会是丧失实际意义的文本片段,导致无法提取出有用信息,从而答非所问。

为了解决以上问题,在设计初期,开放平台答疑助手设定了以下策略来前置解决准确性问题:

  • 用户提问的结构化
  • 向量的分割界限以及元信息处理
  • CO-STAR Prompt结构
  • 相似性搜索的K值探索

用户提问结构化

目标:通过大模型将用户提问的结构化,将用户提问分类并提取出精确的内容,便于提前引导、终止以及提取相关信息。

例如,用户提问今天天气怎么样,结构化Runnable会将用户问题进行初次判断。

一个相对简单的Prompt实现如下:

java 复制代码
# 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描述如下:

java 复制代码
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与普通函数。

java 复制代码
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甚至具备了可单测的能力。

java 复制代码
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应用的知识库准备是实施过程中的关键环节,涉及多个步骤和技术。以下是知识库准备的主要过程:

  1. 知识库选择:【全面性与质量】数据源的信息准确性在RAG应用中最为重要,基于错误的信息将无法获得正确的回答。
  2. 知识库收集:【多类目数据】数据收集通常涉及从多个来源提取信息,包括不同的渠道,不同的格式等。如何确保数据最终可以形成统一的结构并被统一消费至关重要。
  3. 数据清理:【降低额外干扰】原始数据往往包含不相关的信息或重复内容。
  4. 知识库分割:【降低成本与噪音】将文档内容进行分块,以便更好地进行向量化处理。每个文本块应适当大小,并加以关联,以确保在检索时能够提供准确的信息,同时避免生成噪声。
  5. 向量化存储:【Embedding生成】使用Embedding模型将文本块转换为向量表示,这些向量随后被存储在向量数据库中,以支持快速检索。
  6. 检索接口构建:【提高信息准确性】构建检索模块,使其能够根据用户查询从向量数据库中检索相关文档。

知识库拆分

知识库文档的拆分颗粒度(Split Chunk Size) 是影响RAG应用准确性的重要指标:

  • 拆分颗粒度过大可能导致检索到的文本块包含大量不相关信息,从而降低检索的准确性。
  • 拆分颗粒度过小则可能导致必要的上下文信息丢失,使得生成的回答缺乏连贯性和深度。
  • 在实际应用中,需要不断进行实验以确定最佳分块大小。通常情况下,128字节大小的分块是一个合适的分割大小。
  • 同时还要考虑LLM的输入长度带来的成本问题。

下图为得物开放平台【开票取消预约上门取件】接口的接口文档:

开票取消预约上门取件接口信息

拆分逻辑分析(根据理论提供128字节大小)

在成功获取到对应文本数据后,我们需要在数据的预处理阶段,将文档根据分类进行切分。这一步将会将一份文档拆分为多份文档。

由上图中信息可见,一个文档的基础结构是由一级、二级标题进行分割分类的。一个基本的接口信息包括:基础信息、请求地址、公共参数、请求入参、请求出参、返回参数以及错误码信息组成。

拆分方式

拆分的实现一般有2种,一是根据固定的文档大小进行拆分(128字节)二是根据实际文档结构自己做原子化拆分。

直接根据文档大小拆分的优点当然是文档的拆分处理逻辑会直接且简单粗暴,缺点就是因为是完全根据字节数进行分割,一段完整的句子或者段落会被拆分成2半从而丢失语义(但可通过页码进行链接解决)。

根据文档做结构化拆分的优点是上下文结构容易连接,单个原子文档依旧具备语义化,检索时可以有效提取到信息,缺点是拆分逻辑复杂具备定制性,拆分逻辑难以与其他知识库复用,且多个文档之间缺乏一定的关联性(但可通过元信息关联解决)。

在得物开放平台的场景中,**因为文档数据大多以json为主(例如api表格中每个字段的名称、默认值、描述等),将这些json根据大小做暴力切分丢失了绝大部分的语义,难以让LLM理解。**所以,我们选择了第二种拆分方式。

拆分实现

在文档分割层面,Markdown作为一种LLM可识别且可承载文档元信息的文本格式,作为向量数据的基础元子单位最为合适。

基础的文档单元根据大标题进行文档分割,同时提供frontmatter作为多个向量之间连接的媒介。

正文层面,开放平台的API文档很适合使用Markdown Table来做内容承接,且Table对于大模型更便于理解。

根据以上这种结构,我们可得到以下拆分流程:

代码实现:

java 复制代码
 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也适用于直接面向用户的问答,例如:

java 复制代码
## Context
我是一名正在寻找酒店信息的旅行者,计划在即将到来的假期前往某个城市。我希望了解关于酒店的设施、价格和预订流程等信息。

## Objective
请提供我所需的酒店信息,包括房间类型、价格范围、可用设施以及如何进行预订。

## Style
请以简洁明了的方式回答,确保信息易于理解。

## Tone
使用友好和热情的语气,给人一种欢迎的感觉。

## Audience
目标受众是普通旅行者,他们可能对酒店行业不太熟悉。

## Response
请以列表形式呈现每个酒店的信息,包括名称、地址、房间类型、价格和联系方式。每个酒店的信息应简短且直接,便于快速浏览。

相似性搜索

当我们使用了问题结构化Runnable后,非开放平台类问题将会提前终止,告知用户无法解答相关问题,其他有效回答将会进入相似性搜索环节。

相似性搜索基于数据之间的相似性度量,通过计算数据项之间的相似度来实现检索。在答疑助手的相似性实现是通过余弦相似度来进行相似性判断的。

我们将用户的提问,与向量数据库中数据进行余弦相似度匹配。取K为5获取最相似的五条记录。

注意:此K值是经过一系列的推断最终决定的,可根据实际情况调整。

java 复制代码
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的方式来承接,通过用户提问、结构化数据、提取的相似性上下文进行结合,最终得到问题的解答。

我们先将上下文进行格式化整理:

java 复制代码
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实现:

java 复制代码
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模板。

问答结果结构化

在开放平台答疑助手的场景下,我们不仅要正面回答用户的问题,同时还需要给出相应的可阅读链接。结构如下:

java 复制代码
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, 上下文,用户提问,用户输出限定这几部分进行组合。

java 复制代码
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能力进行流程串联。

java 复制代码
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,
});

串联流程如下:

返回结果示例:

输入:

java 复制代码
{
  "question": "正式环境调用:直发订单获取打印面单v2【品牌直发/众筹直发/拍卖直发】接口返回报错(https://openapi.dewu.com/dop/api/v2/order/brand_deliver/express_sheet)。请求参数:{\"order_no\":\"110152014363632294\",\"app_key\":\"953a43bec40943c98a5cdbc63333242f;\",\"sign\":\"AD968ED6A359D144B61F1FEF6BB380ED\",\"timestamp\":1720430064169}"
}

输出:

java 复制代码
{
  "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和开发介入答疑的成本。

文 / 惑普

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

相关推荐
嗷o嗷o3 分钟前
Android BLE 收到字节流以后,为什么业务状态还是不对
前端
莪_幻尘6 分钟前
Prompt 工程化落地:从"手工咒语"到工业级软件系统
前端
荒天帝6 分钟前
Android App 最强APM来袭
前端
vim怎么退出7 分钟前
我给 Claude Code 写了一个自适应学习 Skill,7 天刷完浏览器原理
前端·人工智能
92year8 分钟前
LLM 应用上线后出了 bug,你怎么查?聊聊 Langfuse 全链路追踪的接入和踩坑
aigc
逍遥归来10 分钟前
UICollectionViewDiffableDataSource 刷新方案总结
前端
小黑兔斯基12 分钟前
前端html+ css布局
前端
Awu122712 分钟前
🍎Claude Code Playground:我愿称之为「前端调参神器」
前端·人工智能·aigc
clue14 分钟前
让微信小程序也能发PATCH
前端·后端
爱吃的小肥羊17 分钟前
从注册到订阅再到防封号,国内用 Claude 的完整避坑手册(2026 最新)
aigc·ai编程