Skip to content

Commit 7fd4515

Browse files
authored
feat(ai-cache): add semantic (L2) cache layer (#13632)
1 parent b3182a5 commit 7fd4515

17 files changed

Lines changed: 3167 additions & 33 deletions

File tree

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,10 @@ install: runtime
403403

404404
$(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/ai-cache
405405
$(ENV_INSTALL) apisix/plugins/ai-cache/*.lua $(ENV_INST_LUADIR)/apisix/plugins/ai-cache
406+
$(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/ai-cache/embeddings
407+
$(ENV_INSTALL) apisix/plugins/ai-cache/embeddings/*.lua $(ENV_INST_LUADIR)/apisix/plugins/ai-cache/embeddings
408+
$(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/ai-cache/vector-search
409+
$(ENV_INSTALL) apisix/plugins/ai-cache/vector-search/*.lua $(ENV_INST_LUADIR)/apisix/plugins/ai-cache/vector-search
406410

407411
$(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/ai-lakera-guard
408412
$(ENV_INSTALL) apisix/plugins/ai-lakera-guard/*.lua $(ENV_INST_LUADIR)/apisix/plugins/ai-lakera-guard

apisix/plugins/ai-cache.lua

Lines changed: 107 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,21 @@ local schema = require("apisix.plugins.ai-cache.schema")
2020
local key_mod = require("apisix.plugins.ai-cache.key")
2121
local binding = require("apisix.plugins.ai-protocols.binding")
2222
local redis_util = require("apisix.utils.redis")
23+
local semantic = require("apisix.plugins.ai-cache.semantic")
2324

2425
local ngx = ngx
2526
local ngx_null = ngx.null
2627
local ipairs = ipairs
28+
local pcall = pcall
2729
local concat = table.concat
30+
local str_format = string.format
2831

29-
local CACHE_STATUS_HEADER = "X-AI-Cache-Status"
30-
local CACHE_AGE_HEADER = "X-AI-Cache-Age"
31-
local DEFAULT_TTL = 3600
32-
local DEFAULT_MAX_BODY = 1048576
32+
local CACHE_STATUS_HEADER = "X-AI-Cache-Status"
33+
local CACHE_AGE_HEADER = "X-AI-Cache-Age"
34+
local CACHE_SIMILARITY_HEADER = "X-AI-Cache-Similarity"
35+
local DEFAULT_TTL = 3600
36+
local DEFAULT_MAX_BODY = 1048576
37+
local DEFAULT_SEMANTIC_TTL = 86400
3338

3439
local _M = {
3540
version = 0.1,
@@ -39,8 +44,31 @@ local _M = {
3944
}
4045

4146

47+
local function has_layer(conf, name)
48+
local layers = conf.layers or { "exact" }
49+
for _, l in ipairs(layers) do
50+
if l == name then
51+
return true
52+
end
53+
end
54+
return false
55+
end
56+
57+
4258
function _M.check_schema(conf)
43-
return core.schema.check(schema, conf)
59+
core.utils.check_https({
60+
"semantic.embedding.openai.endpoint",
61+
"semantic.embedding.azure_openai.endpoint",
62+
}, conf, _M.name)
63+
local ok, err = core.schema.check(schema, conf)
64+
if not ok then
65+
return false, err
66+
end
67+
if conf.semantic and not has_layer(conf, "semantic") then
68+
core.log.warn("ai-cache: 'semantic' is configured but not listed in ",
69+
"'layers'; the semantic (L2) cache is inactive")
70+
end
71+
return true
4472
end
4573

4674

@@ -53,12 +81,17 @@ local function release(conf, red)
5381
end
5482

5583

56-
local function serve_hit(conf, ctx, cached)
57-
ctx.ai_cache_status = "HIT"
84+
local function serve_hit(conf, ctx, cached, similarity)
85+
local status = "HIT"
86+
ctx.ai_cache_status = status
5887
if conf.cache_headers ~= false then
59-
core.response.set_header(CACHE_STATUS_HEADER, "HIT")
88+
core.response.set_header(CACHE_STATUS_HEADER, status)
6089
local age = ngx.time() - (cached.created_at or ngx.time())
6190
core.response.set_header(CACHE_AGE_HEADER, age < 0 and 0 or age)
91+
if similarity then
92+
core.response.set_header(CACHE_SIMILARITY_HEADER,
93+
str_format("%.4f", similarity))
94+
end
6295
end
6396
core.response.set_header("Content-Type", "application/json")
6497
return core.response.exit(200, cached.body)
@@ -126,16 +159,56 @@ function _M.access(conf, ctx)
126159
ctx.ai_cache_status = "MISS"
127160
return
128161
end
129-
release(conf, red)
130-
131162
if res ~= nil and res ~= ngx_null then
132163
local cached = core.json.decode(res)
133164
if cached and cached.body then
165+
release(conf, red)
134166
return serve_hit(conf, ctx, cached)
135167
end
136168
core.log.warn("ai-cache: discarding malformed cache entry for ", ctx.ai_cache_key)
137169
end
138170

171+
-- L1 miss -> L2 semantic lookup. Release the L1 connection before
172+
-- embed_query()'s HTTP call so the pool isn't pinned across the embedding
173+
-- round-trip; re-acquire for the vector search. pcall keeps throws fail-open.
174+
if has_layer(conf, "semantic") and conf.semantic then
175+
release(conf, red)
176+
177+
local ok, vec = pcall(semantic.embed_query, conf, ctx, body)
178+
if not ok then
179+
core.log.warn("ai-cache: semantic embed error, fail-open as MISS: ", vec)
180+
vec = nil
181+
-- prevent log() from scheduling a write with partial/bad state
182+
ctx.ai_cache_embedding = nil
183+
end
184+
185+
if vec then
186+
local sred
187+
sred, err = redis_util.new(conf)
188+
if not sred then
189+
core.log.warn("ai-cache: redis unavailable for semantic search, ",
190+
"fail-open as MISS: ", err)
191+
else
192+
local sok, hit = pcall(semantic.search, sred, conf, ctx, vec)
193+
if not sok then
194+
sred:close()
195+
core.log.warn("ai-cache: semantic search error, fail-open as MISS: ", hit)
196+
ctx.ai_cache_embedding = nil
197+
else
198+
release(conf, sred)
199+
if hit then
200+
return serve_hit(conf, ctx,
201+
{ body = hit.body, created_at = hit.created_at }, hit.similarity)
202+
end
203+
end
204+
end
205+
end
206+
207+
ctx.ai_cache_status = "MISS"
208+
return
209+
end
210+
211+
release(conf, red)
139212
ctx.ai_cache_status = "MISS"
140213
end
141214

@@ -174,7 +247,8 @@ end
174247
-- The response-capturing phases (body_filter / log) run in contexts where
175248
-- cosockets are disabled, so the Redis write is deferred to a 0-delay timer
176249
-- (timers run in a light thread where cosockets are allowed).
177-
local function write_to_cache(premature, conf, cache_key, response_body)
250+
-- l2 (optional) = { partition, embedding, dim, fingerprint, ttl } for L2 write.
251+
local function write_to_cache(premature, conf, cache_key, response_body, l2)
178252
if premature then
179253
return
180254
end
@@ -192,6 +266,13 @@ local function write_to_cache(premature, conf, cache_key, response_body)
192266
core.log.warn("ai-cache: redis set failed: ", err)
193267
return
194268
end
269+
if l2 then
270+
l2.created_at = ngx.time()
271+
local wok, werr = pcall(semantic.write, red, conf, l2, response_body)
272+
if not wok then
273+
core.log.warn("ai-cache: semantic write error: ", werr)
274+
end
275+
end
195276
release(conf, red)
196277
end
197278

@@ -218,7 +299,21 @@ function _M.log(conf, ctx)
218299
local response_body = concat(buf, "", 1, buf.n)
219300

220301
local cache_key = key_mod.build(conf, ctx, ctx.ai_cache_fingerprint)
221-
local ok, err = ngx.timer.at(0, write_to_cache, conf, cache_key, response_body)
302+
303+
-- Build the L2 doc from ctx fields stashed by semantic.embed_query(); the
304+
-- embedding is only set on a successful embed, so a nil check guards the write.
305+
local l2
306+
if has_layer(conf, "semantic") and ctx.ai_cache_embedding then
307+
l2 = {
308+
partition = ctx.ai_cache_partition,
309+
embedding = ctx.ai_cache_embedding,
310+
dim = ctx.ai_cache_dim,
311+
fingerprint = ctx.ai_cache_fingerprint,
312+
ttl = (conf.semantic and conf.semantic.ttl) or DEFAULT_SEMANTIC_TTL,
313+
}
314+
end
315+
316+
local ok, err = ngx.timer.at(0, write_to_cache, conf, cache_key, response_body, l2)
222317
if not ok then
223318
core.log.warn("ai-cache: failed to schedule cache write: ", err)
224319
end
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
--
2+
-- Licensed to the Apache Software Foundation (ASF) under one or more
3+
-- contributor license agreements. See the NOTICE file distributed with
4+
-- this work for additional information regarding copyright ownership.
5+
-- The ASF licenses this file to You under the Apache License, Version 2.0
6+
-- (the "License"); you may not use this file except in compliance with
7+
-- the License. You may obtain a copy of the License at
8+
--
9+
-- http://www.apache.org/licenses/LICENSE-2.0
10+
--
11+
-- Unless required by applicable law or agreed to in writing, software
12+
-- distributed under the License is distributed on an "AS IS" BASIS,
13+
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
-- See the License for the specific language governing permissions and
15+
-- limitations under the License.
16+
--
17+
local base = require("apisix.plugins.ai-cache.embeddings.base")
18+
19+
local _M = {}
20+
21+
-- get_embeddings(conf, text, httpc, ssl_verify) -> (vector_table, err)
22+
function _M.get_embeddings(conf, text, httpc, ssl_verify)
23+
local req = { input = text }
24+
if conf.dimensions then
25+
req.dimensions = conf.dimensions
26+
end
27+
return base.fetch({
28+
endpoint = conf.endpoint,
29+
headers = {
30+
["Content-Type"] = "application/json",
31+
["api-key"] = conf.api_key,
32+
},
33+
request = req,
34+
httpc = httpc,
35+
ssl_verify = ssl_verify,
36+
})
37+
end
38+
39+
return _M
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
--
2+
-- Licensed to the Apache Software Foundation (ASF) under one or more
3+
-- contributor license agreements. See the NOTICE file distributed with
4+
-- this work for additional information regarding copyright ownership.
5+
-- The ASF licenses this file to You under the Apache License, Version 2.0
6+
-- (the "License"); you may not use this file except in compliance with
7+
-- the License. You may obtain a copy of the License at
8+
--
9+
-- http://www.apache.org/licenses/LICENSE-2.0
10+
--
11+
-- Unless required by applicable law or agreed to in writing, software
12+
-- distributed under the License is distributed on an "AS IS" BASIS,
13+
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
-- See the License for the specific language governing permissions and
15+
-- limitations under the License.
16+
--
17+
18+
-- Shared request/parse core for the embeddings drivers. Each provider differs
19+
-- only in its request shape, auth header, and default endpoint; the HTTP call,
20+
-- status check, and data[1].embedding extraction live here so a parsing fix
21+
-- lands in one place.
22+
local core = require("apisix.core")
23+
local type = type
24+
25+
local HTTP_OK = ngx.HTTP_OK
26+
27+
local _M = {}
28+
29+
-- fetch(opts) where opts = { endpoint, headers, request, httpc, ssl_verify }
30+
-- -> (vector_table, err)
31+
function _M.fetch(opts)
32+
local payload, err = core.json.encode(opts.request)
33+
if not payload then
34+
return nil, "encode embeddings request: " .. (err or "")
35+
end
36+
37+
local res
38+
res, err = opts.httpc:request_uri(opts.endpoint, {
39+
method = "POST",
40+
headers = opts.headers,
41+
body = payload,
42+
ssl_verify = opts.ssl_verify,
43+
})
44+
if not res or not res.body then
45+
return nil, "embeddings request failed: " .. (err or "")
46+
end
47+
if res.status ~= HTTP_OK then
48+
return nil, "embeddings endpoint returned " .. res.status
49+
end
50+
51+
local decoded = core.json.decode(res.body)
52+
if not decoded or type(decoded.data) ~= "table" or type(decoded.data[1]) ~= "table"
53+
or type(decoded.data[1].embedding) ~= "table" then
54+
return nil, "malformed embeddings response"
55+
end
56+
return decoded.data[1].embedding
57+
end
58+
59+
return _M
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
--
2+
-- Licensed to the Apache Software Foundation (ASF) under one or more
3+
-- contributor license agreements. See the NOTICE file distributed with
4+
-- this work for additional information regarding copyright ownership.
5+
-- The ASF licenses this file to You under the Apache License, Version 2.0
6+
-- (the "License"); you may not use this file except in compliance with
7+
-- the License. You may obtain a copy of the License at
8+
--
9+
-- http://www.apache.org/licenses/LICENSE-2.0
10+
--
11+
-- Unless required by applicable law or agreed to in writing, software
12+
-- distributed under the License is distributed on an "AS IS" BASIS,
13+
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
-- See the License for the specific language governing permissions and
15+
-- limitations under the License.
16+
--
17+
local base = require("apisix.plugins.ai-cache.embeddings.base")
18+
19+
local _M = {}
20+
21+
_M.DEFAULT_ENDPOINT = "https://api.openai.com/v1/embeddings"
22+
23+
-- get_embeddings(conf, text, httpc, ssl_verify) -> (vector_table, err)
24+
function _M.get_embeddings(conf, text, httpc, ssl_verify)
25+
local req = { model = conf.model, input = text }
26+
if conf.dimensions then
27+
req.dimensions = conf.dimensions
28+
end
29+
return base.fetch({
30+
endpoint = conf.endpoint or _M.DEFAULT_ENDPOINT,
31+
headers = {
32+
["Content-Type"] = "application/json",
33+
["Authorization"] = "Bearer " .. conf.api_key,
34+
},
35+
request = req,
36+
httpc = httpc,
37+
ssl_verify = ssl_verify,
38+
})
39+
end
40+
41+
return _M

0 commit comments

Comments
 (0)