fenecdb

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

Typesbool 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)
Metricscosine l2 dot
Operators= != < <= > >=, ~ text contains (case-insensitive), has list contains, in [..], is null
Logicand or notand binds tighter
Parameters$1, $2, … as in PostgreSQL
Functionslower upper len coalesce now timestamp cosine l2 dot norm normalize, plus plugins
Orderingorder year desc, title asc — a tie on the first key is decided by the second; id can be ordered too
Countingcount returns one row with one column; it does not combine with select, near, match, order, limit or offset
Ceilingsnear 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 10

ef 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 10

match 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 language

prefix=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 10

Counting

get articles where year >= 2024 count
-- one row, one column

Update and delete

set articles {year: 2025} where id = 42
del articles where year < 2000

Inspecting the database

collections
describe articles
compact articles

compact 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.