Metadata discovery - #1
Draft
aaronccasanova wants to merge 5 commits into
Draft
aaronccasanova wants to merge 5 commits into
aaronccasanova wants to merge 5 commits into
Conversation
Adds listMetadata() to src/metadata-store.ts, the one store function every discovery surface will call: - Summarizes keys, per-type coverage, distinct counts, windowed value counts with an exact remainder, numeric min/median/max, array-ness, and contributing collections over the documents in scope. - Reuses the filtered-search eligibility gate (active, current extraction, no error) plus collection scope and the compiled filter AST, so every value discovery reports is one an eq filter can match. - Returns ListMetadataResult with the active-document denominator and, when a filter is given, how many documents pass it. - key and value are picomatch patterns. String values are pruned in SQL with a GLOB superset of the pattern before picomatch confirms, so high-cardinality keys never round-trip every distinct value to JS. Lists bind as one JSON parameter through json_each. Covers counting semantics, the gate, scope, filter narrowing, both patterns, window/sort/minCount, medians, type splits, attribution, ordering, the GLOB/picomatch superset contract, and agreement with searchFTS under an eq filter. Assisted-by: Claude Fable 5.1 via Pi
Adds the CLI surface for metadata discovery so an agent can learn what to filter on before it queries: - `qmd collection metadata [name...]` prints one block per key: a header with type, coverage over the active documents in scope, and distinct count, then a value window. Strings list vertically with right-aligned counts, numbers show min/median/max plus the values when they fit, booleans print true/false counts. Keys whose documents disagree on type split per type, with contributing collections in the multi-collection view. Every truncated list ends with the remainder and `-n <num> or --all`. - `--key` and `--value` are picomatch patterns selecting the region to describe, `--filter` counts only matching documents (same AST as search), `-n`/`--all` size the window, `--sort count|value` orders it, `--min-count` drops the tail. Omitting the name covers the default collections, as an unscoped search does. - src/metadata-format.ts holds the renderer so the MCP tool can print the same shape. It takes the CLI's color palette or none. - The pending-extraction warning prints on stderr whenever documents are gated out, since discovery always applies the gate. Covers the keys view, truncation footer, -n/--all, reverse lookup, filter header, sort and min-count, default scope, empty matches, and exit codes for unknown collections and invalid flags. Assisted-by: Claude Fable 5.1 via Pi
Makes metadata visible from the commands a user or agent already runs first, so the drill-down is discoverable without knowing it exists: - `qmd collection list` adds a `Metadata:` line per collection naming the top five keys by coverage and counting the rest (`+N more`). Omitted when the collection has no metadata, like `Ignore:`. - `qmd collection show` adds a `Metadata` section: a coverage line (keys, documents with metadata, pending extraction), then the top five keys as aligned rows with type, coverage, distinct count, and a short value preview (top three strings, numeric range and median, boolean counts, or a pointer when types disagree), then a pointer at `qmd collection metadata <name>` when keys were left out. - `qmd status` adds one summary line under Documents, placed with the existing pending-extraction line so the two read together. - src/metadata-store.ts gains the light queries these views need: `listMetadataKeys()` (names, coverage, types; one GROUP BY, no values), `countDocumentsWithMetadata()`, and an optional collection scope on `countDocumentsPendingMetadata()`. Assisted-by: Claude Fable 5.1 via Pi
Ships discovery on the remaining surfaces with the same options object and result shape the CLI uses: - SDK: `listMetadata(options?)` on QMDStore under Collection Management, next to listCollections(). Filters are validated at the runtime boundary like the search methods. Discovery types are re-exported from the package root. - MCP `metadata` tool: flat and read-only, taking `collections`, `key`, `value`, `filter` (validated like the query tool), `limit`, `sort`, and `minCount`. The description teaches the key/value pattern composition table and how to read `remaining` and `range`. Text content renders the CLI shape through the shared formatter with "a higher 'limit'" as the escape hatch; structuredContent is the ListMetadataResult. - MCP `status`: CollectionInfo gains `metadataKeys` (names, coverage, types from listMetadataKeys()), mirrored in StatusResult and listed in the text summary with a pointer at the metadata tool, so the call agents make first reveals that metadata exists. - HTTP `POST /metadata`: same body as the tool, 400 on a non-object body, non-object or invalid filter, or bad sort. Returns the ListMetadataResult as its own envelope. Logged like POST /query. Updates the exact tool-list assertions in test/mcp.test.ts and covers each surface in test/metadata-surfaces.test.ts, including the 400 paths and status structured content. Assisted-by: Claude Fable 5.1 via Pi
aaronccasanova
force-pushed
the
feature/metadata-discovery
branch
from
September 13, 2026 00:53
49b1d44 to
c61fffc
Compare
- README: a "Metadata Discovery" subsection after "Metadata Filtering" with the mental model (patterns select where to look, filter selects what is counted), the six-row composition table, a wide-to-narrow walkthrough ending in a validated query, exact CLI output for string, number, boolean, and type-conflict keys, the reading rules, and the SDK method, MCP tool, and HTTP route. Also the new subcommand in the collection commands block, the `metadata` tool parameters, and the `POST /metadata` endpoint. - CHANGELOG: one entry under Unreleased / Added. - skills/qmd/SKILL.md: a "Discover metadata before filtering" section written for an agent deciding what to filter on: how to read the header, the truncation footer, numeric ranges, and type splits. - CLAUDE.md: `qmd collection metadata` in the command table and the Collection Management block. Examples use the status, topics, priority, and reviewed keys the filtering docs already establish, so the two sections read as one. Assisted-by: Claude Fable 5.1 via Pi
aaronccasanova
force-pushed
the
feature/metadata-discovery
branch
from
September 13, 2026 01:08
c61fffc to
9cfc2f0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to tobi#910. That PR let documents declare typed metadata and every search surface filter on it. This one closes the loop: every surface can now report the metadata keys, types, and value counts already in the index, so a filter can be written from what is indexed instead of guessed.
Every value shown is one a filter will match, and the counts say how many documents each condition reaches:
The same discovery is available on the SDK (
listMetadata()), the MCP server (ametadatatool, plus key names instatus), and HTTP (POST /metadata), with the same options and the same result shape. It reads the tables tobi#910 created, so there is no re-indexing, no schema change, and no new dependency.The motivating use case is the same one that motivated filtering: agentic retrieval. A filter is only useful if the caller knows which keys exist and what values they hold, and an agent working against an index it did not build has no way to learn that today except guessing or reading files one by one. Discovery gives it a first call that reveals the dimensions and a second call that validates a filter before it ever runs a search.
What this adds
qmd collection metadata [name...], a drill-down with--key <glob>and--value <glob>to select a region of the key/value space,--filter <json>to count only documents matching a filter, and-n/--all,--sort count|value,--min-count <n>to shape the value window per key.qmd collection listnames each collection's top keys,qmd collection showdetails them with a value preview, andqmd statussummarizes coverage.listMetadata(options)on the SDK, ametadatatool on the MCP server,POST /metadataon the HTTP server, and per-collection key names and types in the MCPstatustool so an agent's first call already reveals that metadata exists.listMetadata()insrc/metadata-store.ts) and one renderer (src/metadata-format.ts) behind every surface, so the CLI and the MCP tool print the same text and every structured surface returns the same object.What this deliberately does not change
document_metadata_valuesand its existing covering indexes.multi-getalready uses.--format jsonon collection commands (deferred in Add metadata support tobi/qmd#910, still deferred). Structured output lives on the SDK, MCP, and HTTP.qmd.metadata.The mental model
Two globs select where to look, and a filter selects which documents are counted. They compose:
--key--valuetopicsmem-*docs-team2025-*topicstype*Adding
--filterto any row shows what remains after narrowing. The header then reports how many documents pass, which is the number you want before committing to that filter in a query:qmd collection metadata notes --key topics --filter '{"key":"status","operator":"eq","value":"published"}'A reverse lookup returns every key that holds a value, here a scalar key and an array key:
Reading the output
Four rules, each observable in tests:
topics: [a, b]contributes one to each. Coverage is "documents declaring this key".remaining, never left for the caller to infer.gt/ltthreshold in one call.Across collections, each type also names where it comes from:
Design notes
The output shapes came out of a survey of how Elasticsearch terms aggregations, Weaviate aggregate queries, and Postgres
pg_statsreport cardinality, truncation, and distributions. The retrieval side reuses tobi#910's machinery rather than adding a parallel path. Details collapsed below.One options object and one result on every surface
The result carries its own denominator so the "N of M documents" header can be computed on every surface without a second call, and so the HTTP route can return the object as its own envelope, identical to the MCP
structuredContent. Scope resolution is the caller's job, mirroring search: the CLI resolves omitted names to the default collections, MCP and HTTP fall back to their existing default collection list, and the SDK passes what it was given.minCountscopes onlyvalues,distinctValues, andremaining. Per-typedocuments,multiValued,range, andcollectionsdescribe every matching value so coverage stays honest when the long tail is hidden. Median is computed over value rows (each element of anumber[]counts), following Weaviate's aggregate semantics.Value patterns at high cardinality
The motivating corpus for
--valueis an agent memory collection with a very large distinct-value set, where post-filtering every value in JavaScript is the wrong default. picomatch is the only dialect the user sees, but when a pattern contains only literals,*, and?(none of\ ( ) [ ] { } | ! + @, no**, no./), it is also a valid SQLiteGLOBwhose match set is a provable superset of the picomatch match set. String candidates are pruned with thatGLOBinside the query and picomatch confirms every survivor, so the pushdown can only ever remove rows picomatch would have rejected anyway. Numbers and booleans skip the prefilter because SQLite renders REALs differently fromString(number). Patterns needing braces, extglobs, or negation fall back to picomatch alone. Tests assert the superset property on the pushed subset and the fallback on the rest.Keys are matched in JavaScript over the small distinct key set, and both matched keys and matched values bind into the aggregates as one JSON parameter (
json_eachand aMATERIALIZEDCTE), so the SQLite variable limit never applies.Same gate, same scope, same compiler
Discovery builds one eligible-documents CTE with exactly the predicate filtered search uses (
active = 1, currentextraction_version, noextraction_error), the optional collection scope, and the optional compiled filter fromcompileMetadataFilter(). Every aggregate joinsdocument_metadata_valuesto that CTE. This is why the PR can make the guarantee that every value discovery reports is matchable withequnder the same collections: a test asserts it by runningsearchFTSwith aneqfilter on each reported value. Documents still pending extraction are counted in the denominator, excluded from the aggregates, and reported on stderr the way filtered search already reports them.The status views (
collection list,collection show,qmd status, the MCPstatustool) use a lighterlistMetadataKeys()(oneGROUP BY key, value_type, no values) so a first look stays cheap. Onlycollection showcallslistMetadata(), withlimit: 3, because its rows preview values.Performance
On a synthetic index of 10,000 documents carrying 60,000 metadata value rows,
listMetadata()runs in 90-250 ms per call depending on options, served by the covering partial indexes tobi#910 added. No new indexes were needed and no query plan work was done. The status views are a singleGROUP BYover the same indexes.Testing
64 new tests across three suites, green under both Node (vitest) and Bun:
test/metadata-discovery.test.ts, 39): counting, the extraction gate, scope and filter narrowing, key and value patterns including theGLOBsuperset property,remaining, sorts,minCount, ranges and medians, type splits within and across collections, and the discovery/filtering agreement test.test/metadata-cli.test.ts, 15): every flag through a spawnedqmd, byte-exact rendering of each key type and the type split, thelist/show/statuslines, and exit codes and messages for every invalid input.test/metadata-surfaces.test.ts, 10): the SDK method,getStatus()keys, the MCPmetadataandstatustools, andPOST /metadataincluding every400path.Full suite: typecheck, lint, 1310 Node tests, 1310 Bun tests, and the package smoke all pass. The five commits build in dependency order (store, CLI drill-down, status views, SDK/MCP/HTTP, docs) and read best one at a time.
Limitations and future work
rangeis the place to grow it compatibly.--value '2025-*'finds them but nothing orders or ranges them as dates. That follows Add metadata support tobi/qmd#910's first-version restriction and can be relaxed there first.multiValuedmeans "some document holds more than one value". A key whose every array has one element is indistinguishable from a scalar key at the storage level and reports as scalar.--format jsonon collection commands remains deferred. The structured result is available today from the SDK, MCP, and HTTP.Related work