Reference
Search API
Run semantic and keyword search across your workspace memories. The search endpoint runs keyword, semantic (pgvector cosine similarity), and graph lookups, merges them, and orders the merged candidates by relevance. Use it to surface relevant past decisions, patterns, and context before starting new work.
See Memory Concepts for how memories are structured, or Knowledge Base Ingestion cookbook for bulk ingestion patterns.
Base URL and Authentication
https://api.neuroloom.devAll requests require an API key:
Authorization: Token $MEMORIES_API_TOKENSearch Memories
POST /api/v1/memories/searchCombined keyword, semantic, and graph search. Results are returned ordered by score descending.
How Ranking Works
A search runs in three steps:
- Retrieve. Keyword (full-text), semantic (vector similarity), and graph lookups each produce a ranked list of candidate memories.
- Fuse. Reciprocal rank fusion (RRF) merges the lists. RRF is a rank-based method: each list gives a memory
1 / (60 + rank)points, so a memory that several lists agree on rises above one found by a single list. An RRF score is small: a top hit found by one list scores about0.016, and one found by all three about0.05. - Rerank. On paid plans, the top 50 fused candidates go to a cross-encoder reranker, a model that reads the query and one memory together and outputs a relevance probability between
0and1. Results are sorted by that relevance.
When reranking runs, stored importance_score and confidence_score do not change the order. Two small adjustments apply on top of the reranker's relevance: a memory that a newer memory supersedes is scaled by 0.7, and the recency search profile scales each result by up to ±5% depending on age. Use min_importance and min_confidence when you want to filter on those fields instead.
Reranking does not run on the Free plan, and it is skipped when the reranker is unavailable (see degradation_reason below). In those responses rerank_score is null and score is the RRF score, which is not comparable to a reranked score. See Reading scores.
Semantic search uses precomputed embeddings and requires an embedding provider to be configured. If none is configured, the endpoint falls back to keyword-only ranking.
memory_search(
query="database connection pooling strategy",
memory_types=["pattern", "decision"],
min_importance=0.6,
limit=10
)The MCP memory_search tool uses different defaults than the REST endpoint: limit defaults to 5 (REST: 20), min_score defaults to 0.05 (REST: no default), and results come back as compact summaries (REST: compact defaults to false). See the MCP tool reference.
The memory_by_file MCP tool is a convenience wrapper that calls search with a files filter:
memory_by_file(
file_path="api/neuroloom_api/routers/memories.py",
limit=10
)import httpx, os
response = httpx.post(
"https://api.neuroloom.dev/api/v1/memories/search",
headers={"Authorization": f"Token {os.environ['MEMORIES_API_TOKEN']}"},
json={
"query": "database connection pooling strategy",
"memory_types": ["pattern", "decision"],
"min_importance": 0.6,
"limit": 10,
"use_semantic": True,
},
)
results = response.json()
for r in results["results"]:
print(r["memory"]["title"], r["score"])curl -X POST "https://api.neuroloom.dev/api/v1/memories/search" \
-H "Authorization: Token $MEMORIES_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"query": "database connection pooling strategy",
"memory_types": ["pattern", "decision"],
"min_importance": 0.6,
"limit": 10
}'Request Body
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
query | string | Yes | — | Free-text query used for both keyword matching and semantic similarity. Must be non-empty after stripping whitespace — a whitespace-only or missing query returns 422 Unprocessable Entity, regardless of what tags/memory_type filters are also set. Use GET /memories/ instead for filter-only browsing with no query text. POST /memories/search/batch's queries array applies the identical required-and-non-empty rule to each entry. |
memory_types | array of strings | No | null | Filter to one or more memory type values (e.g. ["decision", "pattern"]). Note: plural field name, unlike the list endpoint's singular memory_type. |
tags | array of strings | No | null | Filter to memories carrying these exact tags. tags_match decides whether a memory needs any one of them or all of them. Every result satisfies the filter, whichever lookup (keyword, semantic or graph) found it. See callout below. |
tags_match | string | No | "any" | How tags combines. "any": a memory matches when it carries at least one listed tag (OR). "all": it must carry every listed tag (AND). For example, tags: ["sdlc:knowledge", "sdlc:domain:architecture"] with tags_match: "all" returns only memories tagged with both. Ignored when tags is empty; any other value returns 422. |
tag_prefixes | array of strings | No | null | Filter to memories with at least one tag that starts with any of these prefixes (e.g. ["lang:"] matches lang:python). Useful for namespaced tagging schemes. Prefixes always combine with OR, regardless of tags_match. When combined with tags, both filters must match. |
files | array of strings | No | null | Filter to memories that reference ANY of these file paths. Note: this field is files, not source_files. |
min_importance | float | No | null | Only return memories with importance_score >= this value. |
min_confidence | float | No | null | Only return memories with confidence_score >= this value. |
use_semantic | boolean | No | true | Enable semantic (embedding) search. Set false for pure keyword search. |
similarity_threshold | float | No | null | Minimum cosine similarity (0.0–1.0) for semantic results. null uses the server default (0.3). Lower values admit weaker semantic matches. Only applies when use_semantic is true. |
min_score | float | No | null | Minimum rerank_score (0.0–1.0) a reranked result needs to be returned. No server default here (the MCP memory_search tool defaults to 0.05). Does nothing on unreranked responses. See Reading scores. |
search_profile | string | No | "default" | Retrieval strategy: default, exact, exploration, recency, or file-scoped. With auto_classify on (the default), the server may override this based on the detected query intent. Only recency adds a recency adjustment to ranking. |
created_after | ISO 8601 datetime | No | null | Only return memories created at or after this time (inclusive). Naive timestamps are treated as UTC. |
created_before | ISO 8601 datetime | No | null | Only return memories created at or before this time (inclusive). Naive timestamps are treated as UTC. |
max_tokens | integer | No | null | Token budget for the results. When set, results are truncated to fit and limit is ignored. |
compact | boolean | No | false | Return flat summaries (id, title, memory_type, score, snippet, tags, source_files, rerank_score, supersession_trail, lineage, low_confidence) instead of full memory objects. Aggregations are omitted. |
limit | integer | No | 20 | Max results to return (min 1, max 200). When reranking runs, a response holds at most 50 results even if limit is higher — see Pagination and the 50-result cap. |
offset | integer | No | 0 | Number of top fused candidates to skip before reranking. See Pagination and the 50-result cap. |
The search endpoint uses memory_types (plural array) while the list endpoint uses memory_type (singular string). Using the wrong one silently drops your filter.
File filtering uses files (not source_files). Using source_files in a search request body is silently ignored.
tags narrows the results your query finds; it does not surface a memory on tag match alone. A memory with a matching tag that none of the keyword, semantic or graph lookups retrieves for the query will not appear. Every filter, including tags, applies to all three lookups, so no returned memory fails a filter you set. For a tag lookup with no query relevance (AND semantics), use GET /memories or the MCP tool memory_get_index(tags=[...]).
Response
200 OK
{
"count": 2,
"query": "database connection pooling strategy",
"filters": {
"workspace_id": "ws-xyz789",
"memory_types": ["pattern", "decision"],
"tags": null,
"tags_match": "any",
"min_importance": 0.6
},
"results": [
{
"memory": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"workspace_id": "ws-xyz789",
"title": "Use connection pooling for all DB connections",
"narrative": "Configure SQLAlchemy with pool_size=10, max_overflow=20. Never create engines per-request — share a single engine per process.",
"memory_type": "pattern",
"tags": ["sqlalchemy", "database", "performance"],
"concepts": ["connection-pooling", "database"],
"source_files": ["api/neuroloom_api/database.py"],
"importance_score": 0.88,
"confidence_score": 0.9,
"content_hash": null,
"pagerank_score": 0.45,
"community_label": "database-patterns",
"access_count": 8,
"retrieval_count": 22,
"created_at": "2026-03-15T10:00:00Z"
},
"score": 0.96,
"keyword_score": 0.0,
"semantic_score": 0.0,
"match_type": "combined",
"rrf_score": 0.0325,
"rerank_score": 0.96,
"low_confidence": false
}
],
"degraded": false,
"degradation_reason": null,
"code_graph_available": true
}Top-level response fields
| Field | Type | Description |
|---|---|---|
count | integer | Number of results returned on this page, not a total record count. Search is bounded by limit. There is no total_count or cursor field. |
degraded | boolean | true when a pipeline stage was skipped or fell back to a lower-quality path. This is a deliberate resilience behavior, not an error state — the request still returns a normal 200 OK with results, rather than failing outright. |
degradation_reason | string | null | Set only when degraded is true. "embed_timeout" — the query-embedding call exceeded its timeout, so semantic and graph matching were skipped and results fell back to keyword-only (FTS). "embed_circuit_open" — the embedding provider's circuit breaker is open after prior failures; same keyword-only fallback as embed_timeout. "reranker_error" — semantic and graph matching ran normally, but the cross-encoder reranking step didn't: either the reranker provider errored or timed out, or fewer than 0.75s remained in the request's deadline when reranking would have started, so it was skipped rather than attempted and likely failing anyway. Results are RRF-ordered (unreranked) instead of reranked, everything else in the pipeline ran, so rerank_score is null and score is on the RRF scale (see Reading scores). Retrying later may restore the skipped stage. |
code_graph_available | boolean | null | true when the workspace has code symbols matching the query (graph-derived candidates were available for this search). false when no matching code symbols were found. null when the code-graph check timed out or was skipped. |
Only "embed_timeout" triggers the MCP quality retry (memory_search automatically retries once on that specific reason). "reranker_error" and "embed_circuit_open" are never retried — a reranker failure or an open circuit breaker won't be fixed by immediately repeating the same call.
SearchResult fields
| Field | Type | Description |
|---|---|---|
memory | object | Full memory object. |
score | float | The value results are sorted by, 0.0–1.0. On a reranked response it is rerank_score, scaled by 0.7 if a newer memory supersedes this one and by up to ±5% under the recency profile, then capped at 1.0 (sorting uses the uncapped value). On an unreranked response it is the RRF score, which stays small (about 0.016 to 0.05 for top hits). Scores from the two kinds of response are not comparable. See Reading scores. |
rerank_score | float | null | The reranker's raw relevance probability, 0.0–1.0. null when the response was not reranked. min_score filters on this field. |
rrf_score | float | The score from fusing the keyword, semantic, and graph lists, before reranking. |
low_confidence | boolean | true when the only way this memory was found was the keyword lookup's last-resort tier: a plain substring match of the query's words against memory text, tried when full-text and title matching find nothing. Treat these as leads to check, not confirmed matches. The flag does not remove or reorder results, and one response can mix low_confidence results with strong semantic matches. |
keyword_score | float | Reserved for future use; currently always 0.0. |
semantic_score | float | Reserved for future use; currently always 0.0. |
match_type | string | Which component(s) contributed: "keyword", "semantic", or "combined". |
Full-memory responses also carry matched_fields, snippet, supersession_trail, and lineage on each result.
Reading scores
score and rerank_score mean different things depending on whether the response was reranked. Check rerank_score first.
| Response | rerank_score | score | min_score |
|---|---|---|---|
| Reranked (paid plans, reranker healthy) | Raw relevance probability | Relevance-sorted value derived from rerank_score | Drops results whose rerank_score is below it |
| Unreranked (Free plan, or the reranker failed or was skipped) | null | RRF score, roughly 0.016–0.05 for top hits | Has no effect; every candidate is kept |
On the Free plan the response is unreranked by design and degraded stays false. When a paid-plan reranker failure forces the fallback, degraded is true and degradation_reason is "reranker_error". A response is either fully reranked or fully unreranked. Candidates the reranker did not score are dropped from a reranked response instead of being appended after the scored ones.
Reranker scores cluster at the extremes. On Neuroloom's own evaluation corpus (hybrid search), memories judged relevant to a query had a median rerank_score of about 0.97 with most above 0.9, and unrelated memories had a median of about 0.0005 with 75% below 0.007. The distribution is close to bimodal, so few results land in a "maybe" middle. Your corpus and queries will differ; treat these as a starting point, not a guarantee.
min_score gates rerank_score, not score, so a result can pass min_score and still show a lower score. A superseded memory with rerank_score: 0.06 has score: 0.042 and passes min_score: 0.05.
Don't threshold score with a fixed cutoff such as > 0.7 unless rerank_score is non-null. An unreranked response has score values near 0.02, so that filter returns nothing. Free-plan workspaces always get unreranked responses.
Pagination and the 50-result cap
offset skips candidates in the fused (pre-rerank) order, then the reranker scores the next 50 and the response returns the top limit of them. Two consequences:
- A reranked response never holds more than 50 results, whatever
limitis. Setlimitabove 50 and you still get at most 50. - Each page is reranked independently, so a memory can appear on two pages or on neither. Don't rely on
offsetfor a stable walk through every match; use narrower filters or a largerlimit(up to 50) instead.
An unreranked response has no 50-result cap; limit applies as written.
Batch Search
POST /api/v1/memories/search/batchRun several queries in one request. The server embeds all queries in one call and searches them concurrently, which is faster than N separate requests.
curl -X POST "https://api.neuroloom.dev/api/v1/memories/search/batch" \
-H "Authorization: Token $MEMORIES_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"queries": [
"database connection pooling strategy",
"async session configuration"
],
"limit": 5,
"min_score": 0.05
}'| Field | Type | Required | Default | Description |
|---|---|---|---|---|
queries | array of strings | Yes | — | 1 to 500 queries, each 1 to 2000 characters and non-empty after trimming whitespace. The number of queries per request is capped by plan: 10 (Free), 50 (Pro), 200 (Team), 500 (Enterprise). |
search_profile | string | No | "default" | Applied to every query. |
limit | integer | No | 20 | Max results per query (1–200). |
min_score | float | No | null | Same meaning as in single search; applied to every query. |
similarity_threshold | float | No | null | Applied to every query. |
include_aggregations | boolean | No | false | Include facet counts per query. |
The response always returns 200 OK with one entry per query, in request order. Each result has the same shape as a single-search result (the example below abbreviates the memory object). A failed query has success: false, results: null, and an error string such as "query_timeout" or "batch_timeout"; the other queries still return results.
{
"batch_size": 2,
"success_count": 2,
"failure_count": 0,
"results": [
{
"query_index": 0,
"query": "database connection pooling strategy",
"success": true,
"results": [ { "memory": { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "title": "Use connection pooling for all DB connections" }, "score": 0.96, "rerank_score": 0.96, "rrf_score": 0.0325 } ],
"error": null
}
]
}Batch responses do not include degraded or degradation_reason, and low_confidence is not populated (it is always false). Check rerank_score on each result: null means that query's results were not reranked, and score is then on the RRF scale (see Reading scores). Use single search when you need the degradation signals.
Common Search Patterns
Find memories by file
Surface decisions and patterns captured while working on a specific file. query is required — pair the files filter with a real query describing what you're looking for in that file, rather than leaving it empty:
curl -X POST "https://api.neuroloom.dev/api/v1/memories/search" \
-H "Authorization: Token $MEMORIES_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"query": "routing and search endpoint changes",
"files": ["api/neuroloom_api/routers/memories.py"],
"limit": 20
}'If you only have a file path and no query text in mind, use GET /memories/ with a tags or memory_type filter instead — files isn't a supported filter on the list endpoint today, but a broad query (e.g. the file's module name) plus the files filter above works well in practice.
Keyword-only search
Disable semantic ranking for exact phrase matching:
curl -X POST "https://api.neuroloom.dev/api/v1/memories/search" \
-H "Authorization: Token $MEMORIES_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"query": "HNSW ef_construction",
"use_semantic": false,
"limit": 10
}'High-importance decisions only
Retrieve only well-established decisions above an importance threshold:
curl -X POST "https://api.neuroloom.dev/api/v1/memories/search" \
-H "Authorization: Token $MEMORIES_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"query": "database schema migration strategy",
"memory_types": ["decision", "architecture"],
"min_importance": 0.8,
"limit": 5
}'Filter by tags
Pair tags with a real query — query is required and rejected with 422 if empty or whitespace-only, so a tags-only request against this endpoint is no longer possible. For a pure tag filter with no query text, use GET /memories/ or the MCP tool memory_get_index(tags=[...]) instead. Both require every listed tag.
Narrow a search to memories tagged with specific labels. By default a memory needs any one of the tags:
curl -X POST "https://api.neuroloom.dev/api/v1/memories/search" \
-H "Authorization: Token $MEMORIES_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"query": "HNSW index tuning",
"tags": ["pgvector", "performance"],
"limit": 20
}'Set tags_match to "all" to require every tag:
curl -X POST "https://api.neuroloom.dev/api/v1/memories/search" \
-H "Authorization: Token $MEMORIES_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"query": "HNSW index tuning",
"tags": ["pgvector", "performance"],
"tags_match": "all",
"limit": 20
}'Semantic Search Fallback
This is different from the degraded / degradation_reason response fields — this section covers missing/not-yet-generated embeddings (see Response above for the transient-failure case).
When semantic search is enabled (use_semantic: true) but embeddings are unavailable — either because no embedding provider API key is configured or because a memory's embedding has not yet been generated — the endpoint falls back to keyword-only ranking. Results are still returned; only the scoring changes.
To check whether a memory has an embedding, inspect whether pagerank_score is non-null (both are populated by the same background job). Embeddings are generated asynchronously after memory creation, typically within seconds.
Error Responses
| Status | When |
|---|---|
401 Unauthorized | Missing or invalid Authorization header. |
422 Unprocessable Entity | Invalid parameter value (e.g. limit > 200, min_importance > 1.0), or query missing, empty, or whitespace-only. |
{
"detail": [
{
"loc": ["body", "limit"],
"msg": "Input should be less than or equal to 200",
"type": "less_than_equal"
}
]
}A whitespace-only query returns:
{
"detail": [
{
"loc": ["body", "query"],
"msg": "Value error, query must not be empty or whitespace-only. Use GET /memories/ for filter-only browsing.",
"type": "value_error"
}
]
}Related
- What is a Memory — memory types, fields, and data model
- Memory Lifecycle — how importance scores affect search ranking
- Knowledge Base Ingestion — bulk ingestion patterns for semantic search