基于检索增强生成与亚马逊Bedrock:打造企业内部Slack智能助手

本文详细介绍了Benchling工程团队如何利用亚马逊Bedrock的检索增强生成技术构建一个内部Slack机器人,以解答关于Terraform Cloud的常见问题。文章涵盖了其技术架构、数据源整合、面临的挑战以及从原型开发中获得的经验教训,并附带了核心的Terraform基础设施代码。

背景

在Benchling,我们在多个区域和环境中运行云基础设施。为了协调和管理这种复杂性,我们的团队运营着一个自托管的Terraform Cloud实现,管理着五个数据中心中大约160,000个Terraform资源。每个月,整个工程组织大约有50名工程师发布某种形式的基础设施变更——其中有些是基础设施专家,有些则是Terraform Cloud的新手。

可以理解,我们收到了大量关于如何使用Terraform Cloud或如何调试特定问题的问题,而这个讨论通常发生在Slack上。我们在Confluence中有一份多达20页的优秀FAQ,回答了大部分问题,同时还有许多Slack线程记录了之前的问题及其最终解决方案。

因此,我们有很好的文档,但查找起来很痛苦。谁想通读一份20页的FAQ?或者在Slack中深入挖掘,找到那个埋藏在40条消息深处的答案?

我们着手通过构建一个Slack机器人来解决这个问题,它能够动态地回答任何用户问题,而无需进行任何繁琐的搜索。为了实现这一目标,我们实施了一个检索增强生成的大型语言模型。以下是我们的实现过程以及在此过程中学到的经验。

我们构建了什么

我们构建了一个内部Slack机器人,使Benchling工程师能够与一个知识库交互,以回答常见的Terraform Cloud问题。它同时也作为Benchling未来LLM驱动工具的参考实现。它展示了我们如何将内部和公共的不同信息源与最新的大型语言模型结合,并通过熟悉的Slack界面将其暴露给用户。这种模式可以被复用以开发面向其他专业知识领域的Slack助手,例如回答HR问题、展示客户问题的过往解决方案,或者解释软件错误代码。

以下是该界面的样子: ![Slackbot界面]

工作原理?

我们使用亚马逊Bedrock构建了工具的RAG LLM部分。更多关于其工作原理的信息,请参阅这篇AWS文章。简单来说:

检索增强生成是通过优化大型语言模型的输出的过程,使其在生成响应之前参考其训练数据源之外的权威知识库。

为简单起见,在本文的其余部分,我们将只使用“知识库”这个术语。其背后的核心概念是:

  1. 在数据库中搜索与用户查询相关的内容
  2. 将此内容连同如何使用此内容和生成响应的指令,一起输入到LLM提示中

您可以将其可视化如下: ![RAG工作流程图]

要了解其在实践中的工作原理,请查看Bedrock的默认知识库LLM提示: ![Bedrock默认提示]

该提示由三个关键部分组成:

  1. 指令
  2. 搜索结果
  3. 用户查询

为了设置我们的知识库,我们使用了亚马逊Bedrock知识库设置向导,该向导在几分钟内引导您完成步骤。幕后,它会创建一个OpenSearch Serverless数据库(亚马逊OpenSearch服务中一种特定类型的向量数据库,用于获取与用户查询相关的内容)。它还设置了所有必要的IAM角色和策略,创建Bedrock资源,并建立数据源(将被嵌入并存储在向量数据库中的参考数据)。这些数据源随后由异步作业处理,并保存在OpenSearch Serverless数据库中。

什么数据驱动我们的知识库?

我们实现的知识库会摄取四个不同的数据源并将其存储在向量数据库中。当收到用户查询时,系统会针对向量数据库运行搜索,以在所有摄取的数据源中找到最相关的文本片段。然后将这些查询结果输入到LLM提示中(我们使用Claude 3.5 Sonnet v2),以便根据检索到的答案合成一个有用的响应。

我们配置的数据源是:

  • Confluence:Terraform Cloud FAQ(此页面被导出为PDF,然后存储到S3)
  • Web:Hashicorp公共文档站点上选定的Terraform Cloud文档
  • Web:Hashicorp公共文档站点上选定的Terraform语言文档
  • Slack:选定的Slack线程,其中提出了Terraform Cloud问题并最终得到解决(对于概念验证,这些是从几个Slack线程中手动复制,粘贴到.txt文件并存储到S3的)

这是一组用于验证这些概念的最小数据集,但我们未来可以扩展和丰富这些数据源,或者添加新的数据源。

以下是亚马逊Bedrock中当前支持的数据源的样子: ![Bedrock数据源配置]

而这些是我们配置的数据源: ![配置的数据源列表]

在经历了构建知识库并将其与Slack集成的过程后,以下是我们学到的内容:

局限性

  • 无图像处理。知识库无法处理作为查询一部分提交的图像,其响应中也不包含我们文档中的任何图像。这很不幸,因为我们的帮助文档包含许多架构图、UI组件截图或错误消息形式的图像。
  • 尚不支持Terraform管理。Terraform AWS提供商目前对亚马逊Bedrock的支持有点匮乏。我们在这里使用的资源都尚未得到提供商的支持,尽管支持可能会很快添加。我们将持续关注Terraform Bedrock资源页面,直到最新的知识库资源得到支持。

潜在的未来增强

  • 向用户呈现答案引用链接。目前这在测试模型时在Bedrock UI中可用。然而,我们发送到Slack的答案不包含任何引用或指向源文档的链接。
  • 轻松保存相关Slack线程到知识库。例如,允许用户从Slack触发一个Webhook,内容类似“@help-terraform-cloud 记住这个线程”会很不错。
  • 每个数据源的自动更新。目前需要手动数据同步。我们计划设置一个Cloudwatch事件定时任务,以至少每周触发一次数据同步。
  • 使用Confluence API。目前我们从Confluence导出FAQ页面到PDF并保存到S3。未来我们计划通过API连接到Confluence。
  • 多轮对话。目前我们的Lambda是一个无状态函数,只有明确标记了我们的@help-terraform-cloud用户的Slack消息才会被处理。一个增强点可以是保留对话上下文,以便用户可以有多轮对话并在之前的回答基础上继续提问。

经验教训

  • 分块策略。在我们的初始原型中,我们使用了Bedrock默认的300个token的分块策略。这大约返回一个段落的文本。这导致了不理想的结果,因为我们许多FAQ答案包含多个有序步骤,并且可能延伸到几个段落。这意味着我们的搜索结果常常在中途被截断,向LLM提示提供了不完整的文档。有几种替代的分块策略可供选择,尝试了几种后,我们发现分层分块效果最好,父令牌大小为1500个token(约5个段落)。目标是选择一个接近您最长答案上限的令牌大小。然而,您也不希望令牌大小比必要的大,因为这会将更多(可能无关的)数据输入LLM,从而可能混淆其答案。对于我们的FAQ,最长的答案长度约为1500个token,因此这很合适。您需要尝试几种不同的分块策略,并测试每种策略的性能,以找到最佳方案。 ![分块策略比较]
  • 解析PDF非常稳健。尽管会丢失所有图像,但它在解析文本方面非常稳健。将Bedrock指向S3中的PDF第一次尝试就成功了。
  • 设置知识库很容易! 以前,自己设置知识库所需的所有必要组件可能是一个需要数天的项目。然而,Bedrock的知识库功能将此过程自动化,使其只需几分钟而不是几天。
  • 更多针对性帮助机器人? 也许部署的简便性为未来众多针对性帮助机器人铺平了道路。使用范围更窄的数据集也减少了幻觉的可能性或从向量数据库返回不相关数据的风险。

架构

我们的架构相当简单。它由以下部分组成:

  • Slack应用
  • AWS API网关
  • AWS Lambda(运行无状态的python函数)
  • AWS Bedrock
  • AWS OpenSearch Serverless(向量数据库)

我们使用了两种不同的模型:

  • Amazon Titan Text Embeddings v2(用于嵌入)
  • Claude 3.5 Sonnet v2(用于推理)

由于Terraform AWS提供商尚未支持我们使用的Bedrock资源,我们的实现是通过UI中的Bedrock知识库设置向导手动创建的。

我们用于API网关和Lambda的基础设施组件是使用开源社区模块构建的,我们可以在此与您分享我们的实现:

variables.tf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
variable "environment" {
  description = "Name of the lambda function"
  type        = string
  validation {
    condition     = contains(["dev", "prod", "sandbox"], var.environment)
    error_message = "Environment must be a valid value"
  }
}

variable "knowledge_base_id" {
  description = "Bedrock knowledge base id"
  type        = string
}

variable "account_name" {
  description = "Name of the AWS account"
  type        = string
}

main.tf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
locals {
  service_name      = "tfc-help-slackbot-${var.environment}"
  bedrock_model_arn = "arn:aws:bedrock:${data.aws_region.current.name}::foundation-model/anthropic.claude-3-5-sonnet-20241022-v2:0"
  account_id        = data.aws_caller_identity.current.account_id
}

data "aws_region" "current" {}
data "aws_caller_identity" "current" {}

data "aws_secretsmanager_secret" "slack_token" {
  name = "${var.account_name}/tfc_help_slackbot/slack_token"
}

data "aws_secretsmanager_secret" "slack_signing_secret" {
  name = "${var.account_name}/tfc_help_slackbot/slack_signing_secret"
}

module "api_gateway" {
  source  = "terraform-aws-modules/apigateway-v2/aws"
  version = "5.2.0"

  name               = "http-${local.service_name}"
  description        = "API Gateway for ${local.service_name}"
  protocol_type      = "HTTP"
  create_domain_name = false

  cors_configuration = {
    allow_headers  = []
    allow_methods  = ["*"]
    allow_origins  = ["*"]
    expose_headers = []
  }

  routes = {
    "$default" = {
      integration = {
        uri                    = module.lambda.lambda_function_arn
        payload_format_version = "2.0"
        timeout_milliseconds   = 30000
      }
    }
  }
}

module "lambda" {
  source  = "terraform-aws-modules/lambda/aws"
  version = "7.4.0"

  function_name = local.service_name
  description   = "@help-terraform-cloud slackbot"
  handler       = "index.lambda_handler"
  runtime       = "python3.12"

  source_path = [
    {
      path             = "${path.module}/files",
      pip_requirements = "${path.module}/files/requirements.txt"
    }
  ]
  trigger_on_package_timestamp      = false # only rebuild if files have changed
  create_role                       = true
  role_name                         = local.service_name
  policies                          = [aws_iam_policy.lambda.arn]
  attach_policies                   = true
  number_of_policies                = 1
  memory_size                       = 128 # MB
  timeout                           = 60  # seconds
  architectures                     = ["arm64"]
  publish                           = true # required otherwise get error "We currently do not support adding policies for $LATEST."
  cloudwatch_logs_retention_in_days = 90

  environment_variables = {
    SLACK_TOKEN_ARN          = data.aws_secretsmanager_secret.slack_token.arn
    SLACK_SIGNING_SECRET_ARN = data.aws_secretsmanager_secret.slack_signing_secret.arn
    REGION_NAME              = data.aws_region.current.name
    KNOWLEDGE_BASE_ID        = var.knowledge_base_id
    MODEL_ARN                = local.bedrock_model_arn
  }

  allowed_triggers = {
    APIGatewayAny = {
      service    = "apigateway"
      source_arn = "${module.api_gateway.api_execution_arn}/*"
    }
  }
}

iam.tf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
resource "aws_iam_policy" "lambda" {
  name   = "tfc-help-slackbot-${var.environment}"
  policy = data.aws_iam_policy_document.lambda.json
}

data "aws_iam_policy_document" "lambda" {
  statement {
    sid    = "CloudWatchCreateLogGroupAccess"
    effect = "Allow"
    actions = [
      "logs:CreateLogGroup",
    ]
    resources = [
      "arn:aws:logs:${data.aws_region.current.name}:${local.account_id}:*",
    ]
  }

  statement {
    sid    = "CloudWatchWriteLogsAccess"
    effect = "Allow"
    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents",
    ]
    resources = [
      "arn:aws:logs:${data.aws_region.current.name}:${local.account_id}:log-group:/aws/lambda/${local.service_name}:*",
    ]
  }

  statement {
    sid    = "BedrockAccess"
    effect = "Allow"
    actions = [
      "bedrock:InvokeModel",
      "bedrock:RetrieveAndGenerate",
      "bedrock:Retrieve",
    ]
    resources = [
      "arn:aws:bedrock:${data.aws_region.current.name}:${local.account_id}:knowledge-base/${var.knowledge_base_id}",
      local.bedrock_model_arn,
    ]
  }

  statement {
    sid    = "SecretsManagerAccess"
    effect = "Allow"
    actions = [
      "secretsmanager:GetSecretValue",
    ]
    resources = [
      data.aws_secretsmanager_secret.slack_token.arn,
      data.aws_secretsmanager_secret.slack_signing_secret.arn,
    ]
  }

  statement {
    effect  = "Allow"
    actions = ["kms:Decrypt"]
    resources = [
      "arn:aws:kms:*:1234567890:key/mrk-abcd123456789abcd123",
    ]
  }
}

outputs.tf

1
2
3
4
output "api_endpoint" {
  value       = module.api_gateway.api_endpoint
  description = "This is the API endpoint to save in the slack app configuration"
}

请注意,此terraform代码假设以下敏感值之前已在AWS Secrets Manager中设置:

1
2
{account_name}/tfc_help_slackbot/slack_token
{account_name}/tfc_help_slackbot/slack_signing_secret

您可以在工作中何处使用知识库?

是否存在您希望访问一个既拥有您团队或公司特定知识的LLM的情况?设想以下场景:

  • 信息查找(例如错误代码)
  • 回答常见问题

您是否拥有高质量的基于文本的数据集?

  • FAQ文档
  • 公共Web文档
  • 对话历史(经过事实核查)

如果您有一个用例,对以上两个问题的答案都是肯定的,那么您可能需要考虑使用知识库。

您还需要评估您公司的安全和隐私风险。我们在开始开发前提出了一些问题:

  • 这些数据是否敏感/专有?
  • 不正确结果或幻觉的下行风险是什么?
  • Benchling已经批准了哪些模型?我们可以使用其中一个模型,还是需要获得新模型的批准?

总的来说,让这个原型启动并运行起来相对较快。我们提倡在新工具和技术一出现时就进行实验,而这项技术似乎已经足够成熟,可以进行更广泛的使用。我们希望这是一个有用的指南,可以支持您构建自己的基于LLM的工具!

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计