“X17 filter replacement” and “What should I check when inlet pressure drops?” may require the same manual. One query depends on an exact identifier; the other may use different wording from the source. Hybrid retrieval combines candidate lists to address both cases.
This walkthrough uses Python 3 and its standard library. The questions and rankings are fictional teaching data, not RAGO-X production settings or measured performance.
Decide what to combine
Lexical retrieval depends on term matching and on tokenization and field configuration. An exact product-code requirement may need a dedicated field or filter. Vector retrieval ranks by similarity in an embedding space, but similar product names or negated statements can still be confused.
Raw lexical and vector scores need not share a scale. Reciprocal Rank Fusion (RRF) instead adds 1 / (k + r) for each list containing a candidate, where rank r starts at 1. A missing candidate contributes zero. See the RRF formula in Elastic documentation.
Run the fusion step
Save this as rrf_demo.py and run python3 rrf_demo.py. Each retriever must use the same identifier for the same chunk and document revision.
from collections import defaultdict
def rrf(rankings, k=60):
if k <= 0:
raise ValueError("k must be positive")
scores = defaultdict(float)
for ranking in rankings:
# Count an ID only once per retriever, preserving order.
unique = list(dict.fromkeys(ranking))
for rank, chunk_id in enumerate(unique, start=1):
scores[chunk_id] += 1.0 / (k + rank)
return sorted(scores.items(), key=lambda item: (-item[1], item[0]))
keyword = ["manual-A", "manual-B", "manual-C"]
semantic = ["manual-C", "manual-A", "manual-D"]
result = rrf([keyword, semantic])
print([(key, round(score, 6)) for key, score in result])
assert [key for key, _ in result] == ["manual-A", "manual-C", "manual-B", "manual-D"]
assert rrf([["A", "A", "B"]]) == rrf([["A", "B"]])
assert rrf([]) == []
Expected output:
[('manual-A', 0.032522), ('manual-C', 0.032266), ('manual-B', 0.016129), ('manual-D', 0.015873)]
A and C benefit from appearing in both lists. Duplicate IDs within a list count once; ID ordering breaks score ties deterministically. The example chooses k=60; it is not an optimal value for every collection. The code combines existing rankings and does not implement lexical search or embeddings.
Align access scope before retrieval
Both retrieval paths must apply the same organization, cabinet, permissions, and document-version scope. Hiding unauthorized text in the UI after sending it to a model is not access control. Determine permitted scope on the server, apply it to each search, and authorize source access again when a user opens it.
Candidate count and final context size are separate choices. An experiment might retrieve 20 candidates per method and retain 5 after fusion. These are trial settings, not product defaults. Measure whether larger candidate pools recover useful evidence and what latency they add.
Compare on fixed questions
| Query type | Failure to inspect | Check |
|---|---|---|
| Exact product code | Manual for another model | Identifier agreement in top results |
| Paraphrase | Evidence missed due to wording | Relevant evidence in the top 5 |
| Unanswerable question | Answer based on unrelated text | Explicit insufficient-evidence response |
| Restricted document | Evidence from another group | No unauthorized candidate text passed onward |
Label the expected evidence before comparing lexical-only, vector-only, and fused retrieval. One useful measure is the fraction of questions with at least one correct evidence chunk in the top 5. Report improvements only after measuring your own data. An RRF score is not a probability of correctness, and reranking cannot recover evidence absent from the candidate set.
If results lose meaning at their boundaries, inspect chunking and source positions before changing fusion. Continue with the document-to-answer workflow.



