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,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:
| Host | How to set |
|---|---|
| Rust | qql_embed::Bm25Params; Embedder::bm25_params override; HttpEmbedderOptions { bm25_k1, bm25_b, bm25_avg_len, .. }; qql::config::QqlConfig.bm25_*; qql_edge::{LocalExecutorOptions, FastEmbedderOptions} |
Python (pyqql) |
pyqql.HttpEmbedder(..., bm25_k1=, bm25_b=, bm25_avg_len=) or the embedder={...} dict keys |
Python (pyqql-edge) |
local_executor(..., bm25_k1=, bm25_b=, bm25_avg_len=), one-shot execute/execute_async kwargs, http_executor(..., bm25_k1=, ...) |
Node (nqql) |
new Client({ embedder: { bm25K1, bm25B, bm25AvgLen } }) (snake_case aliases accepted) |
Node (nqql-edge) |
localExecutor(dir, { bm25K1, bm25B, bm25AvgLen }), httpExecutor(..., bm25K1, bm25B, bm25AvgLen), standalone execute({ bm25K1, ... }) |
| CLIs | qql config edge --bm25-k1/--bm25-b/--bm25-avg-len + QQL_EDGE_BM25_*; remote CLI config ~/.qql/config.json bm25_k1/bm25_b/bm25_avg_len |
| WASM | client.setBm25Params(k1, b, avgLen) |
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).
Like the server defaults, the pipeline is English-only (snowball English
stemmer + English stopwords). Non-English corpora should use server-side
qdrant/bm25 inference 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