bge-m3 producing dense and sparse representations from one pass, a cross-encoder as the quality step everyone skips, Qdrant as the store, and the morphology problem that makes Polish behave differently.
Retrieval is a two-stage problem, and conflating the stages is why so many systems return plausible but wrong passages. Stage one is recall: get the right chunk into a candidate set of fifty, cheaply. Stage two is precision: order those fifty properly, expensively. Vector search is good at the first and mediocre at the second, which is why the reranker matters so much.
For a mixed Polish and English corpus, bge-m3 is the default recommendation and the reason is architectural rather than a leaderboard position: it produces dense, sparse (lexical-weighted) and multi-vector representations from a single forward pass, over 100+ languages, under an MIT licence. Getting both halves of a hybrid search from one model removes an entire class of "the two indexes disagree about tokenisation" problems.
The alternative worth considering is the Qwen3-Embedding family, which leads multilingual retrieval benchmarks in its larger sizes — the 0.6B fits in about 1.5 GB and the 8B is stronger still, and on this machine you can afford the 8B without thinking about it. If you go that route you will need a separate sparse index.
Do not load the embedding model inside your ingestion script. Serve it, so the same instance serves ingestion, query time and any other tool, and so its memory is accounted for once.
# A dedicated embedding server, OpenAI-compatible
docker run -d --name embeddings --gpus all -p 8001:8001 \
-v /mnt/models:/models \
<your-gb10-vllm-image> \
vllm serve /models/bge-m3 \
--task embed --served-model-name bge-m3 \
--host 0.0.0.0 --port 8001
curl -s localhost:8001/v1/embeddings \
-H 'Content-Type: application/json' \
-d '{"model":"bge-m3","input":["umowa najmu lokalu użytkowego"]}' \
| jq '.data[0].embedding | length'
The infinity server is a good alternative if you want embeddings and reranking from one process; either is fine. Together the embedding model and the reranker are a couple of gigabytes — a rounding error next to the generation model's ~95 GiB.
Dense vectors handle morphology reasonably well: the embedding for umowie lands near the embedding for umowa because the model learned they are related. Keyword search does not get that for free.
Polish inflects heavily. Umowa, umowy, umowie, umową, umów are one lemma and five surface forms. A BM25 index built with English stemming — the default in most stacks — treats them as five unrelated tokens, so a query in one form misses documents written in another. English speakers rarely notice this class of bug because English morphology is thin enough that the default mostly works.
Three ways to handle it, in increasing order of effort:
lang field from the previous article was for.There is also a query-side trick that costs nothing: expand the user's question into two or three paraphrases with the LLM before searching, and union the results. On an inflected language this quietly solves a good deal of the surface-form mismatch, and it helps in English too.
Qdrant is a good default here — one binary, native hybrid support, straightforward filtering on metadata. Weaviate is equally reasonable; the GB10 stacks in circulation use both.
docker run -d --name qdrant -p 6333:6333 \
-v /mnt/rag/qdrant:/qdrant/storage qdrant/qdrant
# /opt/spark/rag/index.py — build a hybrid collection
import json, httpx
from qdrant_client import QdrantClient, models
EMB = "http://127.0.0.1:8001/v1/embeddings"
qc = QdrantClient(url="http://127.0.0.1:6333")
COLLECTION = "corpus"
qc.recreate_collection(
COLLECTION,
vectors_config={"dense": models.VectorParams(
size=1024, distance=models.Distance.COSINE)},
sparse_vectors_config={"sparse": models.SparseVectorParams()},
)
# Metadata fields you will filter on must be indexed explicitly
for field, schema in [("lang", "keyword"), ("doc_type", "keyword"),
("modified", "keyword"), ("source", "keyword")]:
qc.create_payload_index(COLLECTION, field_name=field,
field_schema=schema)
def embed(texts: list[str]) -> list[list[float]]:
r = httpx.post(EMB, json={"model": "bge-m3", "input": texts}, timeout=120)
r.raise_for_status()
return [d["embedding"] for d in r.json()["data"]]
def index(chunks: list[dict], batch: int = 32):
for i in range(0, len(chunks), batch):
part = chunks[i:i + batch]
vecs = embed([c["embed_text"] for c in part])
qc.upsert(COLLECTION, points=[
models.PointStruct(
id=c["id"],
vector={"dense": v},
payload={"text": c["text"], **c["meta"]},
) for c, v in zip(part, vecs)])
print(f"indexed {i + len(part)}/{len(chunks)}")
if __name__ == "__main__":
chunks = [json.loads(l) for l in open("/mnt/corpus/chunks/all.jsonl")]
index(chunks)
Index the embed_text — the version carrying the heading path — and store the plain text in the payload for display. That distinction, established in the previous article, is what makes fragments retrievable and citations readable at the same time.
A cross-encoder reads the query and a candidate passage together and scores the pair. That is fundamentally more informative than comparing two independently computed vectors, and it is why reranking typically improves answer quality more than any other single change. It is also why it cannot be used for search itself: scoring every chunk in the corpus against every query is infeasible. Hence the two stages.
bge-reranker-v2-m3 pairs naturally with bge-m3 — same family, same multilingual coverage, Apache-2.0. Retrieve 40–50 candidates, rerank, keep the top 5–8.
# /opt/spark/rag/search.py
import httpx
from qdrant_client import QdrantClient, models
EMB = "http://127.0.0.1:8001/v1/embeddings"
RERANK = "http://127.0.0.1:8002/rerank"
qc = QdrantClient(url="http://127.0.0.1:6333")
CANDIDATES, KEEP = 50, 6
def search(question: str, lang: str | None = None, keep: int = KEEP):
qv = httpx.post(EMB, json={"model": "bge-m3", "input": [question]},
timeout=60).json()["data"][0]["embedding"]
flt = None
if lang:
flt = models.Filter(must=[models.FieldCondition(
key="lang", match=models.MatchValue(value=lang))])
hits = qc.search(
"corpus", query_vector=("dense", qv), limit=CANDIDATES,
query_filter=flt, with_payload=True)
if not hits:
return []
scored = httpx.post(RERANK, timeout=120, json={
"model": "bge-reranker-v2-m3",
"query": question,
"documents": [h.payload["text"] for h in hits],
}).json()["results"]
ranked = sorted(scored, key=lambda r: -r["relevance_score"])[:keep]
return [{
"text": hits[r["index"]].payload["text"],
"source": hits[r["index"]].payload.get("source"),
"page": hits[r["index"]].payload.get("page"),
"path": hits[r["index"]].payload.get("heading_path"),
"score": round(r["relevance_score"], 4),
} for r in ranked]
The reranker's absolute scores are also a useful signal in their own right. If the best candidate scores poorly, the honest response is "I could not find this in your documents" rather than an answer built from irrelevant passages. A threshold below which you refuse to answer is one of the cheapest hallucination defences available, and the next article wires it in.
| Setting | Guidance |
|---|---|
| Candidates before reranking | 40–50. Below 20 the reranker has nothing to fix; above 100 you pay latency for little gain. |
| Passages kept | 5–8. More context is not more accuracy — irrelevant passages measurably degrade answers. |
| Dense/sparse balance | Favour dense for conceptual questions, sparse for names, codes, numbers and quotations. If you must pick one weighting, lean dense and rely on the reranker. |
| Language filter | Off by default — cross-language retrieval is usually what you want. Expose it as an option for when it is not. |
| Recency | Do not sort by date, but do surface it. When two documents conflict, the model needs to see which is newer to choose correctly. |
| Deduplication | Filter near-identical passages after reranking. Overlapping chunks and multiple versions of the same document otherwise fill the context with one idea repeated. |
| Failure | Diagnosis and fix |
|---|---|
| The right document exists but is never returned | Check it was ingested at all (the failed.log from article 2), then search for a literal phrase from it. If a literal search misses, parsing broke — likely OCR. |
| Right document, wrong passage | Chunking. The relevant idea is probably split across a boundary. Prefer structure-aware splits; verify the heading prefix is present. |
| Good Polish results, poor English (or vice versa) | An embedding model with lopsided language quality, or a language filter applied unintentionally. |
| Exact names and codes are missed | The sparse half is not doing its job. Check the analyser configuration, or add the query-expansion step. |
| Everything returns something, nothing is relevant | The answer is not in the corpus. This is a correct outcome — make sure the system can say so. |
| Answers cite an outdated version | Surface modified in the context and tell the model to prefer recent sources when they conflict. |
Diagnose in that order. It is almost always ingestion or chunking, and almost never the embedding model — which is the opposite of where most people look first.