Most embedded databases give you two verbs and a shrug. Put a value. Get a value.
Anything cleverer than that, filtering, transforming, combining reads, you do in
your application language, which means pulling data across the boundary, working on
it, and pushing it back.
That round-trip is fine until the logic gets interesting. Increment a counter
atomically. Read a document, embed it, store the vector. Do a similarity search and
feed the top hit into an LLM. Every one of those becomes several client calls with
your app code as the glue.
Liath takes a different bet. It is an embedded key-value store where the query
language is Lua, a real programming language, running next to the data. You send
logic to the data instead of dragging the data to your logic.
The core idea
The pitch is three lines to a working database:
from liath import EmbeddedLiath
db = EmbeddedLiath(data_dir="./data")
db.put("user:1", '{"name": "Alice", "role": "admin"}')
print(db.get("user:1"))
db.close()
That is the boring half. The interesting half is that you can hand Liath a Lua
script and it runs against the store in one call, with the db object and a set of
plugins available inside the script:
db.execute_lua('''
-- Store data
db:put("counter", "0")
-- Read and modify
local count = tonumber(db:get("counter"))
db:put("counter", tostring(count + 1))
return db:get("counter")
''')
The read, the modify, and the write happen server-side in one execution instead of
three Python calls. Lua is a good choice for this. It is tiny, it embeds cleanly,
and it is the same language people already accept inside Redis and Nginx for exactly
this reason.
How it works
Liath is a Python package with a pluggable storage backend and a plugin system that
extends the Lua runtime.
Storage is not fixed. You get LevelDB for development and RocksDB for production, and
a storage_type of auto, rocksdb, or leveldb picks the engine. For a
production deployment you point it explicitly:
db = EmbeddedLiath(
data_dir="/var/lib/liath",
storage_type="rocksdb"
)
The capabilities that make Liath more than a KV wrapper come from plugins, exposed
inside Lua under the plugins table. Some are always present, others you install
as extras:
| Plugin | Function | Install |
|---|---|---|
db |
Core CRUD | Included |
file |
File read/write | Included |
cache |
Query result caching | Included |
backup |
Backup/restore | Included |
monitor |
System monitoring | Included |
embed |
Text/image embeddings | liath[embed] |
vdb |
Vector similarity search | liath[vdb] |
llm |
LLM completions/chat | liath[llm] |
So pip install liath[embed,vdb,llm] turns a key-value store into something that
can embed text, index vectors, and call a model, all reachable from a Lua script.
Namespaces give you multi-tenant isolation without running separate databases. You
create a namespace, switch to it, and keys written there do not collide with another
namespace:
db.create_namespace("production")
db.set_namespace("production")
db.put("config", '{"debug": false}')
db.set_namespace("development")
db.put("config", '{"debug": true}') # Separate from production
You can also write your own plugin in Python by subclassing PluginBase and
exposing Lua-callable functions, then load it with a plugins_dir. That is the
escape hatch when the built-ins are not enough.
RAG without leaving the database
The example that shows why co-locating logic and data matters is retrieval-augmented
generation. In most stacks that is an orchestration script gluing an embedding
service, a vector database, and an LLM API. In Liath the README does it in one Lua
script per phase.
Indexing creates a vector index, stores a document, embeds it, and adds the vector:
db.execute_lua('''
local json = require("cjson")
plugins.vdb.vdb_create_index("docs", 384)
local text = "Liath is a programmable database with Lua queries."
db:put("doc:1", text)
local emb = json.decode(plugins.embed.embed(text)).embedding
plugins.vdb.vdb_add("docs", "doc:1", emb)
return "Indexed"
''')
Querying embeds the question, searches for neighbours, pulls the matching document,
builds a prompt, and calls the model, all in one server-side script:
answer = db.execute_lua('''
local json = require("cjson")
local query = "What is Liath?"
local q_emb = json.decode(plugins.embed.embed(query)).embedding
local results = json.decode(plugins.vdb.vdb_search("docs", q_emb, 3))
local context = db:get("doc:" .. results.results[1].id)
local prompt = "Context: " .. context .. "\\n\\nQuestion: " .. query
return plugins.llm.llm_complete(prompt)
''')
The embedding backend is FastEmbed, the vector search is USearch, and the LLM plugin
targets OpenAI or Llama. The point is not that any one of those is novel. It is that
the retrieve, augment, generate loop runs where the data already sits.
Where it does not fit
Now the honest part.
Liath is single-node and embedded. There is a liath-server with an HTTP API and a
liath-cli, but the architecture is an embedded store, not a distributed cluster.
If you need horizontal scale, replication, or failover, this is the wrong tool.
Running application logic inside the database is a trade you should make with your
eyes open. It is the same tension people have argued about with Redis Lua and
Postgres stored procedures for years. The logic is fast because it is next to the
data, but it also lives in a place that is harder to test, version, and debug than
your normal application code. A Lua string in a Python file is not a first-class
citizen of your test suite unless you make it one.
The AI plugins are powerful and they are also new surface area. Embeddings, vector
search, and LLM calls each pull in their own dependency and their own failure modes.
An LLM call from inside a Lua script still has network latency, rate limits, and
cost. Co-location removes round-trips between your services. It does not remove the
model API on the far side.
And Lua is a real language, which means Lua is real rope. Expressive server-side
scripting is exactly the kind of power that turns into an unmaintainable pile if the
team does not treat those scripts with the same discipline as the rest of the code.
Takeaways
- Sending logic to the data instead of data to the logic is a genuinely different shape for an embedded store, and Lua is a proven choice for it.
- The plugin model is the real story. Optional
embed,vdb, andllmextras let the same KV store host a full RAG loop. - Pluggable RocksDB or LevelDB backends and namespace isolation make it more production-shaped than a toy, within single-node limits.
- Treat in-database Lua like stored procedures: powerful, fast, and easy to abuse. Test them, version them, or regret them.
Install it, the code, and the plugin reference are here:
https://github.com/incredlabs/liath
If you are running a RAG pipeline as three separate services today, I would like to
know whether collapsing it into one embedded store is a relief or a footgun for your
workload. Try pip install liath[embed,vdb,llm], run the RAG example, and tell me
where it breaks.
Top comments (0)