首页 / 文章

RAG领域的ElastiCache——如何存储与查询嵌入向量

用于 RAG 的 ElastiCache:从 Node.js 和 TypeScript 中在 Redis 中存储和查询嵌入向量,搭建集群,创建向量索引并执行相似度搜索。

发布者:REACTAPP.TOP阅读约 13 分钟2918 词

来源: ElastiCache for RAG: How to Store and Query Embeddings in Redis with Node.js and TypeScript

可将此内容视为《ElastiCache for RAG:如何使用 Node.js 和 TypeScript 在 Redis 中存储和查询嵌入向量》一文中理念的面向操作员的重构版本:清晰的阶段划分、有序的代码模块以及便于交接时参考的恢复说明。将概览视为可度量的框架使用效果最佳,在扩大范围之前,先记录一份标准案例、一个故障场景以及回滚说明。应将此阶段视为输入与经过验证的输出之间的契约,为相关成果命名、明确成功标准,并杜绝默许的半完成状态。

为何 Redis 可以作为向量存储

在修改代码之前,为“为何 Redis 可作为向量存储”这一主题明确输入数据、各步骤的负责人以及结束标准。操作人员应能够从已知的检查点重新运行该步骤,而无需猜测隐藏状态。除了功能结果外,还需记录执行时间以及令牌或查询成本。提前了解成本情况,可避免在从演示环境过渡到共享环境时出现意外费用。必须引用实际支撑答案的段落;没有引用的话,操作人员就无法区分是幻觉内容还是索引缺失导致的错误。

搭建 ElastiCache Serverless 集群

在搭建 ElastiCache Serverless 集群时,应在修改代码之前明确输入参数、该步骤的负责人以及结束标准。操作人员应能够从已知的检查点重新运行该步骤,而无需猜测隐藏的状态。 配置信息应置于应用程序代码之外。环境文件、密钥存储以及功能标志应集中存放于一个位置,这样操作人员无需查看整个系统结构即可进行审核。 需注明实际作为答案依据的段落。如果没有引用,操作人员就无法区分是虚假信息还是索引缺失导致的错误。

1. 创建集群

对于第1点:在修改代码之前,需先创建集群、明确输入参数、指定该步骤的负责人以及设定退出条件。操作人员应能够从已知的检查点重新运行该步骤,而无需猜测其中的隐藏状态。需同时记录正常流程与故障恢复流程。重试机制、人工审核环节以及错误处理方式都是产品本身的组成部分,而非后续需要补充的内容。必须引用那些真正作为答案依据的段落;没有引用的话,操作人员就无法区分是虚假信息还是索引缺失导致的错误。

aws elasticache create-serverless-cache \
  --engine redis \
  --cache-name rag-cache \
  --security-group-ids sg-0123456789abcdef0 \
  --subnet-group-name my-subnet-group \
  --description "Redis for RAG embeddings" \
  --cluster-mode-enabled   # <-- critical for multi‑key Lua scripts

对于第1点:在修改代码之前,需先创建集群、明确输入参数、指定该步骤的负责人以及设定退出条件。操作人员应能够从已知的检查点重新运行该步骤,而无需猜测其中的隐藏状态。应将这一阶段视为输入与经过验证的输出之间的契约,为相关成果命名、明确成功标准,并杜绝默默完成部分任务的情况。

2. 注意潜在问题

在处理“2. 注意潜在问题”时,首先写下契约内容:所需的输入参数、成功信号以及部分失败时的处理方式。这样的清单能确保后续的代码修改保持一致性。 在功能结果旁记录执行时间以及令牌或查询成本。提前了解成本情况,可避免从演示环境过渡到共享环境时出现意外费用。 在调整提示词之前,先使用固定的问题集测试召回率。仅仅更换提示词往往无法改善较差的检索效果。

使用satisfies运算符编写类型安全的Lambda代码

在使用 satisfies 运算符处理类型安全的 Lambda 代码时,首先需明确契约内容:所需的输入参数、成功信号以及部分失败时的处理方式。这样的检查清单能确保后续的代码修改保持一致性。 应将配置信息置于应用程序代码之外。环境文件、密钥存储以及功能开关应集中存放,这样运算符无需查看整个系统结构即可进行审计。 在调整提示词之前,先在固定的问题集上测试召回率。仅仅更换提示词很难改善较差的检索效果。

1. 安装所需包

在处理1.安装所需包时,首先写下相关规范:所需的输入参数、成功信号以及部分失败时的处理方式。这样的检查清单能确保后续的代码修改保持一致性。 同时记录正常流程和异常恢复流程。重试机制、人工审核环节以及错误消息处理都是产品本身的组成部分,而非后续需要补充的功能。 在调整提示词之前,先使用固定的问题集来衡量检索的准确率。仅仅更换提示词很难解决检索效果不佳的问题。

npm install @aws-sdk/client-elasticache @aws-sdk/client-lambda @aws-sdk/client-redis @aws-sdk/lib-dynamodb
npm install redis@4 ioredis@5   # ioredis supports cluster mode and Lua
npm install openai                # for embedding API (optional)

2. Lambda框架结构

在处理“2. Lambda框架”时,首先需明确接口规范:所需的输入参数、成功信号以及部分失败时的处理方式。这样的检查清单能确保后续的代码修改保持一致性。 建议采用小型、可测试的单元而非冗长的脚本。当某个步骤出错时,错误应指向单一责任模块,而非复杂的流程链。 在调整提示词之前,先使用固定的问题集测试召回率。仅仅更换提示词很难改善较差的信息检索效果。

import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
import { ElastiCacheClient, DescribeCacheClustersCommand } from "@aws-sdk/client-elasticache";
import Redis from "ioredis";
// ---- 1️⃣  Type for the shape of our Redis JSON document ----
interface DocumentRecord {
  id: string;               // unique identifier for the source chunk
  text: string;             // original text
  embedding: number[];      // vector of floats from OpenAI
}
// The `satisfies` operator guarantees that the constant below conforms exactly to DocumentRecord
const sampleDoc = {
  id: "doc-001",
  text: "Redis can store vectors.",
  embedding: [0.12, -0.34, 0.56],
} satisfies DocumentRecord;
// ---- 2️⃣  Helper to get the Redis endpoint from ElastiCache ----
async function getRedisEndpoint(): Promise<string> {
  const client = new ElastiCacheClient({});
  const cmd = new DescribeCacheClustersCommand({ ShowCacheNodeInfo: true });
  const resp = await client.send(cmd);
  // Assume only one cluster exists for simplicity
  const node = resp.CacheClusters?.[0].CacheNodes?.[0];
  if (!node?.Endpoint?.Address) throw new Error("Redis endpoint not found");
  return `${node.Endpoint.Address}:${node.Endpoint.Port}`;
}
// ---- 3️⃣  Lambda entry point ----
export const handler = async (
  event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
  // Parse the incoming query string
  const query = JSON.parse(event.body ?? "{}").question;
  if (!query) {
    return { statusCode: 400, body: JSON.stringify({ error: "Missing question" }) };
  }
  // 3️⃣️⃣️⃣ Get an embedding (replace with real OpenAI call)
  const embedding = await fakeEmbedding(query); // returns number[]
  // 4️⃣ Connect to Redis (cluster mode)
  const redisEndpoint = await getRedisEndpoint();
  const redis = new Redis(redisEndpoint, {
    // If your cluster uses TLS (recommended) add:
    // tls: {}
  });
  // 5️⃣ Store the embedding using RedisJSON
  const docId = `doc:${Date.now()}`; // simple unique key
  await redis.call(
    "JSON.SET",
    docId,
    ".",
    JSON.stringify({
      id: docId,
      text: query,
      embedding,
    })
  );
  // 6️⃣ Run the KNN Lua script (defined later) to fetch top‑3 nearest vectors
  const topK = 3;
  const neighbors = await redis.eval(
    KNN_SCRIPT,
    0, // number of KEYS we pass (none, we use SCAN inside the script)
    docId,
    topK
  );
  // 7️⃣ Build a mock answer (in reality you would feed the neighbor texts to the LLM)
  const answer = `Found ${neighbors.length} similar chunks.`;
  await redis.quit();
  return {
    statusCode: 200,
    body: JSON.stringify({ answer, neighbors }),
  };
};
// ---- 4️⃣ Fake embedding generator (replace with real OpenAI call) ----
async function fakeEmbedding(text: string): Promise<number[]> {
  // Simple deterministic pseudo‑embedding for demo purposes
  const hash = Array.from(text).reduce((a, c) => a + c.charCodeAt(0), 0);
  return Array.from({ length: 1536 }, (_, i) => ((hash + i) % 100) / 100);
}

使用RedisJSON存储嵌入向量

在研究使用RedisJSON存储嵌入向量时,首先需明确相关规范:所需的输入参数、成功标志,以及部分失败时的处理方式。这样的检查清单能确保后续的代码修改始终符合要求。 将这一阶段视为输入与经过验证的输出之间的契约。为相关对象命名,定义成功判定标准,杜绝无声的半完成状态。 在调整提示词之前,先在固定的问题集上测试召回率。仅仅更换提示词很难改善较差的检索效果。

1. 为何选择JSON而非原始字符串?

在研究“为何选择 JSON 而非原始字符串?”这一主题时,首先需明确相关规范:所需的输入参数、成功信号,以及部分失败时的处理方式。这样的清单能确保后续的代码修改保持一致性。 在功能结果旁记录执行时间以及令牌或查询成本。提前了解成本情况,可避免在从演示环境过渡到共享环境时出现意外费用。 在调整提示词之前,先使用固定的问题集测试召回率。仅仅更换提示词往往无法改善较差的检索效果。

2. 编写文档所需的最少代码量

在学习“2. 编写文档的最小代码量”这一内容时,首先需明确相关约定:所需的输入参数、成功信号,以及部分失败时的处理方式。这样的清单能确保后续的代码修改保持一致性。 应将配置信息与应用程序代码分开存放。环境文件、密钥存储以及功能开关应集中管理,这样操作人员无需查看整个系统结构即可进行审计。 在调整提示词之前,先使用固定的问题集来测试召回率。仅仅更换提示词往往无法解决检索效果不佳的问题。

// Assume `redis` is an ioredis instance connected to the cluster
const docKey = "doc:12345";
const payload: DocumentRecord = {
  id: docKey,
  text: "Why Redis can store vectors",
  embedding: [0.01, 0.23, -0.45, /* … up to 1536 dims … */],
};
// Store the whole object as JSON under the root path "."
await redis.call(
  "JSON.SET",
  docKey,
  ".",
  JSON.stringify(payload) // serialize to string before sending
);

3. 仅读取嵌入向量以节省带宽

在处理第3步时,为节省带宽仅读取嵌入向量,首先需明确相关约定:所需输入、成功信号以及部分失败时的处理方式。这份清单能确保后续的代码修改保持一致性。 同时记录正常流程与异常恢复流程。重试机制、人工审核环节以及死信处理都是产品功能的一部分,而非后续需要补充的内容。 在调整提示词之前,需先用固定的问题集来衡量检索效果。仅仅更换提示词往往无法解决检索能力薄弱的问题。

// Retrieve only the `.embedding` field
const rawEmbedding = await redis.call("JSON.GET", docKey, ".embedding");
// Convert the JSON string back to a number array
const embedding: number[] = JSON.parse(rawEmbedding as string);

在处理第3步时,为节省带宽仅读取嵌入向量,首先需明确相关约定:所需输入、成功信号以及部分失败时的处理方式。这份清单能确保后续的代码修改保持一致性。 应将此阶段视为输入与经过验证的输出之间的契约。为相关输出命名,定义成功判定标准,并杜绝无声的半完成状态。

4. ElastiCache上使用RedisJSON的注意事项

  1. 在ElastiCache上使用RedisJSON时,若能将其视为可度量的对象,将会更易发现潜在问题。在扩大应用范围之前,先记录一份最佳运行案例、一个故障实例以及回滚说明。同时将功能测试结果与执行时间、令牌消耗或查询成本一并记录下来。提前了解成本情况,可避免从演示环境过渡到共享环境时出现意外费用。此外,应将分块策略与检索策略分开处理——当质量指标发生变化时,修改其中一项不应强制要求重新编写另一项的代码。

实时查询与向量评分

将“实时查询与向量评分”视为可度量的表面来处理时,其效果最佳。在扩大范围之前,先收集一份理想的转录样本、一个失败案例以及回滚说明。 将配置置于应用程序代码之外。环境文件、密钥存储和功能标志应集中存放于一个位置,以便操作人员无需查看整个系统结构即可进行审计。 将分块策略与检索策略分开。当质量指标发生变化时,修改其中一项不应迫使重新编写另一项。

1. 相似度指标

  1. 将相似度指标视为可测量的对象时,其效果最佳。在扩大范围之前,需记录一份理想的处理结果、一个故障案例以及回滚说明。同时将正常流程与恢复流程记录下来。重试机制、人工审核环节以及错误处理都属于产品本身的功能,而非后续需要补充的内容。应将分块策略与检索策略分开,当质量指标发生变化时,修改其中一项不应迫使另一项也必须重新编写。
  2. 将相似度指标视为可测量的对象时,其效果最佳。在扩大范围之前,需记录一份理想的处理结果、一个故障案例以及回滚说明。应将此阶段视为输入数据与经过验证的输出结果之间的契约,为相关文档命名,明确成功标准,杜绝默默完成部分任务的情况。
cosine(a, b) = (a · b) / (|a| * |b|)

2. Lua脚本

对于第2点:在修改Lua脚本之前,需先定义输入参数、该步骤的负责人以及终止条件。操作人员应能够从已知的检查点重新运行该步骤,而无需猜测隐藏状态。应在功能结果旁记录执行时间以及令牌或查询成本。提前显示成本信息,可避免在从演示环境切换到共享环境时出现意外费用。需注明实际作为答案依据的段落;没有引用的话,操作人员就无法区分是幻觉内容还是索引缺失导致的错误。

-- KNN_SCRIPT: Find top‑K nearest vectors to a query embedding stored at `queryKey`.
-- Arguments:
--   KEYS[1] = (unused) – we pass 0 keys, the script scans the keyspace itself.
--   ARGV[1] = queryKey (the key that holds the fresh embedding)
--   ARGV[2] = k (how many neighbors to return)
local queryKey = ARGV[1]
local k = tonumber(ARGV[2])
-- 1️⃣ Load the query embedding from JSON
local queryEmbeddingJson = redis.call('JSON.GET', queryKey, '.embedding')
local queryEmbedding = cjson.decode(queryEmbeddingJson)
-- Helper: compute dot product
local function dot(a, b)
  local sum = 0
  for i = 1, #a do
    sum = sum + a[i] * b[i]
  end
  return sum
end
-- Helper: compute vector length
local function norm(v)
  return math.sqrt(dot(v, v))
end
local queryNorm = norm(queryEmbedding)
-- 2️⃣ Scan all keys that match the pattern "doc:*"
local cursor = "0"
local results = {}   -- will hold {id, score, text}
repeat
  local scan = redis.call('SCAN', cursor, 'MATCH', 'doc:*', 'COUNT', 1000)
  cursor = scan[1]
  local keys = scan[2]
  for _, key in ipairs(keys) do
    -- Skip the query key itself
    if key ~= queryKey then
      local docJson = redis.call('JSON.GET', key, '.')
      local doc = cjson.decode(docJson)
      local sim = dot(queryEmbedding, doc.embedding) / (queryNorm * norm(doc.embedding))
      table.insert(results, { id = doc.id, score = sim, text = doc.text })
    end
  end
until cursor == "0"
-- 3️⃣ Sort by descending similarity and keep top‑k
table.sort(results, function(a, b) return a.score > b.score end)
local top = {}
for i = 1, math.min(k, #results) do
  table.insert(top, results[i])
end
return cjson.encode(top)

3. 从TypeScript运行脚本

3. 从 TypeScript 运行脚本时,应在修改代码之前定义输入参数、步骤负责人以及退出条件。操作员应能够从已知的检查点重新运行该步骤,而无需猜测隐藏状态。 配置信息应置于应用程序代码之外。环境文件、密钥存储以及功能标志应集中存放于一个位置,这样操作员无需查看整个流程即可进行审核。 需注明实际作为答案依据的段落。如果没有引用,操作员就无法区分是幻觉内容还是索引缺失导致的错误。

// The script is stored as a string constant in our code
const KNN_SCRIPT = `...lua code from above...`;
// Execute the script
const topK = 5;
const rawResult = await redis.eval(KNN_SCRIPT, 0, docId, topK);
const neighbors: Array<{ id: string; score: number; text: string }> = JSON.parse(
  rawResult as string
);
// `neighbors` now contains the most similar chunks, ready for the LLM.

4. 为何我们选择 Lua 而非客户端循环

对于第4点——为何选择Lua而非客户端循环——在修改代码之前,需明确输入参数、该步骤的负责人以及终止条件。操作员应能够从已知的检查点重新执行该步骤,而无需猜测隐藏状态。 需同时记录正常流程与异常恢复路径。重试机制、人工审核环节以及错误处理都是产品本身的组成部分,而非后续需要补充的功能。 必须引用实际作为答案依据的段落。没有引用的话,操作员就无法区分是虚假信息还是索引缺失导致的错误。 对于第4点——为何选择Lua而非客户端循环——在修改代码之前,需明确输入参数、该步骤的负责人以及终止条件。操作员应能够从已知的检查点重新执行该步骤,而无需猜测隐藏状态。 应将此阶段视为输入与验证后输出之间的契约。为相关成果命名,定义成功判定标准,并拒绝默许部分完成的状态。

n.

核心要点

在处理核心要点时,首先列出相关约定:所需输入、成功标志以及部分失败时的处理方式。这样的清单能确保后续的代码修改保持一致性。

在功能结果旁记录执行时间以及令牌或查询成本。提前了解成本情况,可避免从演示环境过渡到共享环境时出现意外费用。

在调整提示词之前,先使用固定的问题集测试系统的召回率。仅仅更换提示词往往无法解决检索效果不佳的问题。

操作检查清单

在处理操作检查清单时,同样要先列出相关约定:所需输入、成功标志以及部分失败时的处理方式。这样的清单能确保后续的代码修改保持一致性。

优先选择小型、可测试的单元,而非庞大的脚本。当某个步骤出现故障时,故障应能指向具体的责任模块,而非复杂的流程链。

在调整提示词之前,先使用固定的问题集测试召回率。仅仅更换提示词很难改善较差的检索效果。

尽量降低渲染成本,只有在经过测试后才能将耗时的计算过程交由缓存处理。过早使用缓存可能会掩盖过时的属性错误。

只要预算允许,就应在持续集成过程中使用测试用例来检测关键路径,而非依赖真实的付费 API。

将配置信息与应用程序代码分开。环境文件、密钥存储以及功能开关应集中存放,以便运维人员无需查看整个系统结构即可进行审计。

在升级技术栈之前,先锁定版本,为关键路径记录标准操作流程,并明确回滚步骤。共享环境需要设置速率限制、租户验证机制,以及负责密钥轮换的明确责任人。与其追求华丽的临时演示,不如注重扎实的可靠性。