BM25 scores might range from 0 to 30 while cosine similarities sit between 0.6 and 0.9, so you can't just add them. Min-max normalise each retriever's scores to [0, 1] first, then blend:
A document missing from one retriever's results gets 0 for that retriever.
Implement min_max(scores) (a dict of doc → score; if all scores are equal, map them all to 1.0; an empty dict gives an empty dict) and hybrid_rank(dense, sparse, alpha=0.5, k=None), returning (doc, score) pairs sorted by blended score, highest first (ties by doc id), truncated to k if given.