macula_rag

Federated retrieval over the Macula mesh: ask every shard of an org, merge the answers.

This exists so an index split across many nodes can be searched as one, without a central server, and without any node the org did not delegate being able to answer for it.

What it does

A shard is one node holding part of an index. It registers a callback that searches its part, and publishes a summary of what it holds. A query asks every shard of the org at once and merges their hits by score.

It needs macula 13.0.1 or later (~> 13.0.1): macula:providers/3,4 and the provider option of macula:call/6 arrived in 12.1, and a service on macula 13 cannot use a macula_rag pinned to 12.

Status

0.1.0, the first release. No node issues federated queries in production yet. What this version does not do:

Using it

{deps, [{macula_rag, "~> 0.1"}]}.

Once a macula pool is connected:

ok = macula_rag:configure(Pool, RealmId,
       #{org => <<"acme">>,
         shard_id => <<"library-shard-1">>,
         realm_name => <<"io.macula">>,
         embedding => #{model => <<"nomic-embed-text">>, dim => 768}}),

%% This node is a shard: answer the org's queries.
ok = macula_rag:register_responder(
       fun(Query, #{top_k := K}) -> {ok, my_index:search(Query, K)} end),
ok = macula_rag:advertise([<<"library/books">>], Bloom),

%% Ask every shard of the org.
{ok, Hits, #{answered := Answered, failed := Failed}} =
    macula_rag:query(#{<<"text">> => <<"federated search">>}, #{top_k => 10}).

A hit needs at least a binary id and a numeric score; the rest of the map is yours and is carried as it is. Hits and queries arrive with binary keys, as every received payload does, and a merged hit also carries node_id and shard_id, the shard it came from.

realm_name must be the name whose SHA-256 is RealmId, or configure/3 refuses: the topics carry the name, and a topic naming another realm reaches nobody.

The grant

register_responder/1 advertises the org's procedure as soon as macula grants it, which needs the org's D25 procedure_delegation naming this node. Until then, status/0 says why not:

#{responder := {not_granted, #{reason := {provider_authorization,
                                          {procedure_delegation, not_found}},
                               since_ms := 42000}}}

{procedure_delegation, not_found} means an operator has to delegate this node for the org. The grant is asked for again every grant_retry_ms (30 s), so a delegation made later is picked up without a restart, and advertised once. A link that drops and comes back needs nothing: macula replays the advertisement on it.

Configuration

Option Default Meaning
org required whose shards these are; the procedure is <org>/rag.query_shard_v1
shard_id required a label for this node's shard
realm_name required the realm's name; its SHA-256 must be the realm id
embedding required #{model => binary(), dim => pos_integer()} this node's index uses
query_timeout_ms 1500 how long a query waits for the slowest shard
grant_retry_ms 30000 how often a refused grant is asked for again
summary_republish_ms 60000 how often this shard's summary is republished; a summary unheard for three periods is stale

The wire contract

procedure <org>/rag.query_shard_v1
fact <realm>/<org>/rag/shard/shard_summarized_v1: shard_id, model, dim, topics, bloom, at_ms
fact <realm>/<org>/rag/shard/shard_withdrawn_v1: shard_id, at_ms
query query, top_k, model, dim
answer shard_id, model, dim, hits

A summary belongs to the node macula verified as its publisher, whatever the payload claims. test/macula_rag_contract_tests.erl pins every key and reads each payload back through macula's codec.

The RAG service contract

What any provider serves and any caller can check: provenance on every hit, the corpus hash naming which corpus answered, and the operator's optional signature over it. The RAG service contract is the canonical text, with frozen vectors in test/vectors/.

macula_rag:corpus_hash(Description)                 %% the hash of a description you built
macula_rag:verify_corpus(Description, P, Profile)   %% {ok, {signed, P}} | {ok, unsigned} | {error, Why}
macula_rag:verify_hit(Hit)                          %% the text is what its provenance hashes

verify_corpus/3 counts a signature only from the provider the caller pinned (macula:call/6 with #{provider => P}), derived from the verified key, never from signed_by.

Build and test

scripts/check.sh      # compile, eunit, lint, xref, dialyzer, as CI runs them

OTP 28.4.3, pinned in .tool-versions and CI. The suite includes a federation test that starts three shard VMs with peer: two answer one query and their hits merge by score; the third, built with another embedding, is reported and never asked.

Releasing

A pushed vX.Y.Z tag publishes to hex (.github/workflows/publish-hex.yml). Before publishing, the workflow refuses anything but the clean, pushed tag whose version is in src/macula_rag.app.src and has a CHANGELOG section, dry-runs the publish, and checks that hex.pm accepts the key. After publishing it checks that hex serves the tagged files.

Guides

License

Apache-2.0.