qql-embed
Shared embedding resolution: host-agnostic [Embedder] trait, local
wire-compatible BM25 [SparseEmbedder], [resolve_embeddings], and schema
[resolve_query_vector_kinds].
Proposition
One embed owner for every backend. No Qdrant I/O, no HTTP client. Runtime
(HttpEmbedder), edge (FastEmbedder), and WASM adapters implement the trait.
Schema fills USING kinds before embed; unknown kinds fail closed
(QQL-VECTOR-KIND) — never silent dense defaults for named vectors.
Embedder trait
Dense embedding is batched by model when the target is single-vector dense.
Sparse is role-split: queries embed with unit term weights
(embed_sparse_query), documents with BM25 term-frequency saturation
(embed_sparse_document) — both matching Qdrant's qdrant/bm25 defaults
(tunable via Embedder::bm25_params, see
SparseEmbedder).
Multivector defaults reject until the host opts in (embed_multi), as does
image embedding (embed_image).
FastEmbed-style host mapping
| Host capability | QQL method | Shape |
|---|---|---|
Sentence / CLIP text dense (TextEmbedding) |
embed_dense |
[f32] |
CLIP vision / image dense (ImageEmbedding) |
embed_image |
[f32] |
| Sparse query (BM25 / SPLADE) | embed_sparse_query |
indices + values |
| Sparse document (BM25 / SPLADE) | embed_sparse_document / _batch |
indices + values |
ColBERT / BGE-M3 ColBERT bags (Bgem3Embedding.colbert) |
embed_multi |
[[f32],…] |
Cross-encoder pair scores (TextRerank) |
rerank_pairs |
per-document [f32] |
CLIP is dual-encoder dense, never multivector. Multivector is late-interaction bags only.
Language:
QUERY IMAGE 'path-or-url' [MODEL '…']→embed_image→DenseUPSERT … USING IMAGE MODEL '…' ON FIELD image INTO image
Schema topology before embed
Parse leaves USING name as kind: null. Execution prep must fill kinds:
use ;
// From collection schema (runtime / WASM):
let topology = TopologyNames ;
resolve_query_vector_kinds?;
resolve_embeddings.await?;
| After topology | TEXT embed result |
|---|---|
| kind Dense, multi false | Dense([f32…]) via embed_dense_batch |
| kind Sparse | Sparse { indices, values } via embed_sparse |
| kind Dense, multi true | MultiDense([[f32…],…]) via embed_multi |
| kind still null | QQL-VECTOR-KIND — never silent dense default |
resolve_embeddings — AST rewriter
use ;
let mut stmt = parse.unwrap;
resolve_embeddings.await?;
// stmt now has text → dense vector for point[0]
Resolution happens in these cases:
| Statement | Input source | Output |
|---|---|---|
QUERY 'text' ... USING name AS DENSE |
Bare string or TEXT '...' |
Dense vector |
QUERY 'text' ... USING name AS SPARSE |
Bare string or TEXT '...' |
Sparse vector |
QUERY 'text' ... USING name AS MULTI |
Bare string or TEXT '...' |
Multivector → MultiDense |
QUERY 'text' ... USING name (no AS) |
Bare string or TEXT '...' |
Errors unless kinds were filled by resolve_query_vector_kinds first (schema may set multivector) |
QUERY RERANK TEXT … MODEL 'm' USING colbert |
Rerank text | Dense or MultiDense using model m |
QUERY HYBRID TEXT '...' |
Hybrid text | Dense + sparse pair expanded to Fusion |
UPSERT ... USING DENSE MODEL 'm' |
Payload text field | Dense vector per point |
UPSERT ... USING HYBRID |
Payload text field | Dense + sparse vectors per point |
UPSERT ... EMBED title INTO vec |
Explicit source field | Dense/sparse via embed directive |
| Auto-embed (no USING) | Payload text/body/content |
Default dense only |
Explicit VECTOR / POINT |
— | No embedding |
Vector roles and default names
Query targets carry an optional role (DENSE or SPARSE) plus a multi flag.
Arbitrary names such as semantic_v2 and lexical_v2 are supported; embedding
behavior never depends on a target literally being named dense or sparse.
DENSE_VECTOR_NAME:"dense"(constant)SPARSE_VECTOR_NAME:"sparse"(constant)
These constants are used only when materializing a new default topology.
SparseEmbedder — local wire-compatible BM25
Client-side BM25 that is wire-compatible with Qdrant's qdrant/bm25 model:
murmur3-32 token IDs (same hash the server uses), word tokenizer (split on
non-alphanumeric), Unicode lowercasing, English stopword removal, and English
snowball stemming — the server's documented defaults. Queries embed with unit
term weights; documents with BM25 tf saturation (k1=1.2, b=0.75, avg_len=256).
IDF is applied server-side via the sparse vector modifier: idf. No network,
no model downloads. A synchronous helper backing the default
Embedder::embed_sparse_query / embed_sparse_document implementations.
use SparseEmbedder;
let q = embed_query; // unit weights
let d = embed_document; // tf saturation
// q/d.indices: [u32; N], q/d.values: [f32; N]
Tuning k1, b, avg_len
qql_embed::Bm25Params makes the BM25 hyperparameters configurable:
use ;
// Defaults: 1.2 / 0.75 / 256 (Qdrant qdrant/bm25).
let params = new?;
let d = embed_document_with_params;
// or, via the helper:
let d = embed_document_with;
This is a client-side, write-path-only setting:
- It shapes documents only —
k1controls tf saturation,bcontrols length normalization, andavg_lenis the expected average document length in tokens. A wrongavg_lensilently misjudges every document (rare terms and long docs get the wrong normalization), so estimate it from the corpus being written. - It does not change query-side weights (always unit) and does not
change server-side
qdrant/bm25inference — the server keeps its own defaults unless configured separately. - It is not a collection/wire setting: existing vectors keep the weights they were written with. Re-ingest to apply a change.
- Invalid values fail closed with
QQL-VALIDATION-CONFIG:k1 >= 0(0= binary weighting, like Qdrant's validator),bin[0, 1],avg_len > 0, all finite (NaN/±Inf rejected).
Unset configuration is byte-identical to the previous hardcoded behavior
(Bm25Params::default() == 1.2 / 0.75 / 256).
Host surfaces (numeric knobs everywhere; text knobs wherever the host exposes them — see each row):
| Host | How to set |
|---|---|
| Rust | qql_embed::Bm25Params / Bm25TextConfig; Embedder::bm25_params / bm25_text_config overrides; HttpEmbedderOptions { bm25_k1, …, bm25_language, bm25_tokenizer, … }; qql::config::QqlConfig.bm25_*; qql_edge::{LocalExecutorOptions, FastEmbedderOptions} |
Python (pyqql) |
pyqql.HttpEmbedder(..., bm25_k1=, …, bm25_language=, bm25_tokenizer=, …) or the embedder={...} dict keys |
Python (pyqql-edge) |
local_executor(..., bm25_k1=, …, bm25_language=, …), one-shot execute/execute_async kwargs, http_executor(..., bm25_k1=, …, bm25_stopwords=, bm25_stemmer=, …) |
Node (nqql) |
new Client({ embedder: { bm25K1, …, bm25Language, bm25Tokenizer, … } }) (snake_case aliases accepted) |
Node (nqql-edge) |
localExecutor(dir, { bm25K1, …, bm25Language, … }), standalone execute({ bm25K1, … }) |
| CLIs | qql config edge --bm25-k1/…/--bm25-language/--bm25-tokenizer/… + QQL_EDGE_BM25_*; remote CLI config ~/.qql/config.json bm25_* |
| WASM | client.setBm25Params(k1, b, avgLen) + client.setBm25Text({…}) |
They only apply when the built-in local BM25 encoder is used. When an ONNX sparse model (SPLADE / BGE-M3) or a remote sparse endpoint is configured, sparse vectors come from that model and these parameters are inert.
Vectors produced here can be mixed with server-side qdrant/bm25 inference on
the same collection (a golden test pins the exact server output from the
Qdrant docs).
Text processing: languages, tokenizers, folding
qql_embed::Bm25TextConfig mirrors Qdrant's Bm25Config option surface with
the same defaults (word tokenizer, English, lowercase on, folding off,
language stopwords/stemmer, no length limits):
use Bm25TextConfig;
// Spanish pipeline: Spanish stopwords + Snowball stemmer, like Qdrant's
// `options: {"language": "spanish"}`.
let config = resolve?;
let pipeline = config.pipeline;
let d = pipeline.embed_document?;
Thirty languages carry Qdrant's stopword lists; seventeen add a Snowball
stemmer (Armenian and Tamil are explicit-stemmer-only, like Qdrant — no
language variant, no stopword lists); the rest pass tokens through, exactly like Qdrant, whose stemmer
defaults resolve per language the same way). ASCII folding uses Qdrant's
Lucene-derived mapping. multilingual parses but fails closed at embed
time — script-aware segmentation (charabia/vaporetto) is intentionally
not a dependency of this lean core. The qql-edge engine path forwards the
same knobs to Qdrant's real pipeline instead, and a cross-implementation
test asserts both produce identical vectors.
Estimating avg_len from real data
The 256 default assumes document-length text; titles and tags need far
smaller values. estimate_avg_len measures the mean post-pipeline token
count over real field texts — the same doc_len the formula consumes — and
Executor::estimate_bm25_avg_len(collection, field, sample) samples it
straight from a collection via SCROLL:
let estimate = executor
.estimate_bm25_avg_len
.await?
.expect;
// feed estimate.mean back into bm25_avg_len, then re-ingest to apply
None means the sample held no usable texts (keep the default rather than
storing a meaningless zero).
Like the server defaults, the default pipeline is English-only (snowball
English stemmer + English stopwords). Other languages select
Bm25TextConfig above; non-English corpora that need server-side inference
should still use qdrant/bm25 with explicit language / stemmer /
stopwords options instead.
Known WASM limitation
WASM Client prepares statements like the native executor: fetch collection
topology, resolve kinds, then embed (when an embedder is configured). Hosts that
need ColBERT must implement embed_multi on their embedder adapter.
Features
std(default):std::error::Errorimpl- All types are
Send + Syncon non-wasm targets;?Sendon wasm32
Verification
Tests cover:
- Dense / sparse / multi query resolution
- Fail-closed
USING namewithout kind - Schema multivector → MultiDense
- RERANK + AS MULTI
- Hybrid, UPSERT, EMBED directives
- Sparse BM25 tokenization