我们面临一个具体的工程挑战:一个部署在Oracle Cloud Infrastructure (OCI)上的核心业务系统,其后端由PHP构建,需要集成一个AI驱动的搜索结果重排(reranking)功能。该功能旨在提升一个检索增强生成(RAG)流程的输入质量。主要的技术约束是,必须在不重写现有PHP服务的前提下,将重排逻辑的额外延迟控制在50毫秒以内。PHP服务本身负责从OCI上的向量数据库中进行初步召回,获取一个包含约50个候选文档的列表。
直接在PHP中执行此任务是不可行的。PHP作为一门语言,其生态和执行模型并不适合CPU密集的计算任务,例如运行一个轻量级的机器学习模型或复杂的排序算法。任何同步的计算都会阻塞PHP-FPM工作进程,严重影响整个应用的吞吐量。
方案A: 引入独立的重排微服务
这是一个符合常规思路的架构。PHP服务在从向量数据库获取召回集后,通过HTTP或gRPC调用一个专门的重排微服务。这个微服务可以用Python、Go或Rust等更适合计算的语言编写。
sequenceDiagram participant Client participant Kong_Gateway as Kong API Gateway participant PHP_Backend as PHP Backend (OCI VM) participant Rerank_Service as Rerank Microservice (Python/OCI) participant VectorDB as Vector Database (OCI) Client->>Kong_Gateway: GET /search?q=... Kong_Gateway->>PHP_Backend: (forward request) PHP_Backend->>VectorDB: Fetch top 50 candidates VectorDB-->>PHP_Backend: Candidate list PHP_Backend->>Rerank_Service: POST /rerank (payload: candidates) Rerank_Service-->>PHP_Backend: Reranked list (top 10) PHP_Backend-->>Kong_Gateway: Final response Kong_Gateway-->>Client: (forward response)
优劣分析:
优点:
- 关注点分离: AI/ML逻辑与核心业务逻辑解耦,由专门的团队和技术栈维护。
- 技术栈适宜性: 可以使用Python及其丰富的ML库(如
sentence-transformers
,onnxruntime
)来实现重排模型,性能和开发效率更高。 - 独立扩展: 重排服务可以根据其自身的负载进行独立的水平扩展。
缺点:
- 延迟开销: 引入了一次完整的网络往返(PHP -> Rerank Service -> PHP)。在OCI的VCN(虚拟云网络)内部,这次调用的延迟通常在5-15毫秒之间,但在高负载下可能会波动。这直接侵蚀了我们本已紧张的50毫札延迟预算。
- 运维复杂性: 增加了一个需要部署、监控、维护和保障高可用的新服务。这带来了额外的基础设施成本和人力成本。
- 数据序列化开销: 在PHP和Python服务之间传递50个文档(可能包含标题、摘要等文本)需要进行JSON序列化和反序列化,这本身也会消耗数毫秒的时间。
一个简单的PHP实现片段可能如下,它清晰地暴露了延迟问题:
<?php
// RerankClient.php
namespace App\Services;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use Psr\Log\LoggerInterface;
class RerankClient
{
private Client $httpClient;
private LoggerInterface $logger;
private string $rerankServiceUrl;
public function __construct(string $rerankServiceUrl, LoggerInterface $logger)
{
$this->httpClient = new Client([
'timeout' => 0.5, // 500ms timeout
'connect_timeout' => 0.1, // 100ms
]);
$this->rerankServiceUrl = $rerankServiceUrl;
$this->logger = $logger;
}
/**
* @param string $query
* @param array $documents // [['id' => 1, 'text' => '...'], ...]
* @return array|null
*/
public function rerank(string $query, array $documents): ?array
{
$startTime = microtime(true);
try {
$response = $this->httpClient->post($this->rerankServiceUrl . '/rerank', [
'json' => [
'query' => $query,
'documents' => $documents,
]
]);
$body = json_decode($response->getBody()->getContents(), true);
$latency = (microtime(true) - $startTime) * 1000;
$this->logger->info(sprintf('Rerank service call took %.2f ms.', $latency));
// 在真实项目中,延迟日志应该包含更多上下文,比如文档数量
// 当 $latency > 30 时,就应该触发告警,因为它已经消耗了大部分预算
return $body['reranked_documents'] ?? null;
} catch (RequestException $e) {
$this->logger->error('Rerank service call failed: ' . $e->getMessage());
return null; // 失败时,可以考虑返回原始文档列表作为降级策略
}
}
}
这段代码很直观,但在生产环境中,$latency
日志将持续显示出网络调用带来的不可避免的性能损耗。这正是方案A的致命弱点。
方案B: 在API网关(Kong)层面实现边缘重排
此方案彻底改变了数据流。PHP后端退化为一个纯粹的数据检索服务,它只负责从向量数据库获取原始的、未经排序的候选集,然后直接返回。真正的重排逻辑作为一个自定义插件在API网关Kong中执行。
sequenceDiagram participant Client participant Kong_Gateway as Kong API Gateway (with Lua Plugin) participant PHP_Backend as PHP Backend (OCI VM) participant VectorDB as Vector Database (OCI) Client->>Kong_Gateway: GET /search?q=... Kong_Gateway->>PHP_Backend: (forward request) PHP_Backend->>VectorDB: Fetch top 50 candidates VectorDB-->>PHP_Backend: Candidate list PHP_Backend-->>Kong_Gateway: Raw response with 50 candidates Note over Kong_Gateway: Lua plugin intercepts the response body. Note over Kong_Gateway: 1. Parses JSON response.
2. Executes reranking logic.
3. Sorts and truncates to top 10.
4. Rewrites the response body. Kong_Gateway-->>Client: Final, reranked response
优劣分析:
优点:
- 极致的低延迟: 重排逻辑在Kong的
body_filter
阶段执行,这是一个内存操作,运行在高性能的LuaJIT上。它完全消除了方案A中的网络调用开销,这是决定性的优势。 - 对后端无侵入: PHP后端无需任何修改,它甚至不知道上游有一个重排层。这对于维护遗留系统或遵循开闭原则至关重要。
- 逻辑解耦: 尽管逻辑不在一个独立的服务中,但它被封装在一个独立的、可版本化的Kong插件里,与PHP代码库物理分离。
- 极致的低延迟: 重排逻辑在Kong的
缺点:
- 插件开发复杂性: 编写、测试和调试生产级的Kong插件比编写一个Python Web服务更具挑战性。需要对Kong的插件生命周期和Lua生态有深入理解。
- 计算能力限制: LuaJIT虽然快,但并不适合执行复杂的、预训练好的深度学习模型。重排算法必须是轻量级的,可以用纯Lua实现,或者通过LuaJIT的FFI(Foreign Function Interface)调用一个C/C++库。
- 运维融合: API网关现在承担了一部分业务逻辑,这可能会打破一些团队的职责边界。SRE或平台团队需要与数据科学团队紧密协作。
考虑到性能是我们的首要约束,我们决定采用方案B。挑战在于设计一个足够高效且能在Lua中实现的重排算法。一个常见的轻量级重排模型是基于特征的线性模型,例如score = w1 * feature1 + w2 * feature2 + ...
。这些特征可以是文本的统计特征(如BM25分数)、实体匹配度、时效性等。
核心实现:Kong Lua插件
我们将构建一个名为 edge-reranker
的Kong插件。
1. PHP后端:纯粹的数据提供者
PHP代码现在变得异常简单。它只负责数据获取和基本的错误处理。
<?php
// search_controller.php
// 假设已经通过依赖注入获取了 $vectorDbClient 和 $logger
// OCI Vector Search, OpenSearch, or other DB client
public function search(Request $request): JsonResponse
{
$query = $request->get('q');
if (empty($query)) {
return new JsonResponse(['error' => 'Query parameter is required'], 400);
}
try {
$startTime = microtime(true);
// 只获取,不做任何业务逻辑处理
$candidates = $this->vectorDbClient->fetchCandidates($query, 50);
$latency = (microtime(true) - $startTime) * 1000;
$this->logger->info(sprintf('Vector DB query took %.2f ms.', $latency));
// 直接返回原始候选集
return new JsonResponse([
'query' => $query,
'documents' => $candidates,
]);
} catch (\Exception $e) {
$this->logger->error('Failed to fetch from vector DB: ' . $e->getMessage());
return new JsonResponse(['error' => 'Internal server error'], 500);
}
}
这个服务的目标就是尽可能快地返回数据,将所有复杂的逻辑都交给上游的Kong。
2. Kong插件目录结构
kong/plugins/edge-reranker/
├── handler.lua
├── schema.lua
└── ranker.lua
-
schema.lua
: 定义插件的配置项。 -
handler.lua
: 插件的入口和生命周期钩子。 -
ranker.lua
: 核心的重排逻辑模块。
3. schema.lua
: 插件配置
我们允许通过配置启用/禁用插件,并设置重排后返回的文档数量。
-- schema.lua
local typedefs = require "kong.db.schema.typedefs"
return {
name = "edge-reranker",
fields = {
{ consumer = { type = "foreign", reference = "consumers" } },
{ route = { type = "foreign", reference = "routes" } },
{ service = { type = "foreign", reference = "services" } },
{
config = {
type = "record",
fields = {
{ top_n = { type = "number", required = true, default = 10, between = { 1, 50 } } },
{ model_weights = {
type = "record",
required = true,
fields = {
{ freshness_weight = { type = "number", default = 0.2 } },
{ title_match_weight = { type = "number", default = 0.5 } },
{ length_weight = { type = "number", default = 0.1 } },
}
}
},
},
},
},
},
}
4. ranker.lua
: 核心重排算法
这里我们实现一个基于特征的简单线性模型。在真实项目中,这些特征和权重会由数据科学家通过离线训练得到。
-- ranker.lua
local string_find = string.find
local string_lower = string.lower
local table_sort = table.sort
local os_time = os.time
local M = {}
-- 计算标题与查询的词元重叠度
local function calculate_title_match_score(query, title)
if not title or title == "" then return 0.0 end
local query_tokens = {}
for token in string_lower(query):gmatch("%w+") do
query_tokens[token] = true
end
local match_count = 0
local title_lower = string_lower(title)
for token, _ in pairs(query_tokens) do
if string_find(title_lower, token, 1, true) then
match_count = match_count + 1
end
end
-- 归一化分数
return match_count / #query:gmatch("%w+")
end
-- 计算时效性分数,越新的文档分数越高
local function calculate_freshness_score(doc_timestamp)
if not doc_timestamp then return 0.0 end
local current_time = os_time()
local age_seconds = current_time - doc_timestamp
-- 使用指数衰减函数,一周前的文档分数衰减到约0.36
local decay_factor = 86400 * 7 -- 7 days in seconds
return math.exp(-age_seconds / decay_factor)
end
-- 文档长度惩罚/奖励
local function calculate_length_score(text)
if not text then return 0.0 end
local len = #text
-- 假设理想长度在200到800字符之间
if len < 200 then
return len / 200
elseif len > 800 then
return math.max(0, 1 - (len - 800) / 1000)
else
return 1.0
end
end
-- 主 rerank 函数
function M.rerank(query, documents, weights)
if not documents or #documents == 0 then
return {}
end
for _, doc in ipairs(documents) do
-- 假设文档结构为 { id, title, text, created_at }
local title_score = calculate_title_match_score(query, doc.title)
local freshness = calculate_freshness_score(doc.created_at)
local length_score = calculate_length_score(doc.text)
-- 计算最终的加权分数
doc.rerank_score = (title_score * weights.title_match_weight) +
(freshness * weights.freshness_weight) +
(length_score * weights.length_weight)
end
-- 根据 rerank_score 降序排序
-- 在生产代码中,应使用稳定的排序算法
table_sort(documents, function(a, b)
return a.rerank_score > b.rerank_score
end)
return documents
end
return M
这段代码是性能敏感的。我们避免了在循环中创建不必要的对象,并使用了local
函数引用以轻微提升性能。
5. handler.lua
: 插件入口与执行流
这是将所有部分粘合在一起的地方。它在响应阶段(body_filter
)拦截上游(PHP服务)的响应体,执行重排,然后用新的内容替换响应体。
-- handler.lua
local cjson = require "cjson.safe"
local ranker = require "kong.plugins.edge-reranker.ranker"
local EdgeRerankerHandler = {}
EdgeRerankerHandler.PRIORITY = 1000 -- or any other priority
EdgeRerankerHandler.VERSION = "1.0.0"
-- 核心逻辑在 body_filter 阶段执行
function EdgeRerankerHandler:body_filter(conf)
-- 1. 获取完整的响应体
local chunk, err = kong.response.get_raw_body()
if err then
kong.log.err("failed to get response body: ", err)
return
end
-- 2. 解析JSON
-- cjson.safe 会在解析失败时返回 nil 而不是抛出异常
local body = cjson.decode(chunk)
if not body or type(body) ~= "table" then
kong.log.warn("upstream response is not a valid JSON object")
-- 直接放行,不处理非JSON响应
return
end
-- 检查响应体是否符合我们期望的结构
if not body.query or not body.documents or type(body.documents) ~= "table" then
kong.log.warn("JSON body does not contain 'query' or 'documents' fields")
return
end
-- 3. 执行重排逻辑
local start_time = kong.clock.get_time()
local reranked_docs = ranker.rerank(body.query, body.documents, conf.model_weights)
local latency = (kong.clock.get_time() - start_time) * 1000
-- 添加诊断头,方便调试
kong.response.set_header("X-Rerank-Latency-ms", string.format("%.2f", latency))
if latency > 40 then
kong.log.warn("reranking took too long: ", latency, " ms for ", #body.documents, " documents")
end
-- 4. 截断到 top_n
local final_docs = {}
for i = 1, math.min(conf.top_n, #reranked_docs) do
-- 在返回给客户端之前,移除我们内部使用的分数
reranked_docs[i].rerank_score = nil
final_docs[i] = reranked_docs[i]
end
-- 5. 重写响应体
body.documents = final_docs
local new_body_str, err = cjson.encode(body)
if err then
kong.log.err("failed to encode new body: ", err)
return
end
kong.response.set_raw_body(new_body_str)
kong.response.set_header("Content-Length", #new_body_str)
end
return EdgeRerankerHandler
部署与配置
- 将
edge-reranker
文件夹放置到 Kong 节点的kong/plugins
目录下。 - 在
kong.conf
中加载自定义插件:plugins = bundled,edge-reranker
。 - 重启 Kong。
- 通过 Admin API 为特定的路由或服务启用该插件:
# curl -i -X POST http://localhost:8001/services/{service_name}/plugins \
--data "name=edge-reranker" \
--data "config.top_n=10" \
--data "config.model_weights.freshness_weight=0.3" \
--data "config.model_weights.title_match_weight=0.6" \
--data "config.model_weights.length_weight=0.1"
架构的扩展性与局限性
扩展性:
- A/B 测试: 可以通过修改插件,根据请求头(如
X-User-Group
)或用户ID的哈希值来选择不同的model_weights
配置,从而在网关层实现服务端的A/B测试,而无需后端配合。 - 缓存: 对于不经常变化的查询,可以将重排结果缓存到
kong.cache
或外部的Redis中,进一步降低延迟。 - FFI集成: 如果需要更复杂的模型(例如,一个用ONNX Runtime C++ API加载的轻量级模型),可以通过LuaJIT的FFI功能调用预编译的动态链接库(
.so
文件),将计算密集部分交给C/C++执行,同时保持Lua的胶水层角色。这是一种高阶玩法,但为性能提升打开了新的大门。
局限性:
- 模型复杂度: 此架构的核心限制在于无法直接在Lua中运行大型的、基于Transformer的重排模型。这类模型需要GPU和复杂的Python环境。我们的方案只适用于那些可以被抽象为数学公式或简单算法的轻量级模型。
- 可测试性: Kong插件的单元测试和集成测试流程比标准微服务要复杂。需要使用
busted
等Lua测试框架,并模拟Kong的环境变量和函数,这需要专门的知识积累。 - 状态管理: Kong插件被设计为无状态的。如果重排逻辑需要访问用户画像等外部状态,每次请求都必须去查询外部数据库(如Redis),这会重新引入网络延迟,尽管通常比服务间HTTP调用要快。设计时必须仔细权衡。
- 技术栈锁定: 将业务逻辑的一部分耦合到API网关上,增加了对特定网关技术(Kong)的依赖。未来的架构迁移可能会因此变得更加困难。这是一个典型的用架构灵活性换取极致性能的权衡。