# Graph search

Graph search queries a knowledge graph stored in an ordinary collection. Most graph retrieval is a
few hops from a handful of seed entities: find the entities a question is about, walk to their
neighbors, score what you reached by how close it is to the seeds, and fetch the passages that
mention them. WarpSearch does this inside one collection, in one request, against one consistent
snapshot, with the same filters, facets and vector search as any other search.

| Feature | What it does |
|---|---|
| [`lookup`](#lookup-one-hop-as-a-query) | A query that selects documents by the values another query's matches hold (a semi-join). Covers one hop, reverse edges, and ACLs read from a user's document. |
| [Graph expand](#graph-expand) | Bounded multi-hop expansion with scoring: up to 3 hops over adjacency fields or edge documents, ranked by spreading activation or personalized PageRank, with facets and searches over the neighborhood, the traversed edges as a subgraph, and a size budget for prompt context. |
| [Graph paths](#graph-paths) | Shortest and k-shortest paths between sets of entities, or every path in a length range, with the edges that link them as evidence. |

## Modeling a graph

A graph lives in an ordinary collection. A **node** is a document, identified by its `id`. Edges
come in two forms, and you can use both in one collection and mix them in one request.

### Adjacency fields

The simplest edge is a multi-valued string field on the node that holds neighbor ids:

```json
{"id": "svc-pay", "kind_s": "entity", "type_s": "service", "name_t": "Payments Service",
 "aliases_ss": ["payments", "pay-svc"], "related_ss": ["team-core", "svc-ledger"],
 "ancestors_ss": ["org", "eng", "platform"], "pagerank_f": 0.9, "acl_ss": ["eng"],
 "embedding_v": [0.12, 0.98, 0.04]}
{"id": "ch1", "kind_s": "chunk", "text_t": "payments outage postmortem",
 "mentions_ss": ["svc-pay", "team-core"], "acl_ss": ["eng"]}
```

- Following an edge **outward** reads the node's field: `svc-pay` points at `team-core`.
- Following it **inward** finds the documents whose field holds a node's id: `team-core` is pointed
  at by `svc-pay`. This uses the field's index, so reverse edges need not be stored.
- Keyword string fields (`_s`, `_ss`) work both ways. A field must be indexed to follow it inward
  and have a column to follow it outward; the default string fields have both.

### Edge documents

When an edge needs a type, a weight, a validity window or provenance, store one document per edge:

```json
{"id": "e1", "kind_s": "edge", "src_s": "alice", "dst_s": "svc-pay", "type_s": "WORKS_ON",
 "weight_f": 0.9, "source_chunk_s": "ch7", "valid_from_dt": "2024-01-01", "valid_to_dt": "2030-01-01"}
```

`src_s` and `dst_s` hold node ids (rename them per request), the weight is any numeric field, and
any other field can be filtered on or returned as evidence. Edge and node documents can share the
collection, told apart by a field such as `kind_s`.

### Which to use

| | Adjacency field | Edge documents |
|---|---|---|
| Storage | One field per relation on the node | One document per edge |
| Edge type | One field per type (`works_on_ss`, `owns_ss`) | A field on the edge, filterable |
| Weight | None (every edge counts 1) | Any numeric field |
| Validity, ACL, provenance | No | Yes, as filters and returned fields |
| Fan-out cap | First values of the field | Heaviest edges first |
| Best for | Entity links, mentions, hierarchies | Extracted relations, temporal facts, cited links |

Two more modeling patterns need no graph feature at all:

- Hierarchies: store the ancestor path (`ancestors_ss: ["org", "eng", "platform"]`). Descendants
  of X are a filter on `ancestors_ss`, and a facet on it rolls counts up the tree.
- Importance priors: store a score such as PageRank (`pagerank_f`) and multiply it in at query time
  with a rescore expression.

## lookup: one hop as a query

`lookup` selects the documents whose `field` holds any value of `from_field` across the documents
matching `from`. It is a query in the native `warpsearch` language, usable in `query` or `filters`,
combined with `AND`, or nested inside another lookup. Native expressions run unsigned on dedicated
collections without row filters or field restrictions; on restricted collections, replay a signed
translation (see [Query languages](query-languages.md)).

```json
{"language": "warpsearch",
 "query": "lookup(id, from=id:=$seed, from_field=related_ss)",
 "vars": {"seed": "svc-pay"}}
```

| Parameter | Meaning |
|---|---|
| `field` | The field to match on. |
| `from` | The query whose matches supply the values. Put values in `vars`. |
| `from_field` | The field whose values those matches hold. |
| `max_values` | Cap on the distinct values (default 65536). Exceeding it is an error. |

| Use | Setting | Result |
|---|---|---|
| Outbound neighbors | `field: id`, `from_field: related_ss` | The entities `svc-pay` points at. |
| Inbound neighbors | `field: related_ss`, `from_field: id` | The entities pointing at the seeds. |
| ACLs from the user's document | `field: acl_ss`, `from: id:=$user`, `from_field: groups_ss` | The documents visible to any of the user's groups. |
| Two hops | A lookup nested in `from` | For example "everyone working on the projects Alice works on", or a group-of-groups closure. |

Both fields are string-valued (keyword and id fields) or both integer-valued (integer and date
fields). The values come from the same snapshot as the rest of the search, and the inner query sees
every live document, not the request's other filters. A repeated lookup, such as the same user's ACL
filter on every search, is cached as one entry however many values it resolves.

## Graph expand

`POST /v1/collections/{collection}/graph/expand` starts from seed documents, follows up to 3 hops,
scores every node it reaches and returns them best first:

```bash
curl -s https://api.warpsearch.io/v1/collections/kg/graph/expand \
  -H "Authorization: Bearer $WARPSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "seeds": {"query": "name_t:\"payments outage\"", "limit": 20},
    "hops": [
      {"field": "related_ss", "direction": "out", "decay": 0.5,
       "max_per_node": 25, "max_frontier": 500, "filter": "type_s:(service OR team)"},
      {"field": "mentions_ss", "direction": "in", "filter": "kind_s:chunk"}
    ],
    "propagate": "sum",
    "limit": 50,
    "fields": ["id", "name_t", "type_s"],
    "facets": {"types": {"field": "type_s"}}
  }'
```

```json
{"took_ms": 6, "collection": "kg", "total": 31,
 "hits": [
   {"id": "svc-pay", "score": 7.2, "hop": 0, "fields": {"name_t": "Payments Service", "type_s": "service"}},
   {"id": "team-core", "score": 3.6, "hop": 1, "via": "svc-pay", "fields": {"name_t": "Core Team", "type_s": "team"}},
   {"id": "ch1", "score": 2.1, "hop": 2, "via": "team-core", "fields": {}}
 ],
 "facets": {"types": {"buckets": [{"val": "service", "count": 9}, {"val": "team", "count": 4}]}}}
```

### How it works

1. `seeds` is a search, or a list of node ids. Its matches start the walk, and their search scores
   are their starting scores (ids start at 1, or at the group's `weight`). Use any query, including
   a kNN search over entity names (`"language": "es"` with an ES `knn` body), or several weighted
   groups combined into one ranked list.
2. Each hop expands the nodes the previous hop reached for the first time. A node is reached once,
   at its first hop, and is never revisited, so the walk cannot loop.
3. A node's score is the sum (or, with `"propagate": "max"`, the best) over the nodes that reached
   it of `their score x decay x edge weight`. Adjacency edges weigh 1. With the default sum, a node
   many seeds point at outranks one reached once.
4. Every hop can cap its work: how many edges each node may follow, how many new nodes survive the
   hop, and which hubs are too connected to expand.
5. The reached nodes come back best first, seeds included unless you exclude them, each with how
   many hops away it is and which node reached it. `total` counts every node reached, and `facets`
   and `search` run over all of them, not just the returned page. `min_hop` hides the nodes nearer
   than that (from hits, `total`, facets and `search`) while they still take part in the walk and
   the scores; `include_seeds: false` is `min_hop: 1` for the seeds alone.

### Request

| Field | Meaning |
|---|---|
| `seeds` | The seed search: `query`, `language`, `filters`, `limit` (default 10), `sort`; or `ids`, a list of node ids. Or an array of up to 10 such groups, each with a `weight` (default 1) multiplying its scores; the group limits add up. `sort` is rejected with `ids` or `weight`. For vector seeds use `"language": "es"` and an ES `knn` search body as `query`. |
| `hops` | Up to 3 hop objects, applied in order (below). |
| `propagate` | `sum` (default) or `max`. |
| `rank` | `spread` (default, the scores above) or `ppr` (personalized PageRank, below). |
| `ppr` | PageRank options: `alpha` (damping, the probability of continuing the walk rather than restarting at a seed; default 0.5, HippoRAG's value; the web-graph 0.85 spreads much wider), `iterations` (default 50), `tolerance` (default 1e-6), `undirected` (default `true`), `reset_field` (a numeric field scaling each seed's restart weight, below). |
| `limit` | How many nodes to return (default 10; `-1` for your plan's maximum). |
| `include_seeds` | Return the seeds too (default `true`). |
| `min_hop` | Hide nodes nearer than this many hops (0 to the number of hops) from the hits, `total`, facets and `search`. They still traverse and score. |
| `fields` | The fields to return for each node. |
| `scores` | Return scores (default `true`). |
| `edges` | `true` returns, with each node, the edge that reached it (below). |
| `subgraph` | `true` returns every traversed edge of the reached subgraph under `edges` in the response (below), at most `max_edges` of them (default and maximum 100000; traversing more is a `429`). |
| `budget` | `{"nodes", "edges", "edge_cost"}`: keep a connected subgraph of bounded size around the seeds before ranking (below). At least one key. |
| `timeout_ms` | Wall-clock budget, counted from the seed search. Once spent, no further hop runs, PPR stops where it is, and what was reached so far comes back with `"timed_out": true`. |
| `filters` | Restrict every node the walk may reach, seeds included, for example an ACL or a tenant filter. |
| `facets`, `metrics` | Analytics over every reached node. Same syntax as [Facets and aggregations](facets-and-aggregations.md). |
| `search` | A second search restricted to the reached nodes: `query`, `language`, `filters`, `limit`, `fields`, `sort` (kNN through `"language": "es"`). Returned as `search`. |
| `max_nodes` | Safety cap on nodes reached (default and maximum 100000). Exceeding it is a `429`. |

A hop over an adjacency field:

| Field | Meaning |
|---|---|
| `field` | The adjacency field. |
| `direction` | `out` (default: the node's own values), `in` (nodes whose field holds this node's id) or `both`. |
| `filter` | Which reached nodes to keep: an expression or an array that must all match. |
| `decay` | Score multiplier for this hop (default 1). |
| `max_per_node` | Follow at most this many edges from each node. |
| `max_frontier` | Keep only the best-scored new nodes after the hop. |
| `max_degree` | Do not expand nodes with more edges than this (hubs). |

A hop over edge documents replaces `field` with `edges`:

```json
{"edges": {"src": "src_s", "dst": "dst_s", "weight": "weight_f",
           "filter": ["type_s:WORKS_ON", "valid_from_dt:<=2025-06-01", "valid_to_dt:>=2025-06-01"],
           "fields": ["source_chunk_s", "type_s"]},
 "direction": "out", "max_per_node": 20, "decay": 0.5}
```

| `edges` field | Meaning |
|---|---|
| `src`, `dst` | The fields holding the edge's endpoint ids (default `src_s` and `dst_s`). `out` follows src to dst, `in` dst to src. |
| `weight` | A numeric field multiplied into the score. `max_per_node` keeps each node's heaviest edges. |
| `filter` | Which edge documents count: edge type, validity at a point in time, ACL. |
| `fields` | Edge fields returned as evidence with `"edges": true`. |

### Results

Each hit has the usual `id`, `score` and `fields`, and:

| Field | Meaning |
|---|---|
| `hop` | 0 for a seed, else the hop that first reached the node. |
| `via` | The id of the node that contributed most to its score. Absent on seeds. |
| `edge` | With `"edges": true`: the link from `via`. For an edge document, `{"id", "weight", ...requested fields}`; for an adjacency field, `{"field": "related_ss"}`. |

Following `via` back to a seed shows why a node was returned, and `edge` cites where the link came
from, for example the passage it was extracted from.

The response also carries `timed_out` (`true` when `timeout_ms` cut the walk short) and, with
`"rank": "ppr"`, `ppr`: `{"ran_iterations", "converged"}`.

### The whole subgraph

`"subgraph": true` adds `edges` to the response: every edge the walk traversed, in traversal order,
each physical edge once even when both ends reached it. An edge is
`{"from", "to", "via", "weight", "hop"}` plus, for an edge document, its `id` and the requested
evidence fields; `from` and `to` are in the stored direction (the node holding the adjacency value
to the value, or `src` to `dst`), `via` is the adjacency field or `edges`, and `hop` is the hop that
traversed it. Edges to nodes hidden by `include_seeds` or `min_hop` are included; edges to nodes
dropped by `max_frontier` are not. `max_edges` bounds the list.

### A size budget

`limit` keeps the best nodes, which need not connect to each other or to the seeds. `budget` keeps a
connected subgraph instead, before ranking and before anything is returned: starting from the seeds,
it repeatedly adds the best-scored node adjacent to the kept set (attached by its heaviest edge)
while at most `nodes` nodes and `edges` edges are kept and the node scores more than `edge_cost`
(default 0); any edge budget left over takes the heaviest remaining edges among the kept nodes.
The choice is greedy, not optimal, and with several seeds the result is a forest whose trees join
where the neighborhoods meet. The hits, `total`, facets, `search` and the subgraph `edges` all cover
the kept subgraph only; `via` and `edge` name the edge that attached each node. Seeds count toward
`nodes` even when hidden.

### Personalized PageRank

With `"rank": "ppr"`, WarpSearch first collects the subgraph the hops reach, then ranks it by
personalized PageRank: a random walk that follows edges (weighted by edge weight) and restarts at the
seeds in proportion to their scores. Scores sum to 1 over the reached nodes. PPR favors nodes that
are well connected to several seeds over nodes that are merely close to one; HippoRAG uses it for
multi-hop question answering. `hop` and `via` are reported as before, and `ppr` in the response
reports the iterations run and whether the last one moved less than `tolerance`.

`ppr.reset_field` names a numeric field whose value multiplies each seed's restart weight
(HippoRAG's node specificity: a phrase node weighted by the inverse of the number of passages it
occurs in). A seed without a value keeps its weight, a negative value counts as 0, and when every
weight is 0 the restart is uniform over the seeds.

### Recipe: GraphRAG passage retrieval

Find the entities a question names, walk to related entities and to the passages that mention them,
and rank the passages:

```json
{"seeds": {"query": "aliases_ss:(ledger OR payments)", "limit": 10},
 "hops": [{"field": "related_ss", "direction": "both", "max_degree": 1000},
          {"field": "mentions_ss", "direction": "in", "filter": "kind_s:chunk"}],
 "rank": "ppr",
 "include_seeds": false,
 "filters": ["acl_ss:(eng OR all-staff)"],
 "fields": ["id", "text_t"],
 "limit": 20}
```

Feed the returned passages, with their `via` chains as context, to your model.

### Recipe: weighted seed groups

HippoRAG 2 seeds the walk with the entities an LLM confirmed, plus the passages a vector search
found at a small weight. Both are one `seeds` array; ids need no query escaping:

```json
{"seeds": [{"ids": ["ent:ledger", "ent:payments"]},
           {"language": "es", "query": {"knn": {"field": "embedding_v", "query_vector": [0.1, 0.9, 0.0], "k": 5}},
            "filters": [{"term": {"kind_s": "chunk"}}], "weight": 0.05}],
 "hops": [{"field": "related_ss", "direction": "both"},
          {"field": "mentions_ss", "direction": "in", "filter": "kind_s:chunk"}],
 "rank": "ppr", "include_seeds": false, "limit": 20}
```

The same form continues a walk from the ids a previous call returned, after your model picked the
ones worth following.

### Recipe: vector search inside a neighborhood

Restrict a kNN search to the graph around the seeds. The restriction applies inside the vector
search, so recall holds:

```json
{"seeds": {"ids": ["svc-pay"]},
 "hops": [{"field": "related_ss", "direction": "both"}],
 "limit": 0,
 "search": {"language": "es",
            "query": {"knn": {"field": "embedding_v", "query_vector": [0.1, 0.9, 0.0], "k": 5}},
            "fields": ["id", "name_t"]}}
```

For one hop, the same works on `/v1/search` with a `lookup` filter.

### Recipe: the subgraph for visualization or triple lists

A client that draws the neighborhood, or a retriever that feeds an LLM a list of triples
(SubgraphRAG and G-Retriever style), needs the edges, not only the nodes. `subgraph` returns every
traversed edge, with edge-document evidence, beside the hits:

```json
{"seeds": {"ids": ["svc-pay"]},
 "hops": [{"field": "related_ss", "direction": "both"},
          {"edges": {"weight": "weight_f", "filter": "kind_s:edge", "fields": ["type_s"]}, "direction": "both"}],
 "subgraph": true, "max_edges": 2000,
 "fields": ["id", "name_t"], "limit": -1}
```

Each `edges` entry has `from`, `to`, `via`, `weight` and `hop`, plus `id` and `type_s` for the
edge documents, so `(from, type_s, to)` is a triple and the hits supply the node labels.

### Recipe: token-bounded context with budget

For a prompt that can hold about thirty nodes, ask for the best connected thirty rather than the
best thirty: `budget` grows a subgraph from the seeds by score and stops at the caps, so every
returned node links back to a seed through returned nodes.

```json
{"seeds": {"query": "aliases_ss:(ledger OR payments)", "limit": 5},
 "hops": [{"field": "related_ss", "direction": "both"},
          {"field": "mentions_ss", "direction": "in", "filter": "kind_s:chunk"}],
 "rank": "ppr",
 "budget": {"nodes": 30, "edges": 40},
 "subgraph": true, "fields": ["id", "name_t", "text_t"], "limit": -1}
```

The choice is greedy; with several seeds whose neighborhoods do not meet, the result is a forest.

### Recipe: HippoRAG node specificity with reset_field

HippoRAG weights each seed phrase by its specificity (the inverse of how many passages mention it),
so a rare entity pulls the walk more than a common one. Store it on the node and name it in
`ppr.reset_field`; the field goes through the same alias and allow-list resolution as any other.

```json
{"seeds": {"query": "aliases_ss:(ledger OR payments)", "limit": 10},
 "hops": [{"field": "related_ss", "direction": "both"},
          {"field": "mentions_ss", "direction": "in", "filter": "kind_s:chunk"}],
 "rank": "ppr", "ppr": {"alpha": 0.5, "reset_field": "specificity_f"},
 "min_hop": 2, "fields": ["id", "text_t"], "limit": 20}
```

`min_hop: 2` returns only the passages; the seeds and the related entities still shape the
PageRank. The response's `ppr` says how many iterations ran and whether they converged.

## Graph paths

`POST /v1/collections/{collection}/graph/paths` finds how entities are connected:

```bash
curl -s https://api.warpsearch.io/v1/collections/kg/graph/paths \
  -H "Authorization: Bearer $WARPSEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "alice", "to": ["svc-pay", "svc-ledger"],
    "edges": [
      {"field": "related_ss"},
      {"edges": {"weight": "weight_f", "filter": "type_s:(WORKS_ON OR OWNS)", "fields": ["source_chunk_s"]}}
    ],
    "max_depth": 4, "k": 3,
    "fields": ["name_t"]
  }'
```

```json
{"took_ms": 3, "collection": "kg",
 "paths": [
   {"nodes": ["alice", "svc-pay"], "length": 1, "cost": 1,
    "edges": [{"from": "alice", "to": "svc-pay", "via": "edges", "id": "e1", "weight": 0.9, "source_chunk_s": "ch7"}]},
   {"nodes": ["alice", "team-core", "svc-pay"], "length": 2, "cost": 2,
    "edges": [{"from": "alice", "to": "team-core", "via": "related_ss"},
              {"from": "team-core", "to": "svc-pay", "via": "related_ss"}]},
   {"nodes": ["alice", "team-core", "svc-ledger"], "length": 2, "cost": 2,
    "edges": [{"from": "alice", "to": "team-core", "via": "related_ss"},
              {"from": "team-core", "to": "svc-ledger", "via": "related_ss"}]}
 ],
 "documents": {"alice": {"name_t": "Alice"}, "team-core": {"name_t": "Core Team"},
               "svc-pay": {"name_t": "Payments Service"}, "svc-ledger": {"name_t": "Ledger Service"}},
 "truncated": false, "timed_out": false, "explored": 23}
```

| Field | Meaning |
|---|---|
| `from`, `to` | A node id, or up to 10 ids each. The search runs from the whole `from` set to the whole `to` set in one call and returns the best `k` paths overall, not `k` per pair (sorted by cost, then length). Duplicate ids are ignored, a missing node is skipped, and a node in both sets is a zero-length path. |
| `edges` | The edge kinds to walk: adjacency hops and edge-document hops, as in graph expand. Undirected unless a spec sets `"direction": "out"` or `"in"`. `decay` and `max_per_node` are rejected here. A spec's own `max_frontier` caps the new nodes that spec may add to one level, so a high fan-out edge type (chunk mentions) cannot crowd out the others. |
| `max_depth` | Longest path, in edges (default 4, at most 6). |
| `k` | How many paths (default 1, at most 10): the shortest, then the next shortest without repeating one. |
| `weighted` | Rank by the sum of `-log(weight)` instead of the number of edges, so strong links win over short weak ones. |
| `mode` | `shortest` (default) or `all`: every simple path from a `from` node to a `to` node with `min_depth` to `max_depth` edges, shortest first and by node ids within a length; `cost` is the length. `k` and `weighted` are rejected with it. |
| `min_depth` | `mode: "all"` only: shortest path length to include (default 1; 0 to `max_depth`). |
| `max_paths` | `mode: "all"` only: how many paths to return (default 100, at most 10000). |
| `max_frontier` | Cap on nodes per exploration level (default 1000). |
| `max_degree` | Do not expand hubs with more edges than this. |
| `timeout_ms` | Wall-clock budget. Once spent, the exploration stops and the paths found over what was explored come back with `"timed_out": true`. |
| `filters` | Restrict the nodes a path may pass through, for example to what the caller may see. |
| `fields` | Fields of every node on a returned path, under `documents` keyed by id, so names and titles need no second request. Nodes you may not read are absent. |

Each path lists its nodes in order and each edge it uses: `via` names the adjacency field or `edges`,
and edge documents add their `id`, `weight` and requested `fields` as evidence. The search explores
from both ends at once and stops when they meet (for one unweighted path) or at `max_depth`; in
`mode: "all"` it explores to the full depth. A node past a frontier or degree cap is still reached,
so it can end or join a path, but it is not expanded. `truncated: true` means a work cap cut the
exploration short (a frontier or node cap, more than `max_paths` paths in `mode: "all"`, or its
internal cap on path prefixes), so a path may exist beyond it; `timed_out: true` means the same for
`timeout_ms`. `explored` counts the nodes explored. The path search is one engine call and `fields`
one more; a plan allows 20 per request.

### Recipe: all paths between two entities up to length 3

To list how two entities are related rather than only the closest link, ask for every simple path
in a length range. The paths come shortest first, so the first ones are the direct links and the
later ones the indirect ones.

```json
{"from": "svc-pay", "to": "team-core",
 "edges": [{"field": "related_ss"}, {"field": "mentions_ss"}],
 "mode": "all", "min_depth": 1, "max_depth": 3, "max_paths": 200,
 "fields": ["name_t"]}
```

`truncated: true` means more than `max_paths` paths exist; raise it (up to 10000), narrow the edge
specs with a filter, or lower `max_depth`.

## Recipes with plain search

Many graph questions need no graph endpoint. All of these run on [`/v1/search`](searching.md):

| Question | Request |
|---|---|
| Who points at `team-core`? | `filters: ["related_ss:team-core"]` |
| Everything under `platform` | `filters: ["ancestors_ss:platform"]` |
| Head count per org | `facets: {"orgs": {"field": "ancestors_ss"}}` over people |
| A seed's neighbors, as ids with counts | `facets: {"neighbors": {"field": "related_ss", "limit": -1}}` over the seeds |
| A seed's neighbors, as documents | `query: "lookup(id, from=id:=$seed, from_field=related_ss)"` (`warpsearch`, `vars: {"seed": "svc-pay"}`) |
| Passages that mention an entity | `filters: ["mentions_ss:svc-pay"]` |
| Which entities a set of passages mention | `facets: {"mentioned": {"field": "mentions_ss"}}` over the passages |
| Rank entities by importance | a rescore with `score * log1p(pagerank_f)` |
| Facts valid on a date | `filters: ["valid_from_dt:<=2025-06-01", "valid_to_dt:>=2025-06-01"]` |
| Resolve an entity name | RRF over fuzzy `aliases_ss`, exact aliases and a name embedding, see [Hybrid search](hybrid-search.md#fusion) |

## Access control

Graph requests follow the collection's [access control](access-control.md) like any search: row
filters and field restrictions apply to every node a walk or a path may reach. A node the caller
cannot see is not returned, not counted in `total` or facets, and not used as a step toward other
nodes.
For document-level permissions, store the allowed groups on each document (`acl_ss`) and filter with
a `lookup` from the user's document; the filter stays small and cached however many groups the user
has.

## MCP tools

Agents get three read-role tools over the same operations (see [Agents](agents.md#mcp-tools)).
`describe_collection` first shows the adjacency fields.

| Tool | Arguments | Returns |
|---|---|---|
| `graph_neighbors` | `collection`; `ids` or `query` (+ `language`); `field` or `edges` (`src`, `dst`, `weight`, `filter`, `fields`); `direction` (default `both`); `depth` 1 to 3 (default 1); `limit` (default 20); `min_hop` (for example 2 with depth 2 keeps only second-degree nodes); `filter`; `fields`; `rank` (`spread` or `ppr`) | `nodes` (id, score, hop, via, fields, edge), `total`, `truncated` (more nodes were reached than returned). Seeds are excluded. |
| `graph_paths` | `collection`; `from` and `to` (arrays of 1 to 10 ids; the best `k` paths over all pairs); `field` or `edges`; `direction`; `max_depth`; `k`; `weighted`; `mode` (`shortest` or `all`); `min_depth` and `max_paths` (mode `all`); `filter`; `fields` | `paths`, `documents`, `truncated`, `timed_out`, `explored`, as above. |
| `graph_expand` | `collection` and `request`, the full expand request as JSON text | The expand response. For weighted seed groups, per-hop decay and caps, `min_hop`, the full edge list (`subgraph`), a size `budget`, `ppr.reset_field`, `timeout_ms`, facets, metrics and a search inside the neighborhood. |

Every tool description links this guide, so an agent that only has the tool list can find the
request shapes. Clause arguments are strings in the tool's `language` (Lucene by default; ES clauses
are JSON objects as text). For example, the people who exchanged mail with a person in the Enron
demo:

```json
{"collection": "enron", "ids": ["person:jeff.skilling@enron.com"],
 "field": "participant_ids_ss", "direction": "in", "filter": "kind:email", "limit": 20}
```

## Limits

| Limit | Value |
|---|---|
| Hops per graph expand | 3 |
| Path depth | 6 edges |
| Paths per request (`k`) | 10 |
| Nodes reached per request (`max_nodes`), edges returned with `subgraph` (`max_edges`), and an explicit `max_frontier` | 100000 by default |
| Paths per request in `mode: "all"` (`max_paths`) | 10000 |
| Seed groups, and `from` or `to` ids per side | 10 |
| Engine calls per request (the path search, plus one for path `fields`) | 20 |
| PPR iterations | 1000 |
| Seeds, returned nodes and the nested `search` | Your plan's `max_limit`; seed ids count as query clauses |
| Distinct values per `lookup` | 65536 by default (`max_values`) |
| Scope | One dedicated collection per request |

Memory used by a walk counts against the request's memory budget like any search. Each engine call
is metered like a search.

## Errors

| Status | Code | Cause |
|---|---|---|
| `400` | `invalid_request` | A malformed hop, seed group or path spec: an unknown parameter, `ids` together with `query`, a non-positive `weight`, a field that is not a keyword or id field, an adjacency field without an index followed inward, `decay` or `max_per_node` on a path edge, `min_hop` outside 0 to the number of hops, an empty `budget`, `k` or `weighted` with `mode: "all"`, `min_depth` or `max_paths` without it, a negative `timeout_ms`. The message names the parameter, for example `edge/hop 0: direction must be out, in or both`. |
| `400` | `invalid_request` | A `lookup` resolving more than `max_values` distinct values, or mixing a string field with a numeric one, or a graph request on a shared collection with namespaced ids. |
| `413` | `request_too_large` | The graph request, with all its queries and filters, exceeds your plan's `max_query_bytes`. |
| `422` | `limit_exceeded` | More than 3 hops, `max_depth` above 6, `k` above 10, `max_paths` above 10000, `max_nodes`, `max_edges` or a `max_frontier` above 100000, PPR `iterations` above 1000, too many seed groups, endpoint ids or engine calls, or a `limit` above your plan. |
| `429` | `resource_exhausted` | A walk reaching more than `max_nodes` nodes, or traversing more than `max_edges` edges with `subgraph`. Lower `max_frontier` or `max_degree`, or set `budget` or `timeout_ms`. Paths never return `429` for work caps: they report `truncated` instead. |

## Not supported yet

- Hops across collections, and walks deeper than 3 hops (6 for paths) in one request.
- Cypher or GQL graph queries.
- Community detection and community summaries, and scheduled graph jobs such as computing
  PageRank priors.
- Entity and relation extraction, and entity merging.

Until then, chain graph requests from your application (the ids a request returns are the seeds of
the next), and write computed priors back as fields.
