将 Weaviate 这样的向量数据库直接暴露给多个内部或外部客户端,在真实项目中是一个高风险操作。问题不在于 Weaviate 本身,而在于它强大的查询能力所带来的安全、成本和复杂性挑战。一个不受约束的 nearText 查询可能会消耗大量计算资源,一个错误的 where 过滤器可能导致数据泄露。我们需要一个控制层,一个既能理解业务意图又能与 Weaviate 底层 API 对话的智能中间件。
传统的 API 网关,如 Kong 或 Apigee,擅长处理路由、认证和速率限制等 L7 层的通用任务,但它们无法理解 Weaviate 查询的“语义”。它们无法对一个 GraphQL 查询体进行深度解析,并根据租户身份注入动态的数据隔离过滤器。因此,我们面临一个架构抉择:是扩展一个重量级的网关,还是构建一个轻量级、高度定制化的代理服务?
方案 A,使用通用 API 网关,优势在于成熟稳定,但其插件系统往往不足以支持我们所需的复杂查询转换和业务逻辑注入。例如,要实现“只允许 A 租户查询 certainty 大于 0.9 的结果”,在通用网关上实现起来非常笨拙,甚至不可能。
方案 B,使用像 Koa 这样的轻量级 Node.js 框架自建一个“语义代理”,则提供了完全的控制力。我们可以将所有与 Weaviate 相关的复杂性封装起来,对外提供一个极简的、面向业务场景的 RESTful API。这个代理的核心职责不再是简单的请求转发,而是:
- 认证与授权: 基于 API Key 识别租户,并确定其权限边界。
- 查询抽象与转换: 将简单的业务查询(如
{"text": "some query"})转换为完整、安全的 Weaviate GraphQL 或 REST 查询。 - 多租户数据隔离: 自动在底层查询中注入
where过滤器,确保租户之间的数据严格隔离。 - 安全与成本控制: 对查询参数进行校验和限制,防止恶意或低效的查询消耗集群资源。
在权衡了开发成本和系统长期可维护性后,方案 B 胜出。它能更好地将基础设施的复杂性与业务逻辑解耦。我们将使用 Koa 构建此代理,并将其部署在 Google Cloud Run 上,以利用其无服务器的弹性伸缩和简化的运维特性。
架构概览
整个系统的请求流非常直接,但关键在于代理服务内部的逻辑分层。
sequenceDiagram
participant Client as 客户端应用
participant CloudRun as Koa 语义代理 (GCP Cloud Run)
participant SecretManager as GCP Secret Manager
participant Weaviate as Weaviate 实例 (GCP GKE/VM)
Client->>+CloudRun: POST /v1/search (携带 X-Api-Key)
CloudRun->>+SecretManager: 读取租户配置 (API Key -> Tenant ID, Permissions)
SecretManager-->>-CloudRun: 返回租户信息
Note right of CloudRun: Auth 中间件验证 Key,
并将租户信息注入 ctx.state
CloudRun->>CloudRun: 查询转换中间件
(业务请求 -> Weaviate GraphQL)
Note right of CloudRun: 自动注入租户隔离的 where 过滤器
CloudRun->>+Weaviate: 执行转换后的 GraphQL 查询
Weaviate-->>-CloudRun: 返回向量搜索结果
CloudRun-->>-Client: 返回简化后的 JSON 结果
核心实现:Koa 中间件驱动的代理逻辑
我们的 Koa 应用将由一系列精心设计的中间件组成,每个中间件负责一个独立的任务。
项目结构:
.
├── src/
│ ├── middlewares/
│ │ ├── errorHandler.js # 统一错误处理
│ │ ├── authHandler.js # 租户认证与授权
│ │ └── queryTransformer.js # 查询转换与校验
│ ├── routes/
│ │ └── search.js # 搜索路由
│ ├── services/
│ │ ├── weaviateClient.js # Weaviate 客户端封装
│ │ └── secretManager.js # GCP Secret Manager 客户端
│ └── app.js # Koa 应用入口
├── Dockerfile
└── package.json
1. 应用入口 app.js
这是所有逻辑的粘合剂,定义了中间件的执行顺序。顺序至关重要:错误处理必须在最外层,其次是认证,然后才是核心的业务逻辑。
// src/app.js
import Koa from 'koa';
import bodyParser from 'koa-bodyparser';
import { errorHandler } from './middlewares/errorHandler.js';
import { authHandler } from './middlewares/authHandler.js';
import { searchRouter } from './routes/search.js';
import { initWeaviateClient } from './services/weaviateClient.js';
const app = new Koa();
// 初始化 Weaviate 客户端单例
try {
initWeaviateClient();
console.log('Weaviate client initialized successfully.');
} catch (error) {
console.error('Failed to initialize Weaviate client:', error);
process.exit(1);
}
// 1. 全局错误处理中间件
app.use(errorHandler);
// 2. Body 解析
app.use(bodyParser());
// 3. 租户认证与授权中间件
app.use(authHandler);
// 4. 注册业务路由
app.use(searchRouter.routes()).use(searchRouter.allowedMethods());
const PORT = process.env.PORT || 8080;
app.listen(PORT, () => {
console.log(`Semantic proxy server running on port ${PORT}`);
});
这里的关键点是,Weaviate 客户端在应用启动时就进行初始化。如果连接 Weaviate 失败,服务将直接退出,这是一种快速失败(Fail-fast)的设计,避免服务在不可用的状态下运行。
2. 认证中间件 authHandler.js
这个中间件是安全的第一道防线。它从 GCP Secret Manager 中获取租户配置,验证 API Key,并将解析出的租户信息附加到 Koa 的上下文 ctx.state 中,供下游中间件使用。在真实项目中,这些配置信息应该被缓存以降低延迟和成本。
// src/middlewares/authHandler.js
import { getTenantConfig } from '../services/secretManager.js';
// 在真实项目中,这里应该有缓存机制 (例如,使用内存缓存或 Redis)
// 以避免每次请求都调用 Secret Manager API。
let tenantConfigCache = null;
let cacheTimestamp = 0;
const CACHE_TTL = 300 * 1000; // 5 分钟缓存
async function getCachedTenantConfig() {
const now = Date.now();
if (tenantConfigCache && (now - cacheTimestamp < CACHE_TTL)) {
return tenantConfigCache;
}
console.log('Cache expired or empty. Fetching tenant config from Secret Manager.');
const config = await getTenantConfig();
tenantConfigCache = config;
cacheTimestamp = now;
return config;
}
export async function authHandler(ctx, next) {
const apiKey = ctx.get('X-Api-Key');
if (!apiKey) {
ctx.throw(401, 'API Key is missing.');
}
const tenants = await getCachedTenantConfig();
const tenant = tenants.find(t => t.apiKey === apiKey);
if (!tenant) {
ctx.throw(403, 'Invalid API Key.');
}
// 将租户信息注入上下文,供后续中间件使用
ctx.state.tenant = {
id: tenant.id,
allowedCollections: tenant.allowedCollections,
// 其他权限信息...
};
await next();
}
getTenantConfig 函数(在 secretManager.js 中实现)会从 GCP Secret Manager 中拉取一个 JSON 字符串,其结构可能如下:
[
{
"id": "tenant-a",
"apiKey": "api-key-for-tenant-a-secret",
"allowedCollections": ["ProductDocs", "SupportTickets"]
},
{
"id": "tenant-b",
"apiKey": "api-key-for-tenant-b-secret",
"allowedCollections": ["InternalKB"]
}
]
3. 查询转换与路由 search.js & queryTransformer.js
这是代理的核心智能所在。路由文件 search.js 负责定义 API 端点,并调用 queryTransformer.js 中的转换逻辑。
// src/routes/search.js
import Router from '@koa/router';
import { transformAndExecuteQuery } from '../middlewares/queryTransformer.js';
export const searchRouter = new Router();
searchRouter.post('/v1/search', async (ctx) => {
// 从请求体中获取用户输入的简化查询
const { collection, queryText, limit } = ctx.request.body;
if (!collection || !queryText) {
ctx.throw(400, '`collection` and `queryText` are required fields.');
}
// 从 ctx.state 中获取已经过认证的租户信息
const { tenant } = ctx.state;
// 检查该租户是否有权访问此 collection
if (!tenant.allowedCollections.includes(collection)) {
ctx.throw(403, `Access to collection '${collection}' is denied.`);
}
try {
const results = await transformAndExecuteQuery({
tenantId: tenant.id,
collection,
queryText,
limit,
});
ctx.body = { data: results };
} catch (error) {
// 捕获 Weaviate 查询时可能发生的特定错误
console.error(`Weaviate query failed for tenant ${tenant.id}:`, error);
ctx.throw(502, 'Failed to query upstream vector database.');
}
});
真正的魔法发生在 queryTransformer.js。它将业务 API 的简单载荷转换为一个带有租户隔离和安全约束的、完整的 Weaviate GraphQL 查询。
// src/middlewares/queryTransformer.js
import { getWeaviateClient } from '../services/weaviateClient.js';
const DEFAULT_LIMIT = 10;
const MAX_LIMIT = 50;
const MIN_CERTAINTY = 0.75; // 设置一个全局的最低置信度阈值
/**
* 将简化的业务查询转换为 Weaviate GraphQL 查询并执行
* @param {object} params
* @param {string} params.tenantId - 租户 ID
* @param {string} params.collection - Weaviate Class 名称
* @param {string} params.queryText - 用户查询文本
* @param {number|undefined} params.limit - 返回结果数量
* @returns {Promise<any>}
*/
export async function transformAndExecuteQuery({ tenantId, collection, queryText, limit }) {
const weaviateClient = getWeaviateClient();
// 1. 安全与成本控制:对输入参数进行清洗和限制
const validatedLimit = Math.min(limit || DEFAULT_LIMIT, MAX_LIMIT);
// 2. 多租户数据隔离:构建 where 过滤器
const whereFilter = {
path: ['tenantId'], // 假设你的 Schema 中有 tenantId 字段
operator: 'Equal',
valueText: tenantId,
};
// 3. 构建 Weaviate nearText 查询参数
const nearText = {
concepts: [queryText],
certainty: MIN_CERTAINTY, // 强制最低置信度
};
// 4. 定义需要返回的字段
const fields = 'title content _additional { certainty distance }';
// 5. 使用 Weaviate JS client 构建并执行查询
const result = await weaviateClient.graphql
.get()
.withClassName(collection)
.withFields(fields)
.withWhere(whereFilter)
.withNearText(nearText)
.withLimit(validatedLimit)
.do();
// 6. 结果清洗,只返回对客户端有用的信息
return result.data.Get[collection].map(item => ({
title: item.title,
content: item.content,
score: item._additional.certainty,
}));
}
这段代码体现了语义代理的核心价值:
- 抽象: 客户端无需了解
nearText,where,_additional等 Weaviate 的专用语法。 - 安全:
whereFilter强制性地被注入,客户端无法绕过它来查询不属于自己的数据。这是在应用层面实现数据隔离的关键。 - 成本控制:
validatedLimit和MIN_CERTAINTY硬编码了查询的边界,防止了可能导致性能雪崩的昂贵查询。
4. 部署到 GCP Cloud Run
为了将此应用部署为可扩展的服务,我们使用 Docker 和 Cloud Run。
Dockerfile:
一个生产级的多阶段 Dockerfile 可以减小最终镜像的体积并提高安全性。
# ---- Base Stage ----
# 使用一个明确的版本以保证构建的可复现性
FROM node:18-slim AS base
WORKDIR /usr/src/app
COPY package*.json ./
# ---- Dependencies Stage ----
FROM base AS dependencies
# 仅安装生产环境依赖
RUN npm ci --only=production
# ---- Release Stage ----
FROM node:18-slim AS release
WORKDIR /usr/src/app
# 从 dependencies 阶段拷贝 node_modules
COPY /usr/src/app/node_modules ./node_modules
# 拷贝源代码
COPY ./src ./src
# 暴露端口,与 Cloud Run 的期望一致
EXPOSE 8080
# 设置环境变量,指向应用入口
ENV NODE_ENV=production
# 运行应用
CMD [ "node", "src/app.js" ]
部署命令:
使用 gcloud CLI 可以轻松地构建镜像并部署到 Cloud Run。
# 变量定义
PROJECT_ID="your-gcp-project-id"
SERVICE_NAME="weaviate-semantic-proxy"
REGION="us-central1"
IMAGE_NAME="gcr.io/${PROJECT_ID}/${SERVICE_NAME}:latest"
# 1. 构建 Docker 镜像并推送到 GCR
gcloud builds submit --tag ${IMAGE_NAME} .
# 2. 部署到 Cloud Run
# 注意:这里需要传入环境变量,并将服务账户与 Secret Manager 的访问权限关联
gcloud run deploy ${SERVICE_NAME} \
--image ${IMAGE_NAME} \
--platform managed \
--region ${REGION} \
--allow-unauthenticated \ # 在真实场景中,你可能会用 IAP 或其他方式进一步保护
--set-env-vars="WEAVIATE_HOST=your-weaviate-host.com,WEAVIATE_SCHEME=https,GCP_SECRET_NAME=projects/your-project-number/secrets/tenant-configs/versions/latest" \
--service-account="your-service-account@${PROJECT_ID}.iam.gserviceaccount.com"
在执行部署前,必须确保你创建的服务账号 (your-service-account) 拥有 Secret Manager Secret Accessor IAM 角色,以便能够读取租户配置。
架构的扩展性与局限性
这个基于 Koa 的语义代理架构提供了极高的灵活性,但它并非万能。
可扩展路径:
- 高级缓存: 对于重复性高的查询,可以在代理层增加一层 Redis 缓存(使用 GCP Memorystore)。缓存的 key 可以是租户 ID 和查询文本的哈希值。这能极大降低 Weaviate 的负载并提升响应速度。
- 查询成本估算: 对于更复杂的场景,可以在执行查询前,对其进行静态分析,估算其大致成本。如果一个查询可能过于昂贵(例如,涉及多个
near向量或复杂的过滤器),代理可以直接拒绝,或将其放入低优先级队列。 - 支持更多 Weaviate 功能: 当前代理只实现了
nearText查询。可以轻松地扩展 API,以受控的方式暴露bm25、hybrid搜索或聚合等功能。
固有局限性:
- 性能瓶颈: 尽管 Node.js 的 I/O 性能出色,但如果查询转换逻辑本身变得非常复杂和 CPU 密集,这个单线程的代理服务可能会成为瓶颈。在这种情况下,需要考虑使用 Go 或 Rust 等性能更强的语言重写,或将代理服务水平扩展。
- 紧耦合: 此代理的设计与 Weaviate 的数据模型和查询语言紧密耦合。如果未来决定更换底层的向量数据库,整个代理服务几乎需要重写。这是一个为了获得深度控制而付出的代价。
- 状态管理: 在 Cloud Run 这样的无服务器环境中,实现需要跨请求共享状态的功能(如复杂的、基于滑动窗口的速率限制)会比较困难。这通常需要引入外部存储(如 Redis),增加了系统的复杂性。我们当前的 API Key 认证缓存是一种简单的内存缓存,在实例冷启动或扩容时会失效,对于高要求的场景需要更稳健的方案。