Text search
Ranked words, typos, highlights and facets, then words and meaning in one ranking.
match ranks a collection's documents by how well their text
matches your words, and the rest of a search box hangs off it:
fuzzy and prefix for typos and words begun,
highlight() and snippet() for where the words are,
and facet for counts beside the page.
Run in the shell, answers and all; the timings are from one run.
fenec> create collection articles (title text @text, body text @text, year int @sorted, topic text @hash);
collection `articles` created
fenec> put articles [{title: "Ownership in Rust", body: "Ownership and borrowing keep memory safe without a garbage collector", year: 2023, topic: "rust"}, {title: "A tour of borrowing", body: "Borrowing lets a function read a value without taking ownership of it", year: 2024, topic: "rust"}, {title: "Garbage collection pauses", body: "A collector that pauses the program trades latency for convenience", year: 2021, topic: "runtime"}, {title: "Indexes for search", body: "An inverted index maps each word to the documents that hold it", year: 2024, topic: "search"}];
4 records (143.88µs)
fenec> get articles select title match body "borrowing"
title _score
------------------- ------
Ownership in Rust 0.7053
A tour of borrowing 0.6814
(2 rows, 377.67µs)
fenec> get articles select title where year >= 2024 match body "ownership"
title _score
------------------- ------
A tour of borrowing 0.6814
(1 rows, 503.92µs)
fenec> get articles select title match title "ownrship" fuzzy
title _score
----------------- ------
Ownership in Rust 1.2218
(1 rows, 345.96µs)
fenec> get articles select title, highlight(body) match body "borrowing"
title highlight(body) _score
------------------- --------------- ------
Ownership in Rust [[14, 23]] 0.7053
A tour of borrowing [[0, 9]] 0.6814
(2 rows, 450.00µs)
fenec> get articles select title match body "collector" facet topic
title _score
------------------------- ------
Ownership in Rust 0.7053
Garbage collection pauses 0.7053
(2 rows, 432.21µs)
facet topic: runtime 1, rust 1match needs a @text index and adds a
_score column. A where narrows the rows while they are
ranked; fuzzy finds ownrship's word; highlight
answers the offsets of the words in a row's text; and facet counts
the values of a field over every matched row, beside the rows (the shell prints
them on the last line). Words and meaning in one ranking, fuse, are
under Hybrid search; the vectors are on
Vector search.
The text clause
match ranks documents by how well their text matches your
words. rerank then reorders the best of them by vector
distance.
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 10await db.from('articles').match('body', 'ownership').limit(10).rows();
await db.from('articles')
.where('year', '>=', 2024)
.match('body', 'ownership')
.limit(10)
.rows();
await db.from('articles')
.match('body', 'ownership')
.rerank('embed', [0.9, 0.1, 0], { candidates: 500 })
.limit(10)
.rows();db.collection("articles").match("body", "ownership").limit(10).rows()
(db.collection("articles")
.where("year", ">=", 2024)
.match("body", "ownership")
.limit(10)
.rows())
(db.collection("articles")
.match("body", "ownership")
.rerank("embed", [0.9, 0.1, 0], candidates=500)
.limit(10)
.rows())db.From("articles").Match("body", "ownership").Limit(10).Rows(ctx)
db.From("articles").
Where("year", ">=", 2024).
Match("body", "ownership").
Limit(10).
Rows(ctx)
db.From("articles").
Match("body", "ownership").
Rerank("embed", []float64{0.9, 0.1, 0}, fenecdb.Candidates(500)).
Limit(10).
Rows(ctx)await db.From("articles")
.Match("body", "ownership")
.Limit(10)
.RowsAsync();
await db.From("articles")
.Where("year", ">=", 2024)
.Match("body", "ownership")
.Limit(10)
.RowsAsync();
await db.From("articles")
.Match("body", "ownership")
.Rerank("embed", new[] { 0.9, 0.1, 0 }, candidates: 500)
.Limit(10)
.RowsAsync();try await db.from("articles")
.match("body", "ownership")
.limit(10)
.rows()
try await db.from("articles")
.where("year", ">=", 2024)
.match("body", "ownership")
.limit(10)
.rows()
try await db.from("articles")
.match("body", "ownership")
.rerank("embed", [0.9, 0.1, 0], candidates: 500)
.limit(10)
.rows()db.from("articles").match("body", "ownership").limit(10).rows()
db.from("articles")
.where("year", ">=", 2024)
.match("body", "ownership")
.limit(10)
.rows()
db.from("articles")
.match("body", "ownership")
.rerank("embed", listOf(0.9, 0.1, 0.0), candidates = 500)
.limit(10)
.rows()await db.from('articles').match('body', 'ownership').limit(10).rows();
await db.from('articles')
.where('year', '>=', 2024)
.match('body', 'ownership')
.limit(10)
.rows();
await db.from('articles')
.match('body', 'ownership')
.rerank('embed', [0.9, 0.1, 0], candidates: 500)
.limit(10)
.rows();get articles match body $1 limit $2
-- ["ownership", 10]
get articles where year >= $1 match body $2 limit $3
-- [2024, "ownership", 10]
get articles match body $1 rerank embed $2 candidates 500 limit $3
-- ["ownership", [0.9, 0.1, 0], 10]matchneeds a@textindex and adds a_scorecolumn.rerankneeds no vector index. It reads the vectors of the matched rows directly.- Reranking cannot find what the words missed. For that, use hybrid search.
How match filters, and what rerank is for
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 — unless
fuse says how the two rankings make one (below). 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.656 against 0.645 for a full dense scan of the
same vectors, and on FiQA 1 000 candidates come within 0.0001 of the full scan —
each while scoring under 2% of the corpus. If the lexical stage cannot find
the answer at all, no amount of reranking will.
Typos and words begun
fuzzy lets a word of a match find the words a
typo or two away from it, and prefix lets the last word find
the words it begins — what a search box needs as someone types into it.
get products match title $1 fuzzy limit 10 -- "databse" finds "database"
get products match title $1 prefix limit 10 -- "datab" finds "database", "databases"
get products match title $1 fuzzy prefix limit 10 -- both, the last word a misspelt beginning
get products match title $1 fuzzy 1 limit 10 -- one edit at most- A word's length decides its edits: none under 5 characters,
one from 5 to 8, two from 9.
fuzzy 1allows one at most,fuzzy 0none. - A word as typed ranks first. A document holding the word itself scores more from it than one holding only a word near it.
- A word reaches 10 000 words of the index at most, and the query is refused past that rather than cut — a prefix of one letter over a large vocabulary is.
highlight()andsnippet()mark what was found:databsemarksdatabase.
How an edit is counted, and the BEIR results
An edit is a letter added, dropped, changed, or two letters next to
each other swapped — Damerau's distance in its restricted form, as Lucene,
Elasticsearch and Meilisearch count it: the four are what Damerau found
four misspellings in five to be, and a swap counted as two edits would put
a five-letter word with one out of reach. An edit that touches a word's first letter counts two, as
Meilisearch counts it: a first letter is rarely the mistyped one, and
letting it be multiplies what is found by words no one meant. The bounds by
length are Meilisearch's too: a short word has too many neighbours —
cart is one letter from card, care,
cars, cat and art. Words are folded
as the index folds them before they are compared, so
İSTANBL finds istanbul. Only the last word is a
prefix; the words before it are whole.
The words a word reaches count as one term of the query, a document's counts of them summed, so BM25's saturation holds over them as over a word written three times, and the term's rarity is theirs together — a rare misspelling in the corpus ranks no higher than the word it misspells. A word one edit away, or one a prefix begins, counts as the word would; two edits away, a quarter as much. Where the index holds the word as typed, its variants together are kept below the least it adds to a document, so a one-word query ranks every document holding it above every document holding only what is near it. Measured on BEIR with one typo put into each query, a word one edit away was the word meant and two edits away as often another word, which the weights follow.
The words are found in a sorted copy of the index's words, made the
first time a match asks for fuzzy or
prefix and kept up by every write after it, as the index is:
derived, never in the file. A prefix is a range of it; the words within a
few edits are found by walking it as the tree of prefixes it is, giving a
branch up once no word below it can be near enough. Over 100 000 documents
of 60 000 words, sorting them took 6 ms and 1.3 MB; finding a word's
neighbours over FiQA's 135 500 words, 14 µs at one edit and 120 at two, its
completions 4 µs.
Measured on BEIR, nDCG@10 (make beir), one
realistic typo put into a word of each query — a letter dropped, a key
beside it typed as well or instead, or two letters swapped, the first
letter 7% of the time:
SciFact's queries as written score 0.662 and 0.665 with
fuzzy; with the typo 0.636, and 0.663 with
fuzzy. FiQA's 0.232 and 0.233; with the typo 0.213, and
0.229. With each query's last word half typed, prefix takes
SciFact from 0.616 to 0.655 and FiQA from 0.188 to 0.208, and leaves the
queries written whole where they were. See the whole table.
Hybrid search
fuse combines a text search and a vector search into one
ranking. Use it when some answers share your words and others only your
meaning.
A filter beside near is another thing, though it is also called
a hybrid query: A hybrid query
on Vector search.
get articles match body $1 near embed $2 fuse limit 10
get articles where year >= 2024 match body $1 near embed $2 fuse limit 10
get articles match body $1 near embed $2 fuse k 60 candidates 50 limit 10- It adds ranks, not scores, so there is no weight to tune.
- It needs an
@hnswindex for the vector side.
How fuse adds ranks, and the BEIR results
fuse lets match and near rank the
same query together. Each side takes its own first candidates —
20 unless given, never fewer than limit + offset — with the
where filter applied to both, and a document scores
1 / (k + rank) from each list it is on, k being 60
unless given. The sum is the _score; ties go to the lower id.
Ranks rather than scores, because the two have no scale in common: a BM25
score grows with the length of the query and the rarity of its words, a
cosine distance does neither, and a weighted sum would need its weight tuned
again for every corpus. Where rerank can only reorder what the
words found, fuse also takes what they missed — at the price of
the @hnsw graph near walks.
Measured on BEIR, nDCG@10, all-MiniLM-L6-v2 vectors (the whole table). On SciFact,
where a claim shares its words with the evidence, fuse scores
0.700 against 0.662 for match and 0.645 for near
alone. On FiQA, where a question shares few words with its answer, it scores
0.366 against 0.232 and 0.366: the weak side does not drag the strong one
down. Up to 61 candidates a side at k = 60, a document both
searches found outranks every document only one found; deeper, agreement in
the middle of both lists starts to outvote the top of one, and at 100 a side
the two scores fall to 0.688 and 0.359.
Highlights and snippets
highlight() and snippet() in the select list
show where the words of a match are in a row's text, so a
results page can mark them.
get articles select title, highlight(body) match body $1 limit 10
get articles select title, highlight(body, "<mark>", "</mark>") match body $1 limit 10
get articles select title, snippet(body, 20, "…") match body $1 limit 10
get articles select title, snippet(body, 20, $2, $3, $4) match body $1 limit 10await db.from('articles')
.select('title')
.highlight('body')
.match('body', 'ownership')
.limit(10)
.rows();
await db.from('articles')
.select('title')
.highlight('body', { pre: '<mark>', post: '</mark>' })
.match('body', 'ownership')
.limit(10)
.rows();
await db.from('articles')
.select('title')
.snippet('body', 20, { ellipsis: '…' })
.match('body', 'ownership')
.limit(10)
.rows();
await db.from('articles')
.select('title')
.snippet('body', 20, { ellipsis: '…', pre: '<b>', post: '</b>' })
.match('body', 'ownership')
.limit(10)
.rows();(db.collection("articles")
.select("title")
.highlight("body")
.match("body", "ownership")
.limit(10)
.rows())
(db.collection("articles")
.select("title")
.highlight("body", pre="<mark>", post="</mark>")
.match("body", "ownership")
.limit(10)
.rows())
(db.collection("articles")
.select("title")
.snippet("body", 20, ellipsis="…")
.match("body", "ownership")
.limit(10)
.rows())
(db.collection("articles")
.select("title")
.snippet("body", 20, ellipsis="…", pre="<b>", post="</b>")
.match("body", "ownership")
.limit(10)
.rows())db.From("articles").
Select("title").
Highlight("body").
Match("body", "ownership").
Limit(10).
Rows(ctx)
db.From("articles").
Select("title").
Highlight("body", fenecdb.Tags("<mark>", "</mark>")).
Match("body", "ownership").
Limit(10).
Rows(ctx)
db.From("articles").
Select("title").
Snippet("body", 20, fenecdb.Ellipsis("…")).
Match("body", "ownership").
Limit(10).
Rows(ctx)
db.From("articles").
Select("title").
Snippet("body", 20, fenecdb.Ellipsis("…"), fenecdb.Tags("<b>", "</b>")).
Match("body", "ownership").
Limit(10).
Rows(ctx)await db.From("articles")
.Select("title")
.Highlight("body")
.Match("body", "ownership")
.Limit(10)
.RowsAsync();
await db.From("articles")
.Select("title")
.Highlight("body", pre: "<mark>", post: "</mark>")
.Match("body", "ownership")
.Limit(10)
.RowsAsync();
await db.From("articles")
.Select("title")
.Snippet("body", 20, ellipsis: "…")
.Match("body", "ownership")
.Limit(10)
.RowsAsync();
await db.From("articles")
.Select("title")
.Snippet("body", 20, ellipsis: "…", pre: "<b>", post: "</b>")
.Match("body", "ownership")
.Limit(10)
.RowsAsync();try await db.from("articles")
.select("title")
.highlight("body")
.match("body", "ownership")
.limit(10)
.rows()
try await db.from("articles")
.select("title")
.highlight("body", pre: "<mark>", post: "</mark>")
.match("body", "ownership")
.limit(10)
.rows()
try await db.from("articles")
.select("title")
.snippet("body", 20, ellipsis: "…")
.match("body", "ownership")
.limit(10)
.rows()
try await db.from("articles")
.select("title")
.snippet("body", 20, ellipsis: "…", pre: "<b>", post: "</b>")
.match("body", "ownership")
.limit(10)
.rows()db.from("articles")
.select("title")
.highlight("body")
.match("body", "ownership")
.limit(10)
.rows()
db.from("articles")
.select("title")
.highlight("body", pre = "<mark>", post = "</mark>")
.match("body", "ownership")
.limit(10)
.rows()
db.from("articles")
.select("title")
.snippet("body", 20, ellipsis = "…")
.match("body", "ownership")
.limit(10)
.rows()
db.from("articles")
.select("title")
.snippet("body", 20, ellipsis = "…", pre = "<b>", post = "</b>")
.match("body", "ownership")
.limit(10)
.rows()await db.from('articles')
.select(['title'])
.highlight('body')
.match('body', 'ownership')
.limit(10)
.rows();
await db.from('articles')
.select(['title'])
.highlight('body', pre: '<mark>', post: '</mark>')
.match('body', 'ownership')
.limit(10)
.rows();
await db.from('articles')
.select(['title'])
.snippet('body', 20, ellipsis: '…')
.match('body', 'ownership')
.limit(10)
.rows();
await db.from('articles')
.select(['title'])
.snippet('body', 20, ellipsis: '…', pre: '<b>', post: '</b>')
.match('body', 'ownership')
.limit(10)
.rows();get articles select title, highlight(body) match body $1 limit $2
-- ["ownership", 10]
get articles select title, highlight(body, $1, $2) match body $3 limit $4
-- ["<mark>", "</mark>", "ownership", 10]
get articles select title, snippet(body, 20, $1) match body $2 limit $3
-- ["…", "ownership", 10]
get articles select title, snippet(body, 20, $1, $2, $3) match body $4 limit $5
-- ["…", "<b>", "</b>", "ownership", 10]- Without tags you get positions:
highlight(body)is a list of[start, end]pairs;snippet(body, 20)is{"text": …, "marks": [[start, end], …]}. - With tags you get the marked text, each mark between the two values you pass — literals or parameters. The engine adds no markup of its own and escapes nothing.
- Offsets count UTF-16 code units, what a JavaScript, Java,
Kotlin, C#, Dart or Swift
NSStringindex counts:text.slice(start, end)is the marked word. - They need
match, and go withfuseandrerank; a query withoutmatchhas no words to mark, and is refused.
How marks are found, and what they cost
A row's text is read again at query time by the tokenizer of the
@text index the match searched — the same splitting,
the same folding (İstanbul, ISTANBUL and
istanbul are one word), the same prefixes and runs of
characters — and every span a term of the query was read from is marked. So
a mark is exactly what the index matched: a word; the whole word a prefix
of @text(prefix=N) was cut from (kitapların for
kitap); the characters of a pair or a triple of Han, kana,
Hangul or Thai, overlapping pairs made one mark (東京都 for
東京都). Nothing is kept in the index for it, and the file does
not change. Marks come in order and never overlap; a mark never ends inside
a character, and takes the marks that belong to the characters at its ends
— a combining accent, a Thai tone mark, the variation selector and keycap
after a digit — so tagged text never splits a letter from its accent.
Any text field can be marked, read by the tokenizer of the
field match searched: select highlight(title)
beside match body $1 marks the title's words the query holds.
Under fuse a row only near found holds no word of
the query and has no marks; a null field is null. Each item
answers under its label, highlight(body) or
snippet(body), after the fields select names, where
the list puts it — and after every field with select *,
highlight(body). The same item twice is refused: the two columns would
answer to one name.
A snippet is the window of N words holding the most marks,
centred on the first and the last of them — the first N words
when none is marked — each character of a run of Han, kana or Hangul counted
a word. The ellipsis, when given, stands for what it leaves out at either
end; a window holding the start or the end of the text has none there. Its
marks count from the start of its text, the ellipsis
included.
The text is not escaped. A page that puts a marked text into
HTML lets the text's own < through. Build the page from the
offsets instead — the text as text nodes, each mark a
<mark> element — or escape the text first and the tags
after.
Over 100 000 documents of 30 words, a mark costs a row about
1 to 3 µs (2 to 4 for a snippet) on top of the match; a
query that asks none runs as it did, match 0.06 ms either way
(make search-bench).
Facets
facet counts the values of a field over every row the query
matches — not only the page — for a filter sidebar: how many results each
brand, color or tag has.
get products where price < 500 limit 20 facet brand, color
get products where price < 500 match title $1 limit 20 facet brand top 10, tags
get products where category = "phones" count facet brand
get products limit 0 facet meta.origin top 5await db.from('products')
.where('price', '<', 500)
.limit(20)
.facet('brand')
.facet('color')
.rows();
await db.from('products')
.where('price', '<', 500)
.match('title', 'tent')
.limit(20)
.facet('brand', { top: 10 })
.facet('tags')
.rows();
await db.from('products')
.where('category', 'phones')
.facet('brand')
.count();
await db.from('products')
.limit(0)
.facet('meta.origin', { top: 5 })
.rows();(db.collection("products")
.where("price", "<", 500)
.limit(20)
.facet("brand")
.facet("color")
.rows())
(db.collection("products")
.where("price", "<", 500)
.match("title", "tent")
.limit(20)
.facet("brand", top=10)
.facet("tags")
.rows())
(db.collection("products")
.where("category", "phones")
.facet("brand")
.count())
db.collection("products").limit(0).facet("meta.origin", top=5).rows()db.From("products").
Where("price", "<", 500).
Limit(20).
Facet("brand").
Facet("color").
Rows(ctx)
db.From("products").
Where("price", "<", 500).
Match("title", "tent").
Limit(20).
Facet("brand", fenecdb.Top(10)).
Facet("tags").
Rows(ctx)
db.From("products").
Where("category", "=", "phones").
Facet("brand").
Count(ctx)
db.From("products").
Limit(0).
Facet("meta.origin", fenecdb.Top(5)).
Rows(ctx)await db.From("products")
.Where("price", "<", 500)
.Limit(20)
.Facet("brand")
.Facet("color")
.RowsAsync();
await db.From("products")
.Where("price", "<", 500)
.Match("title", "tent")
.Limit(20)
.Facet("brand", top: 10)
.Facet("tags")
.RowsAsync();
await db.From("products")
.Where("category", "phones")
.Facet("brand")
.CountAsync();
await db.From("products")
.Limit(0)
.Facet("meta.origin", top: 5)
.RowsAsync();try await db.from("products")
.where("price", "<", 500)
.limit(20)
.facet("brand")
.facet("color")
.rows()
try await db.from("products")
.where("price", "<", 500)
.match("title", "tent")
.limit(20)
.facet("brand", top: 10)
.facet("tags")
.rows()
try await db.from("products")
.where("category", "phones")
.facet("brand")
.count()
try await db.from("products")
.limit(0)
.facet("meta.origin", top: 5)
.rows()db.from("products")
.where("price", "<", 500)
.limit(20)
.facet("brand")
.facet("color")
.rows()
db.from("products")
.where("price", "<", 500)
.match("title", "tent")
.limit(20)
.facet("brand", top = 10)
.facet("tags")
.rows()
db.from("products").where("category", "phones").facet("brand").count()
db.from("products").limit(0).facet("meta.origin", top = 5).rows()await db.from('products')
.where('price', '<', 500)
.limit(20)
.facet('brand')
.facet('color')
.rows();
await db.from('products')
.where('price', '<', 500)
.match('title', 'tent')
.limit(20)
.facet('brand', top: 10)
.facet('tags')
.rows();
await db.from('products')
.where('category', 'phones')
.facet('brand')
.count();
await db.from('products').limit(0).facet('meta.origin', top: 5).rows();get products where price < $1 limit $2 facet brand, color
-- [500, 20]
get products where price < $1 match title $2 limit $3 facet brand top $4, tags
-- [500, "tent", 20, 10]
get products where category = $1 count facet brand
-- ["phones"]
get products limit $1 facet meta.origin top $2
-- [0, 5]{"rows": [ ... ],
"facets": {"brand": [{"value": "acme", "count": 12}, {"value": "nova", "count": 7}],
"color": [{"value": "red", "count": 9}, {"value": null, "count": 2}]}}- Most first, ties by value;
top Nkeeps the commonestN. - A list counts each value it holds: a row tagged
["sale", "new"]counts once under each. nullis a value, so a field's counts add up to the rows.- They come back beside the rows, never in one: the answer is
{"rows": [...], "facets": {...}}when a query asks for facets, and the bare list of rows when it does not.
Which rows a facet counts, and the cost
The rows counted are the rows the query selects before
offset and limit: those the where
keeps, and with match, those holding a word of the query
(rerank reorders them and counts the same). A
lookup ... required decides who is counted, as it decides who
is on the page; with count the facets go beside the number.
near ranks every row the filter passes rather than selecting
some, so counts beside it would be the filter's alone whatever the vector:
a facet with near or fuse is refused. Ask them
without near, in the same /batch.
A field is a field or a path into a json one. The counts are
group ... count's over the same filter, value for value
(tests/facets.rs holds them to it), but for a list, counted a
value at a time, and a path, which group does not read. A
@hash field of text, int, bool or timestamp is counted from its
buckets — over every row a bucket's length, under a filter how many of a
bucket's rows the set holds — and anything else reads the field of every
matched row. Without top a facet answers every value it found,
and one holding more than 10 000 is refused rather than cut: a list cut where
it happened to stop is a wrong answer believed right.
Over 100 000 rows (make search-bench): a @hash
field over every row 0.01 ms, read from every row 6.1 ms
(a list of tags 17); under a filter keeping half, 5.8
and 9.3 ms against the filter's own 5.9 ms
for a count. Beside a match reaching about
16 667 rows, two facets took a query from 0.06 to 1.6 ms.
Counted without its own filter
With a brand chosen, a sidebar's "Brand" should still list the other
brands to add, each with the count it would have. disjunctive
counts a facet over the rows the query selects with the filter's own
conditions on that field left out; the page, and every other facet, stay
the query's.
get products where category = "tents" and brand in ["acme", "nova"] and color = "red"
order price asc limit 24
facet brand disjunctive, color disjunctive, materialawait db.from('products')
.where('category', 'tents')
.where('brand', 'in', ['acme', 'nova'])
.where('color', 'red')
.order('price')
.limit(24)
.facet('brand', { disjunctive: true })
.facet('color', { disjunctive: true })
.facet('material')
.rows();(db.collection("products")
.where("category", "tents")
.where("brand", "in", ["acme", "nova"])
.where("color", "red")
.order("price")
.limit(24)
.facet("brand", disjunctive=True)
.facet("color", disjunctive=True)
.facet("material")
.rows())db.From("products").
Where("category", "=", "tents").
Where("brand", "in", []any{"acme", "nova"}).
Where("color", "=", "red").
Order("price", "asc").
Limit(24).
Facet("brand", fenecdb.Disjunctive()).
Facet("color", fenecdb.Disjunctive()).
Facet("material").
Rows(ctx)await db.From("products")
.Where("category", "tents")
.Where("brand", "in", new object?[] { "acme", "nova" })
.Where("color", "red")
.Order("price")
.Limit(24)
.Facet("brand", disjunctive: true)
.Facet("color", disjunctive: true)
.Facet("material")
.RowsAsync();try await db.from("products")
.where("category", "tents")
.where("brand", "in", ["acme", "nova"])
.where("color", "red")
.order("price")
.limit(24)
.facet("brand", disjunctive: true)
.facet("color", disjunctive: true)
.facet("material")
.rows()db.from("products")
.where("category", "tents")
.where("brand", "in", listOf("acme", "nova"))
.where("color", "red")
.order("price")
.limit(24)
.facet("brand", disjunctive = true)
.facet("color", disjunctive = true)
.facet("material")
.rows()await db.from('products')
.where('category', 'tents')
.where('brand', 'in', ['acme', 'nova'])
.where('color', 'red')
.order('price')
.limit(24)
.facet('brand', disjunctive: true)
.facet('color', disjunctive: true)
.facet('material')
.rows();get products where category = $1 and brand in [$2, $3] and color = $4 order price asc limit $5 facet brand disjunctive, color disjunctive, material
-- ["tents", "acme", "nova", "red", 24]Here brand counts over the red tents of every brand,
color over the acme and nova tents of every colour, and
material over the page's rows. A condition left out is a term
of the where's and chain that reads the field
alone — brand = ..., brand in [...], (brand =
"a" or brand = "b"), a range — and one reading the field beside
another (brand = "a" or sale) is refused rather than guessed
at. A token's rules and a collection's expiry are never left out: they are
ANDed into each facet's own filter too.
Ranges
ranges [b1, b2, ...] counts the rows whose number falls in
each range — from a bound, included, to the next, excluded — rather than by
value: every range, in order, an empty one with 0, its value the
[from, to] pair. A null, a NaN and a number
outside every range count in none; a list counts once in each range a value
of it falls in.
get products where category = "tents" limit 24
facet price ranges [0, 2500, 5000, 10000, 25000] disjunctiveawait db.from('products')
.where('category', 'tents')
.limit(24)
.facet('price', { ranges: [0, 2500, 5000, 10000, 25000], disjunctive: true })
.rows();(db.collection("products")
.where("category", "tents")
.limit(24)
.facet("price", ranges=[0, 2500, 5000, 10000, 25000], disjunctive=True)
.rows())db.From("products").
Where("category", "=", "tents").
Limit(24).
Facet(
"price",
fenecdb.Ranges(0, 2500, 5000, 10000, 25000),
fenecdb.Disjunctive(),
).
Rows(ctx)await db.From("products")
.Where("category", "tents")
.Limit(24)
.Facet("price", ranges: [0, 2500, 5000, 10000, 25000], disjunctive: true)
.RowsAsync();try await db.from("products")
.where("category", "tents")
.limit(24)
.facet("price", ranges: [0, 2500, 5000, 10000, 25000], disjunctive: true)
.rows()db.from("products")
.where("category", "tents")
.limit(24)
.facet(
"price",
ranges = listOf(0, 2500, 5000, 10000, 25000),
disjunctive = true
)
.rows()await db.from('products')
.where('category', 'tents')
.limit(24)
.facet('price', ranges: [0, 2500, 5000, 10000, 25000], disjunctive: true)
.rows();get products where category = $1 limit $2 facet price ranges [0, 2500, 5000, 10000, 25000] disjunctive
-- ["tents", 24]"price": [{"value": [0, 2500], "count": 31}, {"value": [2500, 5000], "count": 112},
{"value": [5000, 10000], "count": 204}, {"value": [10000, 25000], "count": 96}]The bounds are numbers written out, rising, two to 10 001 of them, and no
top goes with them. A @sorted field counts them from
its index when every bound is a key it holds exactly — over every row two
searches a range and the lengths of the chunks between, under a filter its
entries walked against the filter's rows — and anything else reads the field
of every row (12.5 against an int field is read).
Ranges and facets, measured on 100 000 rows
Over 100 000 rows (make search-bench, an M1 Air): six ranges
of a @sorted price over every row 0.004 ms against 4.7 ms read
from every row; under a filter keeping 3%, 0.15 against 0.19 ms; keeping
half, 5.1 against 8.1. A category page with a brand and a colour chosen,
its page and two disjunctive facets beside a range facet in one statement,
took 0.75 ms in process against 0.75 ms for the three statements it was
before; over HTTP 0.95 ms against 1.19 for the three in turn and 0.61 for
the three at once on three connections. What one statement buys is one
request, one connection and counts read under one lock — the same rows
every facet counted — not less work.