Current section
Files
Jump to
Current section
Files
lib/ragex/retrieval/hybrid.ex
defmodule Ragex.Retrieval.Hybrid do
@moduledoc """
Hybrid retrieval combining symbolic graph queries with semantic similarity search.
Provides multiple strategies for combining structural and semantic search:
- **Semantic-first**: Use embeddings to find candidates, refine with graph
- **Graph-first**: Use symbolic queries to filter, rank by similarity
- **Fusion**: Combine results from both approaches using RRF
"""
alias Ragex.Embeddings.Bumblebee
alias Ragex.Graph.Store
alias Ragex.Retrieval.MetaASTRanker
alias Ragex.VectorStore
@doc """
Performs hybrid search combining semantic and symbolic approaches.
## Strategies
- `:semantic_first` - Semantic search followed by graph filtering
- `:graph_first` - Graph query followed by semantic ranking
- `:fusion` - Combine both with Reciprocal Rank Fusion (default)
## Options
- `:strategy` - Search strategy (default: :fusion)
- `:limit` - Maximum results (default: 10)
- `:threshold` - Semantic similarity threshold (default: 0.7)
- `:node_type` - Filter by entity type
- `:graph_filter` - Additional graph constraints
- `:metaast_ranking` - Enable MetaAST-based ranking boosts (default: true)
- `:metaast_opts` - Options for MetaAST ranking:
- `:prefer_pure` - Boost pure functions more (default: true)
- `:penalize_complex` - Penalize complex code more (default: true)
- `:cross_language` - Enable cross-language equivalence search (default: false)
## Examples
# Fusion strategy (default)
Hybrid.search("parse JSON", limit: 5)
# Semantic-first strategy
Hybrid.search("HTTP handler", strategy: :semantic_first)
# Graph-first with constraints
Hybrid.search("calculate",
strategy: :graph_first,
graph_filter: %{module: "Math"}
)
# With MetaAST ranking for cross-language results
Hybrid.search("map operations",
metaast_ranking: true,
metaast_opts: [cross_language: true]
)
"""
def search(query, opts \\ []) when is_binary(query) do
strategy = Keyword.get(opts, :strategy, :fusion)
case strategy do
:semantic_first -> semantic_first_search(query, opts)
:graph_first -> graph_first_search(query, opts)
:fusion -> fusion_search(query, opts)
_ -> {:error, "Unknown strategy: #{strategy}"}
end
end
@doc """
Performs Reciprocal Rank Fusion on multiple result sets.
RRF combines rankings from different sources by:
1. Converting ranks to scores: 1 / (rank + k)
2. Summing scores across all sources
3. Re-ranking by combined score
The constant k (default 60) prevents high rankings from dominating.
"""
def reciprocal_rank_fusion(result_sets, opts \\ []) do
k = Keyword.get(opts, :k, 60)
limit = Keyword.get(opts, :limit, 10)
# Collect all unique items with their RRF scores
all_items =
result_sets
|> Enum.with_index()
|> Enum.flat_map(fn {results, _source_idx} ->
results
|> Enum.with_index()
|> Enum.map(fn {item, rank} ->
rrf_score = 1.0 / (rank + k)
{get_item_key(item), item, rrf_score}
end)
end)
# Sum scores for duplicate items
fused_scores =
all_items
|> Enum.group_by(fn {key, _item, _score} -> key end)
|> Enum.map(fn {key, items} ->
total_score = Enum.reduce(items, 0.0, fn {_k, _i, score}, acc -> acc + score end)
# Take the item from the first occurrence
{_k, item, _s} = hd(items)
{key, item, total_score}
end)
|> Enum.sort_by(fn {_k, _i, score} -> score end, :desc)
|> Enum.take(limit)
|> Enum.map(fn {_key, item, score} ->
Map.put(item, :fusion_score, Float.round(score, 4))
end)
fused_scores
end
# Private functions
defp semantic_first_search(query, opts) do
limit = Keyword.get(opts, :limit, 10)
threshold = Keyword.get(opts, :threshold, 0.7)
node_type = Keyword.get(opts, :node_type)
graph_filter = Keyword.get(opts, :graph_filter, %{})
# Generate query embedding
case Bumblebee.embed(query) do
{:ok, query_embedding} ->
# Semantic search
# Get more candidates
search_opts = [limit: limit * 2, threshold: threshold]
search_opts =
if node_type, do: Keyword.put(search_opts, :node_type, node_type), else: search_opts
semantic_results = VectorStore.search(query_embedding, search_opts)
# Apply graph filters
filtered_results =
semantic_results
|> Enum.filter(&matches_graph_filter?(&1, graph_filter))
# Apply MetaAST ranking if enabled
ranked_results =
if Keyword.get(opts, :metaast_ranking, true) do
metaast_opts =
opts
|> Keyword.get(:metaast_opts, [])
|> Keyword.put(:query, query)
MetaASTRanker.apply_ranking(filtered_results, metaast_opts)
else
filtered_results
end
|> Enum.take(limit)
{:ok, ranked_results}
{:error, reason} ->
{:error, reason}
end
end
defp graph_first_search(query, opts) do
limit = Keyword.get(opts, :limit, 10)
# Lower threshold for graph-first
threshold = Keyword.get(opts, :threshold, 0.5)
graph_filter = Keyword.get(opts, :graph_filter, %{})
# Generate query embedding
case Bumblebee.embed(query) do
{:ok, query_embedding} ->
# Get candidates from graph (all nodes matching filters)
candidates = get_graph_candidates(graph_filter)
# Get embeddings for candidates and calculate similarity
candidate_results =
candidates
|> Enum.map(fn {node_type, node_id} ->
case Store.get_embedding(node_type, node_id) do
{embedding, text} ->
score = VectorStore.cosine_similarity(query_embedding, embedding)
%{
node_type: node_type,
node_id: node_id,
score: score,
text: text,
embedding: embedding
}
nil ->
nil
end
end)
|> Enum.reject(&is_nil/1)
|> Enum.filter(fn result -> result.score >= threshold end)
# Apply MetaAST ranking if enabled
ranked_results =
if Keyword.get(opts, :metaast_ranking, true) do
metaast_opts =
opts
|> Keyword.get(:metaast_opts, [])
|> Keyword.put(:query, query)
MetaASTRanker.apply_ranking(candidate_results, metaast_opts)
else
candidate_results
|> Enum.sort_by(fn result -> result.score end, :desc)
end
|> Enum.take(limit)
{:ok, ranked_results}
{:error, reason} ->
{:error, reason}
end
end
defp fusion_search(query, opts) do
limit = Keyword.get(opts, :limit, 10)
# Run both strategies
case {semantic_first_search(query, opts), graph_first_search(query, opts)} do
{{:ok, semantic_results}, {:ok, graph_results}} ->
# Apply RRF fusion
pre_fusion_results =
reciprocal_rank_fusion(
[semantic_results, graph_results],
limit: limit * 2
)
# Apply MetaAST ranking if enabled
fused_results =
if Keyword.get(opts, :metaast_ranking, true) do
metaast_opts =
opts
|> Keyword.get(:metaast_opts, [])
|> Keyword.put(:query, query)
MetaASTRanker.apply_ranking(pre_fusion_results, metaast_opts)
else
pre_fusion_results
end
|> Enum.take(limit)
{:ok, fused_results}
{{:error, reason}, _} ->
{:error, reason}
{_, {:error, reason}} ->
{:error, reason}
end
end
defp matches_graph_filter?(_result, filter) when map_size(filter) == 0, do: true
defp matches_graph_filter?(result, filter) do
node_data = Store.find_node(result.node_type, result.node_id)
Enum.all?(filter, fn {key, value} ->
case key do
:module when result.node_type == :function ->
{module, _name, _arity} = result.node_id
Atom.to_string(module) == value or module == String.to_atom(value)
_ ->
# Check node data
node_data[key] == value or
(is_atom(node_data[key]) and Atom.to_string(node_data[key]) == value)
end
end)
end
defp get_graph_candidates(filter) do
# Get nodes based on filter
node_type =
case filter[:node_type] do
"module" -> :module
"function" -> :function
_ -> nil
end
# Get all nodes of specified type (or all if no type)
nodes = Store.list_nodes(node_type, 1000)
# Convert to {type, id} tuples
Enum.map(nodes, fn node -> {node.type, node.id} end)
end
defp get_item_key(%{node_type: type, node_id: id}), do: {type, id}
defp get_item_key(item), do: inspect(item)
end