[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"blog-article-pt-BR-busca-hibrida-com-rrf":3,"blog-related-924b3ca2-7bd5-43e3-8b62-a0ea64171948-pt-BR-busca":61},{"post":4,"html":59,"reading_time_minutes":60},{"id":5,"tenant_id":6,"author":7,"status":11,"published_at":12,"scheduled_at":13,"reading_time_minutes":14,"view_count":15,"like_count":16,"featured":17,"created_at":18,"updated_at":19,"translations":20,"tags":38},"924b3ca2-7bd5-43e3-8b62-a0ea64171948","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-07-28T08:48:49.010736Z","2026-07-28T12:30:00Z",9,28,1,true,"2026-07-28T08:48:48.773791Z","2026-07-30T18:18:43.476223Z",[21,30],{"id":22,"post_id":5,"tenant_id":6,"lang":23,"slug":24,"title":25,"excerpt":26,"content_md":27,"cover_image_url":28,"created_at":18,"updated_at":29},"dbf1003d-cafa-4630-897f-37293c6ecb45","en","hybrid-search-reciprocal-rank-fusion","Hybrid Search with RRF: why the best systems combine BM25 and vectors","BM25 and vector search fail in different places. Reciprocal Rank Fusion merges both rankings without you calibrating a single weight. Second post in a series on modern search.","This post assumes you've read [Semantic Search: teaching machines to understand intent](/en/blog/how-semantic-search-works). Two-line recap: BM25 nails the surface of the text and misses the meaning; vector search nails the meaning and misses the literal. The blind spots are complementary, so the obvious move is to use both.\n\nThe catch is that \"use both\" hides a much nastier decision than it sounds.\n\n## Two lists on your screen, one results page\n\nSay you've run both searches. BM25 returned ten ranked documents. Vector search returned another ten, with some overlap. Now you have to hand the user a single list.\n\nWhich document goes in position one?\n\nThat's genuinely the only question hybrid search has to answer. It's also where most homegrown implementations start falling apart, because the intuitive answer is the wrong one.\n\n## Adding the scores looks obvious and almost always breaks\n\nEveryone's first attempt is a linear combination:\n\n```\nfinal_score = α × bm25_score + (1 - α) × cosine_score\n```\n\nNeat on paper. Turn the α dial, favor one side or the other, done. Except the two numbers don't live on the same ruler.\n\nCosine similarity is bounded: with text, in practice, it sits between 0 and 1. BM25 has no ceiling. Its score depends on term IDF, document length, query length, and corpus size. A single rare term against a million-document index can produce a score dozens of times larger than the maximum possible cosine. Add them raw and BM25 swallows vector search whole. You didn't configure weights, you configured a scale.\n\nThe obvious fix is to normalize first. Per-query min-max, for instance: the best result in each list becomes 1.0, the worst becomes 0.0. And that's where the trap lives.\n\nMin-max is relative to the query. If vector search found nothing good, the best of the bad results still becomes 1.0. Normalization erased precisely the information that mattered: this list has nothing useful in it. You just handed maximum weight to a terrible result because of an arithmetic rescale.\n\nYou can work around it with distribution-based normalization, z-scores, per-domain calibration. All of that works, up to a point. But look at what happened: you showed up to solve ranking and ended up doing score statistics. That α is a hyperparameter that shifts when you swap the embedding model, when the corpus grows, when query mix changes. It's permanent maintenance.\n\n## Rank is universal, score isn't\n\nReciprocal Rank Fusion sidesteps all of it by throwing the scores away.\n\nThe idea, published by Cormack, Clarke and Büttcher at SIGIR 2009, fits on one line:\n\n```\nRRF(d) = Σ  1 / (k + rank_r(d))\n        r∈R\n```\n\nFor each ranking `r` where document `d` shows up, add the inverse of its position, shifted by a constant `k`. Sort by the sum. That's the whole algorithm.\n\nNo normalization. No weights to calibrate. No training. Both lists come in as sequences of positions, and a position is a unit every ranker on earth produces identically. BM25's first place and the vector search's first place are the same thing: first place. That's what makes fusion possible without anyone having to answer what a score of 14.7 is worth.\n\nThe most important consequence comes for free. Since each list contributes a small, similar increment, the document that rises is the one both searches agree is decent. Not either one's absolute favorite. RRF rewards consensus.\n\n## Five documents, two lists, one winner\n\nWorth watching this happen with actual numbers. Query: `running shoes for hot weather`, on a sporting goods catalog.\n\nBM25 matches literal tokens and returns:\n\n| Rank | Document                                          |\n| ---- | ------------------------------------------------- |\n| 1    | C: Dry-fit tee for running in hot weather         |\n| 2    | A: Ventus running shoe, high-ventilation mesh     |\n| 3    | B: Rocha 3 trail running shoe, reinforced upper   |\n| 4    | E: Technical running sock for warm days           |\n| 5    | D: Lightweight shoe for humid, warm climates      |\n\nThe t-shirt takes first place because it matches two discriminative terms, \"running\" and \"hot weather\". It's useless for the user's intent, but BM25 has no way to know that. Meanwhile `D`, which is nearly a perfect paraphrase of the query, sinks: \"warm\" isn't \"hot\" to an inverted index.\n\nVector search returns a different order:\n\n| Rank | Document                          |\n| ---- | --------------------------------- |\n| 1    | D: Lightweight shoe, warm climate |\n| 2    | A: Ventus running shoe, mesh      |\n| 3    | E: Technical sock for warm days   |\n| 4    | C: Dry-fit tee                    |\n| 5    | B: Rocha 3 trail shoe             |\n\nNow `D` climbs to the top, as expected. But so does the sock, because \"running\" and \"heat\" dominate the query vector and the product category carries less weight than it should.\n\nEach list has a wrong first place, for opposite reasons. Now RRF with `k = 60`:\n\n| Document        | BM25 rank | Vector rank | RRF                     | Final |\n| --------------- | --------- | ----------- | ----------------------- | ----- |\n| A: Ventus shoe  | 2         | 2           | 1/62 + 1/62 = 0.032258  | 1st   |\n| C: Dry-fit tee  | 1         | 4           | 1/61 + 1/64 = 0.032018  | 2nd   |\n| D: Lightweight  | 5         | 1           | 1/65 + 1/61 = 0.031778  | 3rd   |\n| E: Running sock | 4         | 3           | 1/64 + 1/63 = 0.031498  | 4th   |\n| B: Trail shoe   | 3         | 5           | 1/63 + 1/65 = 0.031258  | 5th   |\n\n`A` wins without having been anybody's first choice. It's the only document both searches put near the top for different reasons: BM25 saw \"running\" and \"shoe\", the embedding model saw \"ventilation mesh\" and understood heat. That's consensus doing its job.\n\nNotice what RRF **didn't** do: the t-shirt is still second. Rank fusion isn't a relevance filter. If a wrong document shows up decently in both lists, it stays decent afterward. Hold that thought, it comes back later.\n\n## What k actually does\n\n`k = 60` gets passed around as a magic number. The origin is far less mystical than that: the paper says the value was fixed during a pilot experiment and never changed in the validation runs that followed.\n\nWhat the published data does support is the shape of the curve. In Table 1 of the paper, MAP for `k` between 20 and 100 stays between 0.2134 and 0.2147. Under 1% of variation. The nominal peak is at `k = 80`, not 60. Only the extremes move the needle: `k = 0` drops to 0.2072 and `k = 500` to 0.2098.\n\nTranslated to your system: `k` controls how much a single list's first place counts against consensus.\n\nTake the same shoe example with `k = 0`, meaning the score is just `1/rank`:\n\n| Document        | RRF with k = 0    | Final |\n| --------------- | ----------------- | ----- |\n| C: Dry-fit tee  | 1/1 + 1/4 = 1.25  | 1st   |\n| D: Lightweight  | 1/5 + 1/1 = 1.20  | 2nd   |\n| A: Ventus shoe  | 1/2 + 1/2 = 1.00  | 3rd   |\n\nThe order flipped. With a small `k`, the gap between first and second place is huge, so each ranker essentially drags its own favorite onto the podium and consensus loses. With a large `k`, positions become nearly indistinguishable and what's left is vote counting.\n\n`k = 60` is a reasonable balance point on that spectrum, not a global optimum for your domain. If you have an evaluation set, sweep the range. If you don't, tweaking `k` by intuition is the last thing that'll improve your results.\n\n## What this looks like in the architecture\n\nThe flow is simpler than most people expect:\n![image](https://assets.rodolfodebonis.com.br/blog-articles-images/how-semantic-search-works/architecture_en.png)\n\nThree things here change your results in practice.\n\n**Both searches run in parallel, so latency is the max of the two, not the sum.** If BM25 takes 12 ms and vector takes 25 ms, the search costs 25 ms plus fusion, and fusion is noise: sorting a few dozen items in memory won't show up in your p99.\n\n**The real bottleneck is usually generating the query embedding.** If you're calling an external API, every search becomes a network round trip before you even touch the index, and that typically dominates total time. Two ways out: cache query embeddings, which works remarkably well because query distribution is heavily concentrated in the short head (the same phrases repeat all day long), or run the model yourself. A small embedding model on CPU already changes that math.\n\n**Fusion window size matters more than `k`.** If each side returns only 10 candidates, a document sitting at position 11 in both lists simply doesn't exist as far as RRF is concerned. Your hybrid recall is capped by the union of the two candidate sets. In Elasticsearch that parameter is `rank_window_size` and it defaults to 10, which is far too small for almost any real use case. Pulling 50 or 100 per side and truncating afterward is often the difference between \"works\" and \"why doesn't this obvious result show up?\".\n\n## Fifteen lines of Python, or one line of JSON\n\nWriting RRF from scratch is almost embarrassingly simple:\n\n```python\nfrom collections import defaultdict\n\ndef rrf(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]:\n    \"\"\"Fuses ranked ID lists. Each list arrives sorted from most to least relevant.\"\"\"\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\nNotice the function knows nothing about search. It takes lists of identifiers. That's a property, not an accident: you can fuse three, four, five rankers, including business rules and popularity ordering, without changing a line.\n\nIn practice you probably won't write it, because the engines ship RRF already:\n\n```json\nGET /products/_search\n{\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\": 50,\n            \"num_candidates\": 200\n        }}\n      ],\n      \"rank_constant\": 60,\n      \"rank_window_size\": 100\n    }\n  },\n  \"size\": 20\n}\n```\n\nIn Elasticsearch, `rank_constant` already defaults to 60 and `rank_window_size` defaults to 10, so both fields above are spelled out on purpose. One planning detail that catches teams off guard: RRF in Elasticsearch is a paid feature, listed by Elastic itself among Platinum and Enterprise capabilities. On a self-hosted cluster with a Basic license, the query comes back with a license error.\n\nOpenSearch added rank-based fusion in 2.19 through the `score-ranker-processor` with the `rrf` technique, also defaulting to a rank constant of 60, and it's open source. Qdrant exposes `Fusion.RRF` directly in the Query API. Weaviate offers `rankedFusion`, which is RRF, and `relativeScoreFusion`, which is score normalization, with the latter as the default since 1.24. If you're on Postgres with pgvector there's nothing native: it's two CTEs with `row_number()` and a `full outer join`, which works fine too.\n\n## Where hybrid wins with no effort at all\n\nTwo query patterns show up in basically every domain.\n\n**Intent descriptions, in e-commerce.** Someone types \"dress for a beach wedding\". BM25 chases \"dress\", \"beach\" and \"wedding\" and brings back a heavy formal gown, a beach cover-up, and maybe a flower girl dress. Vector search understands the context (light, flowing, breathable fabric, long cut, outdoor formal event) and returns the right category, then falls apart the moment the shopper adds \"Zara\" or a specific model number. Fused, vector search defines the right candidate set and BM25 keeps the literal term anchoring the top.\n\n**Technical support and FAQs.** Someone searches \"401 error\". One article in your knowledge base explains that authentication fails when the token expires, talks about `Bearer`, JWT and refresh tokens, and never writes the number 401 anywhere. Vector search finds it. At the same time you have a troubleshooting doc with `HTTP 401` literally in the first line, and that's probably the one the user wants first. Vector alone buries the second, BM25 alone never finds the first. RRF puts both on page one.\n\nWhat both cases have in common is exactly what makes hybrid good: the errors are independent. When one ranker fails, the other fails differently.\n\n## Where RRF breaks\n\nThis is where I part ways with the internet's default enthusiasm for the technique.\n\n**RRF is blind to magnitude, and that cuts both ways.** Ignoring scores is precisely what solves the scale problem, and it's also what prevents the algorithm from knowing the entire vector list is garbage. If the best similarity in that list was 0.31, RRF still grants it full voting power, because all it sees is \"first place\". Score-based methods like Weaviate's `relativeScoreFusion` or Qdrant's DBSF exist for exactly this reason. If one of your retrievers frequently has nothing good to offer in your domain, measure both approaches.\n\n**The output scores mean nothing.** Look at the table: 0.032258 against 0.031258. Every result lives squeezed into a tiny band, and the value isn't comparable across queries. That kills anything that depends on a threshold. You can't say \"only show results above X\", and you can't say \"if the score is low, render the empty state\". If your product needs that decision, you'll need a different signal, usually a raw ranker score captured before fusion.\n\n**A wrong document with consensus stays on top.** That's the dry-fit tee holding second place. RRF orders, it doesn't judge relevance.\n\n**Exact identifiers get diluted.** If the user types `SKU-A4729`, the desired behavior isn't fusion, it's exact match dominating everything. Hybrid can push up a mediocre document that shows up decently in both lists. The fix isn't tuning `k`, it's detecting the query pattern and routing around the hybrid pipeline before it starts.\n\n**If both rankers agree too much, you're paying for two searches and getting one.** Fusion only adds value when the lists disagree. Measure the overlap between the two top 20s on real traffic. If it's very high, vector search is probably just reproducing BM25, and the problem is your embedding model or what you chose to index.\n\n**The operational cost is real.** Two indexes over the same corpus, two write paths that need to stay consistent, and one detail that's easy to forget: filters (category, stock, permissions) have to be applied on both sides, with identical criteria, before fusion. Filtering after fusion wrecks pagination and result counts. Switching embedding models means reindexing everything, and while the reindex runs you have two generations of vectors in the same index.\n\n## How to start, and what to measure\n\nThe sequence that avoids rework:\n\n1. Turn on hybrid with equal weights and `k = 60`. Don't touch anything else yet.\n2. Build a small, honest evaluation set. Somewhere between 50 and 200 real queries from your logs, with relevant documents labeled by hand. It's half a day of tedious work and it's the only way to know whether any later change helped.\n3. Measure three things against baselines: nDCG@10, Recall@50 and MRR. Compare hybrid against BM25 alone and against vectors alone.\n4. If hybrid doesn't beat both, stop and investigate. It's usually a fusion window that's too small, an embedding model that doesn't fit your vocabulary, or the wrong field indexed. It isn't `k`.\n5. Only after that should you touch `rank_window_size`, then per-ranker weights, and `k` last.\n\nThe order matters. Weights and `k` are the most visible knobs and the ones that pay off least.\n\nThere are libraries for the evaluation side, like [ranx](https://github.com/AmenRa/ranx), which implements RRF alongside other fusion methods and all the standard metrics. Using it for offline comparison is faster than writing your own nDCG.\n\n## The last 10%\n\nHybrid search with RRF is, in my opinion, the best return on effort in search today. You switch it on, you calibrate nothing, and the result is consistently better than either side alone. For most products, this is where you can stop.\n\nBut look at the limit that surfaced along the way: RRF orders by positional consensus, and consensus isn't the same thing as relevance. The t-shirt stayed in second place because both searches found it plausible, and neither ranker ever read the query and the document together to decide whether one answered the other.\n\nThat's what a cross-encoder does. It takes the 50 or 100 candidates hybrid retrieved, processes query and document in the same forward pass, and reorders them with an understanding no first-stage retriever can have. It's far too expensive to run over the full catalog, which is exactly why it only makes sense on top of good retrieval.\n\nWhen you want a perfect top 10 rather than a decent top 100, that's the next step. Saving it for the third post.\n\nBefore then, if you already have BM25 and vector search running separately, today's experiment is short: grab twenty real queries, save both rankings, fuse them with the fifteen lines of Python above, and eyeball what changed in the top 5. You'll know in an afternoon whether your domain needs this, and you'll probably find out where each of your two searches is failing badly.\n\n## References\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 docs: [RRF retriever](https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/rrf-retriever) and [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), on RRF availability per subscription tier\n- OpenSearch, [score-ranker-processor](https://docs.opensearch.org/latest/search-plugins/search-pipelines/score-ranker-processor/) and the [2.19 RRF announcement](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), a fusion and ranking evaluation library\n\n---\n\n*Second post in a series on modern search. The first one is [Semantic Search](/en/blog/how-semantic-search-works). The next closes it out with cross-encoder reranking. If you're building hybrid search somewhere, tell me how it's going.*","https://assets.rodolfodebonis.com.br/blog-covers/2937beef-df6e-4450-8800-b7dffa5e7ade.png","2026-07-28T09:23:27.367288Z",{"id":31,"post_id":5,"tenant_id":6,"lang":32,"slug":33,"title":34,"excerpt":35,"content_md":36,"cover_image_url":37,"created_at":18,"updated_at":29},"94b7cc4d-384b-4ce0-907b-a4c95303c158","pt-BR","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",[39,43,47,51,55],{"id":40,"tenant_id":6,"slug":41,"created_at":42},"3600c7f5-46a1-44c1-ab4d-14b54bc49300","busca","2026-05-16T05:37:43.100096Z",{"id":44,"tenant_id":6,"slug":45,"created_at":46},"e017c671-1f93-407e-9334-d439622653c5","elasticsearch","2026-07-28T08:47:33.948155Z",{"id":48,"tenant_id":6,"slug":49,"created_at":50},"7bfa9291-72ca-46cf-b0e0-4ff8d29a4f78","embeddings","2026-05-16T05:38:28.835806Z",{"id":52,"tenant_id":6,"slug":53,"created_at":54},"3ec3cd7e-c8e8-4179-af96-1db8e19d55ff","ia","2026-07-28T08:47:55.819755Z",{"id":56,"tenant_id":6,"slug":57,"created_at":58},"af8ebc67-e047-40f3-9fac-b498bd570c92","rrf","2026-07-28T08:47:12.831063Z","\u003Cp>Esse post assume que você leu \u003Ca href=\"/blog/como-funciona-busca-semantica\">Busca Semântica: como ensinar máquinas a entender intenção\u003C/a>. 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.\u003C/p>\n\u003Cp>O problema é que “somar” esconde uma decisão bem mais espinhosa do que parece.\u003C/p>\n\u003Ch2>Duas listas na tela, uma página de resultados\u003C/h2>\n\u003Cp>Imagine 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.\u003C/p>\n\u003Cp>Qual documento vai na primeira posição?\u003C/p>\n\u003Cp>Essa é 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.\u003C/p>\n\u003Ch2>Somar os scores parece óbvio e quase sempre dá errado\u003C/h2>\n\u003Cp>A primeira tentativa de todo mundo é combinação linear:\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>score_final = α × score_bm25 + (1 - α) × score_cosseno\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Elegante 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.\u003C/p>\n\u003Cp>A 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.\u003C/p>\n\u003Cp>A 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.\u003C/p>\n\u003Cp>Min-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.\u003C/p>\n\u003Cp>Dá 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.\u003C/p>\n\u003Ch2>Rank é universal, score não é\u003C/h2>\n\u003Cp>O Reciprocal Rank Fusion resolve isso ignorando os scores.\u003C/p>\n\u003Cp>A ideia, publicada por Cormack, Clarke e Büttcher na SIGIR 2009, cabe numa linha:\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>RRF(d) = Σ  1 / (k + rank_r(d))\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan>        r∈R\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan>\u003C/span>\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Para cada ranking \u003Ccode>r\u003C/code> em que o documento \u003Ccode>d\u003C/code> aparece, some o inverso da sua posição, deslocada por uma constante \u003Ccode>k\u003C/code>. Ordene pela soma. Fim.\u003C/p>\n\u003Cp>Nã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.\u003C/p>\n\u003Cp>A 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.\u003C/p>\n\u003Ch2>Cinco documentos, duas listas, um vencedor\u003C/h2>\n\u003Cp>Vale ver isso acontecer com números. Query: \u003Ccode>tênis pra correr no calor\u003C/code>, num catálogo de e-commerce esportivo.\u003C/p>\n\u003Cp>O BM25 casa os tokens literais e devolve:\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>C: Camiseta dry-fit para correr no calor\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>2\u003C/td>\n\u003Ctd>A: Tênis de corrida Ventus, mesh com ventilação alta\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>3\u003C/td>\n\u003Ctd>B: Tênis de corrida trail Rocha 3, cabedal reforçado\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>D: Tênis leve para clima quente e úmido\u003C/td>\n\u003C/tr>\n\u003C/tbody>\n\u003C/table>\n\u003Cp>A 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 \u003Ccode>D\u003C/code>, que é quase uma paráfrase perfeita da query, afunda: “quente” não é “calor” pro índice invertido.\u003C/p>\n\u003Cp>A busca vetorial devolve outra ordem:\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>D: Tênis leve para clima quente\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>2\u003C/td>\n\u003Ctd>A: Tênis de corrida Ventus, mesh\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>3\u003C/td>\n\u003Ctd>E: Meia técnica para dias quentes\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>4\u003C/td>\n\u003Ctd>C: Camiseta dry-fit\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>5\u003C/td>\n\u003Ctd>B: Tênis trail Rocha 3\u003C/td>\n\u003C/tr>\n\u003C/tbody>\n\u003C/table>\n\u003Cp>Aqui o \u003Ccode>D\u003C/code> 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.\u003C/p>\n\u003Cp>Cada lista tem um primeiro lugar errado, por motivos opostos. Agora o RRF com \u003Ccode>k = 60\u003C/code>:\u003C/p>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Documento\u003C/th>\n\u003Cth>rank BM25\u003C/th>\n\u003Cth>rank vetorial\u003C/th>\n\u003Cth>RRF\u003C/th>\n\u003Cth>Final\u003C/th>\n\u003C/tr>\n\u003C/thead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>A: Tênis Ventus\u003C/td>\n\u003Ctd>2\u003C/td>\n\u003Ctd>2\u003C/td>\n\u003Ctd>1/62 + 1/62 = 0.032258\u003C/td>\n\u003Ctd>1º\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>C: Camiseta\u003C/td>\n\u003Ctd>1\u003C/td>\n\u003Ctd>4\u003C/td>\n\u003Ctd>1/61 + 1/64 = 0.032018\u003C/td>\n\u003Ctd>2º\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>D: Tênis leve\u003C/td>\n\u003Ctd>5\u003C/td>\n\u003Ctd>1\u003C/td>\n\u003Ctd>1/65 + 1/61 = 0.031778\u003C/td>\n\u003Ctd>3º\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>E: Meia técnica\u003C/td>\n\u003Ctd>4\u003C/td>\n\u003Ctd>3\u003C/td>\n\u003Ctd>1/64 + 1/63 = 0.031498\u003C/td>\n\u003Ctd>4º\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>B: Tênis trail\u003C/td>\n\u003Ctd>3\u003C/td>\n\u003Ctd>5\u003C/td>\n\u003Ctd>1/63 + 1/65 = 0.031258\u003C/td>\n\u003Ctd>5º\u003C/td>\n\u003C/tr>\n\u003C/tbody>\n\u003C/table>\n\u003Cp>O \u003Ccode>A\u003C/code> 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.\u003C/p>\n\u003Cp>Repare também no que o RRF \u003Cstrong>não\u003C/strong> 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.\u003C/p>\n\u003Ch2>O que o k faz de verdade\u003C/h2>\n\u003Cp>O \u003Ccode>k = 60\u003C/code> 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.\u003C/p>\n\u003Cp>O que dá pra afirmar com base nos dados publicados é o comportamento da curva. Na Tabela 1 do artigo, o MAP para \u003Ccode>k\u003C/code> variando de 20 a 100 fica entre 0.2134 e 0.2147. Menos de 1% de variação. O pico nominal está em \u003Ccode>k = 80\u003C/code>, não em 60. Só nos extremos a coisa muda: \u003Ccode>k = 0\u003C/code> cai pra 0.2072 e \u003Ccode>k = 500\u003C/code> pra 0.2098.\u003C/p>\n\u003Cp>Traduzindo pro seu sistema: \u003Ccode>k\u003C/code> controla o quanto o primeiro lugar de uma lista individual pesa contra o consenso.\u003C/p>\n\u003Cp>Pegue o mesmo exemplo dos tênis com \u003Ccode>k = 0\u003C/code>, ou seja, score igual a \u003Ccode>1/rank\u003C/code>:\u003C/p>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Documento\u003C/th>\n\u003Cth>RRF com k = 0\u003C/th>\n\u003Cth>Final\u003C/th>\n\u003C/tr>\n\u003C/thead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>C: Camiseta\u003C/td>\n\u003Ctd>1/1 + 1/4 = 1.25\u003C/td>\n\u003Ctd>1º\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>D: Tênis leve\u003C/td>\n\u003Ctd>1/5 + 1/1 = 1.20\u003C/td>\n\u003Ctd>2º\u003C/td>\n\u003C/tr>\n\u003Ctr>\n\u003Ctd>A: Tênis Ventus\u003C/td>\n\u003Ctd>1/2 + 1/2 = 1.00\u003C/td>\n\u003Ctd>3º\u003C/td>\n\u003C/tr>\n\u003C/tbody>\n\u003C/table>\n\u003Cp>A ordem inverteu. Com \u003Ccode>k\u003C/code> 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 \u003Ccode>k\u003C/code> grande, as posições ficam quase indistinguíveis entre si e o que sobra é contagem de votos.\u003C/p>\n\u003Cp>\u003Ccode>k = 60\u003C/code> é 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 \u003Ccode>k\u003C/code> no chute é a última coisa que vai melhorar seu resultado.\u003C/p>\n\u003Ch2>Como isso fica na arquitetura\u003C/h2>\n\u003Cp>O fluxo é mais simples do que a maioria imagina:\n\u003Cimg src=\"https://assets.rodolfodebonis.com.br/blog-articles-images/how-semantic-search-works/architecture_pt.png\" alt=\"image\">\u003C/p>\n\u003Cp>Três pontos que mudam o resultado na prática.\u003C/p>\n\u003Cp>\u003Cstrong>As duas buscas rodam em paralelo, então a latência é o máximo entre elas, não a soma.\u003C/strong> 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.\u003C/p>\n\u003Cp>\u003Cstrong>O gargalo real costuma ser gerar o embedding da query.\u003C/strong> 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.\u003C/p>\n\u003Cp>\u003Cstrong>O tamanho da janela de fusão importa mais que o \u003Ccode>k\u003C/code>.\u003C/strong> 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 \u003Ccode>rank_window_size\u003C/code> 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?”.\u003C/p>\n\u003Ch2>Quinze linhas de Python, ou uma linha de JSON\u003C/h2>\n\u003Cp>Implementar RRF do zero é quase constrangedoramente simples:\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\"> collections \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">import\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> defaultdict\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\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\"> rrf\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">(rankings: list[list[\u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">str\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">]], 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\"> 60\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">) -> list[tuple[\u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">str\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">, \u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">float\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\">    \"\"\"Funde listas ordenadas de IDs. Cada lista já vem do mais relevante pro menos.\"\"\"\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\"> defaultdict(\u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">float\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\">    for\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> ranking \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">in\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> rankings:\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\"> position, doc_id \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">in\u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\"> enumerate\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">(ranking, \u003C/span>\u003Cspan style=\"--shiki-dark:#FFAB70;--shiki-light:#E36209\">start\u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">1\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\">            scores[doc_id] \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">+=\u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\"> 1\u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\"> /\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> (k \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">+\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> position)\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">    return\u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\"> sorted\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">(scores.items(), \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\"> item: item[\u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">1\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\">\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">bm25   \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> [\u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"C\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">, \u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"A\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">, \u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"B\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">, \u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"E\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">, \u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"D\"\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\">vector \u003C/span>\u003Cspan style=\"--shiki-dark:#F97583;--shiki-light:#D73A49\">=\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\"> [\u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"D\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">, \u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"A\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">, \u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"E\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">, \u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"C\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">, \u003C/span>\u003Cspan style=\"--shiki-dark:#9ECBFF;--shiki-light:#032F62\">\"B\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">]\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">rrf([bm25, vector])\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003Cspan style=\"--shiki-dark:#6A737D;--shiki-light:#6A737D\"># [('A', 0.032258), ('C', 0.032018), ('D', 0.031778), ('E', 0.031498), ('B', 0.031258)]\u003C/span>\u003C/span>\n\u003Cspan class=\"line\">\u003C/span>\u003C/code>\u003C/pre>\n\u003Cp>Repare 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.\u003C/p>\n\u003Cp>Na prática, provavelmente você não vai escrever isso, porque os motores já trazem RRF pronto:\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\">    \"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\">50\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\">200\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_constant\"\u003C/span>\u003Cspan style=\"--shiki-dark:#E1E4E8;--shiki-light:#24292E\">: \u003C/span>\u003Cspan style=\"--shiki-dark:#79B8FF;--shiki-light:#005CC5\">60\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>\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\">20\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>No Elasticsearch o \u003Ccode>rank_constant\u003C/code> já tem default 60 e o \u003Ccode>rank_window_size\u003C/code> 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.\u003C/p>\n\u003Cp>O OpenSearch ganhou fusão por rank na versão 2.19, através do \u003Ccode>score-ranker-processor\u003C/code> com técnica \u003Ccode>rrf\u003C/code>, também com rank constant 60 por padrão, e é open source. O Qdrant expõe \u003Ccode>Fusion.RRF\u003C/code> direto na Query API. O Weaviate oferece \u003Ccode>rankedFusion\u003C/code>, que é RRF, e \u003Ccode>relativeScoreFusion\u003C/code>, 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 \u003Ccode>row_number()\u003C/code> e um \u003Ccode>full outer join\u003C/code>, o que também funciona bem.\u003C/p>\n\u003Ch2>Onde a híbrida ganha sem esforço nenhum\u003C/h2>\n\u003Cp>Dois padrões de query aparecem em praticamente todo domínio.\u003C/p>\n\u003Cp>\u003Cstrong>Descrição de intenção, no e-commerce.\u003C/strong> 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.\u003C/p>\n\u003Cp>\u003Cstrong>Suporte técnico e FAQ.\u003C/strong> Alguém busca “erro 401”. Um artigo da sua base explica autenticação falha por token expirado, fala de \u003Ccode>Bearer\u003C/code>, 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 \u003Ccode>HTTP 401\u003C/code> 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.\u003C/p>\n\u003Cp>O 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.\u003C/p>\n\u003Ch2>Onde o RRF quebra\u003C/h2>\n\u003Cp>Aqui é onde eu discordo do entusiasmo padrão da internet com essa técnica.\u003C/p>\n\u003Cp>\u003Cstrong>RRF é cego a magnitude, e isso corta pros dois lados.\u003C/strong> 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 \u003Ccode>relativeScoreFusion\u003C/code> 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.\u003C/p>\n\u003Cp>\u003Cstrong>Os scores de saída não significam nada.\u003C/strong> 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.\u003C/p>\n\u003Cp>\u003Cstrong>Documento errado com consenso continua no topo.\u003C/strong> Foi o que aconteceu com a camiseta dry-fit ficando em segundo lugar. RRF ordena, não julga relevância.\u003C/p>\n\u003Cp>\u003Cstrong>Identificador exato pode ser diluído.\u003C/strong> Se o usuário digita \u003Ccode>SKU-A4729\u003C/code>, 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 \u003Ccode>k\u003C/code>, é detectar o padrão da query e desviar do pipeline híbrido antes dele começar.\u003C/p>\n\u003Cp>\u003Cstrong>Se os dois rankers concordam demais, você está pagando duas buscas pelo preço de uma.\u003C/strong> 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.\u003C/p>\n\u003Cp>\u003Cstrong>O custo operacional é real.\u003C/strong> 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.\u003C/p>\n\u003Ch2>Como começar e o que medir\u003C/h2>\n\u003Cp>A sequência que evita retrabalho:\u003C/p>\n\u003Col>\n\u003Cli>Ligue a híbrida com pesos iguais e \u003Ccode>k = 60\u003C/code>. Não toque em nada ainda.\u003C/li>\n\u003Cli>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.\u003C/li>\n\u003Cli>Meça três coisas contra os baselines: nDCG@10, Recall@50 e MRR. Compare híbrida contra BM25 puro e contra vetorial puro.\u003C/li>\n\u003Cli>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 \u003Ccode>k\u003C/code>.\u003C/li>\n\u003Cli>Só depois disso mexa em \u003Ccode>rank_window_size\u003C/code>, depois em pesos por ranker, e por último em \u003Ccode>k\u003C/code>.\u003C/li>\n\u003C/ol>\n\u003Cp>A ordem importa. Peso e \u003Ccode>k\u003C/code> são os botões mais visíveis e os que menos entregam.\u003C/p>\n\u003Cp>Existem bibliotecas prontas pra essa parte de avaliação, como a \u003Ca href=\"https://github.com/AmenRa/ranx\" target=\"_blank\" rel=\"noopener noreferrer\">ranx\u003C/a>, 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.\u003C/p>\n\u003Ch2>Os últimos 10%\u003C/h2>\n\u003Cp>Busca 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.\u003C/p>\n\u003Cp>Mas 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.\u003C/p>\n\u003Cp>É 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.\u003C/p>\n\u003Cp>Quando você quer top 10 perfeito, e não só top 100 decente, é o próximo passo. Fica pro terceiro post.\u003C/p>\n\u003Cp>Antes 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.\u003C/p>\n\u003Ch2>Referências\u003C/h2>\n\u003Cul>\n\u003Cli>Cormack, G. V., Clarke, C. L. A., Büttcher, S. \u003Cstrong>Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods\u003C/strong>. SIGIR 2009. \u003Ca href=\"https://cormack.uwaterloo.ca/cormacksigir09-rrf.pdf\" target=\"_blank\" rel=\"noopener noreferrer\">PDF\u003C/a>\u003C/li>\n\u003Cli>Elasticsearch, documentação do \u003Ca href=\"https://www.elastic.co/docs/reference/elasticsearch/rest-apis/retrievers/rrf-retriever\" target=\"_blank\" rel=\"noopener noreferrer\">RRF retriever\u003C/a> e de \u003Ca href=\"https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion\" target=\"_blank\" rel=\"noopener noreferrer\">reciprocal rank fusion\u003C/a>\u003C/li>\n\u003Cli>Elastic, \u003Ca href=\"https://www.elastic.co/pricing/faq\" target=\"_blank\" rel=\"noopener noreferrer\">Pricing FAQ\u003C/a>, sobre disponibilidade de RRF por tier de assinatura\u003C/li>\n\u003Cli>OpenSearch, \u003Ca href=\"https://docs.opensearch.org/latest/search-plugins/search-pipelines/score-ranker-processor/\" target=\"_blank\" rel=\"noopener noreferrer\">score-ranker-processor\u003C/a> e o \u003Ca href=\"https://opensearch.org/blog/introducing-reciprocal-rank-fusion-hybrid-search/\" target=\"_blank\" rel=\"noopener noreferrer\">anúncio de RRF na 2.19\u003C/a>\u003C/li>\n\u003Cli>Qdrant, \u003Ca href=\"https://qdrant.tech/documentation/search/hybrid-queries/\" target=\"_blank\" rel=\"noopener noreferrer\">Hybrid Queries\u003C/a>\u003C/li>\n\u003Cli>\u003Ca href=\"https://github.com/AmenRa/ranx\" target=\"_blank\" rel=\"noopener noreferrer\">ranx\u003C/a>, biblioteca de fusão e avaliação de rankings\u003C/li>\n\u003C/ul>\n\u003Chr>\n\u003Cp>\u003Cem>Segundo post da série sobre busca moderna. O primeiro é \u003Ca href=\"/blog/como-funciona-busca-semantica\">Busca Semântica\u003C/a>. O próximo fecha com reranking usando cross-encoder. Se você está montando busca híbrida em algum lugar, me conta como está indo.\u003C/em>\u003C/p>\n",16,[62,93,103],{"id":63,"tenant_id":6,"author":64,"status":11,"published_at":65,"reading_time_minutes":66,"view_count":67,"like_count":68,"featured":17,"created_at":69,"updated_at":65,"translations":70,"tags":78},"e37e9cc5-4c9c-4bbe-a538-257b44c32487",{"sub":8,"name":9,"email":10},"2026-08-07T17:21:16.673906Z",19,17,0,"2026-08-07T17:21:16.610092Z",[71],{"id":72,"post_id":63,"tenant_id":6,"lang":32,"slug":73,"title":74,"excerpt":75,"content_md":76,"cover_image_url":77,"created_at":69,"updated_at":69},"9e8b4420-ae3a-49a2-83d4-68de81aaeb0a","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",[79,80,84,85,89],{"id":40,"tenant_id":6,"slug":41,"created_at":42},{"id":81,"tenant_id":6,"slug":82,"created_at":83},"9de48f6e-646d-4d79-9499-a1822a48c0f1","cross-encoder","2026-08-07T17:18:01.99022Z",{"id":52,"tenant_id":6,"slug":53,"created_at":54},{"id":86,"tenant_id":6,"slug":87,"created_at":88},"43e39379-ec07-4aff-82a6-2946c56fa4f2","ml","2026-05-16T05:39:39.427214Z",{"id":90,"tenant_id":6,"slug":91,"created_at":92},"7886ed4e-ba65-4452-8fd0-e1ea4ce28b07","reranking","2026-08-07T17:18:19.021111Z",{"id":5,"tenant_id":6,"author":94,"status":11,"published_at":12,"scheduled_at":13,"reading_time_minutes":14,"view_count":15,"like_count":16,"featured":17,"created_at":18,"updated_at":19,"translations":95,"tags":97},{"sub":8,"name":9,"email":10},[96],{"id":31,"post_id":5,"tenant_id":6,"lang":32,"slug":33,"title":34,"excerpt":35,"content_md":36,"cover_image_url":37,"created_at":18,"updated_at":29},[98,99,100,101,102],{"id":40,"tenant_id":6,"slug":41,"created_at":42},{"id":44,"tenant_id":6,"slug":45,"created_at":46},{"id":48,"tenant_id":6,"slug":49,"created_at":50},{"id":52,"tenant_id":6,"slug":53,"created_at":54},{"id":56,"tenant_id":6,"slug":57,"created_at":58},{"id":104,"tenant_id":6,"author":105,"status":11,"published_at":106,"cover_image_url":107,"reading_time_minutes":108,"view_count":109,"like_count":110,"featured":17,"created_at":111,"updated_at":112,"translations":113,"tags":122},"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",[114],{"id":115,"post_id":104,"tenant_id":6,"lang":32,"slug":116,"title":117,"excerpt":118,"content_md":119,"cover_image_url":120,"created_at":111,"updated_at":121},"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",[123,124,125,129],{"id":40,"tenant_id":6,"slug":41,"created_at":42},{"id":48,"tenant_id":6,"slug":49,"created_at":50},{"id":126,"tenant_id":6,"slug":127,"created_at":128},"c0801447-701e-4cf6-8859-e4cf89f9d8a0","machine-learning","2026-05-16T05:38:07.501314Z",{"id":86,"tenant_id":6,"slug":87,"created_at":88}]