FenecQL
A small query language with vectors in the core. It is not
SQL and does not try to be, though the familiar select … from word
order is accepted.
Statements
create collection [if not exists] <name> ( <field> <type> [@index], ... )
drop collection [if exists] <name>
create index [if not exists] on <name> (<field>) @index
put <name> { field: value, ... } -- or [ {...}, {...} ]
get <name> [select a, b] [where <expr>]
[near <field> <vector> [ef N] [exact]]
[match <field> <text>] [rerank <field> <vector> [candidates N]]
[order <field> [asc|desc], ...] [limit N] [offset N]
get <name> [where <expr>] count -- number of matching rows
select a, b from <name> [where ...] -- the classic SQL order works too
set <name> { field: value } [where <expr>]
del <name> [where <expr>]
collections | describe <name> | compact [<name>]The id field is automatic. Supplying id inside a
put turns it into an upsert.
Reference
| Types | bool int float text bytes timestamp vector<N[, f16]> [type] |
| Indexes | @hash, @hnsw(metric, m=.., ef_construction=.., ef_search=..) |
| Defaults | @hnsw(cosine, m=16, ef_construction=200, ef_search=100) |
| Metrics | cosine l2 dot |
| Operators | = != < <= > >=, ~ text contains (case-insensitive), has list contains, in [..], is null |
| Logic | and or not — and binds tighter |
| Parameters | $1, $2, … as in PostgreSQL |
| Functions | lower upper len coalesce now timestamp cosine l2 dot norm normalize, plus plugins |
| Ordering | order year desc, title asc — a tie on the first key is decided by the second; id can be ordered too |
| Counting | count returns one row with one column; it does not combine with select, near, match, order, limit or offset |
| Ceilings | near and match at most 10 000 rows (limit + offset), rerank at most 10 000 candidates; expression depth 512 levels — see Limits |
The vector clause
get articles near embed $1 limit 10
get articles where year >= 2024 near embed $1 ef 200 limit 10
get articles near embed $1 exact limit 10ef raises the search beam for this query only, trading latency
for recall. exact replaces the ANN walk with a full scan, which is
how you verify recall against ground truth. A query that uses
near gains a _score column.
A filter next to near is planned, not stacked: the filter set
is extracted first and the planner picks between scanning it directly and
running the ANN with a membership test. The mechanism, and the fallback that
keeps it correct, is in How
it works.
The text clause
get articles match body $1 limit 10
get articles where year >= 2024 match body $1 limit 10
get articles match body $1 rerank embed $2 candidates 500 limit 10match ranks by BM25 over a @text index and adds a
_score column. Like near it decides the ordering, so
it combines with neither order nor near. A
where filter is applied while the postings are merged, so it costs
no second pass.
rerank reorders what match found by exact vector
distance. It needs match — there is nothing to reorder without it
— but it does not need an @hnsw index: the vectors are read
straight out of the store, so this path builds, holds and validates no graph at
all. candidates sets how many of the matched rows are rescored
(default 200); it is never allowed below limit + offset.
The two stages answer different questions. match is
recall and knows nothing about meaning; rerank is precision but
only over what the first stage handed it. Measured on BEIR: on SciFact
50 candidates score nDCG@10 0.676 against 0.645 for a full dense scan of the
same vectors, and on FiQA 1 000 candidates reproduce the full scan exactly —
each while scoring under 2% of the corpus. If the lexical stage cannot find
the answer at all, no amount of reranking will.
Indexes
create collection articles (
title text,
body text @text(k1=0.9, b=0.4, prefix=0),
year int @hash,
embed vector<768> @hnsw(cosine, m=16, ef_construction=200)
)
create index on articles (embed) @hnsw(cosine)
create index on articles (body) @text
create index on articles (body) @text(prefix=6) -- for an inflected languageprefix=N indexes each word's prefixes alongside it
(3..N by default; prefix_min moves the lower bound). It is a
stemmer that needs no dictionary and no language setting, and it is off
unless asked for because it is a trade the corpus decides. Word-boundary
matching suits English; it does not suit a language that inflects by gluing
suffixes on, where kitap, kitabı and
kitapların are three terms sharing a stem the index never
sees.
Measured on Turkish WebFAQ (144 846 documents, 10 000 queries): nDCG@10 0.4841 → 0.5547, +14.6%, closing about two fifths of the gap to a 278M-parameter multilingual transformer (0.650). The cost is the 3.4× postings it indexes — 58 MB → 182 MB, and 82 → 485 µs per query. On English SciFact the same option is worth +0.0137.
Only @hash equalities inside an and chain
reach an index. Every other predicate — >=,
~, has, in, and anything under an
or — is a full scan: each matching row is decoded and evaluated.
~ in particular is substring matching without ranking and reaches
no index; ranked retrieval is match over a @text
field. order has no top-k either: every match is sorted and then
limit applies.
Time
timestamp holds UTC epoch milliseconds as an
i64. Writes accept both text and numbers; a text literal in a
comparison is parsed.
put events {name: "login", t: "2026-09-19T12:34:56Z"}
put events {name: "logout", t: 1758285296000} -- epoch ms
get events where t >= "2026-01-01" and t < now()The representation is always ISO-8601
(2026-09-19T12:34:56.789Z). On the wire fenec-pg
reports PostgreSQL's own output format
(2026-09-19 12:34:56.789+00) and the timestamptz OID,
because client parsers expect the server format. The calendar conversion is
integer arithmetic including leap-year and century rules — no table, no
dependency.
It exists as a separate type rather than an alias over int
because ResultSet does not carry the schema, so an alias would
show every client a raw number.
Bulk loading
For a bulk load, build the index afterwards. The write path becomes a pure append and the graph is built in one parallel pass.
create collection docs (title text, embed vector<768>)
put docs [ ... 100 000 documents ... ]
create index on docs (embed) @hnsw(cosine)Worked examples
A hybrid query
get articles select title, year
where year >= 2024 and tags has "rust" and not (title ~ "draft")
near embed $1 ef 128
limit 10Counting
get articles where year >= 2024 count
-- one row, one columnUpdate and delete
set articles {year: 2025} where id = 42
del articles where year < 2000Inspecting the database
collections
describe articles
compact articlescompact is a full rebuild rather than a garbage collection:
every index is built from scratch even with zero dead bytes, and writes block
throughout. See Limits.