Paste this into Claude Code, Codex, Cursor or any agent that can fetch a page and run a command.
Download and install Inillucent on this machine. It is an embedded SQL database with vector and keyword search, in one file, with an MCP server.
1. Fetch https://inillucent.com/install.md and follow the steps for my operating system.
2. Confirm it worked by running `inillucent --version`.
3. Register the MCP server with this agent using the MCP section of that page.
4. Then tell me the version you installed, where the four programs are, and the command that creates my first database.
01
What Inillucent is
Inillucent is an embedded SQL database written in Rust. One file holds your tables, your full text indexes and your vector indexes. The engine runs inside your program.
Embedded means there is no database server to install or keep running. Your program opens a file, sends SQL to a library in the same process, and gets typed rows back. You can use the same engine from a terminal with inillucent-shell.
The SQL dialect follows SQLite 3.53.4. If you have used SQLite, most statements will look familiar. If SQL is new to you, read the next four chapters and type each example in order.
The examples in these chapters build one database, one chapter after another. Every result shown was printed by the engine when the examples ran in that order.
Tables are stored in B+trees. A reader never sees half of a transaction. Every change goes to a write ahead log before it reaches the file.
A VECTOR(N) column and an inillucent_hnsw index give you nearest neighbor search in ordinary SQL.
The inillucent_search table combines keyword search and vector search. It reports a confidence value, so your application can decide to return no answer.
A feature the engine has not built fails with the status unsupported. You get an error, never an approximate answer.
The first query
This SELECT needs no table. Run it to check that the shell and the engine work.
SQL / input
SELECT 'Inillucent is ready' AS status;
Resultobserved
| status |
|---------------------|
| Inillucent is ready |
02
Install and open a database
One command installs the four programs. Then you give inillucent-shell a file name. The shell creates the file when it does not exist and opens it when it does.
The install commands are in the first example below. Each script downloads the archive for your machine, checks its SHA-256 against the published SHA256SUMS file, and puts the programs on your PATH. Neither script needs administrator rights, and neither writes outside your home folder.
macOS gets one universal archive that runs on Apple silicon and on Intel. Linux gets archives for x86-64 and ARM. The downloads page also has a signed macOS installer package and .deb and .rpm packages. Homebrew, npm, pip, Go, Composer and cargo can install the programs too. https://inillucent.com/install.md lists every way to install.
You get four programs. inillucent is the command line, with 35 commands. inillucent-shell is a shell that works like sqlite3. inillucent-mcp serves a database to an AI agent, with 30 of the 35 commands as MCP tools. inillucent-migrate builds a database from a legacy retrieval index.
The .rdb suffix is a convention. Any file name works, and the path can be relative or absolute. Keep the file on a local disk. Several processes can open the same file. PRAGMA locking_mode is normal by default, which lets processes take turns on the file. PRAGMA locking_mode = exclusive keeps the file for one process.
Each command of inillucent takes --output json, which prints typed rows a program can parse. inillucent help lists the commands, and inillucent help <command> explains one command and every parameter it takes. inillucent-mcp serves 30 of the commands as MCP tools, each named inillucent_ and the command name. The other five run only from the command line. encrypt, decrypt and rekey handle the key, which is a decision for the person who holds the key. shell reads a keyboard, and run drives the same shell for an agent. mcp is the server itself.
The commands of inillucent, and the MCP tool for each
Command
What it does
MCP tool
query
Run a SELECT and get its rows back.
inillucent_query
exec
Run one statement that changes something, and get the row count back.
inillucent_exec
batch
Run several statements as one transaction.
inillucent_batch
run
Run shell input, dot commands and all, and get back what it printed.
inillucent_run
create
Make a new, empty database file.
inillucent_create
tables
List the tables and views.
inillucent_tables
describe
Everything about one table: columns, types, keys, indexes, row count and DDL.
inillucent_describe
schema
Show the CREATE statements for the whole database or for what a pattern names.
inillucent_schema
indexes
List the indexes and the table each one is on.
inillucent_indexes
databases
List the attached databases and the file behind each.
inillucent_databases
explain
Show the query plan for a statement without running it.
inillucent_explain
import
Load a CSV or tab separated file into a table.
inillucent_import
embed
Fill a vector column with embeddings, on the processor or a graphics card.
inillucent_embed
export
Write a table or a query's rows out as CSV, JSON or one of six other formats.
inillucent_export
dump
Render the database as the SQL that would rebuild it.
inillucent_dump
backup
Write a copy of the database to another file.
inillucent_backup
encrypt
Write an encrypted copy of a plaintext database.
none
decrypt
Write a plaintext copy of an encrypted database.
none
rekey
Change the key an encrypted database is encrypted with.
none
restore
Point this session at a backup file, in place of the database it opened.
inillucent_restore
checkpoint
Fold the write ahead log back into the database file.
inillucent_checkpoint
integrity-check
Read every page and report whether the database holds together.
inillucent_integrity_check
analyze
Gather the statistics the query planner reads.
inillucent_analyze
stats
Report the page cache, the pool size and the layout of the file.
inillucent_stats
search
Full text search over an FTS5 or inillucent_search table.
inillucent_search
vector-search
Find the rows whose vector is nearest to one you supply.
inillucent_vector_search
capabilities
Ask what this engine can do before composing a statement.
inillucent_capabilities
functions
List the SQL functions this engine answers.
inillucent_functions
migrate
Build an inillucent database from a SQLite file, PostgreSQL or MySQL.
inillucent_migrate
setup-embeddings
Download and install the embedding model and the runtime it needs.
inillucent_setup_embeddings
tokenizer
List, install or remove the tokenizer packs a search table can name. See Tokenizer packs.
inillucent_tokenizer
version
Report the engine, the dialect and the driver versions.
inillucent_version
help
List every command, or explain one in full.
inillucent_help
shell
Start the interactive shell.
none
mcp
Serve these commands to an agent over MCP on standard input and output.
none
Install with one command
The first line is for Windows, in PowerShell or Command Prompt. The second line is for macOS and Linux. Run the line for your machine. Then open a new terminal and ask for the version.
From a checkout of the repository, the first command builds the release programs. The release profile uses full link time optimization and one code generation unit, the same settings the performance measurements use. The -version flag prints the SQLite version the shell follows and the engine under it. Leave out -version to get the prompt, then type .help to list the shell commands.
SQLite 3.53.4
inillucent (a first-party engine implementing the SQLite ABI)
03
Use the shell
inillucent-shell works like sqlite3. It reads SQL from its prompt, from an argument, or from standard input. It prints results as a table, CSV, JSON, Markdown and other formats.
A line that starts with a dot is a shell command. The shell handles it and does not send it to the SQL engine. Every other line is SQL. While you learn, turn on .headers on and .mode table, because column names make a result easier to check.
The shell answers 63 of SQLite's 65 dot commands. The two it does not have are .expert and .session. A SQLite .dump of a database with an AUTOINCREMENT table or an FTS5 table loads into inillucent-shell, and the FTS5 table arrives with its rows and answers MATCH in the same session. The shell prints the rows of a statement as the engine produces them in the list, csv, tabs, quote, ascii, tcl, c, jatom, line, insert, json, jobject, html and count modes. The modes that size each column to the widest value, which are column, table, box, markdown, qbox, psql and split, hold the whole result. inillucent query keeps the rows it prints, up to --limit, and counts the rest, so total is exact and memory stays small. --limit 0 holds the result. A statement that fails partway prints the rows produced before the error, then the error. .dump and inillucent dump work on a table name that has characters of several bytes.
The -bail option stops a script at the first error. Use -bail in scripts. Without -bail, a script that fails at a CREATE TABLE keeps going, and each later error points at the wrong cause.
.help lists the shell commands.
.tables lists the tables and views in the open database.
.schema prints the CREATE statement stored for each object.
.mode sets the output format for every result after it.
.parameter sets values that later statements can bind.
.quit closes the database and leaves the shell.
Run one query without entering the prompt
Flags can be combined. This command prints one Markdown table with a header row, then exits.
SQL / input
inillucent-shell -markdown -header library.rdb "SELECT 2 + 2 AS answer;"
Terminalobserved
| answer |
|--------|
| 4 |
04
Values, types and NULL
Every SQL value is NULL, an integer, a real number, text or a blob. A column type says how a value is converted when it is stored. In a STRICT table, the column type also says which values the column accepts.
NULL means missing or unknown. NULL is different from 0 and from an empty string. Test for NULL with IS NULL and IS NOT NULL. The comparison NULL = NULL gives NULL, and a WHERE clause treats NULL as false.
Ordinary tables use SQLite type affinity. Affinity converts a value to the column type when the conversion loses nothing. For example, the text 42 becomes the integer 42 in an INTEGER column. A STRICT table applies affinity first, then rejects any value that is still the wrong type.
INTEGER holds a signed whole number of up to 64 bits.
REAL holds a 64 bit floating point number.
TEXT holds a string of UTF-8 bytes.
BLOB holds bytes exactly as you gave them.
VECTOR(N) holds exactly N 32 bit floating point numbers, stored little endian.
ANY, in a STRICT table, accepts every type of value.
See the storage class of each value
typeof() returns the type of each stored value, after the column's affinity converted it. The text 42 became an integer, the integer 3 became a real number, and the integer 7 became text.
SQL / input
CREATE TABLE measurements (whole INTEGER, decimal REAL, note TEXT, missing TEXT);
INSERT INTO measurements VALUES ('42', 3, 7, NULL);
SELECT typeof(whole), typeof(decimal), typeof(note), missing IS NULL AS is_missing
FROM measurements;
Resultobserved
| typeof(whole) | typeof(decimal) | typeof(note) | is_missing |
|---------------|-----------------|--------------|------------|
| integer | real | text | 1 |
05
Tables and constraints
A table defines the columns every row has. Constraints reject a row that breaks a rule, such as a missing value, a repeated name, a value out of range, or a reference to a row that does not exist.
A column declared INTEGER PRIMARY KEY is the row's key. When an INSERT leaves the key out, the engine picks the next number. AUTOINCREMENT stops the engine from reusing the key of a deleted row. WITHOUT ROWID stores the table by its declared primary key. Use WITHOUT ROWID when the natural key is not an integer.
Foreign keys are checked only when PRAGMA foreign_keys is ON. The default is OFF, as in SQLite. A foreign key check runs at once, or at the commit when the key is declared DEFERRABLE INITIALLY DEFERRED. ON UPDATE and ON DELETE accept CASCADE, SET NULL, SET DEFAULT, RESTRICT and NO ACTION.
NOT NULL requires a value in every row.
UNIQUE stops two rows from having the same values in the listed columns.
CHECK requires its expression to be true or NULL.
DEFAULT gives the value to use when an INSERT leaves the column out.
STRICT rejects a value that cannot be converted to the declared type.
Generated columns, composite keys and named constraints use SQLite syntax.
A row can have 2,000 integer columns on a 32 KiB page when the values are small. Once the integers of a row would take more than half a page, each is written in the fewest bytes that hold it, which fits about 3,200 columns of small numbers. A row of integers that need eight bytes each holds about 1,925 columns. The first such wide row moves the file to format 4.
Create two related tables
Each book refers to an author. The DEFAULT and CHECK constraints apply to every new row. The result also lists measurements, the table from chapter 04, because all the chapters build one database in order.
SQL / input
PRAGMA foreign_keys = ON;
CREATE TABLE authors (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE books (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
author_id INTEGER NOT NULL REFERENCES authors(id),
year INTEGER NOT NULL,
rating REAL DEFAULT 0 CHECK (rating BETWEEN 0 AND 5)
) STRICT;
SELECT name, type FROM sqlite_schema WHERE type = 'table' ORDER BY name;
INSERT adds rows. UPDATE changes the rows you select. DELETE removes the rows you select. A RETURNING clause gives you back the rows each statement changed.
The WHERE clause decides which rows UPDATE and DELETE change. With no WHERE clause, they change every row in the table. Before you run a new UPDATE or DELETE, run a SELECT with the same WHERE clause and check the rows it returns.
ON CONFLICT decides what happens when an INSERT would repeat a unique value. DO NOTHING skips the new row. DO UPDATE changes the row that is already there. INSERT OR IGNORE and INSERT OR REPLACE work as they do in SQLite. The ON CONFLICT target can be a column list, a partial unique index (its WHERE must match the index) or an expression such as ON CONFLICT(lower(name)). DO UPDATE runs the CHECK, NOT NULL and STRICT checks and fires the UPDATE triggers of the table.
DELETE and UPDATE also accept ORDER BY and LIMIT. A loop of DELETE ... LIMIT 1000 removes many rows in small transactions. SQLite accepts this form only when it is compiled with SQLITE_ENABLE_UPDATE_DELETE_LIMIT.
INSERT ... SELECT reads all of its source before it writes a row. Source rows past 32 MiB go to a temporary file as they arrive and are read back in order, so a copy does not need memory for every row. If a row fails, every earlier row of the statement is put back.
Write rows and return the change
The authors go in first, because each book refers to an author and foreign keys are on. The last statement updates one row and returns that row as it was stored.
SQL / input
INSERT INTO authors (id, name) VALUES
(1, 'Octavia Butler'),
(2, 'Ursula Le Guin'),
(3, 'Ted Chiang');
INSERT INTO books (id, title, author_id, year, rating)
VALUES (1, 'Kindred', 1, 1979, 4.8),
(2, 'Parable of the Sower', 1, 1993, 4.7),
(3, 'The Left Hand of Darkness', 2, 1969, 4.6),
(4, 'Stories of Your Life', 3, 2002, 4.9);
UPDATE books SET rating = 4.9 WHERE id = 1
RETURNING id, title, rating;
Resultobserved
| id | title | rating |
|----|---------|--------|
| 1 | Kindred | 4.9 |
Upsert a row
excluded is the row the INSERT tried to add. This statement adds the author, or changes the name when a row with the same id already exists.
SQL / input
INSERT INTO authors (id, name) VALUES (1, 'Octavia E. Butler')
ON CONFLICT(id) DO UPDATE SET name = excluded.name
RETURNING id, name;
Resultobserved
| id | name |
|----|-------------------|
| 1 | Octavia E. Butler |
07
Select, filter and sort rows
SELECT names the rows and columns you want. FROM picks the table. WHERE filters the rows. ORDER BY sorts them. LIMIT sets how many rows come back.
A SELECT describes the answer you want. It does not say how to find the answer. The query planner decides whether to scan the table, search an index, or sort in a temporary table. Chapter 11 shows how to see the plan the planner picked.
LIKE matches text patterns. In a LIKE pattern, % matches any run of characters and _ matches one character. GLOB uses * and ? the way a Unix shell does. COLLATE NOCASE compares and sorts ASCII letters without regard to case. COLLATE RTRIM ignores spaces at the end of a string.
DISTINCT removes duplicate rows from the result.
LIMIT n OFFSET m skips m rows and returns the next n. LIMIT and OFFSET take any expression that reads no row, such as LIMIT 1 + 1 or a bound parameter. A value that is not an integer, such as 2.7 or NULL, is refused with datatype mismatch, as SQLite refuses it.
CASE picks a value based on conditions.
CAST converts a value to the type you name.
IN tests a value against a list. BETWEEN tests a value against a range.
A parameter written ?1 or $1 binds the first value, ?2 or $2 the second. $N binds by its number, as PostgreSQL does, whatever order the placeholders appear in.
IS and IS NOT compare two values and treat two NULLs as equal.
Find recent books
WHERE removes the older books before ORDER BY sorts the rest. Only the columns you name appear in the result.
SQL / input
SELECT title, year
FROM books
WHERE year >= 1990
ORDER BY year;
Resultobserved
| title | year |
|----------------------|------|
| Parable of the Sower | 1993 |
| Stories of Your Life | 2002 |
08
Joins, subqueries and CTEs
A join combines rows from two tables. A subquery is a query inside another query. A common table expression, or CTE, gives a query a name for the length of one statement.
INNER JOIN returns the pairs of rows that match. LEFT JOIN also keeps the rows of the left table that have no match. RIGHT JOIN keeps the unmatched rows of the right table. FULL OUTER JOIN keeps the unmatched rows of both. CROSS JOIN returns every possible pair, so use CROSS JOIN only on small tables.
A subquery can appear in WHERE, in FROM, or in place of a value. A correlated subquery refers to the current row of the outer query. WITH RECURSIVE runs a query again on its own output until the query adds no new rows. Use WITH RECURSIVE to walk a tree of rows or to generate a sequence.
A row value such as (a, b) on the left of IN takes a list of values or a subquery, as in (a, b) IN (SELECT x, y FROM s). A window function inside a subquery in FROM, a CTE or a view runs, so a rank computed in an inner query can be filtered in an outer one.
When a join or a correlated subquery compares a column that has no index, the engine reads the other table once into a hash table and looks up each outer row in it. This applies to a correlated EXISTS, NOT EXISTS or scalar subquery, and to a join whose ON clause has conditions beside the equality, such as ON a.k = b.k AND a.id < b.id, or an expression on one side of the equality, such as ON a.k = b.k + 1. The hash join tests the rest of the condition for each pair it finds, so LEFT, RIGHT and FULL joins keep the rows that nothing matched. EXPLAIN QUERY PLAN shows the automatic index. PRAGMA automatic_index = OFF turns the hash table off, and the engine then scans the table for each outer row. A subquery inside UPDATE, DELETE or INSERT is always scanned. A correlated subquery whose one table is a view or a derived table that reads nothing from the outer row, such as a view with a GROUP BY, runs the query of the view once and looks up each outer row in the result. A comparison against an expression of the outer row, such as agg.s = o.v * 5, runs the query of the view for each outer row.
Join books to authors
The ON clause says how a row in books matches a row in authors.
SQL / input
SELECT b.title, a.name AS author
FROM books AS b
JOIN authors AS a ON a.id = b.author_id
ORDER BY b.year;
Resultobserved
| title | author |
|---------------------------|-------------------|
| The Left Hand of Darkness | Ursula Le Guin |
| Kindred | Octavia E. Butler |
| Parable of the Sower | Octavia E. Butler |
| Stories of Your Life | Ted Chiang |
Use a CTE and a subquery
The CTE named recent holds the books from 1990 on. The IN subquery finds the ids of the authors whose name contains Butler.
SQL / input
WITH recent AS (
SELECT * FROM books WHERE year >= 1990
)
SELECT title FROM recent
WHERE author_id IN (
SELECT id FROM authors WHERE name LIKE '%Butler%'
);
Resultobserved
| title |
|----------------------|
| Parable of the Sower |
09
Groups, windows and compound queries
An aggregate function turns many rows into one value. A window function computes a value for each row from the rows around it. A compound operator combines the results of two queries.
GROUP BY makes one result row for each group. HAVING filters the groups after the aggregates are computed. The aggregate functions include count, sum, total, avg, min, max, group_concat, string_agg, median, the percentile functions and the JSON group functions.
UNION combines two results and removes duplicate rows. UNION ALL keeps the duplicates. INTERSECT keeps the rows found in both results. EXCEPT keeps the rows of the first result that are not in the second.
A window function needs an OVER clause. OVER accepts PARTITION BY, ORDER BY, the ROWS, RANGE and GROUPS frames, and EXCLUDE. The eleven window functions are row_number, rank, dense_rank, percent_rank, cume_dist, ntile, lag, lead, first_value, last_value and nth_value. Each one gives the same answers as SQLite 3.53.4. A window function computes one partition at a time, so memory holds the largest partition and not the table. A partition larger than 32 MiB is computed a run of peer groups at a time when every call is row_number, rank, dense_rank or an aggregate over the default frame. A window with another call, such as ntile or lead, or a frame with following rows, holds one partition whole. DISTINCT, UNION, INTERSECT and EXCEPT keep their rows in a hash table while the keys and rows fit in 32 MiB, and sort them in runs in a temporary file past that. The rows and their order are the same either way. A window query with a LIMIT stops reading once the limit is met.
Summarize each author
COUNT and AVG run once for each author_id group. The average is rounded, because the mean of two ratings is a binary floating point number and prints with many digits.
SQL / input
SELECT author_id, COUNT(*) AS books,
ROUND(AVG(rating), 2) AS avg_rating
FROM books
GROUP BY author_id
ORDER BY author_id;
A transaction groups several statements into one change. COMMIT keeps all of the changes. ROLLBACK undoes all of them and returns the database to its state before BEGIN.
A savepoint marks a point inside a transaction. ROLLBACK TO undoes the changes made after the savepoint and keeps the transaction open. RELEASE removes the savepoint and keeps its changes. ROLLBACK TO also ends every savepoint opened after its target, so ROLLBACK TO s0 leaves only the savepoints opened before s0.
ROLLBACK restores everything the transaction changed, including after DROP TABLE and ALTER TABLE. With foreign keys on, BEGIN; DROP TABLE p; ROLLBACK brings p back with its rows. An INSERT, UPDATE or DELETE followed by ALTER TABLE ADD COLUMN or DROP COLUMN in one transaction is undone by ROLLBACK as well. A rolled back DROP of an FTS5 table or of a table with a vector index leaves the table usable, a rolled back DROP TABLE keeps the AUTOINCREMENT counter, and schema_version goes back too.
One writer works at a time. A second writer waits for up to PRAGMA busy_timeout milliseconds, 5000 by default, and then fails with busy. A reader in another process does not wait for a write transaction: it reads the last committed state, and never sees half of a transaction. Opening a file another process is writing waits too; set INILLUCENT_BUSY_TIMEOUT in milliseconds, or pass the wait to the open, to change how long. PRAGMA synchronous sets how often the engine waits for the disk: OFF, NORMAL or FULL. FULL is the default.
BEGIN and BEGIN DEFERRED take no lock, as in SQLite. The first statement inside the transaction takes the lock it needs, so a read transaction reads beside another process that has a write transaction open. BEGIN IMMEDIATE and BEGIN EXCLUSIVE take the write lock at once. A transaction that reads first and then writes fails with busy when another process committed after its first read. Roll it back and run it again, or start it with BEGIN IMMEDIATE.
A process that waits for a busy lock tries again every 2 or 3 milliseconds once it has waited 15 milliseconds. BEGIN IMMEDIATE from many processes at once is fairer, because a process that just committed no longer takes the lock again ahead of a process that has waited for seconds.
A connection can always end its transaction. ROLLBACK, a COMMIT of a transaction that wrote nothing, and pragmas that set only the connection, such as busy_timeout, take no lock, so they do not fail with busy while another connection writes the file. A transaction that has read and written nothing goes on after another connection runs VACUUM.
A write transaction does not need memory for every row it changes. The transaction keeps each changed row as it was before, so that a rollback can put it back. Those before images stay in memory until the transaction holds 16 MiB of them. Past that, the older images go to a temporary file next to the database, which only ROLLBACK and ROLLBACK TO read. A commit discards the file unread, and the operating system deletes it when the connection closes it. A transaction can therefore change more rows than fit in memory, and PRAGMA cache_size is not what limits it. VACUUM reads an ordinary table a page of rows at a time. CREATE INDEX and REINDEX hold at most 32 MiB of index entries in memory, write sorted runs to a temporary file past that, and merge the runs into the index. A UNIQUE index is checked before any page is written, so a failure leaves nothing behind. The shell command .import reads a block of the file at a time and inserts at most 20,000 rows in one statement.
Deferred foreign keys are checked at COMMIT.
A statement that fails keeps none of its rows, including one whose trigger broke a foreign key. Under OR FAIL it keeps the rows it wrote before the failure, as in SQLite.
Full text indexes and vector indexes commit and roll back with the rows they index.
A transaction that writes two attached files commits both files or neither. A super journal records which way it went.
When a database opens, recovery replays every complete record in the log. Running recovery twice gives the same result as running it once.
Keep one row and discard the draft
Bloodchild is inserted before the savepoint. The notes row is inserted after the savepoint, so ROLLBACK TO draft removes it. COMMIT keeps Bloodchild.
SQL / input
BEGIN;
INSERT INTO books VALUES (5, 'Bloodchild', 1, 1984, 4.7);
SAVEPOINT draft;
INSERT INTO books VALUES (6, 'Unpublished notes', 1, 2026, 0);
ROLLBACK TO draft;
RELEASE draft;
COMMIT;
SELECT id, title FROM books WHERE id IN (5, 6);
Resultobserved
| id | title |
|----|------------|
| 5 | Bloodchild |
11
Indexes, views, triggers and schema changes
An index makes some lookups faster. A view saves a query under a name. A trigger runs SQL when rows change. ALTER TABLE, DROP, ANALYZE and REINDEX change the schema after you create it.
CREATE INDEX builds a B+tree over one or more columns. Partial indexes, indexes on expressions and indexes on WITHOUT ROWID tables all work. EXPLAIN QUERY PLAN shows how a query will run: a table scan, an index search, a vector index search, or a sort in a temporary B-tree. A WHERE that joins conditions with OR uses an index for each condition when every condition has one, and the plan shows MULTI-INDEX OR.
An indexed value or a primary key value can be up to 1,048,576 bytes long. A key longer than one eighth of the page size, 4,096 bytes at the default page size, is stored on pages of its own like any other large value. The index leaf keeps the first 512 bytes of the key, so a search compares that prefix and reads the rest only when the prefix is equal. A longer key fails with a message that names the limit. Before release 2.5.0 the limit was about 32 KB.
CREATE TRIGGER supports BEFORE, AFTER and INSTEAD OF, FOR EACH ROW, WHEN, the OLD and NEW rows, and RAISE. ALTER TABLE can rename a table, rename a column, add a column, drop a column, and, as in SQLite 3.53, change a column with SET NOT NULL or DROP NOT NULL and add or drop a CHECK constraint. A rename rewrites the foreign keys, views and triggers that name the table or column, and fails with SQLite message error in view v when a dependent view or trigger would break. A rename rewrites only what it renames: RENAME TO leaves a CTE that has the table name alone, and RENAME COLUMN leaves an ORDER BY that names a result alias alone, so a view keeps its row order. RENAME of a table with a vector index carries the index, and RENAME of an FTS5 or inillucent_search table renames its shadow tables. A column added with ADD COLUMN c AS (c) fails with generated column loop on c, and the expression of a generated column added this way is checked as CREATE TABLE checks one. ADD COLUMN with a literal default takes the same time whatever the number of rows, because it writes the catalog row and no leaf. A leaf written before the column existed reads the default for the column, and is rewritten with the column the first time a write lands in it. A CHECK constraint on the new column still runs over the existing rows. A table with no rows, a statement inside a transaction and a default the engine has to evaluate rewrite the rows. The first ADD COLUMN on a table that has rows moves the file to format 4, and a release that reads only format 3 refuses the file by name. On a connection with PRAGMA defensive on, which the shell sets, a shadow table cannot be dropped, altered or given a trigger.
An INSERT, UPDATE or DELETE on a view is refused unless the view has an INSTEAD OF trigger for that statement. With the trigger, the statement runs the trigger body, and the trigger is still there after the file is closed and opened again. INSERT with ON CONFLICT on a view fails with cannot UPSERT a view.
CREATE VIEW stores a SELECT under a name.
ANALYZE writes statistics to sqlite_stat1 for the query planner.
REINDEX rebuilds indexes, for example after a collation changes.
DROP TABLE, DROP VIEW, DROP INDEX and DROP TRIGGER remove objects. DROP VIEW also drops the triggers of the view.
A trigger body can use subqueries. RETURNING is refused inside a trigger body.
Build an index and inspect the plan
EXPLAIN QUERY PLAN prints the plan in the same format as SQLite. The SEARCH line shows that the query uses books_by_author, the index the first statement created.
SQL / input
CREATE INDEX books_by_author ON books(author_id);
EXPLAIN QUERY PLAN
SELECT * FROM books WHERE author_id = 1;
Resultobserved
QUERY PLAN
`--SEARCH books USING INDEX books_by_author (author_id=?)
12
Built in and application functions
Inillucent has 190 built in function names for text, numbers, dates, JSON, aggregates, windows and vectors. 172 of them are functions of the pinned SQLite 3.53.4 library, which has 177. The other 18 are vector functions. Your application can add its own scalar functions and collations.
Common scalar functions include abs, coalesce, hex, instr, length, lower, upper, printf, random, replace, round, substr, trim, typeof, unicode and zeroblob. The math functions include the trigonometric functions, logarithms, pow, floor, ceil and sqrt.
The date and time functions are date, time, datetime, julianday, unixepoch, strftime and timediff. Modifiers such as start of month and +1 day work as they do in SQLite.
The five SQLite library functions Inillucent does not have are fts3_tokenizer, fts5, fts5_get_locale, fts5_insttoken and fts5_locale. load_extension() is present and always refuses, because the engine has no way to load a C extension.
An application registers its own scalar functions and collations through the driver. A statement can call an application function. A schema cannot: a DEFAULT, a CHECK, a generated column or a view that names an application function is refused.
Date and time: date, datetime, julianday, strftime, time, timediff and unixepoch.
Aggregates: avg, count, group_concat, json_group_array, json_group_object, jsonb_group_array, jsonb_group_object, max, median, min, percentile, percentile_cont, percentile_disc, string_agg, sum and total.
Windows: cume_dist, dense_rank, first_value, lag, last_value, lead, nth_value, ntile, percent_rank, rank and row_number. Each one needs an OVER clause.
The JSON functions build, check, read and change JSON documents stored as text. JSONB is a binary form of the same document. Store JSONB when several operations read one document, so the text is not parsed again each time.
A JSON path starts with $. An object key follows a dot, and an array position goes in brackets, as in $.tags[0]. A path that finds nothing returns NULL. A malformed path or a malformed document returns an error.
The -> operator returns the JSON found at a path. The ->> operator returns it as an SQL value.
json_each and json_tree are table valued functions: you use them in FROM, like a table. json_each returns one row for each element of an array or object. json_tree returns one row for every element at every level of the document.
The other functions include json, json_array, json_object, json_extract, json_set, json_insert, json_replace, json_patch, json_remove, json_type, json_valid, json_pretty, json_quote, json_error_position and json_array_length, with a jsonb form of each function that returns a document.
Create and read a document
json_object builds a valid JSON document. json_extract returns the value at the path as an SQL text value.
SQL / input
SELECT json_extract(
json_object('format', 'paperback', 'pages', 264),
'$.format'
) AS format,
json_array_length(json_array('sql', 'search', 'json')) AS topics;
An FTS5 table indexes the words in your text, so a search does not read every row. MATCH searches the index. bm25() gives each match a relevance score you can sort by.
Create an FTS5 virtual table with the text columns you want to search. The FTS5 table can store its own copy of the text, or read the text from another table. Prefix, phrase, boolean and column queries use the SQLite FTS5 syntax.
The index is stored in the same file as your tables and is part of the same transaction. A row inserted by a transaction that rolls back never appears in a search. Maintenance commands such as rebuild use the FTS5 form: an INSERT into the column that has the table's own name. Each commit writes its postings as a segment of its own, and small segments are merged into larger ones, so a load that commits each statement does not rewrite the list of every word for every row. A deleted row is hidden by a marker in a newer segment until a merge drops it. INSERT INTO t(t) VALUES('optimize') merges everything into one run. The first write to an FTS5 index that an earlier build wrote marks it as layout 3, and a build that does not read segments refuses it by name.
The tokenizers are ascii, unicode61, porter and trigram. trigram matches any run of three or more characters, takes the options case_sensitive and remove_diacritics, and lets an index narrow LIKE and GLOB on its columns. Any other tokenizer name is refused by name.
The query language is read by a parser written from SQLite's grammar, so a NOT, a nested column filter and a query SQLite refuses behave as they do in SQLite. bm25(), highlight() and snippet() count a phrase in a row only where the part of the query holding it matched that row.
FTS5, R-Tree and geopoly tables work in temporary and attached databases as well as in main.
The index stores every word position. The options detail and columnsize are refused.
highlight(), snippet() and bm25() work as they do in SQLite.
snippet() on an FTS3 or FTS4 table chooses its fragments the way SQLite does. It picks up to four fragments that cover the most phrases and joins them with the ellipsis text.
FTS3 and FTS4 tables also work. The fts4aux and fts3tokenize modules are not built.
An FTS5 table can take its content from a view, as in content='v', and a scan of any external content table reads the rows of its owner, so it answers before the first rebuild.
FTS3 and FTS4 MATCH read the default query language of SQLite. x NOT y searches for the three words x, NOT and y, and AND is a word. NEAR and NEAR/n work, and a bare NEAR is NEAR/10. A NEAR with no term on one side is the error malformed MATCH expression. The operators bind as SQLite binds them: NEAR tightest, then OR, then two words side by side, so x y OR z is x AND (y OR z). A term written with a leading - applies to the whole query, so x -y NEAR/3 w is (x NEAR/3 w) NOT y. Parentheses only separate words.
Search a small library
MATCH searches every indexed column. The rowid of each match lets you join the match back to an ordinary table.
SQL / input
CREATE VIRTUAL TABLE library_search USING fts5(title, body);
INSERT INTO library_search(rowid, title, body) VALUES
(1, 'Kindred', 'A time travel novel about family and history'),
(2, 'Parable', 'A future shaped by climate and community');
SELECT rowid, title FROM library_search
WHERE library_search MATCH 'family';
An R-Tree finds the rectangles that overlap a point or an area without reading every row. The rtree module stores floating point coordinates. The rtree_i32 module stores 32 bit integers exactly. An rtree_i32 table written by release 2.8.1 or earlier, which stored its cells as floats, is read as floats and converted the first time it is written.
The first column is an id. Each dimension then has a minimum column and a maximum column. A table in two dimensions has the columns id, min_x, max_x, min_y and max_y. A table can have one to five dimensions.
Two rectangles overlap when, on every axis, each one starts before the other one ends. The query below finds the stored rectangles that overlap the square from 8 to 12 on both axes.
The geopoly module and its functions, for polygons, are also built in.
Find an overlapping rectangle
Only the first rectangle overlaps the square.
SQL / input
CREATE VIRTUAL TABLE places USING rtree(
id, min_x, max_x, min_y, max_y
);
INSERT INTO places VALUES
(1, 0, 10, 0, 10),
(2, 20, 30, 20, 30);
SELECT id FROM places
WHERE min_x <= 12 AND max_x >= 8
AND min_y <= 12 AND max_y >= 8;
Resultobserved
| id |
|----|
| 1 |
16
Vector columns and HNSW indexes
A VECTOR(N) column stores an embedding with N dimensions. The distance functions compare vectors exactly. An inillucent_hnsw index finds the nearest vectors quickly, with a small chance of missing one. Inillucent can also compute the embeddings in your process.
An embedding is a list of numbers that stands for the meaning of a text. Texts with similar meanings get vectors that point in similar directions. HNSW is an index that finds the nearest vectors without comparing the query with every row.
A vector is stored as a BLOB of 32 bit floating point numbers in little endian order. In application code, bind the vector as a byte array parameter. The examples below write the bytes as hexadecimal literals so you can see them.
vector_distance_cos returns 0 for two vectors that point the same way and 1 for two vectors at right angles. vector_distance_l2 returns the straight line distance. vector_dot returns the dot product.
The planner uses an HNSW index for a query that orders by a distance function and then has LIMIT k. An index measures cosine distance unless you create it WITH (metric = 'l2'). When the query's distance function does not match the metric of the index, the planner scans the table instead.
An inillucent_hnsw index walks the HNSW graph by default. This is approximate mode: it is fast on a large table and can miss a true neighbor. CREATE INDEX ... WITH (mode = 'exact') makes the index compare the query with every stored vector instead, so the answer is always the true nearest rows. Below about 2,048 rows the index compares every vector in either mode, because that is faster than the walk. How many true neighbors the walk finds depends on your vectors, so compare its answers with an exact index on your own data before you rely on it, and raise ef_search if it misses too many. An index created by inillucent 1.0.29 or earlier is exact. Drop it and create it again to make it approximate.
Most applications compute their own vectors. Inillucent can also compute them with the SQL function embed(TEXT). One command, shown in the last example, downloads ONNX Runtime and the nomic-embed-text-v1.5 model on Windows, macOS or Linux. The download is about 620 MB, and every file is checked against a digest pinned in the build. The release archives include embed(TEXT). A build without it answers with the status unsupported.
Loading the model takes 650 to 800 ms. After that, one embedding takes 12 to 36 ms, and the loaded model holds about 1.9 GB of memory. A residency profile decides how long the model stays loaded. The resident profile keeps the model for the life of the process. The idle profile, the default, unloads the model 5 minutes after the last call. A third profile unloads the model after every call. On a graphics card, unloading leaves about 500 MB on the card until the process exits. That is the CUDA context, which ONNX Runtime cannot release.
A VECTOR(N) column refuses a value with the wrong number of bytes.
CREATE INDEX fills the index from the rows already in the table, and later writes keep the index current.
A query can combine a WHERE filter with nearest neighbor ordering.
An HNSW index covers one column. An index over two columns is refused.
The mode is set when the index is created. WHERE mode = 'approximate' in a query fails with no such column: mode.
embed(TEXT) returns the 3,072 bytes that a VECTOR(768) column holds. When its argument is the same for every row, embed(TEXT) runs once for the whole statement.
embed(TEXT) can go anywhere an expression can go in a statement your program sends: a VALUES row, UPDATE ... SET, RETURNING or INSERT ... SELECT.
embed(TEXT) is refused inside a trigger, a view, a generated column, a DEFAULT and a CHECK, with the message embed may only be used from top-level SQL. A database file must not be able to make the program that opens it load a model on every insert. A trigger can copy a vector that your own statement computed.
Measure three directions
The query vector points east. northeast is closer to east than north is, and the distances show that order.
SQL / input
CREATE TABLE passages (
id INTEGER PRIMARY KEY, body TEXT, embedding VECTOR(2)
);
INSERT INTO passages VALUES
(1, 'east', x'0000803f00000000'),
(2, 'north', x'000000000000803f'),
(3, 'northeast', x'f304353ff304353f');
SELECT id, body, ROUND(vector_distance_cos(
embedding, x'0000803f00000000'
), 4) AS distance
FROM passages ORDER BY distance LIMIT 3;
Resultobserved
| id | body | distance |
|----|-----------|----------|
| 1 | east | 0.0 |
| 3 | northeast | 0.2929 |
| 2 | north | 1.0 |
Put HNSW on the column
The plan names the vector index and k, the number of neighbors asked for. That shows the query does not scan the whole table. The second line sorts only the k rows the index returned.
SQL / input
CREATE INDEX passage_vectors
ON passages USING inillucent_hnsw (embedding);
EXPLAIN QUERY PLAN
SELECT id FROM passages
ORDER BY vector_distance_cos(
embedding, x'0000803f00000000'
) LIMIT 2;
Resultobserved
QUERY PLAN
|--SEARCH passages USING VECTOR INDEX passage_vectors (k=2)
`--USE TEMP B-TREE FOR ORDER BY
Embed text inside the database
Run the setup command once on each machine. After that, embed(TEXT) works with no other setup. The result is 768 dimensions at four bytes each. This example needs a build with the embed feature. The release archives have the embed feature.
SQL / input
-- once per machine, about 620 MB
-- $ inillucent setup-embeddings all
CREATE TABLE note (id INTEGER PRIMARY KEY, body TEXT, v VECTOR(768));
INSERT INTO note (body, v) VALUES (?1, embed(?1));
SELECT length(embed('flight details for next week'));
Resultobserved
| length(embed('flight details for next week')) |
|-----------------------------------------------|
| 3072 |
17
Hybrid search with inillucent_search
inillucent_search is a virtual table that searches text two ways at once: by the words it contains and by what it means. It applies your filters during the search, and it reports how confident it is in each result.
Declare the text columns you want to search. With no dims option, the table does keyword search only. With dims = N, the table also takes a column named vector, in the same byte format as VECTOR(N), and a query can search by meaning as well.
A virtual table is a table whose storage a module handles instead of the ordinary table code. It is not held in memory. An inillucent_search table keeps its rows, their vectors and its index in five ordinary tables inside the database file, named <table>_config, _content, _delta, _gen and _state. You insert a document once, and it is there after a restart. A search reads the stored rows and embeds only the question.
The table keeps its own copy of the text and the vector. It cannot read them from another table: the FTS5 option content = 'table' is refused. When your own table has to stay the record, triggers can copy each write into the search table, and chapter 18 shows the other way, where the search table holds the chunks.
MATCH gives the words to search for, and k sets how many results to return. A query that also sets vector to the embedding of the question runs both searches and combines their two lists into one. That is hybrid search.
Each result has four hidden columns. rank orders the results. score() is the number the order comes from. confidence() is calibrated against how strong the evidence is, so an application can give no answer when it is low. origin() says whether keyword search, vector search or both found the result. The k constraint sets how many hits the search collects, 10 by default, and the largest k is 1,000,000. A larger k is refused with inillucent_search: k cannot be more than 1000000. A vector of the wrong width is refused with a message that says it is not a vector of the declared width. A result that only keyword search found has a low confidence even when it is right, so check origin() before you decide that the table cannot answer.
The table takes the options in Table options below. metric is cosine by default, and l2 is the other choice. metric is fixed when the index is built, because a cosine index normalizes every vector it stores. fusion and vector_weight choose how the two lists are combined, and rerank_depth sets how many rows a reranker scores. tokenize chooses how text becomes terms, and Tokenizer packs covers text that is not English.
To use this table to find passages for a language model, read chapter 18, Retrieval Augmented Generation (RAG). It explains chunking, embedding, fusion and reranking one step at a time, with what a measured study found about each.
Filters are applied during the search.
For a small set of candidate rows, the planner picks an exact search.
Keyword scores use BM25, with extra weight for matching more of the query words and for words that appear close together.
Inserts, updates, deletes, compaction and rebuilds follow COMMIT and ROLLBACK and survive a reopen.
Each commit writes its rows to a new segment of the index. Segments are merged later, and the merge work one commit does has a limit.
An inillucent_search table can be joined to ordinary tables by rowid, or by a facet column.
INSERT INTO ... SELECT fills an inillucent_search table from an ordinary table in one statement.
A facet column is stored as text and has text affinity, so email_id = 3 and email_id = '3' select the same rows, in a search and in a plain SELECT, UPDATE or DELETE. Release 2.0.1 and earlier matched nothing for the integer outside a search.
A search applies =, IN, >, >=, <, <= and BETWEEN on a facet column inside the search, so it returns k rows that pass them. Any other test on a facet, such as !=, LIKE or a test inside an OR, runs on the k rows the search returned and can leave fewer than k. Release 2.6.0 and earlier applied only = inside the search.
Options go after the column names in the CREATE statement, such as inillucent_search(title, body, dims = 768, fusion = 'rrf'). An option written twice in one declaration is refused. The table records its options in the <table>_config table, so SELECT k, v FROM docs_config shows what a table was declared with.
The options of an inillucent_search table
Option
What it does
Default
dims = N
The vector width, from 1. Without it the table is keyword only and refuses a vector. dims = 0 is refused
none
mode
'exact' compares every vector. 'approximate' walks the HNSW graph
'exact'
vector_weight
A fixed weight from 0 to 1 for the vector list in a search with both parts
chosen for each query
fusion
How the keyword list and the vector list are combined: 'adaptive', 'rrf' or 'weighted'. Fuse the two lists explains each rule
'adaptive'
rerank_depth
How many rows of the combined list the reranker scores, from 1 to 1,000
60
metric (or distance)
'cosine' or 'l2'. Any other value is refused
'cosine'
m, ef_construction, ef_search
The HNSW graph settings
16, 64, 64
tokenize
The tokenizer: porter, which is English, or the name of an installed tokenizer pack. See Tokenizer packs
porter
compact
How many pending changes a commit collects before it writes them into the index
1,024
segment_merge
How many segments of one size are merged into one larger segment
4
merge_budget
How many rows one commit may merge before it leaves the rest for a later commit
A tokenizer turns text into terms. A term is what a word becomes after the tokenizer cleans it up, and the index stores terms. porter, the default tokenizer, is English. porter stems English words, drops English stopwords such as the, and keeps accents. So in a porter table the query notificacao, typed without accents, does not find the row Notificação enviada.
A tokenizer pack is a small JSON file that says how the words of another language become terms. A pack names a stemmer, a stopword list, whether accents are folded, and suffix rules. The repository ships one pack, examples/tokenizers/pt-br.json, for Brazilian Portuguese. The last example in this chapter installs it and searches with it.
A pack file has seven fields. format is 1. name is 1 to 32 characters: lowercase letters, digits, - and _, and the name may not be porter. stemmer is none or one of the 18 Snowball stemmers, such as portuguese, spanish, french or german. fold set to true replaces each accented letter with the plain letter, so notificação and notificacao become the same term. stopwords lists words that produce no term. suffixes lists word endings to rewrite before the stemmer runs. description is free text. A field that is not one of these seven is refused, so a misspelled field is an error. A file over 1 MiB is refused before it is read. Tokenizer packs, in the repository docs, has every limit and the six steps a pack applies to one word.
inillucent tokenizer install --file pt-br.json checks the pack and copies it into the tokenizer folder. The tokenizer folder is the folder INILLUCENT_TOKENIZER_DIR names when that variable is set. Otherwise it is the tokenizers folder in the install root. inillucent tokenizer list shows porter and every installed pack. inillucent tokenizer remove --name pt-br deletes an installed pack. install and remove are refused under --root and under --readonly. inillucent-mcp serves the same command as the tool inillucent_tokenizer.
CREATE VIRTUAL TABLE docs_pt USING inillucent_search(body, tokenize = 'pt-br') reads the installed pack once. The table then writes two rows into docs_pt_config: tokenize, which holds the name, and tokenizer_pack, which holds the whole pack as JSON. Every later search, write and integrity check reads the pack from docs_pt_config, and the tokenizer folder is not read again. So removing or changing the installed pack never changes a table that already exists, and the database file opens on a machine that never installed the pack. A table cannot change its tokenizer after it is created. To change the tokenizer, create a new table and copy the rows into it.
A table with no tokenize option, or with tokenize = 'porter', writes tokenize = porter and no tokenizer_pack row. Its terms are the same terms the engine produced before tokenizer packs existed.
Releases before 2.6.0 do not have tokenizer packs. Such a release reads tokenize = 'pt-br' from the table's CREATE statement and does not know the name. Release 2.5.5 opens the file, every other table reads and writes as before, and a statement that uses the pack table fails with no such tokenizer: pt-br. Releases up to 2.5.4 cannot open the file at all: every statement fails, even one that reads only a plain table. A database whose search tables all use porter opens in every release.
The table below lists the errors. invalid_state means the statement is right and the machine is missing something: install the pack and run the same statement again. A table whose stored pack cannot be read refuses a search and still answers a plain SELECT, so its rows can be read and copied out.
What a tokenizer pack can refuse
When
Status
Exit code
Message
tokenize = '../x', or any name that breaks the naming rule
syntax
1
inillucent_search: a tokenizer name is lowercase letters, digits, - and _, not ../x
The pack is not installed
invalid_state
1
inillucent_search: no tokenizer named klingon is installed. This build has porter. Installed packs: pt-br. Run inillucent tokenizer install --file <pack>.json to add one
The installed file is not a valid pack
syntax
1
inillucent_search: the tokenizer pack klingon does not read: ...
A table's stored pack is in a later format
unsupported
3
inillucent_search: this table's tokenizer pack is in format 2, and this build reads format 1
A table's stored name and its declared name differ, or its stored pack is missing
syntax
1
inillucent_search: the terms were produced by porter, but the table declares pt-br
Run a keyword search
The result has the values an application needs to rank a match and to decide whether to use it. origin is lexical, which means keyword search found the match. This table has no vector column.
SQL / input
CREATE VIRTUAL TABLE docs
USING inillucent_search(title, body);
INSERT INTO docs(rowid, title, body) VALUES
(1, 'Shipping', 'The release process builds and signs the installer'),
(2, 'Accounts', 'A discount applies to eligible accounts'),
(3, 'Weather', 'Rain and wind are expected tomorrow');
SELECT rowid, title, ROUND(score(docs), 4) AS score,
ROUND(confidence(docs), 4) AS confidence,
origin(docs) AS origin
FROM docs WHERE docs MATCH 'release process' AND k = 3
ORDER BY rank;
Run this from a checkout of the repository, which has the pack in examples/tokenizers. The query has no accents and is plural. The row has accents and is singular. The Brazilian Portuguese pack turns both into the term notific, so the row is found. The same row in a table with no tokenize option is not found. The last command names a pack that is not installed and is refused with the status invalid_state.
Shell / input
inillucent tokenizer install --file examples/tokenizers/pt-br.json
inillucent create app.rdb
inillucent --db app.rdb exec "CREATE VIRTUAL TABLE docs USING inillucent_search(body, tokenize = 'pt-br')"
inillucent --db app.rdb exec "INSERT INTO docs(rowid, body) VALUES (1, 'Política de reembolso'), (2, 'Notificação enviada'), (3, 'Horário de atendimento')"
inillucent --db app.rdb query "SELECT rowid, body FROM docs WHERE docs MATCH 'notificacoes'"
inillucent --db app.rdb exec "CREATE VIRTUAL TABLE other USING inillucent_search(body, tokenize = 'klingon')"
Terminalobserved
Installed the tokenizer pack pt-br in <tokenizer folder>. Create a search table with tokenize = 'pt-br' to use it.
created app.rdb
ok. 0 rows changed.
ok. 3 rows changed.
rowid body
----- -------------------
2 Notificação enviada
Error [invalid_state]: inillucent_search: no tokenizer named klingon is installed. This build has porter. Installed packs: pt-br. Run inillucent tokenizer install --file <pack>.json to add one
18
Retrieval Augmented Generation (RAG)
RAG is how an application answers questions from its own text. It finds the passages that answer a question and gives them to a language model along with the question. This chapter explains each step, the choices at each step, and what a measured study found about each choice.
A language model has never read your email, your wiki or your support tickets. RAG gives it that text at the moment of the question. The application searches its own text, takes the best few passages, and puts them in the model's input next to the question. The model answers from those passages.
The answer can only be as good as the passages. If the search misses the passage that holds the answer, the model says it does not know, or it makes something up. So most of the work in RAG is the search, and the search is the part Inillucent does.
RAG has two phases. The prepare phase runs once for each document, when it is added. The answer phase runs for every question. The figure shows both, and the parts below take one step at a time.
The numbers in this chapter come from a study that searched 67,369 emails with 119 graded questions. The research page describes how it was run, every result, and why each feature was built the way it was.
Terms used in this chapter
RAG
Retrieval augmented generation. Search your own text for passages, then give them to a language model so it answers from them.
Language model
A program that writes text, such as a chat assistant. Also called an LLM. It knows only what it was trained on.
Document
One item in your collection: an email, an article, a support ticket.
Chunk
A piece of a document, a few paragraphs long. The search returns chunks, not whole documents.
Overlap
The characters a chunk repeats from the end of the chunk before it, so a sentence cut at a boundary is whole in one of the two.
Heading
A short line at the top of every chunk that names the document it came from, such as its title, or an email's subject, sender and date.
Token
A piece of a word. It is the unit a model reads. English text averages about four characters a token.
Embedding
A list of numbers that a model makes from a text. Texts with similar meanings get similar lists.
Embedding model
The model that makes embeddings. Inillucent runs nomic-embed-text-v1.5, which makes 768 numbers for each text.
Keyword search
Finds the chunks that contain the words of the question. Its scores come from BM25, a formula where rare words count for more than common ones.
Vector search
Finds the chunks whose embeddings are closest to the embedding of the question. Also called search by meaning.
Hybrid search
Keyword search and vector search for the same question, with the two lists combined into one.
Fusion
The rule that combines the two lists of a hybrid search into one list.
RRF
Reciprocal rank fusion. A fusion rule that uses only the position of each chunk in each list and ignores the scores.
Reranker
A second model that reads the question and one chunk together and scores how well the chunk answers the question. It is slower than an embedding model and more accurate.
Candidates
The chunks from the first search that the reranker scores. rerank_depth sets how many.
nDCG@10
The main score in the study. It checks whether the right chunks are in the top 10 results and how near the top they are. 1 is perfect and 0 means nothing useful was found.
Prepareonce for each document
01Cut the document into chunks
02Put a heading on each chunk
03Embed each chunk
04Store the text and the vector in an inillucent_search table
05Replace the chunks when the document changes
Answerfor every question
01Keyword search and vector search
02Fuse the two lists into one
03Rerank the top candidates
04Give the best 5 to 10 chunks to the language model
The two phases of RAG. The prepare phase fills the search table once for each document. The answer phase runs for every question. Inillucent does every step except the last, which is your application or agent calling a language model.
A search result should be short enough for the model to read and specific enough to be about one thing. So each document is cut into chunks of a few paragraphs. The table function chunk_text(text, size, overlap, heading) does the cutting. size is the largest chunk in characters. overlap is how many characters each chunk repeats from the one before. A chunk ends at a paragraph break, a sentence end or a space when one falls near its end.
Put a heading on every chunk. A chunk from the middle of a long document does not say which document it belongs to. The heading, such as the title, or an email's subject, sender and date, tells both the search and the reranker. In the study, chunks without a heading lost 0.043 nDCG@10, and 0.111 when a reranker ran. Read the heading result.
The size of a chunk mattered little. Sizes from 450 to 6,000 characters scored the same within the noise. 900 characters with an overlap of 100 is a good place to start. Read the chunk size result.
The embedding model turns each chunk into 768 numbers. A chunk is embedded once, when it is stored, and never again unless its text changes. A search embeds only the question.
There are two ways to run the model, and a program usually uses both. A document that arrives on its own, such as one new email, is embedded in the statement that stores it, with embed('search_document: ' || chunk) in the INSERT. Keep the search table current shows it. The first import of a whole collection is faster with the command inillucent embed --table chunk --text body --vector v, which embeds every row whose vector is NULL. It runs on the processor, or on a graphics card with --device cuda:0, and a run that stops continues where it ended.
A graphics card is much faster for a whole collection. It embedded 3,696 chunks at 813 rows a second, against 11 rows a second for embed() in SQL on the processor. In the study, 558,429 chunks took 22 minutes on a card and would have taken about 12 hours on the processor. Read the embedding result.
The model reads at most 1,900 tokens of a chunk and ignores the rest without a message. embed_tokens(text) counts the tokens a chunk has, so you can find chunks that are too long. Chunks of 900 characters are far under the limit.
An inillucent_search table holds the keyword index and the vector index for each chunk. Declare it with dims = 768, then insert each chunk's id as the rowid, its text, and its vector. The heading is part of the text, so keyword search and the reranker both see it. The table commits and rolls back with the rest of the database. Chapter 17 explains the table.
The table stores the text, the vectors and the index inside the database file. It is built once and is there after a restart, so a program does not rebuild it before it searches.
After the first import, documents arrive a few at a time: an email comes in, a page is edited, a ticket is closed. Each change touches only that document's chunks. The examples at the end of this chapter run the whole cycle on a small mailbox.
email holds each message. email_search holds the chunks, and each chunk carries its email's id and sender as FACET columns. A search can filter on a facet, and a program can delete a chunk or join it back to its email by the facet.
When a new email arrives, insert it into email, then insert its chunks into email_search with one INSERT ... SELECT over chunk_text, computing each vector with embed('search_document: ' || c.chunk) in the same statement. Put both in one transaction, so a failure leaves neither. Each chunk costs one call to the model, 12 to 36 ms on the processor, and the first call in a process loads the model in under a second.
When an email changes, update it in email, delete its chunks with DELETE FROM email_search WHERE email_id = ?1, and insert them again the same way. The number of chunks can change with the text, so replacing them is simpler than updating each one, and only that email is embedded again.
When an email is deleted, delete its chunks by email_id, then delete the email.
A trigger cannot do the embedding, because it cannot call embed(). The function is refused inside a trigger, a view, a generated column, a DEFAULT and a CHECK, because a database file must not be able to make the program that opens it load a model on every insert. So the statement your program sends computes the vector. A trigger can copy a vector that statement computed, from your own table into the search table, if your own table has to stay the record. Each vector is then stored twice.
Keyword search finds exact names, numbers and rare words, such as invoice 4471 or a surname. It fails when the question uses different words from the text. Vector search finds meaning, so a question about a job offer finds an email that says we would like to extend a position. It is weak at exact names and numbers.
Hybrid search runs both for the same question, so each one covers the other's failures. In a query, MATCH gives the words and vector = embed('search_query: ' || ?1) gives the embedding of the question. In the study, hybrid search beat keyword search alone by 0.038 nDCG@10.
The two lists score on different scales. A BM25 score can be 14.2 and a vector score 0.83, so the two cannot simply be added. The fusion option of the table chooses the rule that combines them.
The figure works through RRF by hand. Each list gives a chunk 1 / (60 + its position), and the numbers are added. A and C appear in both lists, so they come first. The 60 keeps the first few positions from counting for too much. An RRF score is at most 2/61, about 0.033, so do not set a threshold on it expecting a number from 0 to 1.
The two collections that were measured disagreed about the best rule. RRF won on the emails, and the adaptive weight won on the collection Inillucent's own score card uses. So adaptive stays the default, and you should try rrf and adaptive on your own questions. With a reranker, the rule matters much less. Read the fusion result.
Keyword search
1A
2B
3C
Vector search
1C
2D
3A
Fused with RRF
A1/61 + 1/630.0323
C1/63 + 1/610.0323
B1/620.0161
D1/620.0161
Reciprocal rank fusion of two short lists. The fused scores are computed on this page with the same rule the engine uses.
The three fusion rules
fusion
How it combines the lists
When to use it
adaptive, the default
Scales each list to run from 0 to 1 and adds them with a weight the engine picks for each question from its words
The default. It won on the score card collection
rrf
Adds 1 / (60 + position) from each list and ignores the scores
It won on the email study. Try it on your own questions
weighted
Scales each list and adds them with the fixed vector_weight you set
When you have measured a weight that works on your data
An embedding model reads the question and each chunk separately. A reranker reads them together, so it can notice that the question asks about 2021 and the chunk is about 2019. That makes it slower, so it scores only the top candidates of the fused list.
Add question = 'the question in plain words' to an inillucent_search query. The search takes the top rerank_depth rows of the fused list, 60 by default, scores them in one call, and returns the best k. score() is then the reranker's score from 0 to 1. rerank(question, passage) scores one pair in SQL. Install the reranker once with inillucent setup-embeddings reranker, which downloads about 600 MB.
The reranker was the largest gain the study measured: 0.10 to 0.13 nDCG@10 on every collection. It needs a graphics card when a person is waiting for the answer. 60 candidates take about 0.07 seconds on a card and 5 to 8 seconds on the processor. On a processor, set rerank_depth to 20, which takes about a third as long. Read the reranker result.
Take the top 5 to 10 chunks and put them in the model's input with the question. Ask the model to answer only from those chunks and to say which chunk it used. Inillucent does not run the language model. Your application or your agent does. An agent can run the search itself through inillucent-mcp.
A capable agent makes up for weaker search by searching again and reading more. In the study, better search meant fewer searches per answer. It did not mean more correct answers. Read the agent result.
Three common techniques ask a language model to help the search. Each adds a language model call to every search or to every document. In the study none of them helped once a reranker ran, so start without them. Read the result.
Option
What it does
What the study measured
Query rewriting
A language model writes other versions of the question, and each one is searched
+0.002 nDCG@10, inside the noise
HyDE
A language model writes an imagined answer, and the search looks for chunks near it
+0.005, inside the noise
Document summaries
A language model summarizes each document, and the summary is added to each of its chunks
+0.063 without a reranker, +0.023 with one, which is inside the noise
This is the setup the study pointed to. Change one setting at a time and measure each change on questions you have graded yourself. Retrieval for RAG, explained from the start, in the repository docs, runs this whole setup on 80 articles with every command and its real output.
Step
Setting
Why
Chunk
chunk_text(body, 900, 100, 'Title: ' || title)
A heading on every chunk. The size matters little
Embed
inillucent embed ... --device cuda:0 for the first import
Minutes on a graphics card, hours on the processor
Store
inillucent_search(body, dims = 768)
The keyword index and the vector index in one table
Keep current
embed('search_document: ' || chunk) in the INSERT of each new document
Each document is embedded once, when it is written
Fuse
fusion = 'rrf', or the default adaptive
Measure both on your own questions
Rerank
question = '...' with rerank_depth 60 on a card, 20 on a processor
The largest gain measured
Answer
The top 5 to 10 chunks, given to the model with the question
The model can only use what the search found
Cut a document into chunks with a heading
chunk_text is a table function. Its arguments are the text, the largest window in characters, how many characters each window repeats from the one before, and the heading. Each chunk is the heading, a blank line and the window. start is where the window begins in the text, and length is how many characters it has. Each window ends at a paragraph break, a sentence end or a space. The result shows the blank line as a slash.
SQL / input
SELECT c.seq, c.start, c.length,
replace(c.chunk, char(10), ' / ') AS chunk
FROM chunk_text(
'Cats sleep all day. Dogs bark at the mail carrier. Birds sing at dawn.',
30, 0, 'Pets') AS c;
Resultobserved
| seq | start | length | chunk |
|-----|-------|--------|------------------------------------------|
| 0 | 0 | 30 | Pets / / Cats sleep all day. Dogs bark |
| 1 | 30 | 27 | Pets / / at the mail carrier. Birds |
| 2 | 57 | 13 | Pets / / sing at dawn. |
Choose the fusion and the rerank depth
The fusion and rerank_depth options are stored with the table, so you can read back what a table was declared with. fusion = rrf combines the two lists with reciprocal rank fusion. rerank_depth = 20 makes a reranked search score 20 rows instead of 60.
SQL / input
CREATE VIRTUAL TABLE chunks
USING inillucent_search(title, body,
fusion = 'rrf', rerank_depth = 20);
SELECT k AS setting, v AS value
FROM chunks_config
WHERE k IN ('fusion', 'rerank_depth');
Run this when an email arrives. The first statement stores the email. The second cuts its body into chunks with a heading and embeds each chunk in the same statement, so the search table never holds a chunk without a vector. email_id and sender are facet columns: a search can filter on them, and a program deletes or joins by them. Each email here is short enough to be one chunk, and a chunk's vector is 768 numbers of four bytes.
SQL / input
CREATE TABLE email (
id INTEGER PRIMARY KEY, sender TEXT, subject TEXT, sent_at TEXT, body TEXT
);
CREATE VIRTUAL TABLE email_search
USING inillucent_search(text, email_id FACET, sender FACET, dims = 768);
INSERT INTO email VALUES
(1, 'dana@acme.com', 'Invoice 4471 for March', '2026-03-02',
'Invoice 4471 for the March consulting work is attached. Payment is due in 30 days.'),
(2, 'hr@globex.com', 'Offer letter', '2026-04-10',
'We would like to extend you a position as Staff Engineer starting in June.'),
(3, 'mom@family.net', 'Dinner Sunday', '2026-04-11',
'Are you coming to dinner on Sunday? Bring the kids.');
INSERT INTO email_search (text, email_id, sender, vector)
SELECT c.chunk, e.id, e.sender, embed('search_document: ' || c.chunk)
FROM email AS e,
chunk_text(e.body, 900, 100,
'Subject: ' || e.subject || char(10) || 'From: ' || e.sender) AS c;
SELECT rowid, email_id, sender, length(vector) AS bytes
FROM email_search;
Only the question is embedded. The join goes back to email through the email_id facet. The offer letter never uses the word job, and the search by meaning found it next to the keyword offer, so its origin is both.
SQL / input
SELECT e.id, e.subject, origin(email_search) AS origin
FROM email_search
JOIN email AS e ON e.id = email_search.email_id
WHERE email_search MATCH 'job OR offer'
AND vector = embed('search_query: did anyone offer me a job')
AND k = 3
ORDER BY rank;
Resultobserved
| id | subject | origin |
|----|------------------------|--------|
| 2 | Offer letter | both |
| 1 | Invoice 4471 for March | vector |
| 3 | Dinner Sunday | vector |
Change an email and delete another
A changed email's chunks are deleted by its email_id and inserted again, because the number of chunks can change with the text. Only that email is embedded again. A deleted email's chunks go first, then the email. Each change is one transaction, so a failure leaves the two tables as they were.
SQL / input
BEGIN;
UPDATE email
SET body = 'A corrected invoice 4471 is attached. Payment is now due in 45 days.'
WHERE id = 1;
DELETE FROM email_search WHERE email_id = 1;
INSERT INTO email_search (text, email_id, sender, vector)
SELECT c.chunk, e.id, e.sender, embed('search_document: ' || c.chunk)
FROM email AS e,
chunk_text(e.body, 900, 100,
'Subject: ' || e.subject || char(10) || 'From: ' || e.sender) AS c
WHERE e.id = 1;
COMMIT;
BEGIN;
DELETE FROM email_search WHERE email_id = 3;
DELETE FROM email WHERE id = 3;
COMMIT;
SELECT email_id, sender FROM email_search ORDER BY email_id;
A PRAGMA statement reads or changes a setting. Some PRAGMA statements describe the schema and the file. Others configure the current connection.
Inillucent answers all 67 of the pragmas that SQLite 3.53.4 lists in pragma_list.
PRAGMA table_info, table_xinfo, table_list, index_list, index_xinfo and database_list describe the schema. PRAGMA page_size, page_count and freelist_count describe the file.
PRAGMA integrity_check, quick_check and foreign_key_check check the database. PRAGMA wal_checkpoint copies the write ahead log into the database file. PRAGMA cache_size, synchronous, busy_timeout, foreign_keys and locking_mode configure the connection. PRAGMA trusted_schema is off by default. Each pragma that answers a value names its result column after the pragma.
PRAGMA integrity_check inside a transaction does not report the pages of an index that REINDEX replaced, or of a dropped table, before the commit gives them back. An INSERT refused for a duplicate key leaves no pages of a long value behind, so integrity_check does not report a page that is never used.
PRAGMA auto_vacuum reads the mode of the file: 0 for NONE, 1 for FULL and 2 for INCREMENTAL. NONE is the default. In NONE mode a page that a delete frees stays in the file and later writes reuse it, so the file does not become shorter. In FULL mode every commit moves the pages at the end of the file into free pages nearer the start and lowers the page count, and the file is cut at the next checkpoint. In INCREMENTAL mode the free pages stay in the file until PRAGMA incremental_vacuum(N) runs. It gives back up to N free pages when the statement commits, and every free page when N is left out. In NONE and FULL mode PRAGMA incremental_vacuum changes nothing.
PRAGMA auto_vacuum = FULL or INCREMENTAL takes effect at once on a file that has no table. On a file in NONE mode that has tables, the new mode applies after VACUUM rebuilds the file. In FULL and INCREMENTAL mode the root page of every table and index is kept at the start of the file. A DELETE frees the out of line values of the rows it deletes in the same statement. A page that cannot be moved stays where it is and the step stops, so PRAGMA freelist_count can read more than 0, and VACUUM rebuilds the file without that limit.
VACUUM aux and VACUUM temp act on the database they name and leave main alone. VACUUM aux INTO writes a rebuilt copy of the attached database aux. VACUUM aux without INTO packs each table and index of the attached file into new trees in the same file and cuts the file after the commit, so the file gets shorter. It does not rename a new file over the old one, so another process that has the file attached keeps working. VACUUM INTO takes a bound parameter for its file name. SQLite writes an empty database for VACUUM temp INTO, and Inillucent writes a copy of the temporary tables.
PRAGMA max_page_count reads the limit on the number of pages in one open file, and PRAGMA max_page_count = N sets the limit when N is a positive integer. A value that is 0, negative or text is ignored. The limit applies to each attached file separately and is not stored in the file. A statement that needs a page past the limit fails with the message database or disk is full and the status full. The statement is undone and the transaction stays open.
PRAGMA automatic_index = OFF turns off the hash table that a join or a correlated subquery builds over a column that has no index. Chapter 08 describes both cases.
A PRAGMA that returns rows can also be used as a table in FROM, as in SELECT name FROM pragma_table_info('books').
Inspect a table
cid is the position of the column, counting from 0. dflt_value is the default expression as written. pk is the position of the column in the primary key, or 0 when the column is not part of the key.
A single row that says ok means every structural check passed.
SQL / input
PRAGMA integrity_check;
Resultobserved
| integrity_check |
|-----------------|
| ok |
20
Attached and temporary databases
ATTACH opens another database file under a schema name. TEMP creates tables, indexes, views and triggers that belong to one connection. One query can use tables from main, temp and attached databases.
Put the schema name in front of an object name, as in archive.books. DETACH closes an attached database when no statement is using it. PRAGMA database_list shows the names and paths that are open. DETACH of a database that was attached before another one works when the open transaction has written the later database. The write is committed, as in SQLite.
ATTACH takes any expression as its file name, such as ATTACH ?1 AS x, ATTACH 'a' || 'b' AS x or ATTACH (SELECT path FROM p) AS x. The engine evaluates the expression when the statement runs, with the bound parameters of the statement, and reads the value as text. A NULL names a private temporary database. The --root limit confines the evaluated name the same way it confines a literal one.
A transaction that writes two database files commits through a super journal, so both files get the change or neither does. Temporary objects live in a database that belongs to one connection and has no file. They disappear when the connection closes.
Each attached or temporary database keeps its own foreign keys, triggers, AUTOINCREMENT counters and schema changes. A foreign key in an attached or temporary table looks up its parent in the same database. A trigger in an attached database that names a table without a database reads and writes that same database. Each database has its own sqlite_sequence. DROP TRIGGER, DROP VIEW and ALTER TABLE RENAME of an attached file inside a transaction are kept when the file is opened again. DROP TRIGGER of a temporary trigger on a main table removes the trigger.
ATTACH ... KEY opens an encrypted database with its own key, and KEY '' opens a plaintext one. Without a KEY clause, an attached database is opened with the key of the connection. Chapter 26 covers encryption.
Join an attached archive
The schema name tells the engine which file holds each table.
SQL / input
ATTACH DATABASE 'archive.rdb' AS archive;
CREATE TABLE archive.books (id INTEGER PRIMARY KEY, title TEXT);
INSERT INTO archive.books VALUES (9, 'A Wizard of Earthsea');
SELECT title, 'archive' AS location FROM archive.books;
DETACH DATABASE archive;
Resultobserved
| title | location |
|----------------------|----------|
| A Wizard of Earthsea | archive |
21
Use Inillucent from an application
A Rust program calls the driver crate, inillucent-driver, directly. The client libraries for other languages call one C library built from the driver. The next chapter has an install line and an example for each language.
Values keep their types: Null, Integer, Real, Text or Blob. query and query_all hold every row of the result until they return. In Rust, query_each hands each row to a RowSink as the engine produces it, so a result too large to hold can be read. If a statement fails partway, the Rust driver returns the rows produced before the error in Error::partial, and inillucent query and the MCP tools return them in the rows member of the failure. The C library returns them once from inillucent_error_take_rows, and the Python, Node, PHP and Go packages put them on their error as partial. A sort, an aggregate, UNION, INTERSECT and EXCEPT build their whole answer first, so they return no rows before an error. Each result has an exact total, the rows up to the limit you asked for, and a flag that says whether the limit left rows out.
Unsupported is its own status. The error names the construct the engine has not built, so your application can tell a missing feature from a mistake in the SQL. The capability table answers the same question before you write the statement. The table has 57 rows: 43 supported, 2 partial and 12 refused. Chapter 25 shows how to read the capability table.
A Database is used from one thread. SharedDatabase lets many threads in one process share one database. The threads take turns, and one statement runs at a time. Several processes can also open the same file.
A Database owns one file and creates connections.
Each connection of one Database has a transaction of its own. The first connection runs on the engine the open made. Every later connection runs on an engine of its own over the same file, opened when the connection runs its first statement, and that engine takes the file locks the way a second process does. A BEGIN on one connection is not joined by another, the other connection reads only what is committed, and a second writer waits for the busy timeout and then fails with busy. A function, a collation, an authorizer, a pragma, a temporary table or an ATTACH set on one connection is not set on another. Opening that engine costs what opening a second Database costs, so a program keeps its connections instead of opening one for each statement. The C library frees the engine of a connection when the last handle on the connection is freed.
In the Python binding, every call on a Database takes one lock that the Database owns, so a second thread waits for the first call to finish. Connection.cancel() does not take the lock.
A program that called embed() holds ONNX Runtime sessions. inillucent_release_models() in the C library (ABI 1.5.0) drops the loaded embedder and reranker sessions and can be called when nothing is loaded. The Node and Python packages call it when the process exits. A binding for another language calls it once from its own exit hook.
The C library copies every value you pass in before the call returns.
Read only mode is enforced by the driver, which refuses any statement that is not a query. The file itself is still open for writing.
A file this process may not write opens read only. Queries work, and a statement that writes fails with the status readonly.
When the disk is full, the statement that met it fails with the status full, and the same connection writes again once space is free.
Connection::transaction runs a batch of statements, passes the changed row counts to a check you write, and commits only when the check succeeds. Connection::begin gives you a Transaction to run one statement at a time. Dropping a Transaction without a commit rolls it back.
Every C handle has a function that frees it. A pointer the library returns stays valid until you free the handle it came from.
The reference Python binding uses ctypes from the Python standard library.
An open waits up to five seconds for a file another process is writing. OpenOptions::busy_timeout in Rust, inillucent_open_with_timeout in C and busy_timeout_ms in Python set a longer wait.
Open, write, and query from Rust
The placeholders ?1 and ?2 keep values out of the SQL text. That prevents quoting bugs and keeps each value's type.
SQL / input
use inillucent_driver::{Database, Value};
let database = Database::open("app.rdb")?;
let connection = database.connect();
connection.execute_batch(
"CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT)",
)?;
connection.execute(
"INSERT INTO authors VALUES (?1, ?2)",
&[Value::Integer(1), Value::Text("Octavia Butler".into())],
)?;
let rows = connection.query(
"SELECT id, name FROM authors", &[], 200
)?;
println!("{} row", rows.total);
Terminalobserved
1 row
22
Client libraries
Client libraries exist for TypeScript, JavaScript, Python, Rust, Go, Java, C# and PHP. Install one, open a file, and read and write rows from your own code.
All eight libraries call the same C library and offer the same API, so a program written in one language reads the same in the next. Values keep their types, NULL is never an empty string, and each result has an exact total next to the rows the limit returned.
The tabs below show the same program in each language. The program creates a person table, inserts two rows, reads them back by column name, and updates one row. Each output shown is what that program printed.
Each library is tested with the same conformance suite that the Rust driver runs. A library passes when its answers match the engine. The libraries are in the inillucent-clients repository, and each one has a README with its full reference.
Android and iOS apps run the engine inside the app. The inillucent repository has an Android library for Kotlin and Java in packages/android, built as an AAR for arm64-v8a, armeabi-v7a and x86_64, and a Swift package for iOS and macOS in packages/swift, built over an XCFramework. Both link the C library, both run the conformance suite, the Android library on an emulator and the Swift package on the iOS simulator, and both are built from source. Neither is on Maven Central or a Swift package index yet. Chapter 27 shows both.
A web page runs the engine as WebAssembly, through the inillucent-web package on npm, in memory or kept in the browser's origin private file system. Chapter 28 shows it, and the explorer runs it on a film database in this site.
Install
npm install inillucent-client
Requires
Node 18 or later
Insert
import { connect } from 'inillucent-client';
const db = connect('app.rdb');
db.execute(`
CREATE TABLE person (
id INTEGER PRIMARY KEY,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT,
age INTEGER,
height_m REAL
)
`);
const insert =
'INSERT INTO person (first_name, last_name, email, age, height_m) VALUES (?1, ?2, ?3, ?4, ?5)';
db.execute(insert, ['Ada', 'Lovelace', 'ada@example.com', 36, 1.65]);
db.execute(insert, ['Grace', 'Hopper', null, 85, 1.57]);
Read
for (const person of db.query(
'SELECT id, first_name, last_name, email, age, height_m FROM person ORDER BY id',
)) {
console.log(person.id, person.first_name, person.last_name, person.email, person.age, person.height_m);
}
// One value.
db.scalar('SELECT COUNT(*) FROM person'); // 2
db.scalar('SELECT email FROM person WHERE last_name = ?1', ['Lovelace']); // 'ada@example.com'
const changed = db.execute('UPDATE person SET email = ?1 WHERE last_name = ?2', [
'grace@example.com',
'Hopper',
]);
console.log('updated:', changed.affected);
console.log('email now:', db.scalar('SELECT email FROM person WHERE last_name = ?1', ['Hopper']));
Outputobserved
updated: 1
email now: grace@example.com
The API is the same in all eight. Each library’s own README has the full reference, and the programs above are in the inillucent-clients repository, where one command runs the same conformance suite against every one of them.
23
Migrate existing data
inillucent migrate copies a SQLite file, or a running PostgreSQL or MySQL server, into a new .rdb file. The new file appears only when the copy is complete. For a server, every table is checked by row count and by digest, and nothing is published when a check fails.
A migration never writes to the source, so the original database stays as it was. The new file is built under another name and renamed into place when it is complete, so an application never opens a half written file. The command refuses a destination that already exists.
A path is read as a SQLite file. A postgres:// or mysql:// URL is a running server. The server is read inside one snapshot with repeatable read isolation, so every table and the schema come from the same instant. A value with no matching type here is copied as the text the server prints for it, so no precision is lost.
Inillucent stores data in its own file format, so it cannot open a SQLite file directly. Import the SQLite file instead. The import recreates the tables, rows, indexes, views, triggers and constraints in the new file. FTS5 tables are rebuilt, an FTS5 table with external content is rebuilt from the table it reads, columns declared UNINDEXED are kept, and R-Tree tables are carried with their rows. A table of 1,000 or more columns is copied, up to about 1,900 numbers per row, which is what fits one 32 KiB page. A table with a wider row fails with the status unsupported and the message a row of N columns does not fit one page.
A connection URL holds a password, and other users of the machine can see a program argument. You can leave the URL out and set INILLUCENT_SOURCE_URL instead. You can also write a single dash in place of the URL and type the URL on standard input.
The --kind index option, or the separate inillucent-migrate program, builds a database from a legacy retrieval index folder.
Import a SQLite file
A path is read as a SQLite file. The --kind option names the source type when the command cannot tell it from the argument.
The server is read over its own protocol, inside one snapshot. Each table is then checked twice: by a row count the server computes separately from the copy, and by a digest that does not depend on row order. The report prints every check, passed or failed. When a check fails, nothing is published, and the report says where the staged file is. The transport line says plaintext because the server is on the local machine. A server on any other host is reached over TLS with a verified certificate. The command refuses to use plaintext for that server unless you pass --insecure-plaintext and put sslmode=disable in the URL.
postgres://postgres@127.0.0.1:5433/doc_library -> library-from-postgres.rdb
PostgreSQL 17.2, 2 tables, 4 rows
transport: plaintext
pass structure.integrity every tree walks in key order on a fresh open
pass source.count.authors 2 rows, counted separately from the scan
pass count.authors 2 rows
pass digest.authors 524844e270589a8f07bedd02e7c89369a1cd5ed0d6bfe4e06ed12cbe62af22a8
pass source.count.books 2 rows, counted separately from the scan
pass count.books 2 rows
pass digest.books be5909cc041c098b53154531a69a79dca67986baaab8b293db54e11993ad92c9
published: library-from-postgres.rdb
24
Storage, recovery and concurrency
The engine stores data in 32 KiB pages and caches them in a 128 MiB buffer pool. Every change goes to a write ahead log first. Recovery replays the log each time the file opens.
A page is one fixed size block of the file. The buffer pool is the memory that holds recently used pages. The write ahead log, or WAL, records each change before the change reaches the database file. A checkpoint copies logged changes into the database file, so old log segments can be removed.
The log is a series of segment files next to the database, such as app.rdb-wal.0000000001. Each log record has a crc32c checksum. Recovery runs on every open and stops at the first record whose checksum does not match, so a record that was only partly written is ignored. A connection that can write and opened a file whose log held a transaction with no commit record folds the log when it closes, so the next open recovers nothing.
The file keeps two copies of its meta page, the page that records where everything is. A rollback journal keeps the earlier contents until a checkpoint is on disk, so a crash in the middle of a checkpoint is undone when the file next opens. An older copy of the database file put back beside the log segments of the newer file also opens. When a committed bulk build names a root page that the file does not hold, or holds from before the checkpoint, the open stops before it writes anything, renames the segments with .foreign added to their names, and opens the file as the copy was. The command line says so, and --output json carries log_segments_set_aside. A log that belongs to the file is replayed as before. A checkpoint makes the data pages durable with a sync of their own before it writes the meta record, so a power loss during the checkpoint cannot leave the new meta record beside a damaged page. A value too large for one page is stored in a run of pages of its own.
One writer works at a time. Writers that commit at the same moment share one disk flush, which is called group commit. A reader never sees half of a transaction. Several processes can use one file under PRAGMA locking_mode = normal, the default. Before each statement a connection checks whether another process committed. On Windows, while no other process has the log open, the connection keeps the length of the log itself and the check costs nothing; another process that opens the log waits until the connection has stopped using that length. Set INILLUCENT_LOG_OPLOCK=0 to turn this off. SharedDatabase lets many threads in one process share one database, one statement at a time. When the log moves to a new segment, every connection to the file sees the commits made before the move, and so does a file opened with ATTACH. On Windows, a reader notices a rollback journal that another process put back, because the process that restores a journal opens the log segments first and holds them until it is done.
Every file created by release 2.5.0 or later is file format 3. Releases 0.1.5 to 2.4.2 refuse such a file by name with the status unsupported and exit code 3. A file made by an earlier release opens with no migration and stays at format 2 until it stores a key out of line, until VACUUM rebuilds it, or until PRAGMA auto_vacuum is set to FULL or INCREMENTAL. A file never returns to a lower format.
A file moves to format 4 the first time ALTER TABLE ADD COLUMN with a literal default is run on a table that has rows, or a row of more than about 1,900 integers is stored. The log then holds a FormatRaised record. A release that reads only format 3 refuses the file by name with the status unsupported and exit code 3, and refuses a log that holds the record. A file that never gains such a column or such a row stays at the format it had.
Releases before 2.1.0 stored ALTER TABLE ADD COLUMN by writing the new column after the table constraints. Release 2.9.0 and later read such a table by moving its columns in front of its constraints, and write the corrected text back at the next checkpoint. A table, index or trigger whose stored text still cannot be read stops the open with a message that names it.
Each table and each index takes at least one 32 KiB page, so 3,000 tables with one index each take about 197 MB. SQLite, at 4 KiB pages, takes about 25 MB for the same tables.
The page size is 32 KiB. PRAGMA page_size on a new file does not change it. A database made at a page size other than 32 KiB keeps that page size when it is opened again, and VACUUM rebuilds it at the same size.
The buffer pool has 4,096 frames of 32 KiB. PRAGMA cache_size reports -131072, which means 128 MiB.
PRAGMA synchronous accepts OFF, NORMAL and FULL. FULL is the default.
PRAGMA busy_timeout sets how long a writer waits for another writer. The default is 5000 ms. An open waits as long as INILLUCENT_BUSY_TIMEOUT says, and the database then starts with that value.
All six journal modes work, and DELETE is the default. After PRAGMA journal_mode = wal, the file reopens in WAL mode.
A transaction can change more pages than the buffer pool holds, in every journal mode. The pages the pool cannot keep go to a temporary spill file that belongs to the connection, until the commit.
Crash tests stop a commit at each step and check that recovery leaves a correct file. They cover power loss in the DELETE, TRUNCATE and PERSIST journal modes, and I/O errors, a full disk and short writes in DELETE mode.
Storm tests run four writer processes and two reader processes on one file, kill them at random moments, and check every acknowledged transaction byte for byte afterwards, with a small page cache, the search table and an encrypted file. Readers check inside every transaction they open that no transaction is ever seen in part.
Read storage state
These values come from the open database. synchronous reports 2, which means FULL. PRAGMA page_count also works and counts the pages in this file.
Inillucent follows SQLite 3.53.4 and is tested against a pinned build of SQLite 3.53.4. The 416 case differential probe runs each case in both shells, each time on a new database, and compares the output byte for byte. 402 of 416 probed cases produce SQLite's exact bytes.
Of the other 14 cases, 6 are vector search features SQLite does not have, so SQLite has no answer to compare with. 2 are DELETE and UPDATE with ORDER BY and LIMIT, which Inillucent runs and the pinned SQLite build refuses. 6 print something different from SQLite.
Two of those 6 come from the 32 KiB page size: the page_size and page_count PRAGMAs, and one line of .recover. Two report on a SQLite library that is not in the program: .vfslist and .stats. EXPLAIN prints the operators of this engine, where SQLite prints its bytecode. .limit reports a trigger_depth of 1000, and the two SQLite reference builds disagree on that value.
None of the 416 cases is refused, but some other constructs are. inillucent capabilities lists 11 refused constructs, and SQLite answers most of them. An example is a full text MATCH under an OR whose pattern reads a column of another table. Six of the 11 fail with exit code 3 and the status unsupported. Five fail with exit code 1 and the status syntax: load_extension, a compound query ordered by an expression, writing to a view, RETURNING inside a trigger, and changing a row of sqlite_schema. inillucent query and the MCP tool inillucent_query report syntax for a statement that does not parse, such as SELEC 1.
Inillucent does not open a SQLite file directly: import the file instead. It does not run two statements at the same time, and it does not run two writers at the same time. It has no interface for loading a C extension. cancel stops a running statement at the next point where the executor checks for a cancel request, so a sort that has started finishes first. In Rust, Database::cancel_handle() gives a handle another thread can cancel with.
Speed: in the graded run of 11 October 2026, at 100,000 rows on Windows with release 2.5.4, the relational engine was 499% faster than SQLite 3.53.4 overall and used 77% less processor time, with 16% more peak memory. SQLite ran within 2% of its idle reference in each of the three runs. Every family is faster than SQLite: reads by key 3,304%, ranges 643%, scans and aggregates 1,020%, joins 394%, writes 312%, transactions 132%, JSON, FTS5 and R-Tree 127%, statement preparation 96% and index builds 33%. The benchmark times 116 workloads, the engine plan of 34 through the engine’s API and through the Connection API and 48 common and edge case workloads through the Connection API, and 110 of them are faster than SQLite. Of the six that are not, a range DELETE is 212% slower because folding the log into the database file lands on it in the benchmark’s fixed order; on its own it is level with SQLite. Compiling SELECT 1 on every call through the engine’s own API is 79% slower; through the Connection API, which keeps compiled plans, it is 66% faster. A join over an index range and an autocommit update are within 6% of SQLite in both APIs, where the same index probes and one disk sync per commit decide the time.
Through the Connection API that every driver calls, on 48 common and edge case workloads, release 2.5.4 is faster than SQLite on 47 of them: 297% faster on the 28 training workloads and 266% faster on the 20 held out workloads, using 66% less processor time. On 3 October 2026 release 2.1.1 was 18% and 49% slower on the same workloads and used 115% more processor time. Earlier runs are on the performance history page of the repository, and the hill climbing lab report tells how each number moved.
Called the way most programs call it, inillucent is faster than SQLite. Measured on 8 October 2026 on 25 workloads (one inillucent process per command, a SQL script piped into inillucent-shell, the Python driver and the npm package) against the same use of SQLite, release 2.4.2 is 74% faster overall, where release 2.1.5 was 216% slower: the npm package is 418% faster than node:sqlite, the Python driver 135% faster than the sqlite3 module in Python, scripts piped into the shell 49% faster, and the command line 6% faster than the sqlite3 shell. All 25 workloads are faster than SQLite, and seven are 200% faster or more, Python lookups by key among them at 427%. The command line is close to SQLite because starting a program costs both engines the same and is most of a short command. The performance page has every workload.
RIGHT JOIN and FULL OUTER JOIN work.
Recursive CTEs and correlated subqueries work.
STRICT tables, generated columns, CHECK, UNIQUE and foreign keys work.
BEFORE, AFTER and INSTEAD OF triggers work.
All four forms of ALTER TABLE work.
Partial indexes, indexes on expressions and indexes on WITHOUT ROWID tables work.
VACUUM and VACUUM INTO both rebuild the file. PRAGMA auto_vacuum = FULL and INCREMENTAL give pages back without a rebuild.
ATTACH, DETACH, temporary objects, savepoints and commits across two files work.
Encryption at rest works for the database file, the log and the journals, with PRAGMA rekey and ATTACH ... KEY.
Inillucent answers 172 of the 177 function names in the pinned SQLite library, all 67 pragmas in SQLite's pragma_list, and 63 of SQLite's 65 dot commands.
The shell and the driver report unsupported separately from syntax, constraint and I/O errors.
Ask what the engine supports
The capability table is checked against the running engine in both directions. A row that says yes and fails, or says no and works, fails the test. cancel is partial because a statement stops only where the executor checks for a cancel request. readonly_open is partial because the driver refuses writes while the file itself is still open for writing.
SQL / input
let table = inillucent_driver::CAPABILITIES;
let yes = table.iter().filter(|e| e.support.name() == "yes").count();
let no = table.iter().filter(|e| e.support.name() == "no").count();
let partial: Vec<&str> = table.iter()
.filter(|e| e.support.name() == "partial")
.map(|e| e.name)
.collect();
println!("{} capabilities reported", table.len());
println!(" supported: {yes}");
println!(" partial: {}", partial.join(", "));
println!(" refused: {no}");
A database opened with a key is encrypted on the disk with XChaCha20-Poly1305. The database file, the log and the journals hold ciphertext, and a byte changed without the key is found on the next read.
The key is a passphrase, or 32 raw bytes written as x' followed by 64 hex digits and '. A passphrase is turned into a key with PBKDF2-HMAC-SHA256 at 600,000 iterations, which costs about 200 ms each time the database is opened. A raw key has no such cost.
The command line, the shell and the MCP server read the key from a file named with --key-file, or from the INILLUCENT_KEY environment variable. None of them takes the key as a word on the command line, because other users of the machine can read a command line. The Rust driver has OpenOptions::key, the C library has inillucent_open_with_key, and the client libraries have a key option.
inillucent encrypt writes an encrypted copy of a plaintext database, and inillucent decrypt writes a plaintext copy of an encrypted one. PRAGMA rekey, or inillucent rekey, changes the key by rewriting one header in each file, so it takes the same time whatever the size of the database. PRAGMA encryption answers xchacha20-poly1305, or none for a plaintext database. It answers for the database the qualifier names, so PRAGMA aux.encryption reports the attached database aux and PRAGMA main.encryption reports the main one. It cannot be set: giving PRAGMA encryption a value fails and says so.
A wrong key, no key for an encrypted database, and a key for a plaintext database all fail with the status corrupt, which is what SQLite and SQLCipher answer for a file they cannot read as a database. An encrypted file is 1.6% larger at the default page size.
Encryption does not stop somebody who can write the file from putting back an older copy of it, and the size of each file can be seen without the key. The key is in memory while the database is open.
Every file the engine writes goes through one encrypting layer, so the log, the journals and the sort files are encrypted as well as the database file.
Each page is encrypted with its own random nonce and carries a tag, so a changed page is reported as damaged and never read back as rows.
Crash tests cut the power at every step of a commit and of a checkpoint on an encrypted database, and every cut recovers.
The ciphers are written in the project and checked against the test vectors their standards publish.
Create an encrypted database
The key lives in a file. The last command has no key and is refused.
created app.rdb
ok. 0 rows changed.
encryption
------------------
xchacha20-poly1305
Error [corrupt]: could not open "app.rdb": this database is encrypted. Open it with its key: --key-file, the INILLUCENT_KEY environment variable, or a driver's key option
Encrypt a database you already have
The source is read without a key and the copy is written with it. The source is left as it was.
A Swift package puts the engine inside an iPhone, iPad or Mac app, and a Gradle library puts it inside an Android app. The engine runs in the app's own process, and the database is one .rdb file in the app's storage, so the tables and the search indexes work offline.
Both packages call the same C library as the Python, Node, Go and PHP packages, so a statement gives the same answer on a phone as on a laptop. The Swift package links the C library as a static library inside an XCFramework, and Swift imports its header as the module InillucentFFI. The Android library links the C library and a small JNI layer into one libinillucent_jni.so for each processor family.
Everything in the earlier chapters runs on a phone: tables, joins, transactions, full text search with bm25, VECTOR columns, HNSW indexes and the inillucent_search table. Two things do not. embed() needs ONNX Runtime and a model file in the app, and neither package includes them, so embed() fails with the status unsupported. Compute the vectors with a model the app already runs, such as a Core ML model on iOS or a TensorFlow Lite model on Android, and bind them as a parameter. The command line, the shell and the MCP server are programs for a computer, and an app calls the library instead.
Put the file in the app's own storage: Application Support on iOS, and context.filesDir on Android. Bind every value with ?1, ?2 and so on. A vector binds as text in the JSON array form, or as a blob of little endian 32 bit floats, which is what an array of Float holds on both platforms.
The engine is single threaded for each file. Every Database holds a lock, and every call on it, on its connections and on its statements takes that lock, so the app can call the library from any thread. cancel() on a connection does not take the lock. Another thread can call it to stop a long statement, which then fails with the status interrupted.
Open the file with a key and the file and its log are encrypted with XChaCha20 and Poly1305, as chapter 26 describes. Keep the key in the Keychain on iOS and in the Android Keystore on Android.
Neither package is on Maven Central or a Swift package index yet. Both build from a checkout of the repository. Its page docs/mobile.md and the readmes in packages/swift and packages/android have the full API.
iOS 13 and macOS 11 or later. The XCFramework has slices for the iPhone, the simulator and the Mac. The package carries a privacy manifest that declares no tracking and no collected data.
Android 7.0, which is API 24, or later, for arm64-v8a, armeabi-v7a and x86_64. Every native library is aligned to 16 KB pages, which Google Play requires of an app that targets Android 15.
Both packages pass the same 35 case conformance suite as every other client library, on the iPhone simulator and on an Android emulator. The last example in this chapter shows both runs.
Terms used in this chapter
XCFramework
A folder Xcode reads that holds one library for each Apple platform, with its header.
AAR
An Android library archive. It holds the Kotlin classes and one native library for each processor family.
JNI
The Java Native Interface. It is how Kotlin and Java call a library written in C.
ABI
An Android processor family, such as arm64-v8a. The library is built once for each one.
Open a database and search it from Swift
The database lives in Application Support. The query vector comes from the app's own model and is bound as a blob, and the typed words are bound as text.
Swift
import Inillucent
let folder = try FileManager.default.url(
for: .applicationSupportDirectory, in: .userDomainMask,
appropriateFor: nil, create: true)
let db = try Database(url: folder.appendingPathComponent("app.rdb"))
let conn = try db.session()
try conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS note
USING inillucent_search(title, body, dims = 384)
""")
let vector = Data(bytes: embedding,
count: embedding.count * MemoryLayout<Float>.size)
for row in try conn.query("""
SELECT title, score(note) FROM note
WHERE note MATCH ?1 AND vector = ?2 AND k = 10
ORDER BY rank
""", [.text(typed), .blob(vector)]) {
print(row[0], row[1])
}
Open a database and search it from Kotlin
The same search on Android. The vector is written into a little endian byte buffer, which is how the engine reads a blob vector.
Kotlin
import com.blackrainbowlabs.inillucent.Database
Database.open(File(context.filesDir, "app.rdb")).use { db ->
db.session().use { conn ->
conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS note
USING inillucent_search(title, body, dims = 384)
""")
val vector = ByteBuffer.allocate(embedding.size * 4)
.order(ByteOrder.LITTLE_ENDIAN)
.apply { embedding.forEach { putFloat(it) } }
.array()
val rows = conn.query("""
SELECT title, score(note) FROM note
WHERE note MATCH ?1 AND vector = ?2 AND k = 10
ORDER BY rank
""", listOf(typed, vector))
for (row in rows) println("${row[0]} ${row[1]}")
}
}
The search a notes app runs
Three notes with four number vectors in place of the 384 a real model makes, and one search for tent stakes. The phone runs the same engine and returns the same rows. origin says whether a note matched the words, the vector or both.
SQL / input
CREATE VIRTUAL TABLE phone_note USING inillucent_search(title, body, dims = 4);
INSERT INTO phone_note (rowid, title, body, vector) VALUES
(1, 'Camping list', 'Tent, stakes, two sleeping bags and the blue stove.', '[0.9, 0.1, 0, 0.1]'),
(2, 'Garage shelves', 'The tent stakes are in the red bin on the top shelf.', '[0.8, 0.3, 0.1, 0]'),
(3, 'Dentist', 'Cleaning on the 14th at 9:30.', '[0, 0, 1, 0.2]');
SELECT title, round(score(phone_note), 3) AS score, origin(phone_note) AS origin
FROM phone_note
WHERE phone_note MATCH 'tent stakes' AND vector = '[0.8, 0.3, 0.1, 0]' AND k = 3
ORDER BY rank;
Resultobserved
| title | score | origin |
|----------------|-------|--------|
| Garage shelves | 1.0 | both |
| Camping list | 0.277 | both |
| Dentist | 0.0 | vector |
Build the Android library and run its tests
The script builds the library for the emulator, starts an emulator with no window, runs the conformance suite and the behavior tests on it, and checks the 16 KB alignment. This run was on Windows on 31 October 2026, on an Android 16 emulator image.
Shell
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
sh packages/android/run-tests.sh
Terminalobserved
starting inillucent_test as emulator-5584
booted in about 35s
Starting 11 tests on inillucent_test(AVD) - 16
Finished 11 tests on inillucent_test(AVD) - 16
BUILD SUCCESSFUL in 2m 20s
16 KB aligned: 2 libraries
conformance record: _agent_output/conformance/android.json
Run the iOS tests on a Mac
The script builds the XCFramework, then runs the tests with swift test on the Mac and with xcodebuild test on the newest iPhone simulator. This is the simulator run from the mobile workflow on GitHub on 31 October 2026, with Xcode 26.6.
Shell
sh packages/swift/run-tests.sh
Terminalobserved
Executed 11 tests, with 0 failures (0 unexpected) in 23.739 (24.419) seconds
** TEST SUCCEEDED **
28
Use Inillucent in a web page
Inillucent runs in a browser as WebAssembly. The engine, its SQL, full text search and vector search are the same code the command line runs, and a database written in a page is an ordinary .rdb file the command line opens.
Two npm packages carry it. inillucent-web is the client most pages want: connect, query, scalar, transactions, and openPersistent for a database the browser keeps. inillucent-wasm is the layer under it: the module inillucent.wasm, the WASI host it runs on, the two file systems, and the C ABI's calls in JavaScript. Install the first with npm install inillucent-web; it brings the second.
connect(name) is awaited once, because the browser compiles the module asynchronously. Every call after it is synchronous, as in the other clients. That database lives in the page's memory and is gone when the page is. exportDatabase() returns it as the bytes of one .rdb file, which a page can offer as a download, and connect(name, { bytes }) opens a file the page was given. This works on a page's main thread, in a worker and in Node 20 or later.
openPersistent(name) keeps a database in the browser's origin private file system, between visits. It starts a worker and runs the engine there, because a browser gives synchronous access to those files only inside a worker, and the engine reads and writes synchronously. Every call on it returns a promise. A database is one folder in OPFS, named after the first argument, and removePersistent(name) deletes it. The log and recovery are the same as on a disk, so a tab closed in the middle of a write is recovered on the next open.
A browser lets one worker hold an OPFS file. A second openPersistent of the same name, in the same tab or another, is refused with DatabaseBusyError until the first is closed. OPFS with synchronous access exists in Chrome 108, Firefox 111 and Safari 16.4, and later versions.
A bundler such as Vite, webpack, esbuild or Rollup finds the module and the worker through new URL(..., import.meta.url), which all four understand. When yours moves the module somewhere else, pass its URL as wasm to connect or openPersistent. Serve it as application/wasm, so the browser compiles it while it downloads.
embed() is not in the WebAssembly build, because the ONNX runtime it needs does not build for WebAssembly. Compute the vectors with a model the page already runs, or ahead of time, and bind them. The explorer on this site does the second: its film database was embedded on our machine with inillucent embed, and it carries precomputed query vectors for its example phrases.
A statement with thousands of nested parentheses can run out of the browser's own call stack before the engine's stack check sees it. The client then replaces the engine with a spare it keeps ready, reopens the database and throws too_big. Nothing committed is lost, and a transaction that was open is rolled back, as after a crash.
SQL, FTS5 with bm25, VECTOR columns, HNSW indexes, inillucent_search and encryption all work, with the same code as the native library.
The module is 6.6 MiB, and 2.3 MiB compressed with gzip.
One thread. A parallel HNSW build runs the same work in order.
Both packages pass the 35 cases of the driver conformance suite under Node, and openPersistent is tested in Chromium and Firefox through Playwright.
Terms used in this chapter
WebAssembly module
A .wasm file a browser compiles and runs. Here it is the engine.
WASI
A fixed list of functions a module calls to reach files, clocks and randomness. Browsers do not provide them, so inillucent-wasm does.
OPFS
The origin private file system: files a browser keeps for one site, between visits.
worker
A script a page starts that runs on its own thread.
The WebAssembly build is the native engine built for one more target, wasm32-wasip1. These are the places a page sees a difference.
The native library and the WebAssembly build
Native library
WebAssembly
SQL, FTS5, vector search, encryption
yes
yes, the same code
The 35 conformance cases
pass
pass, under Node and in the clients repository's runner
Files
the disk
memory, or OPFS in a worker
Threads
a parallel HNSW build uses every core
one thread: the same work runs in order
embed()
in a build with the embed feature
not available
Two openers of one file
locked by the operating system
one worker holds a database; others are refused
Download size
not applicable
6.6 MiB, 2.3 MiB with gzip
A database in memory
Create a table, write two rows, read them back, and open a copy from the exported bytes. This is the output of the code under Node 24 with inillucent-web 0.1.0; a page prints the same.
JavaScript
import { connect } from 'inillucent-web';
const db = await connect('notes.rdb');
db.execute('CREATE TABLE note (id INTEGER PRIMARY KEY, body TEXT)');
db.execute('INSERT INTO note (body) VALUES (?1)', ['hello']);
db.execute('INSERT INTO note (body) VALUES (?1)', ['from a web page']);
const rows = db.execute('SELECT id, body FROM note ORDER BY id');
console.log(rows.objects());
console.log(rows.columns, rows.total);
const bytes = db.exportDatabase();
const copy = await connect('copy.rdb', { bytes });
console.log(copy.scalar('SELECT count(*) FROM note'));
An FTS5 table with highlight, and a VECTOR column with an HNSW index. The query plan shows the vector search used the index.
JavaScript
import { connect } from 'inillucent-web';
const db = await connect('search.rdb');
db.executeBatch(`
CREATE VIRTUAL TABLE doc USING fts5(body);
INSERT INTO doc (body) VALUES ('a database in the browser'), ('a database on a server');
CREATE TABLE passage (id INTEGER PRIMARY KEY, body TEXT, v VECTOR(3));
INSERT INTO passage (body, v) VALUES ('north', '[1, 0, 0]'), ('east', '[0, 1, 0]');
CREATE INDEX passage_v ON passage USING inillucent_hnsw (v);
`);
console.log(db.query("SELECT highlight(doc, 0, '[', ']') AS body FROM doc WHERE doc MATCH 'browser'"));
console.log(db.query("SELECT body FROM passage ORDER BY vector_distance_cos(v, '[0.9, 0.1, 0]') LIMIT 1"));
console.log(db.execute("EXPLAIN QUERY PLAN SELECT body FROM passage ORDER BY vector_distance_cos(v, '[0.9, 0.1, 0]') LIMIT 1").rows[0][3]);
Terminalobserved
[ { body: 'a database in the [browser]' } ]
[ { body: 'north' } ]
SEARCH passage USING VECTOR INDEX passage_v (k=1)
Read an error
A failure is an `InillucentError` with `statusName` set to one of the driver's names. A construct the WebAssembly build does not have is an `UnsupportedError`, and `feature` names it.
JavaScript
import { connect, InillucentError, UnsupportedError } from 'inillucent-web';
const db = await connect('app.rdb');
for (const sql of ['SELECT * FROM missing', 'SELEC 1', "SELECT embed('hello')"]) {
try {
db.execute(sql);
} catch (error) {
if (error instanceof UnsupportedError) console.log('unsupported:', error.feature);
else if (error instanceof InillucentError) console.log(error.statusName, '-', error.engineMessage);
}
}
Terminalobserved
not_found - no such table: missing
syntax - near "SELEC": syntax error
unsupported: embed(TEXT): this build has no embedding support compiled in
Keep a database between visits
openPersistent runs in a worker on OPFS, so every call returns a promise. A transaction commits when its function resolves and rolls back when it throws. This needs a browser, so no output is shown.
JavaScript
import { openPersistent, removePersistent } from 'inillucent-web';
const db = await openPersistent('notes');
await db.executeBatch('CREATE TABLE IF NOT EXISTS note (id INTEGER PRIMARY KEY, body TEXT)');
await db.execute('INSERT INTO note (body) VALUES (?1)', ['still here tomorrow']);
await db.transaction(async (tx) => {
await tx.execute('UPDATE note SET body = upper(body)');
});
console.log(await db.query('SELECT body FROM note'));
const file = await db.exportDatabase(); // the same .rdb bytes, from the worker
await db.close();
// await removePersistent('notes'); // deletes the folder from OPFS
Open a database file the page downloads
How the explorer opens its film database: it fetches the gzipped .rdb, unzips it with the browser's DecompressionStream, and passes the bytes to connect or openPersistent.
JavaScript
const response = await fetch('/demo/films.rdb.gz');
const gzipped = new Blob([await response.arrayBuffer()]);
const bytes = new Uint8Array(await new Response(
gzipped.stream().pipeThrough(new DecompressionStream('gzip'))).arrayBuffer());
// In memory, for this visit:
const db = await connect('films.rdb', { bytes });
// Or kept between visits; bytes are used only the first time:
const kept = await openPersistent('films', { bytes });
Build the module from source
build.mjs builds the C ABI driver for wasm32-wasip1 with the release profile and copies the module into packages/web/dist. Run it from a checkout of the engine repository.
Shell
rustup target add wasm32-wasip1
node packages/web/build.mjs # writes packages/web/dist/inillucent.wasm
npm --prefix packages/web test # the 35 conformance cases and the engine tests, under Node