Querying¶
Semantic search¶
The default query path embeds your question, runs cosine similarity against ACTIVE particles, and synthesises a natural-language answer with citations:
Results are ranked by a combined score of cosine similarity and
effective_confidence (stored confidence × extractor trust × source
trust × age decay). See Concepts → confidence.
To ask what the store believed at a past instant, and see what replaced
each since-retired belief and when, pass --as-of. See
As-of time travel for the full walkthrough.
Seeing the source passage¶
Every belief links back to the source it was extracted from. To read that
source text next to the answer, add --show-source:
After the answer, the top hits are listed with the passage behind each. For a single belief, use its ID:
The passage is re-derived from the stored snapshot each time it is shown: nothing extra is stored at ingest, and the original file or URL is never re-read, so anything removed before archiving stays removed. Each passage is labelled with how it was found, and the labels are not interchangeable:
| Label | Meaning |
|---|---|
exact |
The chunk the extractor was shown, verified against the hash recorded on the belief's provenance. This is a quote-level guarantee. |
located |
The paragraph (or line) sharing the most terms with the belief, with the share shown as a percentage. A reading aid, not verification. |
whole source |
No single passage stood out, so the source text is shown instead of a guess. |
unavailable |
There is nothing to show, and a note says why: the belief derives from other beliefs, the stored content is missing, or the source is an image. |
Expect located for short documents. Only a source long enough to be
extracted in several chunks records a per-chunk hash; a note, a memory file,
or a short page is extracted in one pass and records none. A recorded hash can
also stop matching if extraction.html_chunk_size has changed since the
belief was extracted; the passage then falls back to located and a note
says so.
The passage is for reading only. It is fetched after the results are ranked
and never influences which beliefs are retrieved or how they are ordered, and
the located percentage is not a confidence. --show-source is not available
together with --store federation; run particle source against the store
that holds the belief. Over HTTP the same result is served by
GET /particles/{particle_id}/source.
Three settings under source_passage in config.yaml tune the display:
locate_min_overlap (the share of the belief's terms a paragraph must contain
to be offered, default 0.5), max_passage_chars (default 6000), and
query_show_limit (how many top hits --show-source covers, default 5).
Grounded answers¶
The answer is composed by a model from the retrieved beliefs, and a model adds things of its own: a motive, a status, a conclusion, now and then a fact it learned elsewhere. In the default answer that addition reads exactly like the recalled beliefs around it. A grounded answer makes it visible instead:
Every sentence ends with a tag saying where it came from:
| Tag | Meaning |
|---|---|
[p-1a2b3c4d] |
The retrieved beliefs the sentence rests on, by ID. particles particle show p-1a2b3c4d opens one. |
[inference] |
No belief states it. The model drew it over the cited beliefs: a motive, a status, a conclusion, or a remark about what the store holds. |
[background] |
It comes from the model's own general knowledge and from no belief at all. |
[unattributed] |
The model gave no tag, or cited only IDs it was never given. |
The model declares the labels and the query checks the citations. Every cited
ID is matched against the beliefs actually retrieved for this answer; an ID
outside that set is never trusted, a warning on stderr reports it, and a
sentence left with no valid citation is labelled [unattributed]. Nothing is
dropped: a labelled sentence is still in the answer, so you can weigh it
yourself. Nothing the model adds is ever stored as a belief either. The
labels exist on this one response and nowhere else.
Below the answer, an attribution line counts the sentences of each kind and lists each cited belief once under the ID the answer uses:
Attribution: 3 cited · 2 inference · 0 background · 0 unattributed
[p-10cdc518] The token caps have been changed to 4096 for answer and 1024 for judge.
[p-f548e2f1] On models with adaptive thinking by default, thinking spend counts …
A refusal is unchanged: when nothing retrieved bears on the question, the answer says so and carries no tags.
Grounded mode is off by default. Set query.grounded_answers: true in
config.yaml to make it the default for every surface, and use
--grounded or --ungrounded to choose per query. Over HTTP the request field
is grounded and the response carries the per-sentence labels and full cited
IDs in answer_attribution; the MCP query tool takes the same grounded
argument. On the owner's store it lowered the
share of silently unsupported sentences only from about 25% to about 22%
(see the leakage benchmark),
which is why it is not on by default.
Creating a taxonomy¶
Before you can filter by tag, a taxonomy has to exist and particles have to be tagged. A taxonomy is a JSON file you deposit like any other source; there is no special command.
Author the file. Tag paths use / as the hierarchy separator; a root
tag has parent: null, and every child's parent must equal its own
path with the last segment stripped:
{
"name": "cloud-native",
"version": "0.1.0",
"author": "jeff",
"domain": "Cloud-native infrastructure and platform engineering",
"tags": [
{ "tag": "cloud-native", "parent": null, "aliases": ["cncf"] },
{ "tag": "cloud-native/observability", "parent": "cloud-native", "aliases": ["monitoring", "o11y"] },
{ "tag": "cloud-native/orchestration", "parent": "cloud-native" }
]
}
Deposit it. The local-file importer recognises the top-level
name / version / tags keys and tags the corpus entry with
source_type = TAXONOMY_DEFINITION automatically:
uv run particles deposit ./cloud-native-taxonomy.json
# entry_id: 10869035-...
# snapshot_id: 09a0ab0b-...
Extract to materialise it into the queryable tag tables:
0 particles is expected and correct: a taxonomy is configuration,
not a set of knowledge claims, so the TaxonomyExtractor populates the
taxonomies / tag_nodes tables and produces no particles. Confirm it
landed with the list_taxonomies MCP tool.
Tagging particles¶
Tags arrive on particles manually (Phase A). Each --tag is validated
against the active taxonomies; --tag is repeatable:
uv run particles particle tag <particle-id> \
--tag cloud-native/observability
uv run particles particle untag <particle-id> \
--tag cloud-native/observability
Use --force to apply an ad-hoc tag that isn't in any active taxonomy.
Tag filters¶
Apply taxonomy tags to constrain the search to a subtree. Tags are repeatable:
uv run particles query "What changed in 1990?" \
--tag "numismatics/germany" \
--tag "history/cold-war"
A tag-filtered query restricts the candidate pool before embedding similarity: fewer particles, sharper results.
Expansion is subtree-only: --tag cloud-native matches particles
tagged cloud-native/observability (a descendant), but --tag
cloud-native/observability does not match a particle tagged at the
bare cloud-native root. Tag at the most specific level you'll want to
query.
Structural claim filters¶
Since many particles carry a derived S-P-O structured claim
annotation beside their prose; the structured filter makes it readable on the same
query verb. Getting and maintaining those annotations (particles structure,
coverage, what to do about a wrong triple) is
Operator guide → structured claims. Structural conditions select claims by their form, not
truths: the output speaks of claims throughout, and counts count
claims, never entities.
Which flags make a query deterministic? Any query with structural
flags and no question (including --count, --group-by, and
--predicates) runs with no embedding and no LLM call: results are
matching claims ordered by effective_confidence (tie: newest
asserted_at first), and the aggregates are exact. Add a question and
the flags instead prefilter the ordinary semantic path. Cosine
ranking, confidence composition, and the LLM answer are untouched; the
filters only narrow the candidate set, exactly like --subject and
--tag.
Discover the vocabulary first; the predicate filter is an exact string (case-insensitive), so a CURIE and its expanded IRI are different terms:
Filter and compare (deterministic, since no question is given):
Objects compare typed: numeric xsd datatypes normalize to numbers,
xsd:date / xsd:dateTime to dates, at read time (nothing is stored).
There is no unit parsing: an untyped "3 grams" does not compare to
3.0; such claims are excluded from --object-gt / --object-lt and
the exclusion count is disclosed in the footer, never silently
dropped. --object-eq falls back to case-insensitive text when either
side doesn't normalize; --object-contains is substring match on any
term kind.
Deterministic aggregates (a simultaneous question is rejected):
uv run particles query --predicate nmo:hasWeight --count
uv run particles query --predicate nmo:hasWeight --group-by subject
Counts come with the effective-confidence min/median/max of the counted
rows. There is no default confidence floor; pass
--min-effective-confidence to exclude rows explicitly (the exclusion
is disclosed). --group-by subject groups by the resolved Subject
(claim.subject_id, falling back to the particle's subject links).
Counting the subject buckets, not the claims, is the honest answer to
"how many coins".
Every structural result ends with a coverage footer ("matched against
the N of M ACTIVE particles carrying a structured claim") because the
filter only ever saw the annotated fraction of the store: absence of a
hit is not absence of a belief. All of this composes with --store,
--as-of, --tag, --subject, and --min-confidence unchanged.
MCP server¶
For LLM agents that want to query the store directly, Particles ships a read-only MCP server:
The read tools are: query, particle_show, particles_list,
particle_search, subjects_list, subjects_search, subjects_show,
lint and quality_report,
list_corpus_entries, list_taxonomies,
links_suggest,
corpus_links_suggest,
events_list / event_show, and
graph_view.
These are read-only: they cannot deposit, extract, or change particle
status. A store can additionally be write-enabled which adds
the assert / supersede / retract / deposit tools.
Connecting your coding agent has the config block for each harness (Claude Code, Claude Desktop, Codex, Cursor, Windsurf, Zed, OpenCode) and says which of them can go further than tools. Two such routes are worth knowing about: the Claude Code integration wires a store in as managed agent memory rather than as a tool an agent must choose to call, and the memory-server swap presents the store through the reference knowledge-graph memory server's own tool contract.
Ranking: what the SDK actually does¶
- Embed the question. SentenceTransformers (default
all-MiniLM-L6-v2). - Filter to ACTIVE particles, optionally constrained by tag / subject.
- Cosine similarity between the question embedding and each
particle's embedding. Top-k truncation (default 40; configurable
under
query.default_top_kinconfig.yaml). - Rank by combined score:
similarity_weight× cosine similarity +confidence_weight×effective_confidence(defaults 0.6 / 0.4, §9.3). Particles whoseeffective_confidencefalls belowmin_confidence(default 0.0) are dropped. - Relevance floor: if the best raw cosine similarity
over the rendered top-k falls below
query.relevance_floor(default 0.25, calibrated to the reference embedding model;0.0disables), the answer is a deterministic "the store holds no beliefs relevant to this question", with no LLM call. The nearest beliefs are still returned, labelled as likely unrelated, and the structuredrelevancefield on the response carriesmax_similarity/floor/below_floor. Disclosure only: the floor never changes ranking, confidence, or filtering. - Synthesise a natural-language answer with citations. Audience
tier (
EXPERT,GENERAL,REGULATORY) shapes the prose; defaultGENERAL. - Coverage gap disclosure: if no particles meet the threshold, the response says "the store doesn't contain relevant evidence" rather than confabulate.
The contested badge¶
Every query result carries a composed contested badge by default: a claim renders contested when at least one of three named bases fires, and the badge always names which.
| Basis | Fires when |
|---|---|
stance |
someone is on record disputing the claim: a DISPUTES edge in its query-time stance distribution. Endorsements alone never fire; when this basis fires the badge carries the unverified-holder caveat. |
divergence |
the claim's effective confidence spreads across your trust policies (local + adopted lenses) by at least contestedness.callout_threshold. Absent (not merely quiet) until you have two or more policies. Adopting a second policy is Operator guide → lenses. |
inconsistency |
an open INCONSISTENCY particle references the claim; the badge keeps that particle's id as the drill-down. Clearing it is the review workflow. |
The CLI prints one ⚠ contested (…) line per badged result;
--contestedness still prints the full per-policy readings, and the
agreement distribution stays behind its own opt-in. A claim with no
basis fired carries no badge; absence of measurement is never rendered
as "uncontested". The badge is disclosure only: it never changes
ranking, confidence, or filtering. Disable it with
contestedness.badge_enabled: false in config.yaml. The same badge
appears in the session-start digest, the MEMORY.md projection, the
graph view, and the
MCP query / particles_list responses (as contested_bases, beside
the existing INCONSISTENCY-id contested key).
Tuning the ranking¶
Operator-side concern; see the operator guide → tuning
for min_confidence, query.top_k, trust weights, and audience
defaults.