DEV Community

Dipankar Sarkar
Dipankar Sarkar

Posted on

A key-value store where the query language is Lua, and you can build RAG inside it

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()
Enter fullscreen mode Exit fullscreen mode

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")
''')
Enter fullscreen mode Exit fullscreen mode

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"
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
''')
Enter fullscreen mode Exit fullscreen mode

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)
''')
Enter fullscreen mode Exit fullscreen mode

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, and llm extras 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)