{
  "uid": "cogitave.learn.query-the-estate-with-cogitave-query.hybrid-retrieval",
  "kind": "moduleUnit",
  "href": "/modules/query-the-estate-with-cogitave-query/hybrid-retrieval/",
  "title": "Why hybrid retrieval, and what the graph adds",
  "summary": "",
  "type": null,
  "products": [],
  "roles": [],
  "levels": [],
  "subjects": [],
  "headings": [
    "The decision: why not just pick one retriever",
    "The graph-aware rerank",
    "Ranking signals read off the graph itself",
    "Facets are traversals, not a second index"
  ],
  "source": "The previous unit walked the pipeline. This one is about the decision behind it - recorded as [ADR-0002](../../../../core/docs/decisions/0002-hybrid-retrieval.md) - and what the graph-aware rerank adds once lexical and dense are already fused.\n\n## The decision: why not just pick one retriever\n\nThe problem ADR-0002 states plainly: technical and agent queries mix natural-language intent with exact tokens, and Core additionally holds a graph neither text index can see. Four options were weighed:\n\n1. Dense-only (a single embedding model + ANN).\n2. Lexical-only (BM25).\n3. **Hybrid: BM25 + dense, fused by Reciprocal Rank Fusion, then graph rerank - the option chosen.**\n4. Hybrid with *learned linear score fusion* - a weighted sum of normalized scores.\n\nOption 3 won on evidence, not intuition: on the WANDS benchmark, RRF scores 0.7068 NDCG against BM25's 0.6983 and kNN's 0.6953 - hybrid beats either retriever alone. Option 4, weighted linear fusion, was rejected as **brittle**: it needs per-corpus score normalization and tuning, and it re-breaks every time a retriever changes underneath it - exactly the recalibration problem rank-based fusion was chosen to avoid.\n\nThe ADR is honest about the trade this buys: fusing three signals means three signals to evaluate, mitigated by logging every one per query into the eval harness rather than guessing at ranking changes. In exchange, the system **degrades gracefully** - if the vector index is cold, lexical retrieval still answers on its own.\n\n## The graph-aware rerank\n\nOnce BM25 and dense are fused into one candidate set by RRF, that set is reranked using the one signal the text indexes never saw: the graph itself. Cogitave Query computes a structural prior with **Personalized PageRank**, seeded on the query's anchor nodes - its products, its skills, the document the caller is currently on - the standard graph-rerank technique in GraphRAG-style systems. Edge `weight` and edge `type` modulate how that prior propagates: a `dependsOn` prerequisite edge propagates differently from a plain `xref`. The final order combines the RRF score with this graph prior and the ranking signals below.\n\n## Ranking signals read off the graph itself\n\n| Signal | Source | Effect |\n|---|---|---|\n| Moniker freshness | `monikerRange` vs. requested version; `lastModified` | Prefer the node valid for the requested product version; decay stale ones. |\n| Audience / region | `audience`, `forRole` edges, `region`, `locale` | Boost matches for the caller's role and locale. |\n| Prerequisite proximity | `dependsOn` distance over the Skill graph from what the learner already knows | Rank \"next learnable\" units above ones gated by unmet prerequisites. |\n| Level fit | `level` vs. caller profile | Match beginner content to beginners, expert to experts. |\n| Authority | Personalized-PageRank centrality; `supersededBy` | Prefer canonical nodes; demote superseded ones. |\n\nThese apply as multiplicative boosts on the reranked score, and every one is logged per query for the eval harness - ranking changes are measured, never guessed at.\n\n## Facets are traversals, not a second index\n\nBecause `type`, `products`, `topics`, `audience`, `level`, and `monikerRange` are first-class node properties, and `partOf`/`appliesTo`/`forRole` are edges, a facet like \"reference docs for product yuva, role platform, version >=2.0\" is just a typed-edge-constrained filter applied before rerank - not a separate taxonomy system to keep in sync. The facet UI and an agent's `query_graph` call share that one traversal, so counts never drift between them.\n",
  "partOf": "cogitave.learn.query-the-estate-with-cogitave-query",
  "durationInMinutes": 7,
  "quiz": null
}