[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"blog-article-pt-BR-reranking-com-cross-encoder":3,"blog-related-e37e9cc5-4c9c-4bbe-a538-257b44c32487-pt-BR-busca":57},{"post":4,"html":56,"reading_time_minutes":13},{"id":5,"tenant_id":6,"author":7,"status":11,"published_at":12,"reading_time_minutes":13,"view_count":14,"like_count":15,"featured":16,"created_at":17,"updated_at":12,"translations":18,"tags":35},"e37e9cc5-4c9c-4bbe-a538-257b44c32487","3272d9cc-43d0-4e23-8d75-3db7f042b2b3",{"sub":8,"name":9,"email":10},"554c6643-8b1c-4484-b190-4d9c71d0c275","Rodolfo De Bonis","dev@rodolfodebonis.com.br","published","2026-08-07T17:21:16.673906Z",19,17,0,true,"2026-08-07T17:21:16.610092Z",[19,27],{"id":20,"post_id":5,"tenant_id":6,"lang":21,"slug":22,"title":23,"excerpt":24,"content_md":25,"cover_image_url":26,"created_at":17,"updated_at":17},"0de46201-c9b5-417f-8822-e48e7a79bf10","en","reranking-with-cross-encoders","Reranking with Cross-Encoders: the step that separates good search from excellent search","Hybrid search ranks by consensus, and consensus isn't relevance. A cross-encoder reads the query and the document together and reorders your top 100. Third and final post in the series on modern search.","This post closes out a series. The [first one](https://rodolfodebonis.com.br/en/blog/how-semantic-search-works) covered semantic search, the [second](https://rodolfodebonis.com.br/en/blog/hybrid-search-reciprocal-rank-fusion) covered hybrid search with Reciprocal Rank Fusion. If you landed here cold: BM25 nails the literal and misses the meaning, vector search does the opposite, and RRF merges the two rankings using position alone, with no weights to calibrate.\n \nPost 2 ended on an open problem. The query in that article was `running shoes for hot weather`, and the dry-fit tee finished in second place after fusion. It wasn't what the user wanted, but both retrievers found it plausible, and RRF ranks by consensus of position. Consensus isn't relevance.\n \nThe underlying reason is that up to this point in the pipeline, nobody has read the query and the document together.\n \n## What a bi-encoder never actually does\n \nThe mechanism is worth being precise about, because the difference between the two setups is the whole story.\n \nIn vector search, the embedding model processes the document on its own, at index time, and emits a vector. Months later a query shows up, the model processes the query on its own, emits another vector, and the system compares the two with cosine. That arrangement has a name: **bi-encoder**. Two independent encodes, and one late, cheap meeting between two points in space.\n \nThat's what makes vector search viable at all. The heavy work happens offline, once per document, and all that's left on the request path is a geometric comparison. It's also what makes HNSW possible: you can only build a neighborhood index if the document vector exists before the query arrives.\n \nAnd that's precisely where the ceiling is. The vector for \"Dry-fit tee for running in the heat\" was computed without anyone knowing the question would be about shoes. It has to represent the entire document, for every conceivable query, in 1024 fixed numbers. It's lossy compression, and what gets lost is exactly the interaction: which parts of this document matter *for this question*.\n \nA **cross-encoder** tears that arrangement down. It takes the `(query, document)` pair concatenated into a single sequence and runs one forward pass over both at once. Every query token can attend to every document token, and the other way around, across all layers. The output isn't a vector, it's a number: how well this document answers this query.\n \nThe original implementation, from Nogueira and Cho's 2019 paper, is almost embarrassingly plain. The query goes in as sentence A, the passage as sentence B, the `[CLS]` token vector feeds a single dense layer, and out comes the probability that the passage is relevant. The query gets truncated to 64 tokens, the whole thing to 512. No new architecture. Just BERT reading both things at the same time.\n \n## How big the gain actually is\n \nThat same paper has the number that, in my opinion, is the strongest argument for reranking, and it's strong because it isolates the variable.\n \nOn MS MARCO, the candidate set was held fixed: BM25's top 1000 for each query. BM25 on its own, ranking those same thousand documents, gets MRR@10 of 16.7 on the dev set. A BERT Large reordering the exact same list gets 36.5.\n \nNo new documents entered. No index changed. No embeddings were generated. Retrieval was identical in both cases, and the metric more than doubled purely because something read the pairs.\n \nWorth noting what that number doesn't say. It's a 2019 benchmark, in English, on a dataset of Bing questions with roughly one relevant passage per query. Your domain isn't that. But the asymmetry it exposes between \"retrieving\" and \"ordering\" is structural, and it shows up again in every benchmark since. Elastic, measuring their own model across 21 BEIR datasets, reports an average 40% improvement in ranking quality when reranking BM25 results.\n \n## Why you can't just run this on everything\n \nIf cross-encoders are that much better, the obvious question is why not throw out the index entirely and score every document directly.\n \nThe answer lives in the word \"pair\". A bi-encoder does N document encodes once in its life, then one encode per query. A cross-encoder does one forward pass per `(query, document)` pair, every single time, because the score depends on both. There's nothing to precompute. Change the query and all prior work is garbage.\n \nYou can put numbers on this using the published `sentence-transformers` table, which measures throughput for the MS MARCO cross-encoders on a V100 GPU:\n \n| Model | nDCG@10 (TREC DL 19) | MRR@10 (MS MARCO Dev) | Docs/sec |\n| --- | --- | --- | --- |\n| ms-marco-TinyBERT-L2-v2 | 69.84 | 32.56 | 9000 |\n| ms-marco-MiniLM-L4-v2 | 73.04 | 37.70 | 2500 |\n| ms-marco-MiniLM-L6-v2 | 74.30 | 39.01 | 1800 |\n| ms-marco-MiniLM-L12-v2 | 74.31 | 39.02 | 960 |\n| ms-marco-electra-base | 71.99 | 36.41 | 340 |\n \nTake `MiniLM-L6-v2`, the middle of that list. At 1800 documents per second, scoring 100 candidates costs 56 ms. Scoring a million-document catalog costs **nine and a half minutes per query**, on a dedicated GPU, for one user. The `L12`, which buys you half a point of nDCG, takes 17 minutes.\n \nThis isn't something you scale horizontally until it goes away. It's an order-of-magnitude difference that changes what kind of problem you're solving.\n \nLook at the third column against the first, too. Between `L6` and `L12` the quality is essentially identical and the cost doubles. Between `TinyBERT-L2` and `L6` you buy 4.5 points of nDCG at 5× the time. The diminishing-returns curve is already drawn inside the model family itself, before you've even picked a depth.\n \nHence the architecture everyone converges on: cheap retrieval picks a handful of candidates, and the expensive model only looks at that handful.\n \n![image](https://assets.rodolfodebonis.com.br/blog-articles-images/reranking/arhitecture_en.png)\n \nThe first stage exists so nothing relevant gets left out. The second exists to get the order right. Different goals, which is exactly why different models make sense.\n \n## The tee finally drops\n \nBack to the example from post 2. Five documents, the query `running shoes for hot weather`, and the ranking RRF produced:\n \n| Position | Document |\n| --- | --- |\n| 1 | A: Ventus running shoe, high-ventilation mesh |\n| 2 | C: Dry-fit tee for running in the heat |\n| 3 | D: Lightweight shoe for hot, humid weather |\n| 4 | E: Technical running sock for hot days |\n| 5 | B: Rocha 3 trail running shoe, reinforced upper |\n \nNow the cross-encoder gets all five pairs and returns a score for each. The values below are illustrative, but the ordering is what any decent reranker produces here:\n \n| Document | Score | Final position |\n| --- | --- | --- |\n| A: Ventus, mesh | 0.94 | 1st |\n| D: Lightweight, hot weather | 0.91 | 2nd |\n| B: Rocha 3 trail | 0.38 | 3rd |\n| C: Dry-fit tee | 0.07 | 4th |\n| E: Technical sock | 0.04 | 5th |\n \nThe tee dropped from second to fourth, and the reason is mundane once you look at the mechanism: the model processed the query token \"shoes\" in the same forward pass as the document token \"tee\". It isn't comparing two summaries of meaning, it's reading a question about footwear alongside a document about apparel and concluding the category is wrong, even though \"running\" and \"heat\" both match literally.\n \n`B` climbed to third on the complementary logic. It's a running shoe, so it hits the primary intent, but \"reinforced upper\" is the opposite of ventilation, and the model penalizes without discarding. Neither earlier stage can draw that distinction, because neither one read both texts at once.\n \nNotice the gap between 0.91 and 0.38. That isn't scale noise the way RRF scores are. That's the model separating two populations.\n \n## Picking a model\n \nThe ecosystem splits three ways, and the choice is almost never about ranking quality.\n \n**Managed API.** Cohere Rerank is the reference here. The current line is `rerank-v4.0-pro` and `rerank-v4.0-fast`, both multilingual across 100+ languages, with a 32k-token context window and support for semi-structured JSON documents. `rerank-v3.5` is still around with 4k. Voyage and Jina play the same game. You add one HTTP call and you're done: no GPU, no deployment, no reindexing.\n \nThe planning detail is the billing unit. A \"search unit\" is one query with up to 100 documents, and long documents get chunked automatically, with each chunk counting as a separate document. Meaning your 100 candidates can turn into several search units if the texts are large. The AWS Marketplace listing for Rerank v3.5 (Bedrock edition) prices it at $0.002 per search unit. That's roughly $200 for a month with 100k reranked searches, and $2,000 for a month with a million. Prices change, so check before you commit, but the shape holds: API reranking is cheap per search and expensive per traffic.\n \n**Self-hosted open source.** The `cross-encoder/ms-marco-*` family from sentence-transformers is the classic starting point, and the table above already shows the whole trade-off. One warning that matters more than it looks: those models are trained on MS MARCO, which is English. If your corpus is Portuguese, Spanish, or anything else, they will disappoint you. For multilingual work the answer is `BAAI/bge-reranker-v2-m3`, built on top of bge-m3, with a 512-token window and a score you map to 0..1 with a sigmoid. The BGE docs give the obvious and correct advice: test on your real use case and pick on speed/quality balance, not on their table.\n \n**Built into the engine.** Elasticsearch exposes reranking through the `text_similarity_reranker` retriever, which can point at an external inference endpoint (Cohere, for instance) or at Elastic's own model, Elastic Rerank, a 184M-parameter DeBERTa v3. Read the limitations before you get excited: English only, 512-token window, available from Stack 8.17, requires an appropriate subscription level, and still flagged as technical preview. Elastic writes in their own docs that the preview version may be cost prohibitive for high query rates with low-latency requirements, and recommends staying under top-30 for CPU inference.\n \n**The middle option.** ColBERT occupies its own niche. Instead of one score per pair, it stores a vector per document token and performs a late interaction between query tokens and document tokens at query time. The original paper measures effectiveness competitive with the BERT models of the day while running two orders of magnitude faster and using four orders of magnitude fewer FLOPs per query. The price is the index: storing a vector per token inflates storage aggressively, and ColBERTv2 exists largely to shrink that by 5 to 8×. If latency is your constraint and you have disk to spare, it's worth a look.\n \n## Depth is the parameter that matters\n \nHere's the most expensive mistake in this stage, and it runs against intuition.\n \nThe default belief is that reranking more candidates can only help, since a good model would have more material to work with. Elastic measured this systematically, sweeping reranking depth across several models and BEIR datasets, and found three patterns:\n \n- **Fast rise, then saturation**, in 72.6% of cases. What everyone expects.\n- **Rise to a peak, then decay**, in 20.2% of cases.\n- **Steady decay with any amount of reranking**, in 7.1% of cases. The reranker makes BM25 worse at every depth tested.\nThe explanation is mechanical and satisfying. nDCG@10 only moves when the reranker promotes something from below into the top 10. If the promoted document is relevant, the metric goes up. If it's a false positive, it evicts a relevant document and the metric goes down. As you go deeper, the density of relevant documents collapses and the density of irrelevant ones grows. There's a depth at which the model's false-positive rate starts to dominate, and past it you're paying for inference to degrade your own ranking.\n \nThe paper *Drowning in Documents* gets to the same place from another direction, and notes something uncomfortable: rerankers frequently assign high scores to documents with no lexical or semantic overlap with the query at all.\n \nThree practical consequences, in the order you'll need them:\n \nFirst, the number. Applying the rule of picking the depth that reaches 90% of the maximum gain, Elastic landed on roughly **top 100** over BM25, at a third of the compute cost of chasing the maximum. When cost is tight they recommend **top 30** with their model, and still measured over 40% uplift in nDCG@10 on the QA portion of their benchmark.\n \nSecond, which way to tune. The better your first stage, the fewer candidates you need to rerank, because recall saturates earlier. And the better your reranker, the deeper it pays to go, because it makes fewer mistakes on the way down. You already have hybrid search with RRF, so your first stage is good: start shallow.\n \nThird, and this one surprised me: under a latency constraint, reranking deep with a small model tends to beat reranking shallow with a big one. In Elastic's benchmark, `MiniLM-L12-v2` processing 80 candidates beat stronger models capped at 10 or 20 for the same time budget. The relationship flips as you relax the constraint.\n \nOn latency, one concrete number to calibrate against: measuring on two T4 GPUs, Elastic reports 0.0869 seconds to score 10 pairs with Elastic Rerank on HotpotQA. Linearized, top-30 lands around 0.26 s and top-100 around 0.87 s. Those aren't the 50 ms that get thrown around in posts on the topic. Measure on your hardware, with your documents, before you promise an SLA.\n \n## Scores mean something again\n \nOne limitation I raised in post 2 gets resolved here, and it's worth closing the loop.\n \nRRF scores mean nothing. They're crammed into a tiny band, they aren't comparable across queries, and so you can't threshold on them. You can't say \"if the best result is bad, show the empty state\".\n \nA cross-encoder score is a different kind of thing. It's a relevance estimate for that pair, produced by a model trained for exactly that, and it doesn't depend on anyone's position. The sentence-transformers models return logits that sit roughly between -10 and 10, and you apply a sigmoid to get something in 0..1. `bge-reranker-v2-m3` works the same way. Cohere returns a normalized `relevance_score`. Elasticsearch exposes `min_score` right on the retriever precisely so you can cut.\n \nThe trap is confusing \"meaningful\" with \"calibrated\". The scale is specific to the model and the domain. A 0.6 from Cohere is not a 0.6 from BGE, and a 0.6 from BGE on your legal corpus is not the same 0.6 on your support FAQ. The threshold has to come from your data. But it exists, and that's a genuinely new capability in the pipeline.\n \n## What this looks like in code\n \nSelf-hosted, with `sentence-transformers`. The point of the snippet is that reranking is a pure function over the candidate list, with no state and no index:\n \n```python\nfrom sentence_transformers import CrossEncoder\nimport torch\n \n# Sigmoid pins scores to 0..1. Without it you get raw logits.\nreranker = CrossEncoder(\n    \"BAAI/bge-reranker-v2-m3\",   # multilingual; for English, ms-marco-MiniLM-L6-v2\n    activation_fn=torch.nn.Sigmoid(),\n    max_length=512,\n)\n \ndef rerank(query: str, candidates: list[dict], top_k: int = 10) -> list[dict]:\n    \"\"\"Takes the output of RRF, returns the reordered top_k.\"\"\"\n    if not candidates:\n        return []\n \n    pairs = [(query, doc[\"text\"]) for doc in candidates]\n    scores = reranker.predict(pairs, batch_size=32)\n \n    for doc, score in zip(candidates, scores):\n        doc[\"rerank_score\"] = float(score)\n \n    ranked = sorted(candidates, key=lambda d: d[\"rerank_score\"], reverse=True)\n    return ranked[:top_k]\n```\n \nTwo decisions are worth a comment. `batch_size` is what determines whether you saturate the GPU or waste it, and it's the first knob to turn when latency won't close. And `max_length=512` isn't cosmetic: anything longer gets truncated, so the model scores the beginning of the text and ignores the rest. If your documents are long, you need to decide deliberately which chunk gets scored instead of letting the tokenizer decide for you.\n \nSame pipeline through an API, with Cohere:\n \n```python\nimport cohere\n \nco = cohere.ClientV2(api_key=\"...\")\n \nresponse = co.rerank(\n    model=\"rerank-v4.0-fast\",\n    query=\"running shoes for hot weather\",\n    documents=[doc[\"text\"] for doc in candidates],\n    top_n=10,\n)\n \n# results carry index (position in the original list) and relevance_score\ntop10 = [candidates[r.index] for r in response.results]\n```\n \nAnd inside Elasticsearch, chained onto the RRF retriever from the previous post:\n \n```json\nGET /products/_search\n{\n  \"retriever\": {\n    \"text_similarity_reranker\": {\n      \"retriever\": {\n        \"rrf\": {\n          \"retrievers\": [\n            { \"standard\": { \"query\": { \"multi_match\": {\n                \"query\": \"running shoes for hot weather\",\n                \"fields\": [\"title\", \"description\"]\n            }}}},\n            { \"knn\": {\n                \"field\": \"embedding\",\n                \"query_vector\": [0.21, -0.05, \"...\"],\n                \"k\": 100,\n                \"num_candidates\": 300\n            }}\n          ],\n          \"rank_window_size\": 100\n        }\n      },\n      \"field\": \"description\",\n      \"inference_id\": \"my-reranker\",\n      \"inference_text\": \"running shoes for hot weather\",\n      \"rank_window_size\": 100,\n      \"min_score\": 0.4\n    }\n  },\n  \"size\": 10\n}\n```\n \nNote there are two `rank_window_size` values in that query and they mean different things. The inner one is how many candidates fusion considers. The outer one is how many of those go to the model. Matching them is the sane default; setting the outer one higher does nothing, because no candidate exists beyond the fusion window.\n \n## Where reranking doesn't help\n \nA few cases where the honest answer is to leave it off.\n \n**When first-stage recall is the problem.** Reranking retrieves nothing. If the right document isn't among the 100 that arrived, no model is going to invent it. Before investing in the expensive stage, measure Recall@100 on your hybrid search. If it's low, the problem is your embedding model, what you indexed, or your fusion window, and reranking will only do a nicer job of ordering a bad set.\n \n**When value per search is low and volume is high.** The classic long-tail ecommerce situation. Millions of searches, thin margin per transaction, and a relevance gain that may not cover the GPU bill or the API bill. This is a business calculation, not an engineering one.\n \n**When the latency SLA is tight.** If your p95 budget is 100 ms and search already eats 40, you don't have room for top-100. You have room for top-20 with a small model, and maybe not even that.\n \n**When the query is an identifier.** `SKU-A4729` doesn't need semantic understanding, it needs an exact match. Detect the pattern before the pipeline and route around it, same as with hybrid search.\n \n**When the corpus is too homogeneous.** If your 100 candidates are near-identical to each other, there's no correct order to discover and the model will just break ties on noise.\n \n## What to measure, before and after\n \nYou already built the most important piece back in post 2: that set of 50 to 200 real queries with the relevant documents marked by hand. It's what decides this.\n \nMeasure nDCG@10 and MRR for hybrid alone, then hybrid plus reranking, sweeping depth at 10, 20, 30, 50, and 100. What you're looking for is the shape of the curve, not one number. If it saturates at 30, going to 100 is money on fire. If it drops after 50, you just learned the model doesn't suit your domain, and you learned it offline, which beats learning it in production.\n \nAlongside that, not afterwards, record the p95 delta and the cost per query. A relevance gain without a latency number next to it isn't a result, it's half of one.\n \nThen comes the production A/B, because offline gains from reranking almost always look great and users don't always notice. The metrics that answer this are CTR on the top positions, query reformulation rate, and conversion. If top-3 CTR goes up and reformulation goes down, reranking is doing real work. If nothing moves, you paid for precision nobody was waiting on.\n \n## Closing the series\n \nFive things I wish I'd known before starting on any of this:\n \nBM25 isn't legacy. It's the strong baseline you'll spend the rest of the project trying to beat, and on some tasks you won't.\n \nEmbeddings turn text into geometry, and everything else follows from that change of representation. But the vector is lossy compression, and the losses show up in the most annoying place possible: identifiers, negations, short queries.\n \nRRF is the best return on effort in the whole list. No calibration, no training, consistently better than either side alone.\n \nReranking is the only stage that reads both texts together, which is why it wins where the others structurally can't. It's also the only one that precomputes nothing, which is why it runs over dozens of candidates and never over the catalog.\n \nAnd the thing that ties it together: none of this gets decided by reading benchmarks. It gets decided with an evaluation set from your own corpus, which takes half a day of tedious work to build and then answers every question that comes after.\n \nThis series covered what I wish I'd known before starting with semantic search. If you're building something similar, tell me what the context is. I'd love to know.\n \n## References\n \n- Nogueira, R., Cho, K. **Passage Re-ranking with BERT**. arXiv:1901.04085, 2019. [Paper](https://arxiv.org/abs/1901.04085)\n- Khattab, O., Zaharia, M. **ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT**. SIGIR 2020. [Paper](https://arxiv.org/abs/2004.12832)\n- Santhanam, K. et al. **ColBERTv2: Effective and Efficient Retrieval via Lightweight Late Interaction**. arXiv:2112.01488\n- Jacob, M. et al. **Drowning in Documents: Consequences of Scaling Reranker Inference**. arXiv:2411.11767\n- Sentence Transformers, [Pretrained Cross-Encoder models](https://sbert.net/docs/cross_encoder/pretrained_models.html) and [Retrieve & Re-Rank](https://sbert.net/examples/sentence_transformer/applications/retrieve_rerank/README.html)\n- Elastic, docs for [Elastic Rerank](https://www.elastic.co/docs/explore-analyze/machine-learning/nlp/ml-nlp-rerank) and the [text_similarity_reranker retriever](https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/text-similarity-reranker-retriever)\n- Elastic Search Labs, [Exploring depth in a retrieve-and-rerank pipeline](https://www.elastic.co/search-labs/blog/elastic-semantic-reranker-part-3)\n- Cohere, [Rerank overview](https://docs.cohere.com/docs/rerank-overview), [best practices](https://docs.cohere.com/docs/reranking-best-practices), and the [Rerank 4.0 announcement](https://docs.cohere.com/changelog/rerank-v4.0)\n- [BAAI/bge-reranker-v2-m3](https://huggingface.co/BAAI/bge-reranker-v2-m3) and the [BGE Reranker docs](https://bge-model.com/bge/bge_reranker_v2.html)\n---\n \n*Third and final post in the series on modern search. The earlier ones are [Semantic Search](https://rodolfodebonis.com.br/en/blog/how-semantic-search-works) and [Hybrid Search with RRF](https://rodolfodebonis.com.br/en/blog/hybrid-search-reciprocal-rank-fusion).*","https://assets.rodolfodebonis.com.br/blog-covers/bf615840-922f-43d7-bd92-c47130cb9330.png",{"id":28,"post_id":5,"tenant_id":6,"lang":29,"slug":30,"title":31,"excerpt":32,"content_md":33,"cover_image_url":34,"created_at":17,"updated_at":17},"9e8b4420-ae3a-49a2-83d4-68de81aaeb0a","pt-BR","reranking-com-cross-encoder","Reranking com cross-encoder: a cereja que separa busca boa de busca excelente","A busca híbrida ordena por consenso, e consenso não é relevância. Um cross-encoder lê query e documento juntos e reordena o top 100. Terceiro e último artigo da série sobre busca moderna.","Esse post fecha uma série. O [primeiro](https://rodolfodebonis.com.br/blog/como-funciona-busca-semantica) tratou de busca semântica, o [segundo](https://rodolfodebonis.com.br/blog/busca-hibrida-com-rrf) de busca híbrida com Reciprocal Rank Fusion. Se você caiu aqui direto, o resumo é que BM25 acerta o literal e erra o significado, a busca vetorial faz o inverso, e o RRF junta os dois rankings usando só posição, sem calibrar peso nenhum.\n \nO post 2 terminou com um problema em aberto. No exemplo daquele artigo, a query era `tênis pra correr no calor` e a camiseta dry-fit ficou em segundo lugar depois da fusão. Ela não era o que o usuário queria, mas as duas buscas acharam ela razoável, e o RRF ordena por consenso de posição. Consenso não é relevância.\n \nO motivo de fundo é que, até esse ponto do pipeline, ninguém leu a query e o documento juntos.\n \n## O que um bi-encoder nunca chega a fazer\n \nVale ser preciso sobre o mecanismo, porque a diferença entre os dois arranjos é a coisa toda.\n \nNa busca vetorial, o modelo de embedding processa o documento sozinho, no momento da indexação, e cospe um vetor. Meses depois chega uma query, o modelo processa a query sozinha, cospe outro vetor, e o sistema compara os dois com cosseno. Esse arranjo tem nome: **bi-encoder**. Dois encodes independentes, um encontro tardio e barato entre dois pontos no espaço.\n \nIsso é o que torna a busca vetorial viável. O trabalho pesado acontece offline, uma vez por documento, e sobra apenas uma comparação geométrica no caminho da requisição. É também o que torna o HNSW possível: você só consegue construir um índice de vizinhança se o vetor do documento existir antes da query chegar.\n \nE é exatamente aí que mora a limitação. O vetor de \"Camiseta dry-fit para correr no calor\" foi calculado sem que ninguém soubesse que a pergunta seria sobre tênis. Ele precisa representar o documento inteiro, para toda query concebível, em 1024 números fixos. É uma compressão com perda, e a informação que se perde é justamente a interação: quais partes desse documento importam *para essa pergunta*.\n \nUm **cross-encoder** desfaz esse arranjo. Ele recebe o par `(query, documento)` concatenado numa única sequência e roda um forward pass sobre os dois ao mesmo tempo. Cada token da query pode atender a cada token do documento, e vice-versa, em todas as camadas. A saída não é vetor, é um número: o quanto esse documento responde essa query.\n \nA implementação original, no paper de Nogueira e Cho de 2019, é quase constrangedoramente direta. Query entra como sentença A, passagem entra como sentença B, o vetor do token `[CLS]` vai para uma única camada densa, e o que sai é a probabilidade da passagem ser relevante. A query é truncada em 64 tokens, o conjunto todo em 512. Nenhuma arquitetura nova. Só BERT lendo as duas coisas juntas.\n \n## O tamanho do ganho\n \nEsse mesmo paper tem o número que, na minha opinião, é o argumento mais forte a favor de reranking, e ele é forte porque isola a variável.\n \nNo MS MARCO, o conjunto de candidatos foi fixo: o top 1000 do BM25 para cada query. O BM25 sozinho, ordenando esses mesmos mil documentos, entrega MRR@10 de 16.7 no dev set. Um BERT Large reordenando exatamente a mesma lista entrega 36.5.\n \nNenhum documento novo entrou. Nenhum índice mudou. Nenhum embedding foi gerado. A recuperação era idêntica nos dois casos, e a métrica mais que dobrou só porque alguém leu os pares.\n \nVale registrar o que esse número não diz. É um benchmark de 2019, em inglês, num dataset de perguntas do Bing, com uma passagem relevante por query em média. Seu domínio não é esse. Mas a assimetria entre \"recuperar\" e \"ordenar\" que ele expõe é estrutural, e ela aparece de novo em todo benchmark posterior. A Elastic, medindo o próprio modelo em 21 datasets do BEIR, reporta em média 40% de melhoria em qualidade de ranking ao reranquear resultados de BM25.\n \n## Por que você não pode simplesmente usar isso em tudo\n \nSe cross-encoder é tão melhor, a pergunta óbvia é por que não jogar fora o índice inteiro e pontuar todos os documentos direto.\n \nA resposta está na palavra \"par\". O bi-encoder faz N encodes de documento uma vez na vida e depois faz um encode por query. O cross-encoder faz um forward pass por par `(query, documento)`, toda vez, porque o score depende dos dois. Não existe nada para pré-computar. Trocou a query, todo o trabalho anterior virou lixo.\n \nDá pra colocar número nisso com a tabela publicada do `sentence-transformers`, que mede throughput dos cross-encoders treinados em MS MARCO numa GPU V100:\n \n| Modelo | nDCG@10 (TREC DL 19) | MRR@10 (MS MARCO Dev) | Docs/s |\n| --- | --- | --- | --- |\n| ms-marco-TinyBERT-L2-v2 | 69.84 | 32.56 | 9000 |\n| ms-marco-MiniLM-L4-v2 | 73.04 | 37.70 | 2500 |\n| ms-marco-MiniLM-L6-v2 | 74.30 | 39.01 | 1800 |\n| ms-marco-MiniLM-L12-v2 | 74.31 | 39.02 | 960 |\n| ms-marco-electra-base | 71.99 | 36.41 | 340 |\n \nPegue o `MiniLM-L6-v2`, que é o meio de campo dessa lista. A 1800 documentos por segundo, pontuar 100 candidatos custa 56 ms. Pontuar um catálogo de um milhão de documentos custa **9 minutos e meio por query**, numa GPU dedicada, para um usuário. O `L12`, que entrega meio ponto a mais de nDCG, leva 17 minutos.\n \nNão é uma questão de escalar horizontalmente até resolver. É uma diferença de ordem de grandeza que muda a categoria do problema.\n \nRepare também na terceira coluna comparada com a primeira. Entre o `L6` e o `L12` a qualidade é praticamente idêntica e o custo dobra. Entre o `TinyBERT-L2` e o `L6` você ganha 4.5 pontos de nDCG pagando 5× mais tempo. A curva de retorno decrescente já está desenhada dentro da própria família de modelos, antes mesmo de você escolher a profundidade.\n \nDaí a arquitetura que todo mundo converge: a recuperação barata seleciona um punhado de candidatos, e o modelo caro só olha esse punhado.\n \n![image](https://assets.rodolfodebonis.com.br/blog-articles-images/reranking/arhitecture_pt.png)\n \nO primeiro estágio existe pra não deixar nada relevante de fora. O segundo existe pra acertar a ordem. São objetivos diferentes, e é por isso que faz sentido usar modelos diferentes.\n \n## A camiseta finalmente cai\n \nVoltando ao exemplo do post 2. Cinco documentos, a query `tênis pra correr no calor`, e o ranking que o RRF produziu:\n \n| Posição | Documento |\n| --- | --- |\n| 1 | A: Tênis de corrida Ventus, mesh com ventilação alta |\n| 2 | C: Camiseta dry-fit para correr no calor |\n| 3 | D: Tênis leve para clima quente e úmido |\n| 4 | E: Meia técnica de corrida para dias quentes |\n| 5 | B: Tênis de corrida trail Rocha 3, cabedal reforçado |\n \nAgora o cross-encoder recebe os cinco pares e devolve um score por par. Os valores abaixo são ilustrativos, mas a ordem é o que qualquer reranker decente produz aqui:\n \n| Documento | Score | Posição final |\n| --- | --- | --- |\n| A: Tênis Ventus, mesh | 0.94 | 1º |\n| D: Tênis leve, clima quente | 0.91 | 2º |\n| B: Tênis trail Rocha 3 | 0.38 | 3º |\n| C: Camiseta dry-fit | 0.07 | 4º |\n| E: Meia técnica | 0.04 | 5º |\n \nA camiseta desabou da segunda posição para a quarta, e o motivo é banal quando você olha o mecanismo: o modelo processou o token \"tênis\" da query no mesmo forward pass que o token \"camiseta\" do documento. Ele não está comparando dois resumos de significado, ele está lendo uma pergunta sobre calçado e um documento sobre vestuário e concluindo que a categoria está errada, apesar de \"correr\" e \"calor\" baterem literalmente.\n \nO `B` subiu para terceiro por um raciocínio complementar. É um tênis de corrida, então acerta a intenção principal, mas \"cabedal reforçado\" é o oposto de ventilação, e o modelo penaliza sem descartar. Nenhum dos dois estágios anteriores tem como fazer essa distinção, porque nenhum dos dois leu as duas coisas ao mesmo tempo.\n \nRepare no salto entre 0.91 e 0.38. Não é ruído de escala como nos scores do RRF. É o modelo separando duas populações.\n \n## Escolher o modelo\n \nO ecossistema se divide em três caminhos, e a escolha entre eles quase nunca é sobre qualidade de ranking.\n \n**API gerenciada.** O Cohere Rerank é a referência da categoria. A linha atual são o `rerank-v4.0-pro` e o `rerank-v4.0-fast`, ambos multilíngues com mais de 100 idiomas, 32 mil tokens de contexto, e suporte a documentos semiestruturados em JSON. O `rerank-v3.5` continua disponível com 4 mil tokens. Voyage e Jina jogam no mesmo campo. Você adiciona uma chamada HTTP e pronto, sem GPU, sem deploy, sem reindexação.\n \nO detalhe de planejamento é a unidade de cobrança. Uma \"search unit\" é uma query com até 100 documentos, e documentos longos são fatiados automaticamente, com cada pedaço contando como um documento separado. Ou seja, seus 100 candidatos podem virar várias search units se os textos forem grandes. Na listagem do Rerank v3.5 no AWS Marketplace (edição Bedrock) o preço é US$ 0,002 por search unit. Isso dá cerca de US$ 200 num mês com 100 mil buscas reranqueadas, e US$ 2 mil num mês com um milhão. Preço muda, então confira antes de fechar conta, mas a ordem de grandeza é essa: reranking em API é barato por busca e caro por tráfego.\n \n**Open-source auto-hospedado.** A família `cross-encoder/ms-marco-*` do sentence-transformers é o ponto de partida clássico, e a tabela lá em cima já mostra o trade-off inteiro. Um alerta que importa muito pra quem escreve em português: esses modelos são treinados em MS MARCO, que é inglês. Se seu corpus é PT-BR, eles vão te decepcionar. Para multilíngue, o caminho é o `BAAI/bge-reranker-v2-m3`, construído em cima do bge-m3, com 512 tokens de janela e score que vira 0 a 1 com uma sigmoide. A própria documentação do BGE recomenda o óbvio e o correto: teste no seu caso real e escolha pelo equilíbrio velocidade/qualidade, não pela tabela deles.\n \n**Embutido no motor.** O Elasticsearch expõe reranking pelo retriever `text_similarity_reranker`, que pode apontar para um endpoint de inference externo (Cohere, por exemplo) ou para o modelo próprio da Elastic, o Elastic Rerank, um DeBERTa v3 de 184 milhões de parâmetros. Vale ler as limitações antes de se animar: só inglês, janela de 512 tokens, disponível a partir do Stack 8.17, exige nível de assinatura adequado, e ainda está marcado como technical preview. A própria Elastic escreve na documentação que a versão preview pode ser proibitivamente cara para alto volume de queries com requisito de baixa latência, e recomenda não passar de top-30 quando a inferência é em CPU.\n \n**A opção do meio.** O ColBERT ocupa um lugar próprio. Em vez de um score por par, ele guarda um vetor por token do documento e faz uma interação tardia entre os tokens da query e os do documento no momento da busca. O paper original mede efetividade competitiva com os modelos BERT da época executando duas ordens de grandeza mais rápido e com quatro ordens de grandeza menos FLOPs por query. O preço é o índice: guardar um vetor por token infla o armazenamento de forma agressiva, e o ColBERTv2 nasceu justamente pra reduzir isso em 5 a 8 vezes. Se seu problema é latência e você tem disco sobrando, vale olhar.\n \n## A profundidade é o parâmetro que importa\n \nAqui está o erro mais caro dessa etapa, e ele é contraintuitivo.\n \nA crença padrão é que reranquear mais candidatos só pode melhorar, porque o modelo bom teria mais material para trabalhar. A Elastic mediu isso de forma sistemática, variando profundidade de reranking em vários modelos e datasets do BEIR, e encontrou três padrões:\n \n- **Subida rápida e depois saturação**, em 72.6% dos casos. É o comportamento que todo mundo espera.\n- **Subida até um pico e depois queda**, em 20.2% dos casos.\n- **Queda contínua com qualquer reranking**, em 7.1% dos casos. O reranker piora o resultado do BM25 em toda profundidade testada.\nA explicação é mecânica e boa. O nDCG@10 só muda quando o reranker promove alguém de baixo para o top 10. Se o promovido é relevante, a métrica sobe. Se é um falso positivo, ele expulsa um documento relevante e a métrica cai. Conforme você desce na lista, a densidade de documentos relevantes despenca e a de irrelevantes cresce. Existe uma profundidade em que a taxa de falso positivo do modelo passa a dominar, e depois dela você está pagando inferência para piorar o próprio ranking.\n \nO paper *Drowning in Documents* chega ao mesmo lugar por outro caminho, e observa algo desconfortável: rerankers frequentemente atribuem score alto a documentos sem nenhuma sobreposição léxica ou semântica com a query.\n \nAs três consequências práticas, na ordem em que você vai precisar delas:\n \nPrimeiro, o número. Aplicando a regra de pegar a profundidade que atinge 90% do ganho máximo, a Elastic chegou a algo em torno de **top 100** em cima de BM25, com um terço do custo computacional de ir até o máximo. Quando custo aperta, eles recomendam **top 30** com o modelo deles, e mesmo assim mediram uplift acima de 40% em nDCG@10 na parte de QA do benchmark.\n \nSegundo, a direção do ajuste. Quanto melhor o primeiro estágio, menos candidatos você precisa reranquear, porque o recall satura antes. E quanto melhor o reranker, mais fundo compensa ir, porque ele erra menos ao descer. Como você já tem híbrida com RRF, seu primeiro estágio é bom, então comece raso.\n \nTerceiro, e esse me surpreendeu: sob restrição de latência, reranquear fundo com um modelo pequeno costuma ganhar de reranquear raso com um modelo grande. No benchmark da Elastic, o `MiniLM-L12-v2` processando 80 candidatos bateu modelos mais fortes limitados a 10 ou 20 pelo mesmo orçamento de tempo. A relação se inverte conforme você afrouxa a restrição.\n \nSobre latência, um número concreto para calibrar expectativa: medindo em duas GPUs T4, a Elastic reporta 0,0869 segundo para pontuar 10 pares com o Elastic Rerank no HotpotQA. Linearizando, top-30 sai em torno de 0,26 s e top-100 em torno de 0,87 s. Não são os 50 ms que costumam circular em posts sobre o assunto. Meça no seu hardware, com seus documentos, antes de prometer SLA.\n \n## O score volta a significar alguma coisa\n \nUma limitação que eu levantei no post 2 se resolve aqui, e vale fechar o ciclo.\n \nO score do RRF não significa nada. Ele vive espremido numa faixa minúscula, não é comparável entre queries, e por isso não dá pra usar em threshold. Você não consegue dizer \"se o melhor resultado for ruim, mostro a tela de nada encontrado\".\n \nO score de um cross-encoder é diferente em natureza. Ele é uma estimativa de relevância daquele par, produzida por um modelo treinado com esse objetivo, e não depende da posição de nada. Os modelos do sentence-transformers devolvem logits que ficam grosso modo entre -10 e 10, e você aplica uma sigmoide pra ter algo entre 0 e 1. O `bge-reranker-v2-m3` funciona igual. O Cohere devolve `relevance_score` já normalizado. O Elasticsearch expõe `min_score` no próprio retriever justamente pra você cortar.\n \nO cuidado é não confundir \"tem significado\" com \"é calibrado\". A escala é específica do modelo e do domínio. Um 0.6 do Cohere não é um 0.6 do BGE, e o 0.6 do BGE no seu corpus jurídico não é o mesmo 0.6 no seu FAQ de suporte. O threshold precisa sair dos seus dados. Mas ele existe, e isso é uma capacidade nova no pipeline.\n \n## Como isso fica no código\n \nAuto-hospedado, com `sentence-transformers`. O ponto do trecho é que reranking é uma função pura sobre a lista de candidatos, sem estado e sem índice:\n \n```python\nfrom sentence_transformers import CrossEncoder\nimport torch\n \n# Sigmoid força o score pra faixa 0..1. Sem isso, o retorno é logit cru.\nreranker = CrossEncoder(\n    \"BAAI/bge-reranker-v2-m3\",   # multilíngue; para inglês, ms-marco-MiniLM-L6-v2\n    activation_fn=torch.nn.Sigmoid(),\n    max_length=512,\n)\n \ndef rerank(query: str, candidatos: list[dict], top_k: int = 10) -> list[dict]:\n    \"\"\"Recebe a saída do RRF, devolve o top_k reordenado.\"\"\"\n    if not candidatos:\n        return []\n \n    pares = [(query, doc[\"texto\"]) for doc in candidatos]\n    scores = reranker.predict(pares, batch_size=32)\n \n    for doc, score in zip(candidatos, scores):\n        doc[\"rerank_score\"] = float(score)\n \n    ordenado = sorted(candidatos, key=lambda d: d[\"rerank_score\"], reverse=True)\n    return ordenado[:top_k]\n```\n \nDuas decisões valem comentário. O `batch_size` é o que controla se você satura a GPU ou desperdiça ela, e é o primeiro botão a mexer quando a latência não fecha. E `max_length=512` não é cosmético: documento maior que isso é truncado, então o modelo pontua o começo do texto e ignora o resto. Se seus documentos são longos, você precisa decidir conscientemente qual pedaço vai ser pontuado, em vez de deixar o tokenizer decidir por você.\n \nVia API, o mesmo pipeline com Cohere:\n \n```python\nimport cohere\n \nco = cohere.ClientV2(api_key=\"...\")\n \nresposta = co.rerank(\n    model=\"rerank-v4.0-fast\",\n    query=\"tênis pra correr no calor\",\n    documents=[doc[\"texto\"] for doc in candidatos],\n    top_n=10,\n)\n \n# results traz index (posição na lista original) e relevance_score\ntop10 = [candidatos[r.index] for r in resposta.results]\n```\n \nE dentro do Elasticsearch, encadeando no retriever RRF do post anterior:\n \n```json\nGET /produtos/_search\n{\n  \"retriever\": {\n    \"text_similarity_reranker\": {\n      \"retriever\": {\n        \"rrf\": {\n          \"retrievers\": [\n            { \"standard\": { \"query\": { \"multi_match\": {\n                \"query\": \"tênis pra correr no calor\",\n                \"fields\": [\"titulo\", \"descricao\"]\n            }}}},\n            { \"knn\": {\n                \"field\": \"embedding\",\n                \"query_vector\": [0.21, -0.05, \"...\"],\n                \"k\": 100,\n                \"num_candidates\": 300\n            }}\n          ],\n          \"rank_window_size\": 100\n        }\n      },\n      \"field\": \"descricao\",\n      \"inference_id\": \"meu-reranker\",\n      \"inference_text\": \"tênis pra correr no calor\",\n      \"rank_window_size\": 100,\n      \"min_score\": 0.4\n    }\n  },\n  \"size\": 10\n}\n```\n \nRepare que existem dois `rank_window_size` na query, e eles são coisas diferentes. O de dentro é quantos candidatos a fusão considera. O de fora é quantos desses vão para o modelo. Igualar os dois é o default razoável; deixar o de fora maior não faz nada, porque não existe candidato além da janela de fusão.\n \n## Onde reranking não resolve\n \nAlguns casos em que a resposta honesta é não ligar isso.\n \n**Quando o recall do primeiro estágio é o problema.** Reranking não recupera nada. Se o documento certo não está entre os 100 que chegaram, nenhum modelo vai inventá-lo. Antes de investir na etapa cara, meça Recall@100 da sua híbrida. Se estiver baixo, o problema é o embedding, o que você indexou, ou a janela de fusão, e reranking só vai reordenar melhor um conjunto ruim.\n \n**Quando o valor por busca é baixo e o volume é alto.** É a situação típica de e-commerce de cauda longa. Milhões de buscas, margem por transação apertada, e um ganho de relevância que pode não pagar nem a conta de GPU nem a de API. Aqui o cálculo é de negócio, não de engenharia.\n \n**Quando o SLA de latência é apertado.** Se seu orçamento de p95 é 100 ms e a busca já consome 40, você não tem espaço para top-100. Você tem espaço para top-20 com um modelo pequeno, e talvez nem isso.\n \n**Quando a query é um identificador.** `SKU-A4729` não precisa de compreensão semântica, precisa de match exato. Detecte o padrão antes do pipeline e desvie, como já valia para a híbrida.\n \n**Quando o corpus é homogêneo demais.** Se seus 100 candidatos são quase idênticos entre si, não existe ordem certa a descobrir e o modelo vai desempatar ruído.\n \n## O que medir antes e depois\n \nVocê já construiu a peça mais importante no post 2: aquele conjunto de 50 a 200 queries reais com os documentos relevantes marcados à mão. É ele que decide isso.\n \nMeça nDCG@10 e MRR da híbrida sozinha, depois da híbrida com reranking, varrendo a profundidade em 10, 20, 30, 50 e 100. O que você quer enxergar é a forma da curva, não um número isolado. Se ela satura em 30, ir até 100 é dinheiro jogado fora. Se ela cai depois de 50, você acabou de descobrir que o modelo não serve para o seu domínio, e descobriu offline, o que é bem melhor do que descobrir em produção.\n \nJunto disso, e não depois, registre o delta de p95 e o custo por query. Ganho de relevância sem número de latência ao lado não é um resultado, é metade de um resultado.\n \nDepois vem o A/B em produção, porque ganho offline em reranking quase sempre parece ótimo e nem sempre o usuário percebe. As métricas que respondem isso são CTR nas primeiras posições, taxa de reformulação de query e conversão. Se o CTR do top 3 sobe e a reformulação cai, o reranking está funcionando de verdade. Se nada se move, você pagou por precisão que ninguém estava esperando.\n \n## Fechando a série\n \nCinco coisas que eu gostaria de ter sabido antes de começar com tudo isso:\n \nBM25 não é legado. Ele é o baseline forte que você vai passar o resto do projeto tentando bater, e em algumas tarefas não vai conseguir.\n \nEmbedding transforma texto em geometria, e é a partir dessa mudança de representação que todo o resto acontece. Mas o vetor é uma compressão com perda, e as perdas aparecem no lugar mais chato possível: identificadores, negações, queries curtas.\n \nRRF é o melhor retorno sobre esforço da lista. Sem calibração, sem treino, consistentemente melhor que qualquer um dos dois lados sozinho.\n \nReranking é o único estágio que lê os dois textos juntos, e é por isso que ele ganha onde os outros não têm como ganhar. Também é o único que não pré-computa nada, e é por isso que ele só roda em cima de dezenas de candidatos, nunca do catálogo.\n \nE o que amarra tudo: nada disso se decide por leitura de benchmark. Se decide com um conjunto de avaliação do seu próprio corpus, que dá meio dia de trabalho chato para montar e depois responde toda pergunta que vier.\n \nEssa série cobriu o que eu queria ter sabido antes de começar com busca semântica. Se você está implementando algo parecido, me conta em que contexto. Adoraria saber.\n \n## Referências\n \n- Nogueira, R., Cho, K. **Passage Re-ranking with BERT**. arXiv:1901.04085, 2019. [Paper](https://arxiv.org/abs/1901.04085)\n- Khattab, O., Zaharia, M. **ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT**. SIGIR 2020. [Paper](https://arxiv.org/abs/2004.12832)\n- Santhanam, K. et al. **ColBERTv2: Effective and Efficient Retrieval via Lightweight Late Interaction**. arXiv:2112.01488\n- Jacob, M. et al. **Drowning in Documents: Consequences of Scaling Reranker Inference**. arXiv:2411.11767\n- Sentence Transformers, [Pretrained Cross-Encoder models](https://sbert.net/docs/cross_encoder/pretrained_models.html) e [Retrieve & Re-Rank](https://sbert.net/examples/sentence_transformer/applications/retrieve_rerank/README.html)\n- Elastic, documentação do [Elastic Rerank](https://www.elastic.co/docs/explore-analyze/machine-learning/nlp/ml-nlp-rerank) e do [retriever text_similarity_reranker](https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/text-similarity-reranker-retriever)\n- Elastic Search Labs, [Exploring depth in a retrieve-and-rerank pipeline](https://www.elastic.co/search-labs/blog/elastic-semantic-reranker-part-3)\n- Cohere, [Rerank overview](https://docs.cohere.com/docs/rerank-overview), [best practices](https://docs.cohere.com/docs/reranking-best-practices) e o [anúncio do Rerank 4.0](https://docs.cohere.com/changelog/rerank-v4.0)\n- [BAAI/bge-reranker-v2-m3](https://huggingface.co/BAAI/bge-reranker-v2-m3) e a [documentação do BGE Reranker](https://bge-model.com/bge/bge_reranker_v2.html)\n---\n \n*Terceiro e último post da série sobre busca moderna. Os anteriores são [Busca Semântica](https://rodolfodebonis.com.br/blog/como-funciona-busca-semantica) e [Busca Híbrida com RRF](https://rodolfodebonis.com.br/blog/busca-hibrida-com-rrf).*","https://assets.rodolfodebonis.com.br/blog-covers/f77aa6cb-65a3-4d40-87d3-5f704712d1d8.png",[36,40,44,48,52],{"id":37,"tenant_id":6,"slug":38,"created_at":39},"3600c7f5-46a1-44c1-ab4d-14b54bc49300","busca","2026-05-16T05:37:43.100096Z",{"id":41,"tenant_id":6,"slug":42,"created_at":43},"9de48f6e-646d-4d79-9499-a1822a48c0f1","cross-encoder","2026-08-07T17:18:01.99022Z",{"id":45,"tenant_id":6,"slug":46,"created_at":47},"3ec3cd7e-c8e8-4179-af96-1db8e19d55ff","ia","2026-07-28T08:47:55.819755Z",{"id":49,"tenant_id":6,"slug":50,"created_at":51},"43e39379-ec07-4aff-82a6-2946c56fa4f2","ml","2026-05-16T05:39:39.427214Z",{"id":53,"tenant_id":6,"slug":54,"created_at":55},"7886ed4e-ba65-4452-8fd0-e1ea4ce28b07","reranking","2026-08-07T17:18:19.021111Z","\u003Cp>Esse post fecha uma série. O \u003Ca href=\"https://rodolfodebonis.com.br/blog/como-funciona-busca-semantica\" target=\"_blank\" rel=\"noopener noreferrer\">primeiro\u003C/a> tratou de busca semântica, o \u003Ca href=\"https://rodolfodebonis.com.br/blog/busca-hibrida-com-rrf\" target=\"_blank\" rel=\"noopener noreferrer\">segundo\u003C/a> de busca híbrida com Reciprocal Rank Fusion. Se você caiu aqui direto, o resumo é que BM25 acerta o literal e erra o significado, a busca vetorial faz o inverso, e o RRF junta os dois rankings usando só posição, sem calibrar peso nenhum.\u003C/p>\n\u003Cp>O post 2 terminou com um problema em aberto. No exemplo daquele artigo, a query era \u003Ccode>tênis pra correr no calor\u003C/code> e a camiseta dry-fit ficou em segundo lugar depois da fusão. Ela não era o que o usuário queria, mas as duas buscas acharam ela razoável, e o RRF ordena por consenso de posição. Consenso não é relevância.\u003C/p>\n\u003Cp>O motivo de fundo é que, até esse ponto do pipeline, ninguém leu a query e o documento juntos.\u003C/p>\n\u003Ch2>O que um bi-encoder nunca chega a fazer\u003C/h2>\n\u003Cp>Vale ser preciso sobre o mecanismo, porque a diferença entre os dois arranjos é a coisa toda.\u003C/p>\n\u003Cp>Na busca vetorial, o modelo de embedding processa o documento sozinho, no momento da indexação, e cospe um vetor. Meses depois chega uma query, o modelo processa a query sozinha, cospe outro vetor, e o sistema compara os dois com cosseno. Esse arranjo tem nome: \u003Cstrong>bi-encoder\u003C/strong>. Dois encodes independentes, um encontro tardio e barato entre dois pontos no espaço.\u003C/p>\n\u003Cp>Isso é o que torna a busca vetorial viável. O trabalho pesado acontece offline, uma vez por documento, e sobra apenas uma comparação geométrica no caminho da requisição. É também o que torna o HNSW possível: você só consegue construir um índice de vizinhança se o vetor do documento existir antes da query chegar.\u003C/p>\n\u003Cp>E é exatamente aí que mora a limitação. O vetor de “Camiseta dry-fit para correr no calor” foi calculado sem que ninguém soubesse que a pergunta seria sobre tênis. Ele precisa representar o documento inteiro, para toda query concebível, em 1024 números fixos. É uma compressão com perda, e a informação que se perde é justamente a interação: quais partes desse documento importam \u003Cem>para essa pergunta\u003C/em>.\u003C/p>\n\u003Cp>Um \u003Cstrong>cross-encoder\u003C/strong> desfaz esse arranjo. Ele recebe o par \u003Ccode>(query, documento)\u003C/code> concatenado numa única sequência e roda um forward pass sobre os dois ao mesmo tempo. Cada token da query pode atender a cada token do documento, e vice-versa, em todas as camadas. A saída não é vetor, é um número: o quanto esse documento responde essa query.\u003C/p>\n\u003Cp>A implementação original, no paper de Nogueira e Cho de 2019, é quase constrangedoramente direta. Query entra como sentença A, passagem entra como sentença B, o vetor do token \u003Ccode>[CLS]\u003C/code> vai para uma única camada densa, e o que sai é a probabilidade da passagem ser relevante. A query é truncada em 64 tokens, o conjunto todo em 512. Nenhuma arquitetura nova. Só BERT lendo as duas coisas juntas.\u003C/p>\n\u003Ch2>O tamanho do ganho\u003C/h2>\n\u003Cp>Esse mesmo paper tem o número que, na minha opinião, é o argumento mais forte a favor de reranking, e ele é forte porque isola a variável.\u003C/p>\n\u003Cp>No MS MARCO, o conjunto de candidatos foi fixo: o top 1000 do BM25 para cada query. O BM25 sozinho, ordenando esses mesmos mil documentos, entrega MRR@10 de 16.7 no dev set. Um BERT Large reordenando exatamente a mesma lista entrega 36.5.\u003C/p>\n\u003Cp>Nenhum documento novo entrou. Nenhum índice mudou. Nenhum embedding foi gerado. A recuperação era idêntica nos dois casos, e a métrica mais que dobrou só porque alguém leu os pares.\u003C/p>\n\u003Cp>Vale registrar o que esse número não diz. É um benchmark de 2019, em inglês, num dataset de perguntas do Bing, com uma passagem relevante por query em média. Seu domínio não é esse. Mas a assimetria entre “recuperar” e “ordenar” que ele expõe é estrutural, e ela aparece de novo em todo benchmark posterior. A Elastic, medindo o próprio modelo em 21 datasets do BEIR, reporta em média 40% de melhoria em qualidade de ranking ao reranquear resultados de BM25.\u003C/p>\n\u003Ch2>Por que você não pode simplesmente usar isso em tudo\u003C/h2>\n\u003Cp>Se cross-encoder é tão melhor, a pergunta óbvia é por que não jogar fora o índice inteiro e pontuar todos os documentos direto.\u003C/p>\n\u003Cp>A resposta está na palavra “par”. O bi-encoder faz N encodes de documento uma vez na vida e depois faz um encode por query. O cross-encoder faz um forward pass por par \u003Ccode>(query, documento)\u003C/code>, toda vez, porque o score depende dos dois. Não existe nada para pré-computar. Trocou a query, todo o trabalho anterior virou lixo.\u003C/p>\n\u003Cp>Dá pra colocar número nisso com a tabela publicada do \u003Ccode>sentence-transformers\u003C/code>, que mede throughput dos cross-encoders treinados em MS MARCO numa GPU V100:\u003C/p>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Modelo\u003C/th>\n\u003Cth>nDCG@10 (TREC DL 19)\u003C/th>\n\u003Cth>MRR@10 (MS MARCO Dev)\u003C/th>\n\u003Cth>Docs/s\u003C/th>\n\u003C/tr>\n\u003C/thead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>ms-marco-TinyBERT-L2-v2\u003C/td>\n\u003Ctd>69.84\u003C/td>\n\u003Ctd>32.56\u003C/td>\n\u003Ctd>9000\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>ms-marco-MiniLM-L4-v2\u003C/td>\n\u003Ctd>73.04\u003C/td>\n\u003Ctd>37.70\u003C/td>\n\u003Ctd>2500\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>ms-marco-MiniLM-L6-v2\u003C/td>\n\u003Ctd>74.30\u003C/td>\n\u003Ctd>39.01\u003C/td>\n\u003Ctd>1800\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>ms-marco-MiniLM-L12-v2\u003C/td>\n\u003Ctd>74.31\u003C/td>\n\u003Ctd>39.02\u003C/td>\n\u003Ctd>960\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>ms-marco-electra-base\u003C/td>\n\u003Ctd>71.99\u003C/td>\n\u003Ctd>36.41\u003C/td>\n\u003Ctd>340\u003C/td>\n\u003C/tr>\n\u003C/tbody>\n\u003C/table>\n\u003Cp>Pegue o \u003Ccode>MiniLM-L6-v2\u003C/code>, que é o meio de campo dessa lista. A 1800 documentos por segundo, pontuar 100 candidatos custa 56 ms. Pontuar um catálogo de um milhão de documentos custa \u003Cstrong>9 minutos e meio por query\u003C/strong>, numa GPU dedicada, para um usuário. O \u003Ccode>L12\u003C/code>, que entrega meio ponto a mais de nDCG, leva 17 minutos.\u003C/p>\n\u003Cp>Não é uma questão de escalar horizontalmente até resolver. É uma diferença de ordem de grandeza que muda a categoria do problema.\u003C/p>\n\u003Cp>Repare também na terceira coluna comparada com a primeira. Entre o \u003Ccode>L6\u003C/code> e o \u003Ccode>L12\u003C/code> a qualidade é praticamente idêntica e o custo dobra. Entre o \u003Ccode>TinyBERT-L2\u003C/code> e o \u003Ccode>L6\u003C/code> você ganha 4.5 pontos de nDCG pagando 5× mais tempo. A curva de retorno decrescente já está desenhada dentro da própria família de modelos, antes mesmo de você escolher a profundidade.\u003C/p>\n\u003Cp>Daí a arquitetura que todo mundo converge: a recuperação barata seleciona um punhado de candidatos, e o modelo caro só olha esse punhado.\u003C/p>\n\u003Cp>\u003Cimg src=\"https://assets.rodolfodebonis.com.br/blog-articles-images/reranking/arhitecture_pt.png\" alt=\"image\">\u003C/p>\n\u003Cp>O primeiro estágio existe pra não deixar nada relevante de fora. O segundo existe pra acertar a ordem. São objetivos diferentes, e é por isso que faz sentido usar modelos diferentes.\u003C/p>\n\u003Ch2>A camiseta finalmente cai\u003C/h2>\n\u003Cp>Voltando ao exemplo do post 2. Cinco documentos, a query \u003Ccode>tênis pra correr no calor\u003C/code>, e o ranking que o RRF produziu:\u003C/p>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Posição\u003C/th>\n\u003Cth>Documento\u003C/th>\n\u003C/tr>\n\u003C/thead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>1\u003C/td>\n\u003Ctd>A: Tênis de corrida Ventus, mesh com ventilação alta\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>2\u003C/td>\n\u003Ctd>C: Camiseta dry-fit para correr no calor\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>3\u003C/td>\n\u003Ctd>D: Tênis leve para clima quente e úmido\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>4\u003C/td>\n\u003Ctd>E: Meia técnica de corrida para dias quentes\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>5\u003C/td>\n\u003Ctd>B: Tênis de corrida trail Rocha 3, cabedal reforçado\u003C/td>\n\u003C/tr>\n\u003C/tbody>\n\u003C/table>\n\u003Cp>Agora o cross-encoder recebe os cinco pares e devolve um score por par. Os valores abaixo são ilustrativos, mas a ordem é o que qualquer reranker decente produz aqui:\u003C/p>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Documento\u003C/th>\n\u003Cth>Score\u003C/th>\n\u003Cth>Posição final\u003C/th>\n\u003C/tr>\n\u003C/thead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>A: Tênis Ventus, mesh\u003C/td>\n\u003Ctd>0.94\u003C/td>\n\u003Ctd>1º\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>D: Tênis leve, clima quente\u003C/td>\n\u003Ctd>0.91\u003C/td>\n\u003Ctd>2º\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>B: Tênis trail Rocha 3\u003C/td>\n\u003Ctd>0.38\u003C/td>\n\u003Ctd>3º\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>C: Camiseta dry-fit\u003C/td>\n\u003Ctd>0.07\u003C/td>\n\u003Ctd>4º\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>E: Meia técnica\u003C/td>\n\u003Ctd>0.04\u003C/td>\n\u003Ctd>5º\u003C/td>\n\u003C/tr>\n\u003C/tbody>\n\u003C/table>\n\u003Cp>A camiseta desabou da segunda posição para a quarta, e o motivo é banal quando você olha o mecanismo: o modelo processou o token “tênis” da query no mesmo forward pass que o token “camiseta” do documento. Ele não está comparando dois resumos de significado, ele está lendo uma pergunta sobre calçado e um documento sobre vestuário e concluindo que a categoria está errada, apesar de “correr” e “calor” baterem literalmente.\u003C/p>\n\u003Cp>O \u003Ccode>B\u003C/code> subiu para terceiro por um raciocínio complementar. É um tênis de corrida, então acerta a intenção principal, mas “cabedal reforçado” é o oposto de ventilação, e o modelo penaliza sem descartar. Nenhum dos dois estágios anteriores tem como fazer essa distinção, porque nenhum dos dois leu as duas coisas ao mesmo tempo.\u003C/p>\n\u003Cp>Repare no salto entre 0.91 e 0.38. Não é ruído de escala como nos scores do RRF. É o modelo separando duas populações.\u003C/p>\n\u003Ch2>Escolher o modelo\u003C/h2>\n\u003Cp>O ecossistema se divide em três caminhos, e a escolha entre eles quase nunca é sobre qualidade de ranking.\u003C/p>\n\u003Cp>\u003Cstrong>API gerenciada.\u003C/strong> O Cohere Rerank é a referência da categoria. A linha atual são o \u003Ccode>rerank-v4.0-pro\u003C/code> e o \u003Ccode>rerank-v4.0-fast\u003C/code>, ambos multilíngues com mais de 100 idiomas, 32 mil tokens de contexto, e suporte a documentos semiestruturados em JSON. O \u003Ccode>rerank-v3.5\u003C/code> continua disponível com 4 mil tokens. Voyage e Jina jogam no mesmo campo. Você adiciona uma chamada HTTP e pronto, sem GPU, sem deploy, sem reindexação.\u003C/p>\n\u003Cp>O detalhe de planejamento é a unidade de cobrança. Uma “search unit” é uma query com até 100 documentos, e documentos longos são fatiados automaticamente, com cada pedaço contando como um documento separado. Ou seja, seus 100 candidatos podem virar várias search units se os textos forem grandes. Na listagem do Rerank v3.5 no AWS Marketplace (edição Bedrock) o preço é US$ 0,002 por search unit. Isso dá cerca de US$ 200 num mês com 100 mil buscas reranqueadas, e US$ 2 mil num mês com um milhão. Preço muda, então confira antes de fechar conta, mas a ordem de grandeza é essa: reranking em API é barato por busca e caro por tráfego.\u003C/p>\n\u003Cp>\u003Cstrong>Open-source auto-hospedado.\u003C/strong> A família \u003Ccode>cross-encoder/ms-marco-*\u003C/code> do sentence-transformers é o ponto de partida clássico, e a tabela lá em cima já mostra o trade-off inteiro. Um alerta que importa muito pra quem escreve em português: esses modelos são treinados em MS MARCO, que é inglês. Se seu corpus é PT-BR, eles vão te decepcionar. Para multilíngue, o caminho é o \u003Ccode>BAAI/bge-reranker-v2-m3\u003C/code>, construído em cima do bge-m3, com 512 tokens de janela e score que vira 0 a 1 com uma sigmoide. A própria documentação do BGE recomenda o óbvio e o correto: teste no seu caso real e escolha pelo equilíbrio velocidade/qualidade, não pela tabela deles.\u003C/p>\n\u003Cp>\u003Cstrong>Embutido no motor.\u003C/strong> O Elasticsearch expõe reranking pelo retriever \u003Ccode>text_similarity_reranker\u003C/code>, que pode apontar para um endpoint de inference externo (Cohere, por exemplo) ou para o modelo próprio da Elastic, o Elastic Rerank, um DeBERTa v3 de 184 milhões de parâmetros. Vale ler as limitações antes de se animar: só inglês, janela de 512 tokens, disponível a partir do Stack 8.17, exige nível de assinatura adequado, e ainda está marcado como technical preview. A própria Elastic escreve na documentação que a versão preview pode ser proibitivamente cara para alto volume de queries com requisito de baixa latência, e recomenda não passar de top-30 quando a inferência é em CPU.\u003C/p>\n\u003Cp>\u003Cstrong>A opção do meio.\u003C/strong> O ColBERT ocupa um lugar próprio. Em vez de um score por par, ele guarda um vetor por token do documento e faz uma interação tardia entre os tokens da query e os do documento no momento da busca. O paper original mede efetividade competitiva com os modelos BERT da época executando duas ordens de grandeza mais rápido e com quatro ordens de grandeza menos FLOPs por query. O preço é o índice: guardar um vetor por token infla o armazenamento de forma agressiva, e o ColBERTv2 nasceu justamente pra reduzir isso em 5 a 8 vezes. Se seu problema é latência e você tem disco sobrando, vale olhar.\u003C/p>\n\u003Ch2>A profundidade é o parâmetro que importa\u003C/h2>\n\u003Cp>Aqui está o erro mais caro dessa etapa, e ele é contraintuitivo.\u003C/p>\n\u003Cp>A crença padrão é que reranquear mais candidatos só pode melhorar, porque o modelo bom teria mais material para trabalhar. A Elastic mediu isso de forma sistemática, variando profundidade de reranking em vários modelos e datasets do BEIR, e encontrou três padrões:\u003C/p>\n\u003Cul>\n\u003Cli>\u003Cstrong>Subida rápida e depois saturação\u003C/strong>, em 72.6% dos casos. É o comportamento que todo mundo espera.\u003C/li>\n\u003Cli>\u003Cstrong>Subida até um pico e depois queda\u003C/strong>, em 20.2% dos casos.\u003C/li>\n\u003Cli>\u003Cstrong>Queda contínua com qualquer reranking\u003C/strong>, em 7.1% dos casos. O reranker piora o resultado do BM25 em toda profundidade testada.\nA explicação é mecânica e boa. O nDCG@10 só muda quando o reranker promove alguém de baixo para o top 10. Se o promovido é relevante, a métrica sobe. Se é um falso positivo, ele expulsa um documento relevante e a métrica cai. Conforme você desce na lista, a densidade de documentos relevantes despenca e a de irrelevantes cresce. Existe uma profundidade em que a taxa de falso positivo do modelo passa a dominar, e depois dela você está pagando inferência para piorar o próprio ranking.\u003C/li>\n\u003C/ul>\n\u003Cp>O paper \u003Cem>Drowning in Documents\u003C/em> chega ao mesmo lugar por outro caminho, e observa algo desconfortável: rerankers frequentemente atribuem score alto a documentos sem nenhuma sobreposição léxica ou semântica com a query.\u003C/p>\n\u003Cp>As três consequências práticas, na ordem em que você vai precisar delas:\u003C/p>\n\u003Cp>Primeiro, o número. Aplicando a regra de pegar a profundidade que atinge 90% do ganho máximo, a Elastic chegou a algo em torno de \u003Cstrong>top 100\u003C/strong> em cima de BM25, com um terço do custo computacional de ir até o máximo. Quando custo aperta, eles recomendam \u003Cstrong>top 30\u003C/strong> com o modelo deles, e mesmo assim mediram uplift acima de 40% em nDCG@10 na parte de QA do benchmark.\u003C/p>\n\u003Cp>Segundo, a direção do ajuste. Quanto melhor o primeiro estágio, menos candidatos você precisa reranquear, porque o recall satura antes. E quanto melhor o reranker, mais fundo compensa ir, porque ele erra menos ao descer. Como você já tem híbrida com RRF, seu primeiro estágio é bom, então comece raso.\u003C/p>\n\u003Cp>Terceiro, e esse me surpreendeu: sob restrição de latência, reranquear fundo com um modelo pequeno costuma ganhar de reranquear raso com um modelo grande. No benchmark da Elastic, o \u003Ccode>MiniLM-L12-v2\u003C/code> processando 80 candidatos bateu modelos mais fortes limitados a 10 ou 20 pelo mesmo orçamento de tempo. A relação se inverte conforme você afrouxa a restrição.\u003C/p>\n\u003Cp>Sobre latência, um número concreto para calibrar expectativa: medindo em duas GPUs T4, a Elastic reporta 0,0869 segundo para pontuar 10 pares com o Elastic Rerank no HotpotQA. Linearizando, top-30 sai em torno de 0,26 s e top-100 em torno de 0,87 s. Não são os 50 ms que costumam circular em posts sobre o assunto. Meça no seu hardware, com seus documentos, antes de prometer SLA.\u003C/p>\n\u003Ch2>O score volta a significar alguma coisa\u003C/h2>\n\u003Cp>Uma limitação que eu levantei no post 2 se resolve aqui, e vale fechar o ciclo.\u003C/p>\n\u003Cp>O score do RRF não significa nada. Ele vive espremido numa faixa minúscula, não é comparável entre queries, e por isso não dá pra usar em threshold. Você não consegue dizer “se o melhor resultado for ruim, mostro a tela de nada encontrado”.\u003C/p>\n\u003Cp>O score de um cross-encoder é diferente em natureza. Ele é uma estimativa de relevância daquele par, produzida por um modelo treinado com esse objetivo, e não depende da posição de nada. Os modelos do sentence-transformers devolvem logits que ficam grosso modo entre -10 e 10, e você aplica uma sigmoide pra ter algo entre 0 e 1. O \u003Ccode>bge-reranker-v2-m3\u003C/code> funciona igual. O Cohere devolve \u003Ccode>relevance_score\u003C/code> já normalizado. O Elasticsearch expõe \u003Ccode>min_score\u003C/code> no próprio retriever justamente pra você cortar.\u003C/p>\n\u003Cp>O cuidado é não confundir “tem significado” com “é calibrado”. A escala é específica do modelo e do domínio. Um 0.6 do Cohere não é um 0.6 do BGE, e o 0.6 do BGE no seu corpus jurídico não é o mesmo 0.6 no seu FAQ de suporte. O threshold precisa sair dos seus dados. Mas ele existe, e isso é uma capacidade nova no pipeline.\u003C/p>\n\u003Ch2>Como isso fica no código\u003C/h2>\n\u003Cp>Auto-hospedado, com \u003Ccode>sentence-transformers\u003C/code>. O ponto do trecho é que reranking é uma função pura sobre a lista de candidatos, sem estado e sem índice:\u003C/p>\n\u003Cpre class=\"shiki shiki-themes github-dark github-light\" style=\"--shiki-dark:#e1e4e8;--shiki-light:#24292e;--shiki-dark-bg:#24292e;--shiki-light-bg:#fff\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">from\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> sentence_transformers \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">import\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> CrossEncoder\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">import\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> torch\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> \u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#6A737D;--shiki-light:#6A737D\"># Sigmoid força o score pra faixa 0..1. Sem isso, o retorno é logit cru.\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">reranker \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> CrossEncoder(\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">    \"BAAI/bge-reranker-v2-m3\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">,   \u003C/span>\u003Cspan style=\"--shiki-dark:#6A737D;--shiki-light:#6A737D\"># multilíngue; para inglês, ms-marco-MiniLM-L6-v2\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#FFAB70;--shiki-light:#E36209\">    activation_fn\u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">torch.nn.Sigmoid(),\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#FFAB70;--shiki-light:#E36209\">    max_length\u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">512\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> \u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">def\u003C/span>\u003Cspan style=\"--shiki-dark:#B392F0;--shiki-light:#6F42C1\"> rerank\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">(query: \u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">str\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">, candidatos: list[\u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">dict\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">], top_k: \u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">int\u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\"> =\u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\"> 10\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">) -> list[\u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">dict\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">]:\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">    \"\"\"Recebe a saída do RRF, devolve o top_k reordenado.\"\"\"\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">    if\u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\"> not\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> candidatos:\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">        return\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> []\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> \u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">    pares \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> [(query, doc[\u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"texto\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">]) \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">for\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> doc \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">in\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> candidatos]\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">    scores \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> reranker.predict(pares, \u003C/span>\u003Cspan style=\"--shiki-dark:#FFAB70;--shiki-light:#E36209\">batch_size\u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">32\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> \u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">    for\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> doc, score \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">in\u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\"> zip\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">(candidatos, scores):\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">        doc[\u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"rerank_score\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">] \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\"> float\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">(score)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> \u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">    ordenado \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\"> sorted\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">(candidatos, \u003C/span>\u003Cspan style=\"--shiki-dark:#FFAB70;--shiki-light:#E36209\">key\u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=lambda\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> d: d[\u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"rerank_score\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">], \u003C/span>\u003Cspan style=\"--shiki-dark:#FFAB70;--shiki-light:#E36209\">reverse\u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">True\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">    return\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> ordenado[:top_k]\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Duas decisões valem comentário. O \u003Ccode>batch_size\u003C/code> é o que controla se você satura a GPU ou desperdiça ela, e é o primeiro botão a mexer quando a latência não fecha. E \u003Ccode>max_length=512\u003C/code> não é cosmético: documento maior que isso é truncado, então o modelo pontua o começo do texto e ignora o resto. Se seus documentos são longos, você precisa decidir conscientemente qual pedaço vai ser pontuado, em vez de deixar o tokenizer decidir por você.\u003C/p>\n\u003Cp>Via API, o mesmo pipeline com Cohere:\u003C/p>\n\u003Cpre class=\"shiki shiki-themes github-dark github-light\" style=\"--shiki-dark:#e1e4e8;--shiki-light:#24292e;--shiki-dark-bg:#24292e;--shiki-light-bg:#fff\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">import\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> cohere\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> \u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">co \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> cohere.ClientV2(\u003C/span>\u003Cspan style=\"--shiki-dark:#FFAB70;--shiki-light:#E36209\">api_key\u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"...\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> \u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">resposta \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> co.rerank(\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#FFAB70;--shiki-light:#E36209\">    model\u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"rerank-v4.0-fast\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#FFAB70;--shiki-light:#E36209\">    query\u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"tênis pra correr no calor\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#FFAB70;--shiki-light:#E36209\">    documents\u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">[doc[\u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"texto\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">] \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">for\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> doc \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">in\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> candidatos],\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#FFAB70;--shiki-light:#E36209\">    top_n\u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">10\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> \u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#6A737D;--shiki-light:#6A737D\"># results traz index (posição na lista original) e relevance_score\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">top10 \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> [candidatos[r.index] \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">for\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> r \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">in\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> resposta.results]\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>E dentro do Elasticsearch, encadeando no retriever RRF do post anterior:\u003C/p>\n\u003Cpre class=\"shiki shiki-themes github-dark github-light\" style=\"--shiki-dark:#e1e4e8;--shiki-light:#24292e;--shiki-dark-bg:#24292e;--shiki-light-bg:#fff\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">GET /produtos/_search\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">{\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">  \"retriever\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">    \"text_similarity_reranker\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">      \"retriever\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">        \"rrf\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">          \"retrievers\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: [\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">            { \u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">\"standard\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: { \u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">\"query\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: { \u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">\"multi_match\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">                \"query\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: \u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"tênis pra correr no calor\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">                \"fields\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: [\u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"titulo\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">, \u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"descricao\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">]\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">            }}}},\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">            { \u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">\"knn\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: {\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">                \"field\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: \u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"embedding\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">                \"query_vector\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: [\u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">0.21\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">, \u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">-0.05\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">, \u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"...\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">],\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">                \"k\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: \u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">100\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">                \"num_candidates\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: \u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">300\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">            }}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">          ],\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">          \"rank_window_size\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: \u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">100\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">        }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">      },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">      \"field\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: \u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"descricao\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">      \"inference_id\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: \u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"meu-reranker\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">      \"inference_text\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: \u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"tênis pra correr no calor\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">      \"rank_window_size\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: \u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">100\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">,\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">      \"min_score\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: \u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">0.4\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">    }\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">  },\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">  \"size\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: \u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">10\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">}\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Repare que existem dois \u003Ccode>rank_window_size\u003C/code> na query, e eles são coisas diferentes. O de dentro é quantos candidatos a fusão considera. O de fora é quantos desses vão para o modelo. Igualar os dois é o default razoável; deixar o de fora maior não faz nada, porque não existe candidato além da janela de fusão.\u003C/p>\n\u003Ch2>Onde reranking não resolve\u003C/h2>\n\u003Cp>Alguns casos em que a resposta honesta é não ligar isso.\u003C/p>\n\u003Cp>\u003Cstrong>Quando o recall do primeiro estágio é o problema.\u003C/strong> Reranking não recupera nada. Se o documento certo não está entre os 100 que chegaram, nenhum modelo vai inventá-lo. Antes de investir na etapa cara, meça Recall@100 da sua híbrida. Se estiver baixo, o problema é o embedding, o que você indexou, ou a janela de fusão, e reranking só vai reordenar melhor um conjunto ruim.\u003C/p>\n\u003Cp>\u003Cstrong>Quando o valor por busca é baixo e o volume é alto.\u003C/strong> É a situação típica de e-commerce de cauda longa. Milhões de buscas, margem por transação apertada, e um ganho de relevância que pode não pagar nem a conta de GPU nem a de API. Aqui o cálculo é de negócio, não de engenharia.\u003C/p>\n\u003Cp>\u003Cstrong>Quando o SLA de latência é apertado.\u003C/strong> Se seu orçamento de p95 é 100 ms e a busca já consome 40, você não tem espaço para top-100. Você tem espaço para top-20 com um modelo pequeno, e talvez nem isso.\u003C/p>\n\u003Cp>\u003Cstrong>Quando a query é um identificador.\u003C/strong> \u003Ccode>SKU-A4729\u003C/code> não precisa de compreensão semântica, precisa de match exato. Detecte o padrão antes do pipeline e desvie, como já valia para a híbrida.\u003C/p>\n\u003Cp>\u003Cstrong>Quando o corpus é homogêneo demais.\u003C/strong> Se seus 100 candidatos são quase idênticos entre si, não existe ordem certa a descobrir e o modelo vai desempatar ruído.\u003C/p>\n\u003Ch2>O que medir antes e depois\u003C/h2>\n\u003Cp>Você já construiu a peça mais importante no post 2: aquele conjunto de 50 a 200 queries reais com os documentos relevantes marcados à mão. É ele que decide isso.\u003C/p>\n\u003Cp>Meça nDCG@10 e MRR da híbrida sozinha, depois da híbrida com reranking, varrendo a profundidade em 10, 20, 30, 50 e 100. O que você quer enxergar é a forma da curva, não um número isolado. Se ela satura em 30, ir até 100 é dinheiro jogado fora. Se ela cai depois de 50, você acabou de descobrir que o modelo não serve para o seu domínio, e descobriu offline, o que é bem melhor do que descobrir em produção.\u003C/p>\n\u003Cp>Junto disso, e não depois, registre o delta de p95 e o custo por query. Ganho de relevância sem número de latência ao lado não é um resultado, é metade de um resultado.\u003C/p>\n\u003Cp>Depois vem o A/B em produção, porque ganho offline em reranking quase sempre parece ótimo e nem sempre o usuário percebe. As métricas que respondem isso são CTR nas primeiras posições, taxa de reformulação de query e conversão. Se o CTR do top 3 sobe e a reformulação cai, o reranking está funcionando de verdade. Se nada se move, você pagou por precisão que ninguém estava esperando.\u003C/p>\n\u003Ch2>Fechando a série\u003C/h2>\n\u003Cp>Cinco coisas que eu gostaria de ter sabido antes de começar com tudo isso:\u003C/p>\n\u003Cp>BM25 não é legado. Ele é o baseline forte que você vai passar o resto do projeto tentando bater, e em algumas tarefas não vai conseguir.\u003C/p>\n\u003Cp>Embedding transforma texto em geometria, e é a partir dessa mudança de representação que todo o resto acontece. Mas o vetor é uma compressão com perda, e as perdas aparecem no lugar mais chato possível: identificadores, negações, queries curtas.\u003C/p>\n\u003Cp>RRF é o melhor retorno sobre esforço da lista. Sem calibração, sem treino, consistentemente melhor que qualquer um dos dois lados sozinho.\u003C/p>\n\u003Cp>Reranking é o único estágio que lê os dois textos juntos, e é por isso que ele ganha onde os outros não têm como ganhar. Também é o único que não pré-computa nada, e é por isso que ele só roda em cima de dezenas de candidatos, nunca do catálogo.\u003C/p>\n\u003Cp>E o que amarra tudo: nada disso se decide por leitura de benchmark. Se decide com um conjunto de avaliação do seu próprio corpus, que dá meio dia de trabalho chato para montar e depois responde toda pergunta que vier.\u003C/p>\n\u003Cp>Essa série cobriu o que eu queria ter sabido antes de começar com busca semântica. Se você está implementando algo parecido, me conta em que contexto. Adoraria saber.\u003C/p>\n\u003Ch2>Referências\u003C/h2>\n\u003Cul>\n\u003Cli>Nogueira, R., Cho, K. \u003Cstrong>Passage Re-ranking with BERT\u003C/strong>. arXiv:1901.04085, 2019. \u003Ca href=\"https://arxiv.org/abs/1901.04085\" target=\"_blank\" rel=\"noopener noreferrer\">Paper\u003C/a>\u003C/li>\n\u003Cli>Khattab, O., Zaharia, M. \u003Cstrong>ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT\u003C/strong>. SIGIR 2020. \u003Ca href=\"https://arxiv.org/abs/2004.12832\" target=\"_blank\" rel=\"noopener noreferrer\">Paper\u003C/a>\u003C/li>\n\u003Cli>Santhanam, K. et al. \u003Cstrong>ColBERTv2: Effective and Efficient Retrieval via Lightweight Late Interaction\u003C/strong>. arXiv:2112.01488\u003C/li>\n\u003Cli>Jacob, M. et al. \u003Cstrong>Drowning in Documents: Consequences of Scaling Reranker Inference\u003C/strong>. arXiv:2411.11767\u003C/li>\n\u003Cli>Sentence Transformers, \u003Ca href=\"https://sbert.net/docs/cross_encoder/pretrained_models.html\" target=\"_blank\" rel=\"noopener noreferrer\">Pretrained Cross-Encoder models\u003C/a> e \u003Ca href=\"https://sbert.net/examples/sentence_transformer/applications/retrieve_rerank/README.html\" target=\"_blank\" rel=\"noopener noreferrer\">Retrieve &amp; Re-Rank\u003C/a>\u003C/li>\n\u003Cli>Elastic, documentação do \u003Ca href=\"https://www.elastic.co/docs/explore-analyze/machine-learning/nlp/ml-nlp-rerank\" target=\"_blank\" rel=\"noopener noreferrer\">Elastic Rerank\u003C/a> e do \u003Ca href=\"https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/text-similarity-reranker-retriever\" target=\"_blank\" rel=\"noopener noreferrer\">retriever text_similarity_reranker\u003C/a>\u003C/li>\n\u003Cli>Elastic Search Labs, \u003Ca href=\"https://www.elastic.co/search-labs/blog/elastic-semantic-reranker-part-3\" target=\"_blank\" rel=\"noopener noreferrer\">Exploring depth in a retrieve-and-rerank pipeline\u003C/a>\u003C/li>\n\u003Cli>Cohere, \u003Ca href=\"https://docs.cohere.com/docs/rerank-overview\" target=\"_blank\" rel=\"noopener noreferrer\">Rerank overview\u003C/a>, \u003Ca href=\"https://docs.cohere.com/docs/reranking-best-practices\" target=\"_blank\" rel=\"noopener noreferrer\">best practices\u003C/a> e o \u003Ca href=\"https://docs.cohere.com/changelog/rerank-v4.0\" target=\"_blank\" rel=\"noopener noreferrer\">anúncio do Rerank 4.0\u003C/a>\u003C/li>\n\u003Cli>\u003Ca href=\"https://huggingface.co/BAAI/bge-reranker-v2-m3\" target=\"_blank\" rel=\"noopener noreferrer\">BAAI/bge-reranker-v2-m3\u003C/a> e a \u003Ca href=\"https://bge-model.com/bge/bge_reranker_v2.html\" target=\"_blank\" rel=\"noopener noreferrer\">documentação do BGE Reranker\u003C/a>\u003C/li>\n\u003C/ul>\n\u003Chr>\n\u003Cp>\u003Cem>Terceiro e último post da série sobre busca moderna. Os anteriores são \u003Ca href=\"https://rodolfodebonis.com.br/blog/como-funciona-busca-semantica\" target=\"_blank\" rel=\"noopener noreferrer\">Busca Semântica\u003C/a> e \u003Ca href=\"https://rodolfodebonis.com.br/blog/busca-hibrida-com-rrf\" target=\"_blank\" rel=\"noopener noreferrer\">Busca Híbrida com RRF\u003C/a>.\u003C/em>\u003C/p>\n",[58,68,102],{"id":5,"tenant_id":6,"author":59,"status":11,"published_at":12,"reading_time_minutes":13,"view_count":14,"like_count":15,"featured":16,"created_at":17,"updated_at":12,"translations":60,"tags":62},{"sub":8,"name":9,"email":10},[61],{"id":28,"post_id":5,"tenant_id":6,"lang":29,"slug":30,"title":31,"excerpt":32,"content_md":33,"cover_image_url":34,"created_at":17,"updated_at":17},[63,64,65,66,67],{"id":37,"tenant_id":6,"slug":38,"created_at":39},{"id":41,"tenant_id":6,"slug":42,"created_at":43},{"id":45,"tenant_id":6,"slug":46,"created_at":47},{"id":49,"tenant_id":6,"slug":50,"created_at":51},{"id":53,"tenant_id":6,"slug":54,"created_at":55},{"id":69,"tenant_id":6,"author":70,"status":11,"published_at":71,"scheduled_at":72,"reading_time_minutes":73,"view_count":74,"like_count":75,"featured":16,"created_at":76,"updated_at":77,"translations":78,"tags":87},"924b3ca2-7bd5-43e3-8b62-a0ea64171948",{"sub":8,"name":9,"email":10},"2026-07-28T08:48:49.010736Z","2026-07-28T12:30:00Z",9,28,1,"2026-07-28T08:48:48.773791Z","2026-07-30T18:18:43.476223Z",[79],{"id":80,"post_id":69,"tenant_id":6,"lang":29,"slug":81,"title":82,"excerpt":83,"content_md":84,"cover_image_url":85,"created_at":76,"updated_at":86},"94b7cc4d-384b-4ce0-907b-a4c95303c158","busca-hibrida-com-rrf","Busca Híbrida com RRF: por que os melhores sistemas combinam BM25 e vetorial","BM25 e busca vetorial erram em lugares diferentes. Reciprocal Rank Fusion junta os dois rankings sem você calibrar peso nenhum. Segundo artigo da série sobre busca moderna.","Esse post assume que você leu [Busca Semântica: como ensinar máquinas a entender intenção](/blog/como-funciona-busca-semantica). Recapitulando em duas linhas: BM25 acerta a superfície do texto e erra o significado; a busca vetorial acerta o significado e erra o literal. Os buracos são complementares, e a conclusão natural é somar as duas.\n\nO problema é que \"somar\" esconde uma decisão bem mais espinhosa do que parece.\n\n## Duas listas na tela, uma página de resultados\n\nImagine que você rodou as duas buscas. O BM25 devolveu dez documentos ordenados. A busca vetorial devolveu outros dez, com alguma sobreposição. Agora você precisa entregar uma lista só pro usuário.\n\nQual documento vai na primeira posição?\n\nEssa é literalmente a única pergunta que a busca híbrida precisa responder. E é onde a maioria das implementações caseiras começa a desandar, porque a resposta intuitiva é a errada.\n\n## Somar os scores parece óbvio e quase sempre dá errado\n\nA primeira tentativa de todo mundo é combinação linear:\n\n```\nscore_final = α × score_bm25 + (1 - α) × score_cosseno\n```\n\nElegante no papel. Você ajusta o α, dá mais peso pra um lado ou pro outro, pronto. Só que as duas grandezas não vivem na mesma régua.\n\nA similaridade do cosseno é limitada: em texto, na prática, fica entre 0 e 1. O BM25 não tem teto. O score depende do IDF dos termos, do tamanho do documento, do tamanho da query e do tamanho do corpus. Uma query de uma palavra rara num índice de um milhão de documentos pode produzir um score que é dezenas de vezes maior que o cosseno máximo possível. Some os dois crus e o BM25 engole a busca vetorial inteira. Você não configurou pesos, você configurou uma escala.\n\nA correção óbvia é normalizar antes de somar. Min-max por query, por exemplo: o melhor resultado de cada lista vira 1.0, o pior vira 0.0. E aqui mora a armadilha que quase ninguém enxerga na primeira leitura.\n\nMin-max é relativo à query. Se a busca vetorial não encontrou nada de bom, o melhor dos resultados ruins ainda vira 1.0. A normalização apagou exatamente a informação que importava: essa lista não tem nada de útil. Você acabou de dar peso máximo a um resultado péssimo por causa de uma conta de escala.\n\nDá pra contornar com normalização por distribuição, z-score, calibração por domínio. Tudo isso funciona até certo ponto. Mas repare no que aconteceu: você entrou pra resolver ranking e saiu fazendo estatística de score. O α virou um hiperparâmetro que muda quando você troca o modelo de embedding, quando o corpus cresce, quando o perfil de query muda. É manutenção permanente.\n\n## Rank é universal, score não é\n\nO Reciprocal Rank Fusion resolve isso ignorando os scores.\n\nA ideia, publicada por Cormack, Clarke e Büttcher na SIGIR 2009, cabe numa linha:\n\n```\nRRF(d) = Σ  1 / (k + rank_r(d))\n        r∈R\n```\n\nPara cada ranking `r` em que o documento `d` aparece, some o inverso da sua posição, deslocada por uma constante `k`. Ordene pela soma. Fim.\n\nNão tem normalização. Não tem peso pra calibrar. Não tem treinamento. As duas listas entram como sequências de posições, e posição é uma unidade que qualquer ranker do mundo produz do mesmo jeito. O primeiro colocado do BM25 e o primeiro colocado do vetorial são a mesma coisa: primeiro colocado. É isso que torna a fusão possível sem que ninguém precise perguntar quanto vale um score 14.7.\n\nA consequência mais importante vem de graça: como cada lista contribui com uma parcela pequena e parecida, o documento que sobe é aquele que as duas buscas concordam em achar razoável. Não o favorito absoluto de uma delas. RRF premia consenso.\n\n## Cinco documentos, duas listas, um vencedor\n\nVale ver isso acontecer com números. Query: `tênis pra correr no calor`, num catálogo de e-commerce esportivo.\n\nO BM25 casa os tokens literais e devolve:\n\n| Posição | Documento                                            |\n| ------- | ---------------------------------------------------- |\n| 1       | C: Camiseta dry-fit para correr no calor             |\n| 2       | A: Tênis de corrida Ventus, mesh com ventilação alta |\n| 3       | B: Tênis de corrida trail Rocha 3, cabedal reforçado  |\n| 4       | E: Meia técnica de corrida para dias quentes         |\n| 5       | D: Tênis leve para clima quente e úmido              |\n\nA camiseta ganha o primeiro lugar porque casa dois termos discriminativos, \"correr\" e \"calor\". Ela é irrelevante pra intenção do usuário, mas o BM25 não sabe disso. E o `D`, que é quase uma paráfrase perfeita da query, afunda: \"quente\" não é \"calor\" pro índice invertido.\n\nA busca vetorial devolve outra ordem:\n\n| Posição | Documento                          |\n| ------- | ---------------------------------- |\n| 1       | D: Tênis leve para clima quente    |\n| 2       | A: Tênis de corrida Ventus, mesh   |\n| 3       | E: Meia técnica para dias quentes  |\n| 4       | C: Camiseta dry-fit                |\n| 5       | B: Tênis trail Rocha 3             |\n\nAqui o `D` sobe pro topo, como esperado. Mas a meia também sobe, porque \"corrida\" e \"calor\" dominam o vetor e a categoria do produto pesa menos do que deveria.\n\nCada lista tem um primeiro lugar errado, por motivos opostos. Agora o RRF com `k = 60`:\n\n| Documento          | rank BM25 | rank vetorial | RRF                     | Final |\n| ------------------ | --------- | ------------- | ----------------------- | ----- |\n| A: Tênis Ventus    | 2         | 2             | 1/62 + 1/62 = 0.032258  | 1º    |\n| C: Camiseta        | 1         | 4             | 1/61 + 1/64 = 0.032018  | 2º    |\n| D: Tênis leve      | 5         | 1             | 1/65 + 1/61 = 0.031778  | 3º    |\n| E: Meia técnica    | 4         | 3             | 1/64 + 1/63 = 0.031498  | 4º    |\n| B: Tênis trail     | 3         | 5             | 1/63 + 1/65 = 0.031258  | 5º    |\n\nO `A` ganha sem ter sido o primeiro de ninguém. Ele é o único documento que as duas buscas colocaram no topo por razões diferentes: o BM25 viu \"tênis\" e \"corrida\", o modelo de embedding viu \"mesh com ventilação\" e entendeu calor. Isso é o consenso funcionando.\n\nRepare também no que o RRF **não** fez: a camiseta continua em segundo. Fusão de rank não é filtro de relevância. Se um documento errado aparece bem posicionado nas duas listas, ele continua bem posicionado depois. Guarde isso, vai voltar mais pra frente.\n\n## O que o k faz de verdade\n\nO `k = 60` circula por aí como número mágico. A origem é bem menos mística do que parece: no paper, os autores dizem que o valor foi fixado num experimento piloto e não foi alterado nas validações seguintes.\n\nO que dá pra afirmar com base nos dados publicados é o comportamento da curva. Na Tabela 1 do artigo, o MAP para `k` variando de 20 a 100 fica entre 0.2134 e 0.2147. Menos de 1% de variação. O pico nominal está em `k = 80`, não em 60. Só nos extremos a coisa muda: `k = 0` cai pra 0.2072 e `k = 500` pra 0.2098.\n\nTraduzindo pro seu sistema: `k` controla o quanto o primeiro lugar de uma lista individual pesa contra o consenso.\n\nPegue o mesmo exemplo dos tênis com `k = 0`, ou seja, score igual a `1/rank`:\n\n| Documento       | RRF com k = 0        | Final |\n| --------------- | -------------------- | ----- |\n| C: Camiseta     | 1/1 + 1/4 = 1.25     | 1º    |\n| D: Tênis leve   | 1/5 + 1/1 = 1.20     | 2º    |\n| A: Tênis Ventus | 1/2 + 1/2 = 1.00     | 3º    |\n\nA ordem inverteu. Com `k` pequeno, o intervalo entre a primeira e a segunda posição é enorme, então cada ranker praticamente arrasta seu favorito pro pódio e o consenso perde. Com `k` grande, as posições ficam quase indistinguíveis entre si e o que sobra é contagem de votos.\n\n`k = 60` é um ponto de equilíbrio razoável nesse espectro, não um ótimo global do seu domínio. Se você tem conjunto de avaliação, varra o intervalo. Se não tem, mexer em `k` no chute é a última coisa que vai melhorar seu resultado.\n\n## Como isso fica na arquitetura\n\nO fluxo é mais simples do que a maioria imagina:\n![image](https://assets.rodolfodebonis.com.br/blog-articles-images/how-semantic-search-works/architecture_pt.png)\n\nTrês pontos que mudam o resultado na prática.\n\n**As duas buscas rodam em paralelo, então a latência é o máximo entre elas, não a soma.** Se o BM25 leva 12 ms e o vetorial 25 ms, a busca custa 25 ms mais a fusão, que é irrelevante: ordenar algumas dezenas de itens em memória não aparece no p99.\n\n**O gargalo real costuma ser gerar o embedding da query.** Se você usa API externa, cada busca vira uma chamada de rede antes mesmo de tocar no índice, e isso normalmente domina o tempo total. Duas saídas: cache de embedding de query, que funciona muito bem porque a distribuição de queries é extremamente concentrada na cauda curta (as mesmas frases se repetem o dia inteiro), ou rodar o modelo localmente. Um modelo pequeno de embedding em CPU já muda essa conta.\n\n**O tamanho da janela de fusão importa mais que o `k`.** Se cada lado devolve só 10 candidatos, um documento que está na posição 11 das duas listas simplesmente não existe pro RRF. O recall da híbrida é limitado pela união dos dois conjuntos de candidatos. No Elasticsearch esse parâmetro é o `rank_window_size` e o default é 10, que é baixo demais pra quase todo caso real. Buscar 50 ou 100 de cada lado e cortar depois costuma ser a diferença entre \"funciona\" e \"por que esse resultado óbvio não aparece?\".\n\n## Quinze linhas de Python, ou uma linha de JSON\n\nImplementar RRF do zero é quase constrangedoramente simples:\n\n```python\nfrom collections import defaultdict\n\ndef rrf(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]:\n    \"\"\"Funde listas ordenadas de IDs. Cada lista já vem do mais relevante pro menos.\"\"\"\n    scores = defaultdict(float)\n    for ranking in rankings:\n        for position, doc_id in enumerate(ranking, start=1):\n            scores[doc_id] += 1 / (k + position)\n    return sorted(scores.items(), key=lambda item: item[1], reverse=True)\n\n\nbm25   = [\"C\", \"A\", \"B\", \"E\", \"D\"]\nvector = [\"D\", \"A\", \"E\", \"C\", \"B\"]\n\nrrf([bm25, vector])\n# [('A', 0.032258), ('C', 0.032018), ('D', 0.031778), ('E', 0.031498), ('B', 0.031258)]\n```\n\nRepare que a função não sabe nada sobre busca. Ela recebe listas de identificadores. Isso é uma propriedade e não um acidente: você pode fundir três, quatro, cinco rankers, incluindo regras de negócio e ordenação por popularidade, sem mudar uma linha.\n\nNa prática, provavelmente você não vai escrever isso, porque os motores já trazem RRF pronto:\n\n```json\nGET /produtos/_search\n{\n  \"retriever\": {\n    \"rrf\": {\n      \"retrievers\": [\n        { \"standard\": { \"query\": { \"multi_match\": {\n            \"query\": \"tênis pra correr no calor\",\n            \"fields\": [\"titulo\", \"descricao\"]\n        }}}},\n        { \"knn\": {\n            \"field\": \"embedding\",\n            \"query_vector\": [0.21, -0.05, \"...\"],\n            \"k\": 50,\n            \"num_candidates\": 200\n        }}\n      ],\n      \"rank_constant\": 60,\n      \"rank_window_size\": 100\n    }\n  },\n  \"size\": 20\n}\n```\n\nNo Elasticsearch o `rank_constant` já tem default 60 e o `rank_window_size` default 10, então os dois campos acima são explícitos de propósito. Um detalhe de planejamento que costuma pegar equipe de surpresa: o RRF no Elasticsearch é recurso de assinatura paga, listado pela própria Elastic entre as capacidades de Platinum e Enterprise. Em cluster self-hosted com licença Basic a query volta com erro de licença.\n\nO OpenSearch ganhou fusão por rank na versão 2.19, através do `score-ranker-processor` com técnica `rrf`, também com rank constant 60 por padrão, e é open source. O Qdrant expõe `Fusion.RRF` direto na Query API. O Weaviate oferece `rankedFusion`, que é RRF, e `relativeScoreFusion`, que é normalização de score, e este último virou o default a partir da 1.24. Se você está no Postgres com pgvector, não existe nada nativo: são duas CTEs com `row_number()` e um `full outer join`, o que também funciona bem.\n\n## Onde a híbrida ganha sem esforço nenhum\n\nDois padrões de query aparecem em praticamente todo domínio.\n\n**Descrição de intenção, no e-commerce.** Alguém digita \"vestido pra casamento na praia\". O BM25 vai perseguir \"vestido\", \"casamento\" e \"praia\" e trazer vestido de festa pesado, saída de praia, e talvez um vestido infantil de daminha. O vetorial entende o contexto (leve, fluido, tecido respirável, comprimento longo, evento formal ao ar livre) e traz a categoria certa, mas erra na hora que a pessoa complementa com \"marca Zara\" ou um modelo específico. Fundidos, o vetorial define o conjunto certo e o BM25 garante que o termo literal continue ancorando o topo.\n\n**Suporte técnico e FAQ.** Alguém busca \"erro 401\". Um artigo da sua base explica autenticação falha por token expirado, fala de `Bearer`, de JWT, de refresh token, e nunca escreve o número 401. O vetorial acha esse artigo. Ao mesmo tempo, você tem um documento de troubleshooting que lista literalmente `HTTP 401` na primeira linha, e é ele que o usuário provavelmente quer ver primeiro. Vetorial sozinho enterra o segundo, BM25 sozinho nunca acha o primeiro. RRF entrega os dois na primeira página.\n\nO que os dois casos têm em comum é o que torna a híbrida boa: os erros são independentes. Quando um ranker falha, o outro falha de um jeito diferente.\n\n## Onde o RRF quebra\n\nAqui é onde eu discordo do entusiasmo padrão da internet com essa técnica.\n\n**RRF é cego a magnitude, e isso corta pros dois lados.** Ignorar score é exatamente o que resolve o problema de escala, e também é o que impede o algoritmo de saber que a lista vetorial inteira é lixo. Se a melhor similaridade da lista foi 0.31, o RRF ainda dá a essa lista poder de voto integral, porque só olha \"primeiro colocado\". Métodos baseados em score, como o `relativeScoreFusion` do Weaviate ou o DBSF do Qdrant, existem exatamente por isso. Se no seu domínio uma das buscas frequentemente não tem nada de bom pra oferecer, vale medir as duas abordagens.\n\n**Os scores de saída não significam nada.** Olhe a tabela do exemplo: 0.032258 contra 0.031258. Todos os resultados vivem espremidos numa faixa minúscula, e o valor não é comparável entre queries. Isso derruba qualquer coisa que dependa de threshold. Não dá pra dizer \"só mostro resultado acima de X\" nem \"se o score for baixo, mostro a tela de nenhum resultado encontrado\". Se seu produto precisa dessa decisão, você vai precisar de outro sinal, geralmente o score bruto do ranker antes da fusão.\n\n**Documento errado com consenso continua no topo.** Foi o que aconteceu com a camiseta dry-fit ficando em segundo lugar. RRF ordena, não julga relevância.\n\n**Identificador exato pode ser diluído.** Se o usuário digita `SKU-A4729`, o comportamento desejado não é fusão, é match exato dominando tudo. A híbrida pode empurrar pra cima um documento medíocre que aparece razoavelmente nas duas listas. A solução não é ajustar o `k`, é detectar o padrão da query e desviar do pipeline híbrido antes dele começar.\n\n**Se os dois rankers concordam demais, você está pagando duas buscas pelo preço de uma.** Fusão só agrega valor quando as listas divergem. Vale medir a sobreposição entre os dois top 20 no seu tráfego real. Se ficar muito alta, o vetorial provavelmente está só reproduzindo o BM25 e o problema está no modelo de embedding ou no que você indexou.\n\n**O custo operacional é real.** Dois índices sobre o mesmo corpus, dois caminhos de escrita que precisam ficar consistentes, e um detalhe fácil de esquecer: os filtros (categoria, estoque, permissão) precisam ser aplicados nos dois lados, com o mesmo critério, antes da fusão. Filtrar depois de fundir estraga a paginação e a contagem de resultados. Trocar de modelo de embedding significa reindexar tudo, e enquanto a reindexação roda você tem duas gerações de vetor no mesmo índice.\n\n## Como começar e o que medir\n\nA sequência que evita retrabalho:\n\n1. Ligue a híbrida com pesos iguais e `k = 60`. Não toque em nada ainda.\n2. Monte um conjunto de avaliação pequeno e honesto. Entre 50 e 200 queries reais do seu log, com os documentos relevantes marcados à mão. É trabalho chato de meio dia e é o único jeito de saber se qualquer mudança seguinte melhorou alguma coisa.\n3. Meça três coisas contra os baselines: nDCG@10, Recall@50 e MRR. Compare híbrida contra BM25 puro e contra vetorial puro.\n4. Se a híbrida não ganhar das duas, pare e investiga. Normalmente é janela de fusão pequena demais, embedding ruim pro seu vocabulário, ou campo errado indexado. Não é o `k`.\n5. Só depois disso mexa em `rank_window_size`, depois em pesos por ranker, e por último em `k`.\n\nA ordem importa. Peso e `k` são os botões mais visíveis e os que menos entregam.\n\nExistem bibliotecas prontas pra essa parte de avaliação, como a [ranx](https://github.com/AmenRa/ranx), que implementa RRF junto com outros métodos de fusão e as métricas todas. Usar isso pra comparar offline sai mais rápido do que escrever seus próprios cálculos de nDCG.\n\n## Os últimos 10%\n\nBusca híbrida com RRF é, na minha opinião, o melhor retorno sobre esforço em busca hoje. Você liga, não calibra nada, e o resultado é consistentemente melhor que qualquer um dos dois lados sozinho. Para a maior parte dos produtos, é aqui que dá pra parar.\n\nMas repare no limite que apareceu no meio do caminho: RRF ordena por consenso de posição, e consenso não é a mesma coisa que relevância. A camiseta continuou em segundo lugar porque as duas buscas acharam ela razoável, e nenhum dos dois rankers jamais leu a query e o documento juntos pra decidir se aquilo respondia a pergunta.\n\nÉ isso que um cross-encoder faz. Ele pega os 50 ou 100 candidatos que a híbrida trouxe, processa query e documento no mesmo forward pass, e reordena com um entendimento que nenhuma busca de primeiro estágio consegue ter. Custa caro demais pra rodar no catálogo inteiro, e é exatamente por isso que ele só faz sentido depois de uma boa recuperação.\n\nQuando você quer top 10 perfeito, e não só top 100 decente, é o próximo passo. Fica pro terceiro post.\n\nAntes disso, se você já tem BM25 e vetorial rodando separados, o experimento de hoje é curto: pegue vinte queries reais, salve os dois rankings, funda com as quinze linhas de Python lá de cima e olhe manualmente o que mudou no top 5. Você vai descobrir em uma tarde se o seu domínio precisa disso, e provavelmente vai descobrir onde cada uma das duas buscas está errando feio.\n\n## Referências\n\n- Cormack, G. V., Clarke, C. L. A., Büttcher, S. **Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods**. SIGIR 2009. [PDF](https://cormack.uwaterloo.ca/cormacksigir09-rrf.pdf)\n- Elasticsearch, documentação do [RRF retriever](https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/rrf-retriever) e de [reciprocal rank fusion](https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion)\n- Elastic, [Pricing FAQ](https://www.elastic.co/pricing/faq), sobre disponibilidade de RRF por tier de assinatura\n- OpenSearch, [score-ranker-processor](https://docs.opensearch.org/latest/search-plugins/search-pipelines/score-ranker-processor/) e o [anúncio de RRF na 2.19](https://opensearch.org/blog/introducing-reciprocal-rank-fusion-hybrid-search/)\n- Qdrant, [Hybrid Queries](https://qdrant.tech/documentation/search/hybrid-queries/)\n- [ranx](https://github.com/AmenRa/ranx), biblioteca de fusão e avaliação de rankings\n\n---\n\n*Segundo post da série sobre busca moderna. O primeiro é [Busca Semântica](/blog/como-funciona-busca-semantica). O próximo fecha com reranking usando cross-encoder. Se você está montando busca híbrida em algum lugar, me conta como está indo.*","https://assets.rodolfodebonis.com.br/blog-covers/f7d67b4b-7f47-45d6-aee9-fee18c459615.png","2026-07-28T09:23:27.367288Z",[88,89,93,97,98],{"id":37,"tenant_id":6,"slug":38,"created_at":39},{"id":90,"tenant_id":6,"slug":91,"created_at":92},"e017c671-1f93-407e-9334-d439622653c5","elasticsearch","2026-07-28T08:47:33.948155Z",{"id":94,"tenant_id":6,"slug":95,"created_at":96},"7bfa9291-72ca-46cf-b0e0-4ff8d29a4f78","embeddings","2026-05-16T05:38:28.835806Z",{"id":45,"tenant_id":6,"slug":46,"created_at":47},{"id":99,"tenant_id":6,"slug":100,"created_at":101},"af8ebc67-e047-40f3-9fac-b498bd570c92","rrf","2026-07-28T08:47:12.831063Z",{"id":103,"tenant_id":6,"author":104,"status":11,"published_at":105,"cover_image_url":106,"reading_time_minutes":107,"view_count":108,"like_count":109,"featured":16,"created_at":110,"updated_at":111,"translations":112,"tags":121},"43c0f013-6a6b-4286-8e74-bbb0bd8eaf93",{"sub":8,"name":9,"email":10},"2026-05-16T05:52:44.530779Z","https://assets.rodolfodebonis.com.br/blog-covers/198d42d7-9779-4d52-85bf-37d93abd1233.png",13,347,4,"2026-05-16T05:52:44.463814Z","2026-07-30T18:26:52.464187Z",[113],{"id":114,"post_id":103,"tenant_id":6,"lang":29,"slug":115,"title":116,"excerpt":117,"content_md":118,"cover_image_url":119,"created_at":110,"updated_at":120},"935d7ee0-2d3a-4018-8572-6b76ae1b805d","como-funciona-busca-semantica","Busca Semântica: como ensinar máquinas a entender intenção (não só palavras)","BM25 ainda vive, mas tem quatro buracos sérios. Embeddings transformam texto em geometria — e essa é a base de tudo que tem hype hoje em IA. Primeiro artigo de uma série sobre busca moderna.","Há uns anos, se você me perguntasse como funciona busca em um sistema sério, eu responderia em três palavras: índice invertido, BM25, fim. Era o estado da arte, era o que rodava em todo lugar, e era o que eu sabia o suficiente pra ensinar.\n \nHoje, depois de colocar busca semântica em produção em cima de quase um milhão de documentos, eu mudaria a resposta. Não porque BM25 ficou ruim — pelo contrário, ele continua sendo a base de quase todo sistema de busca no mundo. Mudaria porque BM25 sozinho está deixando muito valor na mesa. E o que preenche esse vazio é uma ideia que parece mágica, até você entender o que está acontecendo por baixo.\n\nEsse é o primeiro post de uma série sobre busca moderna. Aqui a gente trata só de **busca semântica** — o quê, o porquê, o como. No próximo, vamos somar BM25 com vetorial em **busca híbrida**. No terceiro, fechamos com **reranking**, a cereja que separa busca boa de busca excelente. Mas antes a gente precisa entender o problema base.\n \n## O problema que ninguém nota\n\nImagine que você está construindo um catálogo de filmes. Pode ser um Letterboxd, um IMDB, um app interno. Um usuário chega e digita:\n \n> \"filme sobre cara preso no mesmo dia\"\n \nVocê sabe o que ele quer. Eu sei o que ele quer. Qualquer pessoa que viu *Feitiço do Tempo* sabe o que ele quer. O problema é que seu banco de dados não sabe.\n \nSe você está usando o que 99% dos sistemas usam — busca textual baseada em índice invertido — o banco vai pegar essa query, procurar pelas palavras literais (\"cara\", \"preso\", \"mesmo\", \"dia\") e te entregar:\n\n- *O Especialista* (tem \"preso\" no roteiro)\n- *Os Detentos* (tem \"preso\" no título)\n- Qualquer documentário sobre o sistema penitenciário\nResultado: o usuário não encontra *Feitiço do Tempo*, fecha o app, abre o Google e digita exatamente a mesma frase. E o Google encontra. Por quê? **Porque o Google não está fazendo `LIKE '%preso%'`.**\n \nA diferença entre \"busca que funciona\" e \"busca que frustra o usuário\" não está no banco que você usa, nem na quantidade de servidores. Está em entender que **palavras não são significados**, e que existem ferramentas pra preencher esse abismo.\n \n## Como a máquina vê texto: BM25 e amigos\n \nAntes de falar do bonito, vamos entender o que está rodando hoje em quase todo lugar.\n \nQuando você manda `\"tenis branco confortavel\"` pro Elasticsearch (ou OpenSearch, ou Solr — todos da mesma família), três coisas acontecem em sequência. Primeiro, **tokenização**: a string é quebrada em palavras individuais. Segundo, **stemming**: sufixos são cortados pra reduzir variações morfológicas. \"tenis\", \"branco\", \"confortavel\" viram \"tenis\", \"branc\", \"confort\". Terceiro, isso bate contra o **índice invertido**: uma estrutura que, pra cada token, guarda a lista de documentos onde aquele token aparece.\n \n```\ntenis    -> [12, 47, 89, 156, ...]\nbranc    -> [12, 102, 230, ...]\nconfort  -> [47, 88, 102, ...]\n```\n \nDocumento 12 aparece em duas listas. Documento 47 também. Documento 88, em uma só. Quanto mais listas o documento aparece, mais provável ele ser relevante. Mas isso é só o começo.\n \nO que ordena os resultados é o **BM25** — Best Match 25, a vigésima quinta iteração de uma família de algoritmos que começou nos anos 70. É o default do Elasticsearch, do OpenSearch e do Solr. Se você usa busca textual em algum lugar, BM25 é quem está pontuando.\n \nA fórmula parece intimidante, mas tem três ideias só:\n \n**Term Frequency (TF)**: quantas vezes o token aparece no documento. Mais vezes, mais relevante. Mas não linear — se aparecer 50 vezes, não é 50× melhor que aparecer 1 vez. A fórmula satura.\n \n**Inverse Document Frequency (IDF)**: termos raros valem mais. A palavra \"tênis\" aparece em 2% do seu catálogo de moda — peso alto. A palavra \"de\" aparece em 100% dos documentos — peso quase zero. Faz sentido: se você procurou \"tênis branco\", acertar \"tênis\" me diz muito mais sobre relevância do que acertar \"branco\".\n \n**Normalização por tamanho**: documento curto que contém o termo é mais relevante que documento longo que contém o termo, porque a chance de ser exatamente sobre aquilo é maior. Sem isso, um manual técnico de 5000 palavras ganharia de uma descrição curta só por volume.\n \nBM25 mistura essas três coisas e gera um score. Documento com score maior vai pro topo. É elegante, escala bem, e funciona razoavelmente em domínio fechado. Mas tem quatro grandes pontos cegos.\n\n## Onde BM25 quebra\n \n**Sinônimos.** Usuário busca \"celular\", catálogo tem \"smartphone\". Zero match. Você pode até resolver com dicionário manual de sinônimos, mas vai manter isso pra português, inglês, gírias regionais e jargão de cada nicho? Boa sorte.\n \n**Vocabulário.** Usuário busca \"roupa fresquinha pra usar no calor\". Catálogo tem \"blusa de linho manga curta\". Mesma intenção, zero palavras em comum. BM25 não devolve nada.\n \n**Intenção.** Usuário busca \"filme bom pra ver com a namorada\". O que isso significa? BM25 vai bater em \"filme\", \"bom\" e \"namorada\" e te entregar resultado aleatório.\n \n**Multilíngue.** Você indexou em português, o usuário busca em inglês. Mesmo conteúdo, idiomas diferentes, BM25 não tem como saber que é a mesma coisa.\n \nO problema, no fundo, é o mesmo: **BM25 olha pra superfície do texto, não pro significado.** Ele é uma ferramenta de coincidência de tokens, não de compreensão.\n \nÉ aí que entra a parte interessante.\n \n## Embeddings: texto vira geometria\n \nA definição mais simples e mais poderosa que existe:\n \n> **Um embedding é um vetor denso de N números que representa o significado de um pedaço de texto.**\n \nVocê manda a palavra \"pizza\" pro modelo de embedding. Ele te devolve uma lista de — digamos — 1024 números entre -1 e 1:\n \n```\n[0.21, -0.05, 0.78, 0.13, -0.42, ..., 0.09]\n```\n \nManda \"lasanha\". Ele devolve outra lista de 1024 números:\n \n```\n[0.19, -0.08, 0.81, 0.10, -0.40, ..., 0.07]\n```\n \nA mágica é que essas duas listas vão ser **quase idênticas**. Não porque o modelo viu \"pizza\" e \"lasanha\" juntas — embora isso tenha ajudado no treinamento — mas porque ele aprendeu que ambas vivem no mesmo bairro semântico: comida italiana, prato principal, base de massa, contexto de jantar.\n \nAgora manda \"Python\". O vetor vai ser muito diferente. Porque Python é linguagem de programação, é tecnologia, é outro contexto inteiro. O vetor de \"Java\" vai ser parecido com o de \"Python\", porque ambos são linguagens. Pizza e lasanha ficam juntas num canto, Python e Java ficam juntos noutro canto, gato e cachorro ficam juntos num terceiro canto.\n \n**A distância entre dois vetores vira uma medida de similaridade semântica.** Esse é o pulo do gato. Você converteu texto — que é simbólico, discreto, difícil de comparar — em geometria. E geometria a gente sabe medir.\n \nNa prática, gerar um embedding parece com isso:\n \n```python\nfrom openai import OpenAI\n \nclient = OpenAI()\nresponse = client.embeddings.create(\n    input=\"pizza margherita\",\n    model=\"text-embedding-3-small\"\n)\nvector = response.data[0].embedding  # lista de 1536 floats\n```\n \nPronto. Texto entrou, geometria saiu.\n\n## Álgebra com significados\n \nPra deixar isso menos abstrato: como esses vetores carregam significado de verdade, você pode fazer conta com eles. Conta de verdade. O experimento clássico, do paper de Mikolov de 2013:\n \n```\nvetor(\"rei\") - vetor(\"homem\") + vetor(\"mulher\") ≈ vetor(\"rainha\")\n```\n \nO modelo aprendeu, sem ninguém ensinar explicitamente, que existe um eixo de masculinidade-feminilidade no espaço vetorial. Outro:\n \n```\nvetor(\"Paris\") - vetor(\"França\") + vetor(\"Itália\") ≈ vetor(\"Roma\")\n```\n\nCapital de país, como conceito, virou uma direção no espaço. Você pode subtrair \"França\" pra remover o componente \"país específico\", aí somar \"Itália\" pra colocar de volta. O resultado aterrissa perto da capital italiana.\n \nIsso não é matemática teórica bonita. É literalmente o que acontece dentro do modelo. Por isso busca semântica funciona: o modelo aprendeu estrutura do mundo, e você está fazendo geometria em cima dessa estrutura.\n \n## De onde vêm esses números\n \nModelos de embedding são redes neurais treinadas em quantidades absurdas de texto pra aprender essas representações. As famílias principais hoje:\n \n**APIs comerciais.** OpenAI (`text-embedding-3-small`, `text-embedding-3-large`), Cohere (`embed-v3`, forte em multilíngue), Voyage AI. Caros, mas qualidade no topo do leaderboard, sem você precisar manter infra.\n \n**Open-source de ponta.** BGE (da BAAI), E5 (da Microsoft), GTE (da Alibaba). Você roda na sua GPU, zero custo de API, zero vendor lock-in. BGE-M3 e BGE-large multilingual competem bem com OpenAI em vários benchmarks.\n \n**Base clássica.** Sentence-Transformers — a biblioteca que popularizou tudo isso. Modelos menores, mais simples, ótimos pra prototipar.\n \n**Como escolher?** Vai no [MTEB](https://huggingface.co/spaces/mteb/leaderboard) — o Massive Text Embedding Benchmark, leaderboard público de referência. Escolhe um modelo na sua faixa de custo e tamanho, e **testa no seu domínio**. Modelo bom em inglês pode ser ruim em PT-BR. Modelo bom em texto curto pode ser ruim em documento longo. Modelo bom em domínio geral pode ser ruim em vocabulário jurídico, médico, técnico. Sempre meça.\n \n## Como comparar dois vetores\n \nVocê tem o vetor da query, tem os vetores dos documentos. Como comparar? Três opções, em ordem do que você provavelmente vai usar.\n \n**Similaridade do cosseno** mede o ângulo entre os vetores, ignorando magnitude. Vai de -1 a 1 (na prática, em texto, fica entre 0 e 1). É o default na esmagadora maioria dos casos, porque o significado mora na direção, não no tamanho do vetor.\n \n**Dot product** é o produto escalar. Importa direção *e* magnitude. Se seus vetores são normalizados — e a maioria dos modelos modernos retorna normalizados — dot product é matematicamente equivalente ao cosseno e mais barato de computar. Use isso pra otimizar.\n \n**Distância euclidiana** é a reta entre dois pontos. Funciona, mas é menos comum em texto. Aparece mais em embeddings de imagem.\n \nRegra prática: comece com cosseno, troque pra dot product quando for otimizar.\n \n## kNN, ANN e o problema de escala\n \nComo achar os documentos mais parecidos com a query? O algoritmo conceitual é **k-Nearest Neighbors (kNN)**. Quatro passos: pega a query, gera o embedding, mede a distância pra cada documento do catálogo, ordena e retorna os top K.\n \nFunciona perfeitamente em mil documentos. Em um milhão, calcular distância contra cada um é O(n), inviável em tempo real.\n \nA solução é o **ANN — Approximate Nearest Neighbors**. Você abre mão de um pouco de precisão pra ganhar ordens de magnitude em velocidade. Ao invés de comparar com todos, compara com um subconjunto inteligentemente escolhido.\n \nO algoritmo mais popular hoje é o **HNSW — Hierarchical Navigable Small World**. Funciona como um mapa do mundo com vários níveis de zoom: você começa no nível mais alto, navega rapidamente até chegar perto da resposta, aí desce pra níveis mais detalhados e refina. De O(n) você cai pra O(log n). É o que o Elasticsearch usa, o que o pgvector usa, o que praticamente todos os bancos vetoriais usam por baixo.\n \nEm produção, recall de 95-98% é fácil de atingir com HNSW bem configurado, e a latência fica na casa dos milissegundos.\n \n## Onde rodar isso\n \nO ecossistema explodiu nos últimos anos. Eu divido em duas famílias.\n \n**Bancos vetoriais dedicados**: Pinecone (SaaS), Weaviate, Qdrant, Milvus, Chroma. Nasceram pra isso. Geralmente entregam melhor performance e features mais maduras pra busca vetorial. A desvantagem é que é mais um banco pra manter.\n \n**Bancos que ganharam suporte vetorial**: Elasticsearch e OpenSearch (já tinham busca textual madura, ganharam vetorial); PostgreSQL com a extensão pgvector; Redis; MongoDB. A vantagem aqui é reaproveitar stack que você já tem. A desvantagem é que features e performance às vezes não são tão polidas quanto nos dedicados.\n \nComo decidir? Se você já tem Elasticsearch rodando em produção, **comece adicionando vetorial nele**. Não troque de banco por causa de uma feature nova. Se está começando do zero e quer simplicidade, Qdrant e Weaviate são ótimos. Se é Postgres-first e volume moderado, pgvector resolve. Não tem resposta única — tem trade-off.\n \n## O exemplo dos filmes, agora com semântica\n \nLembra da query do começo? \"filme sobre cara preso no mesmo dia\". Antes, com BM25, ela retornava *O Especialista* e *Os Detentos* pelo \"preso\" literal. Agora, com busca vetorial:\n \n| Posição | Filme | Score |\n|---|---|---|\n| 1 | *Feitiço do Tempo* | 0.89 |\n| 2 | *Edge of Tomorrow* | 0.85 |\n| 3 | *Palm Springs* | 0.81 |\n \nOlha o detalhe importante: a sinopse de *Feitiço do Tempo* diz \"homem revive o mesmo dia repetidamente\". A palavra \"preso\" não aparece. Mas o modelo entendeu que loop temporal é uma forma de estar preso no tempo. *Palm Springs* é um filme indie que muita gente nunca viu — mas o modelo conhece, porque treinou em descrições da internet inteira.\n \nMesma query. Resultados completamente diferentes. **Sem dicionário de sinônimos. Sem regras manuais.** O modelo só fez geometria.\n \n## Onde a busca vetorial também quebra\n \nAntes que você saia daqui só com brilho no olho — vetorial também tem buracos sérios.\n \n**Identificadores exatos.** Usuário busca `SKU-A4729`. Busca vetorial vai retornar coisas semanticamente parecidas, que não é o que ele quer. Pra SKU, código de produto, ID, número de pedido — você precisa de match exato, não similaridade.\n \n**Negações.** \"Sapato sem cadarço\" pode te retornar sapato com cadarço, porque o conceito \"cadarço\" está fortemente representado no vetor da query. Modelos modernos lidam melhor com isso, mas continua frágil.\n \n**Queries muito curtas ou ambíguas.** \"java\" — é a linguagem, a ilha ou o café? BM25 também sofre, mas vetorial não resolve magicamente.\n \n**Custo.** Gerar embedding pra cada documento custa. Storage de vetor custa (1024 dimensões × 4 bytes × N documentos). Reindexar quando você muda de modelo custa. Latência de gerar embedding da query a cada busca também custa.\n \nQuando você junta os pontos cegos — exatidão, queries curtas, custo — fica claro: **substituir BM25 por vetorial é trocar um conjunto de problemas por outro**.\n \n## A solução é somar, não substituir\n \nA boa notícia é que esses dois mundos têm pontos cegos complementares. BM25 é forte exatamente onde vetorial é fraco: match exato, SKU, palavras-chave específicas. Vetorial é forte exatamente onde BM25 é fraco: significado, intenção, vocabulário divergente.\n \nQuando você junta, o que uma falha a outra cobre.\n \nÉ disso que se trata o próximo post: **busca híbrida**. Como rodar BM25 e vetorial em paralelo, como fundir os rankings sem cair na armadilha óbvia de pesar score com score, e por que essa é a arquitetura que entrega o melhor resultado em produção desde o dia zero, sem ajuste manual de pesos.\n \nAté lá, se você nunca brincou com embeddings, **abre um notebook**. Pega a API da OpenAI (ou roda o BGE local), embeda umas dezenas de frases do seu domínio, calcula cosseno entre elas e olha o que aparece junto. É a melhor forma de internalizar a ideia: ver com os próprios olhos que o modelo realmente entende.\n \n---\n \n*Esse é o primeiro post de uma série sobre busca moderna. Próximo: busca híbrida com Reciprocal Rank Fusion. Se você está aplicando isso em algum contexto, me manda mensagem. Adoraria saber no que você está mexendo.*","https://assets.rodolfodebonis.com.br/blog-covers/c31389b0-4774-4f89-b621-7d23ae7b1198.png","2026-07-28T09:29:52.268081Z",[122,123,124,128],{"id":37,"tenant_id":6,"slug":38,"created_at":39},{"id":94,"tenant_id":6,"slug":95,"created_at":96},{"id":125,"tenant_id":6,"slug":126,"created_at":127},"c0801447-701e-4cf6-8859-e4cf89f9d8a0","machine-learning","2026-05-16T05:38:07.501314Z",{"id":49,"tenant_id":6,"slug":50,"created_at":51}]