Qdrant 1.20 - Full-Text Search Improvements

19 minread4.20scoreNo. 310-27 Infra issue
Rel 5Nov 3Prac 5Evid 3Time 4

Teams that run retrieval on Qdrant, or are evaluating vector databases, should read the 1.20 release notes. It is not a paper, but three changes directly affect retrieval quality and machine cost. In one sentence: keyword search no longer requires converting text into sparse vectors, and vector storage gains a format with the same bytes but lower error. Key numbers: nested filters on 100,000 points ran 7 to 38 times faster; the new 16-bit turbo16 format has 7.5 times lower reconstruction error than float16; the 8-bit turbo8 takes half the space of float16 and searches 1.1 to 2.4 times faster.

What changed in this release?

Ranked by impact on retrieval teams:

ChangeBeforeIn 1.20Who should care
BM25 directly on full-text indexesConvert text to sparse vectors and track average document length yourselfIndex computes statistics; query directlyKeyword and hybrid search teams
Code tokenizergetUserName indexed as one tokenSplit on underscores and case changesCode search teams
Per-element indexes for nested filtersIndexes only narrowed candidates; payloads read to verifyAnswered from indexes aloneData with arrays of objects
turbo16 and turbo8 datatypesfloat16, or self-quantized uint8Rotate then quantize, automaticallyLarge vector sets, tight memory
Memory tiers and internal authID tracker always in RAM; internal port on public addressCan live on disk; bind to private interfaceOps and upgrade owners

Why move BM25 from sparse vectors to the full-text index?

BM25, the standard keyword ranking formula, scores a document from how often the query term appears, how rare the term is in the corpus, and how much longer the document is than average. Previously, every document and query had to be converted into a sparse vector, with the average length computed and baked in by hand.

The problem is that the average goes stale. A hand-computed illustration: the length term is k×(1−b+b×length÷avg), with common values k=1.2 and b=0.75; a 10-word document contains the query term twice.

Average lengthLength termTerm-frequency score 2×2.2÷(2+term)
5 (current)1.2×(0.25+0.75×2)=2.14.4÷4.1≈1.07
10 (stale value from build time)1.2×(0.25+0.75×1)=1.24.4÷3.2≈1.38

The same document scores about 28% higher only because the average was not updated, and fixing it meant re-converting every document. In 1.20 the full-text index computes these statistics itself, so text becomes BM25-searchable once indexed, with no model inference. k and b can be set per query without rebuilding. Existing sparse-BM25 users can migrate without re-uploading: create a scoring-enabled full-text index with the same text processing and switch to text queries.

What does the code tokenizer fix?

The default tokenizer splits on spaces and punctuation, so getUserName is a single token and searches for "user name" or "get_user_name" miss it. The code tokenizer splits identifiers on underscores and case changes, including acronyms, so all three forms match. It also indexes the full identifier, so hashmap still finds HashMap. Combined with BM25, code search can be ranked without embeddings.

Why were nested filters slow?

A nested filter finds points where a single array element satisfies all conditions. Illustration: product X has two offers, shop A at 50 and shop B at 200. The filter is "shop B and price below 100." The old shop index says X has a shop-B offer, and the price index says X has an offer below 100, so X becomes a candidate even though the conditions come from different offers. Qdrant had to read X's payload to reject it. A per-element index records each offer separately: the shop match is offer 2, the price match is offer 1, so X is rejected without reading the payload. On 100,000 points with three nested filters, even with all payloads in memory (the best case for reading them), per-element indexes were 7 to 38 times faster. The search API is unchanged.

turbo16: same 16 bits, why less error?

float16 has relative precision: larger values get coarser spacing. With 10 mantissa bits, adjacent representable numbers between 16 and 32 differ by 2^4÷2^10≈0.016, while near 0.01 the spacing is about 0.00001. Some embedding models produce a few very large coordinates (the post names text-embedding-ada-002, CLIP, and Cohere), and their rounding error hurts overall precision. turbo16 first applies the TurboQuant rotation, which Qdrant's docs describe as a fast random rotation: multiplying by a random orthogonal matrix preserves lengths and distances but mixes every coordinate into all others, so one large coordinate is spread across many. In two dimensions, (4, 0) rotated by 45 degrees becomes about (2.83, 2.83). Each rotated coordinate is then rounded to one uniform scale recorded as a 16-bit integer (65,536 steps); if the scale spanned −1 to 1 (illustrative), steps would be about 0.00003 for every coordinate.

Across 13 datasets, turbo16 reconstruction error was 7.5 times lower than float16. On 12 datasets with exact search, turbo16 recall@10 matched float32 within 0.001 everywhere, while float16 dropped to 0.995 on ada-002 and CLIP. With HNSW, a graph-based approximate nearest-neighbor index, turbo16 median latency was 0.81 times float16 on x86 and 0.83 times on ARM, so the same bytes buy better precision and speed.

turbo8: what does halving again cost?

From the Qdrant 1.20 post: recall@10 of turbo4, turbo8, and float16 on six datasets ordered by dimension, HNSW on x86.
From the Qdrant 1.20 post: recall@10 of turbo4, turbo8, and float16 on six datasets ordered by dimension, HNSW on x86.

turbo8 stores 1 byte per dimension and, unlike uint8, takes regular float vectors and quantizes them for you. Its recall is close to float16, and its largest gain over the 4-bit turbo4 is on low-dimensional data: on SIFT (128d), turbo4 is about 0.8 and turbo8 about 0.97 by the chart, because with fewer dimensions each one carries more information. Across 11 datasets turbo8 searched 1.1 to 2.4 times faster than float16, mostly within 10% of turbo4. Against float32 plus scalar quantization with rescoring, turbo8 had higher recall on 10 of 11 datasets and tied on the other at similar latency, storing about a fifth of the bytes.

Raw vector storage computed from bits per dimension (illustrative): 1 million 1536-d vectors take about 6.14 GB in float32, 3.07 GB in float16 or turbo16, 1.54 GB in uint8 or turbo8, and 0.77 GB in turbo4.
Raw vector storage computed from bits per dimension (illustrative): 1 million 1536-d vectors take about 6.14 GB in float32, 3.07 GB in float16 or turbo16, 1.54 GB in uint8 or turbo8, and 0.77 GB in turbo4.

The same 8-bit encoding is available as bits8 quantization, compressing about 4 times versus 8 times for the default bits4.

Upgrade pitfalls

Memory tiers: pinned stays in RAM, cached lives on disk and is loaded into the page cache when a segment opens, cold lives on disk and is read on demand. Inline storage now keeps original vectors only in the HNSW index file, saving disk, but the vectors follow the index's memory tier. The index defaults to cached, so with vectors.memory set to cold and nothing else, originals become cached and quantized vectors become pinned in RAM; set hnsw_config.memory before upgrading. Internal requests now require a valid read-write key when api_key is set, and clusters with only a read-only key will lose peer communication unless they add api_key or disable enforcement. Separately, the ID tracker, about 52 bytes per point or roughly 5 GB for 100 million points, can now live on disk.

Limitations I see

All numbers are vendor benchmarks; the 7–38× nested-filter result covers only 100,000 points and three filters. turbo16's precision gain concentrates on models with a few large coordinates; elsewhere float16 is already near float32 and the main benefit is the 17–19% latency drop. BM25 on full-text indexes solves keyword ranking only; fusing keyword and vector scores in hybrid search still needs your own experiments.

How a retrieval team could try it

Check inline-storage memory tiers and key configuration in staging before upgrading. Migrate one sparse-BM25 collection to a full-text index and compare rankings and latency on the same queries. Store a sample of your production embeddings as float16, turbo16, and turbo8 and compare recall@10, latency, and memory. If your data has object arrays, add per-element indexes and load-test real filters.

Verdict

The standout is turbo8 reaching higher recall than float32 plus scalar quantization at a fifth of the storage, a real cost cut for large, memory-bound retrieval services.

DimensionAssessment
Problem importanceMedium-high: ranking accuracy, nested-filter speed, and vector storage cost are daily pain points
NoveltyMedium: rotate-then-quantize and per-element indexes are known ideas; the value is shipping them as defaults
EvidenceMedium: many datasets, but vendor-run, and nested filters tested on only 100,000 points
Engineering costLow to medium: mostly index or config changes; upgrades need care on memory tiers and auth
Worth tryingYes, Qdrant users should evaluate turbo8 and full-text BM25 first

How did this read for you?

One tap, no sign-in; tap again to undo. Only anonymous counts are kept, never who you are.

Discussion

Add experiment details, reproduction notes or a different take. Requires a GitHub sign-in.

Loading the discussion…