构建基于LLM的Slack知识库机器人技术解析

本文详细介绍了Benchling工程团队如何利用检索增强生成技术和Amazon Bedrock构建智能Slack机器人,解决Terraform Cloud使用问题,包含技术架构、数据源集成和实际部署经验。

构建基于LLM的Slackbot:Benchling如何构建知识库

背景

在Benchling,我们在多个区域和环境中运行云基础设施。为了协调和管理这种复杂性,我们的团队运营着自托管的Terraform Cloud实现,管理着五个数据中心中约16万个Terraform资源。每月约有50名工程师发布某种形式的基础设施变更——有些是基础设施专家,有些则是完全不了解Terraform Cloud的应用工程师。

可以理解的是,我们收到了很多关于如何使用Terraform Cloud或如何调试特定问题的问题,而这些讨论通常发生在Slack中。我们在Confluence中有一个长达20页的FAQ,回答了大多数问题,并辅以大量记录先前问题及其最终解决方案的Slack线程。

所以我们有很好的文档,但查找起来很痛苦。谁想阅读20页的FAQ?或者深入Slack线程40条消息去寻找答案?

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

我们构建了什么

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

界面如下所示:

[按回车或点击查看完整尺寸图像]

它是如何工作的?

我们使用Amazon Bedrock构建了工具的RAG LLM部分。有关其工作原理的更多信息,请参阅这篇AWS文章。简而言之:

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

为简单起见,我们将在本文的其余部分仅使用"知识库"这一术语。其核心概念是:

  • 在数据库中搜索与用户查询相关的内容
  • 将此内容以及有关如何使用此内容和生成响应的指令输入到LLM提示中

您可以这样可视化:

[按回车或点击查看完整尺寸图像]

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

[按回车或点击查看完整尺寸图像]

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

  • 指令
  • 搜索结果
  • 用户查询

为了设置我们的知识库,我们使用了Amazon Bedrock知识库设置向导,该向导在几分钟内引导您完成步骤。在后台,它创建了一个OpenSearch Serverless数据库(Amazon 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中)

这是证明这些概念的最小数据集,但我们将来可以扩展和丰富这些数据源中的每一个或添加新的数据源。

以下是Amazon Bedrock中当前支持的数据源的样子:

[按回车或点击查看完整尺寸图像]

这些是我们已配置的数据源:

[按回车或点击查看完整尺寸图像]

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

限制

没有图像。 知识库无法处理作为查询一部分提交的图像,也不在其响应中包含我们文档中的任何图像。这很不幸,因为我们的帮助文档包含许多图像,形式包括架构图、UI组件的屏幕截图或错误消息。

尚无Terraform支持。 Terraform AWS提供商当前对Amazon Bedrock的支持有点不足。我们在此处使用的任何资源尚未得到提供商的支持,但可能会很快添加支持。我们将继续检查Terraform Bedrock资源页面,直到支持最新的知识库资源。

潜在的未来增强

向用户呈现答案引用链接。 目前这在测试模型时可在Bedrock UI中使用。然而,我们发送到Slack的答案不包括任何引用或指向源文档的链接。

使将相关Slack线程保存到知识库变得容易。 例如,允许用户从Slack触发webhook,例如"@help-terraform-cloud记住这个线程"会很不错。

每个数据源的自动更新。 当前需要手动数据同步。我们计划设置Cloudwatch事件cron以至少每周触发一次数据同步。

使用Confluence API。 目前我们正在将FAQ页面从Confluence导出为PDF并保存到S3。将来我们计划通过API连接到Confluence。

多轮对话。 目前我们的Lambda是一个无状态函数,只有明确标记我们的@help-terraform-cloud用户的Slack消息可用。一个增强可能是保留对话上下文,以便用户可以进行多轮对话并基于先前的答案构建。

经验教训

分块策略。 在我们的初始原型中,我们使用了默认的Bedrock分块策略,即300个令牌。这返回大约一个段落的文本。这导致了不理想的结果,因为我们的许多FAQ答案包括几个有序步骤,并且可以延伸到几个段落。这意味着我们的搜索结果经常中途被截断,向LLM提示提供了不完整的文档。有几种替代的分块策略可供选择,在尝试了几种之后,我们发现分层分块效果最好,父令牌大小为1500个令牌(约5个段落)。目标是选择接近您最长答案上限的令牌大小。然而,您也不希望令牌大小超过必要,因为这会向LLM提供更多(可能不相关的)数据,这可能会混淆其答案。对于我们的FAQ,我们最长的答案长度约为1500个令牌,因此这是一个很好的匹配。您需要尝试几种不同的分块策略,并测试每种策略的表现,以找到最佳匹配。

[按回车或点击查看完整尺寸图像]

解析PDF非常稳健。 尽管它丢失了所有图像,但在解析文本方面非常稳健。将Bedrock指向S3中的PDF第一次尝试就成功了。

设置知识库很容易! 以前,自己设置知识库所有必要的管道将是一个多天的项目。然而,Bedrock的知识库功能将此过程自动化,只需几分钟而不是几天。

更多针对性的帮助机器人? 也许部署的便利性为未来众多针对性的帮助机器人铺平了道路。使用更紧密范围的数据集也减少了幻觉的可能性或从向量数据库返回不相关数据的潜在风险。

架构

我们的架构非常简单。它包括:

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

[按回车或点击查看完整尺寸图像]

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

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

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

我们用于API Gateway和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
87
88
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,该LLM还具有特定于您团队或公司的知识?考虑以下场景:

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

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

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

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

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

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

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

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