You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
My goal is to allow filtering by tag and by date range. To do that we allow documents to be submitted with an optional array of tags, an optional start time and an optional end time. Tags and start end dates propagate by taking the union of the documents values to knowledge base entries and relations.
Filtering by tag while doing vector lookup can very likely be supported by: Qdrant, Milvus, PostgreSQL, MongoDB, OpenSearch. Others will need to overfetch and filter.
Querying is modified to support only returning chunks and knowledge base entries and relationships that have overlap, are contained or are a superset of the query daterange. Queries without required / forbidden tags and date ranges return the same data as they do now.
The full plan also adds a json metadata field that's not queryable and is transparantly returned when querying but it's not needed for this change.
It is often useful to be able to query a dataset while explicitly not taking into account certain information. An example is querying a knowledge base about a book based on what was known in which chapter.
My proposal tags documents as described above but allows configuring tag prefixes as scopes. Knowledge graph generation happens as normal but when a knowledge entry or relation gains an addition that is tagged with a scope it is also added to the entry with the same name in that scope (created if it doesn't yet exist).
Querying with a scoped in the required tags then only considers the knowledge base entries and relations for that scope.
For example if you have two scope prefixes (known-in-chapter: and language:) and you have a two chapter book in english and spanish you would tag:
Querying with required language:es would give you only the conclusions you could take from the spanish book. All chapters would be included in the knowledge graph results.
Querying with required known-in-chapter:01 would only give you knowledge graph items with conclusions you could take from the first chapter of both languages.
Canonical merged plan — upload metadata, nested DateFilter + tag filters, KG union-interval denormalization with kg_union_skip_open_bounds (skip missing bounds instead of ±∞), opt-in vector pushdown (default off), evidence-level KG filtering with optional apply_forbidden_tags_to_kg (default off), relationship dates/tags from relation_chunks evidence, and full WebUI.
todos
id
content
status
models-storage
Add DocumentMetadata model, user_* keys on carry-over AND directive whitelists, DocStatusResponse mapping, API limit constants
pending
id
content
status
upload-plumbing
Wire metadata through upload/text/texts, apipeline_enqueue_documents; ainsert v1 shared metadata only (optional metadata applies to all docs in call)
pending
id
content
status
query-params
Add DateFilter, tag filters (normalize at validation), apply_forbidden_tags_to_kg to QueryRequest/QueryParam and REST routes; Ollama chat path out of scope v1
pending
id
content
status
filter-engine
Implement query_filters.py (has_active_filters fix, orphan-chunk rule), doc/chunk KG post-filter, recompute hooks on all entity_chunks/relation_chunks writers, answer-cache filter hashing
Upload metadata form, DateFilter controls with presets, apply_forbidden_tags_to_kg toggle, document list display (tags, dates, meta.href link), API types, i18n, Bun tests
pending
id
content
status
tests
API, pipeline (carry-over + FAILED→PENDING retry), query-filter, KG recompute, pushdown, WebUI tests; run targeted subsets
pending
isProject
false
Document Metadata and Query Filtering
Scope
In scope
Upload metadata on all three ingest paths: POST /documents/upload, /text, /texts
Query filters on POST /query, /query/stream, /query/data: nested date_filter, required_tags, required_tags_mode (and | or), forbidden_tags, apply_forbidden_tags_to_kg
SDK/pipeline plumbing via apipeline_enqueue_documents
Persistence through pipeline status transitions and API readback via DocStatusResponse
Opt-in vector-backend pushdown for chunks_vdb and (when enabled) entities_vdb / relationships_vdb (default off)
KG union-interval denormalization of dates/tags on entities and relations, recomputed on merge/purge
kg_union_skip_open_bounds config (default true) — missing contributor bounds skip union min/max instead of widening to ±∞
KG query filtering — evidence-level post-filter (default); optional union pushdown as coarse gate
Per-backend schema/migration notes and compatibility when pushdown is disabled or schema not upgraded
Full WebUI — upload metadata form, query filter controls with archive presets, document list display, API client types, i18n, Bun unit tests
Out of scope (v1)
Query filtering on user_meta / meta — roundtrip only
PATCH /documents/{id}/metadata and post-upload metadata edits (see Re-upload policy below)
Metadata on scan-discovered files without upload metadata
starts_in / ends_in range modes
Ollama-compatible chat query path (ollama_api.py builds QueryParam separately) — no date_filter / tag filters in v1; REST /query* only
Per-document metadata on ainsert list input (shared blob only — see SDK metadata)
Backfill/migration of filter fields on pre-existing chunks or KG rows (see Existing corpora)
Storage contract
Store user metadata on doc_status.metadata under dedicated, namespaced keys:
Key
Type
Notes
user_start_date
ISO-8601 string with offset or absent
Optional document effective start; timezone-aware; offset preserved at rest
user_end_date
ISO-8601 string with offset or absent
Optional document effective end; timezone-aware; offset preserved at rest
user_tags
list[str]
Normalized lowercase at write time; may be empty
user_meta
dict
Arbitrary JSON object; roundtrip only — never queryable
Upload without dates is valid — zero, one, or both bounds may be present. When a bound is present, it must be timezone-aware.
Timezone policy (all surfaces)
Principle: all datetimes are timezone-aware on input and in storage. Do not convert to UTC for persistence — future calendar dates in a named offset (e.g. legislation effective 2030-03-30T00:00:00+01:00) cannot be reliably re-projected through UTC when DST rules change. Comparisons use instant semantics at query time only.
Surface
Rule
Upload API (DocumentMetadata)
Non-null dates must be timezone-aware; reject naive → 422
Query API (DateRangeFilter)
Same
Storage (user_* keys)
ISO-8601 strings with offset preserved (Z / +00:00 allowed; +01:00 etc. kept as submitted). Never store naive.Never astimezone(UTC) before write.
SDK (ainsert / apipeline_enqueue_documents)
Optional shared metadata; datetimes must be aware; strings must include offset or Z
WebUI
Date pickers emit timezone-aware ISO with explicit offset (operator's chosen zone or Z — not naive, not offset-stripped)
KG denormalized fields (filter_start_date / filter_end_date)
Same offset-preserving storage; union min/max picks the contributing doc's stored string for the winning bound
Interval / ordering checks
Compare aware datetimes by instant (timestamp() or equivalent); no UTC conversion required for storage
No implicit local timezone anywhere in the pipeline.
Survives ordinary status transitions (PARSING → PROCESSING → PROCESSED/FAILED, etc.)
_DOC_STATUS_METADATA_DIRECTIVE_KEYS
Survives FAILED→PENDING reset and interrupted-doc normalization via doc_status_reset_metadata()
Carry-over alone is insufficient. Reset keeps only directive keys; without them, user metadata is wiped on manual retry.
Regression test (required): upload with metadata → force FAILED → manual retry (/documents/reprocess_failed or scan retry) → user_start_date / user_end_date / user_tags / user_meta still present on doc_status.metadata and DocStatusResponse.
Expose in DocStatusResponse as top-level start_date, end_date, tags, meta.
start_date <= end_date when both present — compare by instant (both must be timezone-aware)
Timezone-aware only: every non-null start_date and end_date must be timezone-aware — reject naive datetimes with HTTP 422 ("Date must include a timezone offset or Z"). Naive values are never passed through or stored.
Accept ISO-8601 with explicit offset or Z (e.g. 2020-01-15T00:00:00Z, 2030-03-30T00:00:00+01:00); document in API docs
Serialize for storage via serialize_aware_datetime(dt) — ISO-8601 with offset preserved; optional cosmetic normalize Z ↔ +00:00 only when offset is actually UTC. Do notastimezone(UTC) for persistence.
Tags: normalize at write via shared normalize_tags() (see Tag normalization); enforce limits below
Tag / meta limits — frozen constants in constants.py (see API limits below)
Storage: persist user_* dates as offset-preserving ISO-8601 strings. Reject naive before any write.
Map to allowed charset [a-z0-9:_-] only: decompose and drop combining marks (Latin accents → base letters); any remaining disallowed rune → _
Collapse repeated _; strip leading/trailing _
Reject tag if empty after normalization or if length > MAX_DOCUMENT_TAG_CHARS
Dedupe preserving first-seen order; cap count at MAX_DOCUMENT_TAGS / MAX_QUERY_FILTER_TAGS
Final stored/filter tags must match ^[a-z0-9:_-]+$. Examples: Patent → patent; Årsrapport → arsrapport; channel/ENG → channel_eng.
Re-upload and duplicate ingest policy (explicit)
Re-uploading the same filename is a duplicate under existing dedup rules — it does not update metadata on the canonical processed document.
Duplicate FAILED stubs (dup-*): upload metadata is written to the stub row that receives the enqueue (the duplicate attempt), not merged onto metadata.original_doc_id. API readback shows metadata on whichever row was written — operators see tags/dates on the FAILED duplicate stub for review, while the canonical processed doc keeps its prior metadata (often none). Filtering always uses full_doc_id → that row's metadata — duplicate-stub metadata does not scope retrieval for the original document's chunks.
Metadata cannot change after ingest in v1 without a future PATCH /documents/{id}/metadata API (listed under Future optimizations).
Operators who need corrected dates/tags on the canonical doc must delete and re-ingest with new metadata, or wait for PATCH.
Optional shared metadata applied to every document in the batch
LightRAG.ainsert(..., metadata=...)
Optional shared metadata for all strings in input (single or list) — mirrors /texts; no per-item metadata map in v1
To attach different metadata per document from the SDK, call apipeline_enqueue_documents once per document (or use REST /text per doc). Do not extend ainsert with list[DocumentMetadata] in v1.
via eligible docs; union gate uses required_tags_mode
not applied to KG union tag sets
applied to filter_tags on entity/relation payloads and evidence filter
Rationale: KG objects union tags across all contributing documents, so they rapidly accumulate the full corpus tag vocabulary. Applying forbidden_tags to KG by default would drop most entities/relations in mixed corpora (e.g. forbid newspaper → entity also mentioned in a tagged newspaper doc loses the whole node). required_tags always applies to document eligibility (and thus to evidence-level KG filtering). Opt in when operators want union-tag hygiene (e.g. forbid draft on KG hits).
Include apply_forbidden_tags_to_kg in compute_args_hash when filters active.
Document classification
Each document falls into one of four shape classes (derived from user_start_date / user_end_date):
Class
Condition
undated
neither bound
start_only
start only
end_only
end only
complete
both bounds
Effective range for interval math (after shape gate passes): missing start → −∞, missing end → +∞.
shape gate
DocumentDateShape
Eligible shape classes
any
all four
dated
start_only, end_only, complete
bounded
complete only
has_start
start_only, complete
has_end
end_only, complete
range_mode predicates (on effective doc range vs query range)
Let Q = query range, D = doc effective range (with ±∞ for open bounds).
DateRangeMode
Predicate
overlap
D_start <= Q_end AND D_end >= Q_start
contained_by
D_start >= Q_start AND D_end <= Q_end
contains
D_start <= Q_start AND D_end >= Q_end
Point-in-time ("in force on date D"): set range.start = range.end = D; overlap and contains agree for typical open-ended law/patent docs.
undated OR-semantics
Evaluated after computing base = shape_matches(doc) AND range_matches(doc, range_mode):
UndatedMatch
Result
exclude
base only (fully undated docs fail unless shape=any and overlap with unbounded — they don't; so undated excluded)
include_or
base OR doc is undated — only fully undated, not partial docs outside range
only
doc is undated (ignore range, range_mode, shape)
Filter semantics — tags
required_tags_mode controls how required_tags is interpreted when the list is non-empty. forbidden_tags is unchanged (must contain none of the listed tags).
required_tags_mode
Predicate (when required_tags non-empty)
and (default)
required_tags ⊆ doc.user_tags — must contain all
or
required_tags ∩ doc.user_tags ≠ ∅ — must contain at least one
required_tags
forbidden_tags
Effect
omitted or []
omitted or []
no tag filter — all docs match (subject to date filter); required_tags_mode ignored
non-empty
empty
apply required_tags_mode (and or or)
empty
non-empty
must contain none of the forbidden tags
non-empty
non-empty
required predicate (per mode) and no forbidden tags
Omitting required_tags or passing [] are equivalent. Default required_tags_mode: "and" preserves prior AND-only behavior.
Query-side tag normalization: apply normalize_tags() to required_tags and forbidden_tags during validation (same charset rules as upload).
Examples:
required_tags: ["patent", "legal"], required_tags_mode: "and" — doc must have both tags
required_tags: ["newspaper", "letter"], required_tags_mode: "or" — doc with either tag matches (archive discovery across formats)
Include required_tags_mode in filter cache hash when filters active (see LLM answer cache).
has_active_filters gate
Decision: extend date_active — shape and undated are first-class filters, not silent no-ops when range is absent.
forbidden_tags alone activates filtering (document scope). KG forbidden-tag enforcement only runs when bothforbidden_tags non-empty andapply_forbidden_tags_to_kg=True.
effective_doc_range(metadata) -> (start, end) with ±∞ (parse stored ISO strings to aware datetimes; open bounds unchanged)
normalize_tags(tags) -> list[str] — shared upload + query; output charset [a-z0-9:_-] only (see Tag normalization)
validate_date_pair(start, end) -> None — reject naive, enforce ordering by instant, no UTC conversion for storage; raises ValueError for Pydantic validators
document_matches_date_filter(metadata, date_filter) -> bool — when range is None, range predicates are skipped; shape + undated still apply
document_matches_filter(metadata, vector_filter) -> bool — date + tags (respects required_tags_mode)
Writes text_chunks / chunks_vdb; may use full_doc_id with nodoc_status row (rebuild_vdb.py documents this)
Scan-discovered files
No upload metadata (out of scope to add at scan)
Orphan / legacy chunks
full_doc_id missing from doc_status
v1 rule: if full_doc_id has nodoc_status row, or the row exists but has nouser_* date/tag fields, treat the chunk's document as undated and untagged for filtering:
Included when undated is include_or or only, or when no date/tag filters are active
Excluded by shape != any, tag filters, and date range predicates (undated docs fail shape/range unless undated allows them)
KG evidence from such chunks follows the same rule via eligible_chunk_ids
Test:ainsert_custom_kg chunk + filtered query (strict date/tags) → custom-KG evidence excluded; undated: include_or may include it.
LLM answer cache (correctness)
Today operate.py answer-cache compute_args_hash for kg_query (~L4641) and naive_query (~L6651) does not include retrieval filter fields — filtered and unfiltered queries with the same text could share a cache entry (bug).
Required: when has_active_filters(query_param), append a stable filter fingerprint to both answer-cache hashes via build_filter_cache_hash():
All date_filter fields (range, range_mode, shape, undated) when date-active
required_tags, required_tags_mode, forbidden_tags, apply_forbidden_tags_to_kg when tag-active
When filters inactive, omit filter fields (preserve today's cache behavior).
Keyword-extraction cache (cache_type="keywords" in extract_keywords, ~L4898): stay filter-agnostic — keywords depend on query text/mode, not retrieval scope.
Chunk filter-field stamping
When enable_vector_metadata_filters=true, stamp denormalized filter fields on chunk rows at pipeline chunk upsert — immediately before chunks_vdb.upsert(chunks) (~line 5403 in the processing path that builds the chunks dict).
Hook: stamp_chunk_filter_metadata(chunks, doc_status_metadata) -> None (in query_filters.py or utils_pipeline.py) copies from the parent doc's user_* metadata into each chunk payload:
filter_start_date, filter_end_date, filter_tags (normalized tag list)
Called only when pushdown is enabled; post-retrieval filtering still reads doc_status directly when pushdown is off.
Two-tier strategy (chunks)
Tier
When
What
Pushdown
filters active AND enable_vector_metadata_filters=true AND supports_metadata_filters AND chunks_vdb
query(..., metadata_filter=filter)
Oversample + in-Python
filters active AND no native pushdown AND chunks_vdb
top_k * factoronly whenmetadata_filter is not None; prune in Python
Include build_filter_cache_hash() in answer-cache paths when has_active_filters (see LLM answer cache).
Knowledge graph filtering and denormalization
KG objects (entities, relations) are shared across documents. Filtering uses two layers: denormalized summaries (optional pushdown) and evidence-level truth (always when filters active).
Resolve eligible_doc_ids / eligible_chunk_ids from doc_status (same as chunks).
Keep entity/relation if any chunk in entity_chunks / relation_chunks (authoritative; fallback truncated graph source_id) intersects eligible_chunk_ids.
Prune evidence: when resolving KG-attached text (_find_related_text_unit_from_*), only use eligible chunks — citations stay in-scope even if the node description summarizes broader corpus.
Optional oversample on entities_vdb / relationships_vdb when filters active (same pattern as FAISS chunks).
For range_mode: contains / contained_by, union pushdown alone is unsafe; evidence filter is required (default path).
Union-interval denormalization (entities and relations)
Policy (fixed — not implementer choice):
Store
When filter_* fields are written
Graph nodes / edges
Always — recompute on every merge/purge/custom-KG upsert (cheap; supports evidence filter bookkeeping and purge recompute even when pushdown is off)
entities_vdb / relationships_vdb payloads
Only whenenable_vector_metadata_filters=true (pushdown coarse gate)
Stamp summary fields:
Field
Computation
filter_start_date
min of contributor doc starts by instant; store the winning contributor's ISO string (offset preserved)
filter_end_date
max of contributor doc ends by instant; store the winning contributor's ISO string (offset preserved)
filter_tags
set union of contributor user_tags
has_undated_evidence
true if any contributor doc has no date bounds
filter_doc_count
optional; count of distinct contributing full_doc_id
Contributor set: for each entity/relation, walk entity_chunks / relation_chunks → chunk IDs → text_chunks[chunk_id].full_doc_id → load doc_status metadata (missing row → undated/untagged per orphan rule). Do not use endpoint entities' union for relation dates.
Recompute hook:recompute_kg_filter_metadata(entity_or_relation_key, chunk_ids, doc_status, text_chunks) — must run after any write that changes entity_chunks / relation_chunks or graph membership.
Manual graph CRUD without document evidence: entities/relations may have entity_chunks / relation_chunks pointing at custom-KG or orphan chunks — recompute still runs; result is undated / untagged until evidence links to docs with user_* metadata. No manual date/tag API on graph nodes in v1.
No migration/backfill for chunks or KG rows ingested before this feature.
Post-retrieval filters read live doc_status.metadata — docs withoutuser_* keys behave as undated / untagged for filter purposes.
Enabling pushdown on an existing corpus: old chunk/KG vector rows lack filter_* payload fields until those documents are reprocessed (or corpus cleared and re-ingested). Until then, pushdown may treat them as empty filter fields (undated, no tags) — document this in operator docs.
Env / vector_db_storage_cls_kwargs / LightRAG global config:
kg_union_skip_open_bounds: bool=True# DEFAULT ON — do not let missing bounds widen union to ±∞
Contributor bound
kg_union_skip_open_bounds=true (default)
false (legacy permissive)
Missing start
Skip for min(start) — does not widen to −∞
Treated as −∞
Missing end
Skip for max(end) — does not widen to +∞
Treated as +∞
Fully undated doc
Sets has_undated_evidence=true only
Same
No contributor has any date
Both filter_start_date and filter_end_date null (undated KG object)
Same
When true (recommended for archives):
Contributor doc bound
Effect on union min/max
Missing start
Skip for min(start) — do not treat as −∞
Missing end
Skip for max(end) — do not treat as +∞
Fully undated doc
Contributes to has_undated_evidence=true only; does not move interval
At least one doc with start
filter_start_date = min(starts present)
At least one doc with end
filter_end_date = max(ends present)
No contributor has any date bound
filter_start_date and filter_end_date remain null (KG object is undated for interval purposes; date_filter.undated / shape gates apply)
Contributors have starts but none have end
filter_end_date stays null (open end = +∞ for query interval math on the summary row, same as single doc with no end)
Query interval math on the summary row: null filter_end_date after union still means +∞ for range predicates when the aggregate is "open ended" (no contributor supplied an end). Null on both bounds → undated KG object; date_filter.shape / undated gates apply.
When kg_union_skip_open_bounds=false (legacy permissive): missing start → −∞, missing end → +∞ in union (one open-ended law doc widens entire entity to eternity).
Coarse candidate gate when pushdown on; same DateFilter + required_tags (per required_tags_mode); forbidden_tags only if apply_forbidden_tags_to_kg
Evidence ∩ eligible_chunk_ids
Authoritative keep/drop for entities/relations
Pruned chunk text
Authoritative citations
For range_mode: overlap, union-only may suffice as gate; for contains / contained_by, always run evidence filter after search.
Relationship dates and tags (how they are set)
Relations do not inherit dates/tags from endpoint entities. They are derived only from relation evidence:
flowchart LR
relChunks[relation_chunks edge key] --> chunkIds[chunk IDs]
chunkIds --> fullDoc[text_chunks.full_doc_id]
fullDoc --> docMeta[doc_status user_start_date / user_end_date / user_tags]
docMeta --> union[Union min/max dates + tag set + has_undated_evidence]
union --> graphEdge[graph edge properties]
union --> relVdb[relationships_vdb payload]
Loading
Steps:
After extraction/merge, read relation_chunks[canonical_edge_id] (or src|tgt key per storage) → list of chunk IDs.
Map each chunk → full_doc_id via text_chunks.
Load each doc's user_start_date, user_end_date, user_tags from doc_status.metadata.
Apply same union rules as entities (kg_union_skip_open_bounds, tag union, has_undated_evidence).
Write to graph edge properties (new fields parallel to entity node) and relationships_vdb payload on upsert; recompute on purge.
Example: edge "Company —[licensed]→ Patent" with chunks from 2010 patent doc (tags patent) and 2015 amendment (tags patent, legal):
filter_start_date = min(2010, 2015)
filter_end_date = per union rules
filter_tags = {patent, legal}
If amendment chunk is purged, recompute from surviving chunks only.
Entities use entity_chunks[entity_name] with identical aggregation — no separate manual date API on graph CRUD in v1; dates/tags on KG objects are always derived from document metadata of evidence docs.
Evidence-level filter ignores union filter_tags for forbidden unless apply_forbidden_tags_to_kg; it only requires eligible chunks (documents already passed doc-level forbidden_tags).
Defaults in settings.ts: no date_filter, empty tag lists, required_tags_mode: 'and', apply_forbidden_tags_to_kg: false. Omit date_filter key entirely when inactive.
Query date range pickers: emit timezone-aware ISO with explicit offset (never naive)
Tags column, date range column, details dialog for start_date / end_date / tags / meta.
meta.href external link (display-only)
meta.href is a display / navigation convenience only — never queryable, never denormalized to chunks or KG, never shown in filter UI. Do not document it alongside tags or dates as a filter dimension.
When doc.meta?.href is a non-empty string and passes URL validation, show a link icon beside the document name in the table row and again in DocumentStatusDetailsDialog.
Rule
Detail
Source
Top-level DocStatusResponse.meta.href only (not nested paths, not internal metadata keys)
Visibility
Render link control only when href is valid; no placeholder when absent
URL policy
Allow http: and https: only after trim; reject javascript:, data:, protocol-relative //, and other schemes
Navigation
<a href={href} target="_blank" rel="noopener noreferrer"> — icon button or anchor with aria-label from i18n
Tooltip
Full URL on hover (truncated display in table if needed)
Click isolation
stopPropagation on link click so row selection / details toggle is not triggered
Pushdown predicates implement document_matches_date_filter for chunks; for KG payloads use denormalized summary interval + kg_union_skip_open_bounds semantics + tag union gate (required_tags_mode + apply_forbidden_tags_to_kg aware). Backend tag clauses: and → all terms must match payload filter_tags; or → at least one term matches (e.g. Qdrant should / OpenSearch should minimum_should_match=1).
Per-backend notes
All pushdown backends: predicates only when metadata_filter is not None. Apply to chunks_vdb; when enable_vector_metadata_filters and backend supports it, same filter expr on entities_vdb / relationships_vdb as coarse gate (evidence filter still runs in operate.py).
Backend
Pushdown
No-filter behavior
Qdrant
bool filter: shape branches + range_mode comparison + tag terms + undated OR
unchanged
Milvus
boolean filter expr
unchanged
Postgres
SQL WHERE with shape/range_mode/undated branches
unchanged
MongoDB
$match after $vectorSearch
unchanged
OpenSearch
bool filter wrapping kNN
unchanged
FAISS
oversample + Python prune only whenmetadata_filter is not None
unchanged
NanoVectorDB
same as FAISS
unchanged
NoopVectorDB
N/A
unchanged
Tests (targeted subsets)
Area
Cases
tests/api/routes/test_document_metadata.py
reject naive; offset preserved on roundtrip; tag charset normalization; duplicate stub vs canonical metadata readback
has_active_filters (shape-only, include_or without range); orphan/custom-KG chunks; tag AND/OR; KG evidence; answer-cache hash differs filtered vs unfiltered
Extend the document metadata filters foundation with configurable scope tag prefixes (e.g. `tenant:`), materializing separate KG entity/relation variants per scope tag plus a null `tenant_tag` aggregate, and selecting the active partition from `required_tags` (no separate tenant_tag param). WebUI graph view gets a dedicated scope selector.
todos
id
content
status
prereq-metadata
Complete document metadata filters plan (user_tags on doc_status, upload plumbing, normalize_tags, required_tags on QueryRequest)
This builds on the pendingdocument metadata filters plan: user_tags on doc_status.metadata, upload plumbing, and normalize_tags(). Scope materialization reads document tags at merge time; it does not replace date/tag query filters — it adds write-time KG partitioning for configured scope prefixes.
Goal
For configured scope prefixes (e.g. tenant:), materialize multiple KG entries per logical name:
Variant
tenant_tag
Evidence
Aggregate
null
All contributing documents (cross-scope union)
Scoped
tenant:1
Only chunks from docs whose user_tags include tenant:1
Scoped
tenant:2
Only chunks from docs tagged tenant:2
Example: entity IKEA mentioned in tenant:1 and tenant:2 docs yields three graph nodes / VDB records sharing display name IKEA but isolated descriptions, source_id, and chunk tracking.
Confirmed semantics:
Relations are scoped the same way; edges connect only endpoints with the sametenant_tag.
Documents without any scope-prefixed tag contribute only to the aggregate (tenant_tag=null); they are invisible to tenant-scoped queries.
v1 rule: one variant per matching scope tag on the document (not Cartesian products across multiple prefix families). A doc tagged tenant:1 and region:eu with both prefixes configured creates separate variants per tag, not a combined key.
target_scopes = [None] + scope_tags (always aggregate; add scoped targets only when doc has scope tags).
For each entity fragment collected in all_nodes:
Call _merge_nodes_then_upsert once per tenant_tag in target_scopes, passing tenant_tag and filtering nodes_data to fragments whose source_id chunk belongs to this merge doc (already true for per-doc merge).
Scoped merges only ingest chunks from this doc; aggregate merge ingests all chunks from this doc.
For each affected canonical entity/relation name (from anchors):
Enumerate surviving chunk IDs per scope variant by re-walking entity_chunks / relation_chunks keys that split_scoped_entity_id maps back to the canonical name.
If a scoped variant has zero surviving chunks: delete graph node/edge, VDB record, and tracking row.
Else: rebuild description via existing rebuild path (rebuild_knowledge_from_chunks scoped) or lighter chunk-list trim + description re-merge.
Hook: recompute_scoped_kg_variants(canonical_entity, doc_status, text_chunks, ...) called from purge completion and optionally from the existing recompute_kg_filter_metadata path in the metadata-filters plan.
Anchors unchanged:full_entities / full_relations keep canonical extraction names (unscoped); purge discovers all scope variants by scanning tracking keys with prefix make_scoped_entity_id(name, "") or indexed reverse map.
5. Query-time selection via required_tags (no tenant_tag param)
Reuse the existing required_tags field from the document metadata filters plan — do not add a separate tenant_tag on QueryParam / QueryRequest. Scope selection is derived at runtime by splitting required_tags into scope tags vs ordinary filter tags.
@dataclass(frozen=True)classResolvedKgScope:
kg_scope_tag: str|None# None => aggregate partitiondoc_filter_tags: list[str] # non-scope tags left for doc eligibilitydefresolve_kg_scope_from_required_tags(
required_tags: list[str],
scope_prefixes: list[str],
) ->ResolvedKgScope:
"""Partition required_tags into at most one KG scope tag + remaining doc filters."""
Resolution rules (v1):
required_tags scope subset
KG partition
Doc filter tags
none
aggregate (tenant_tag=null)
all of required_tags
exactly one tag matching a scope prefix (e.g. tenant:1)
that scoped variant
remaining non-scope tags (e.g. patent)
more than one scope-prefixed tag
422 — "At most one scope tag allowed in required_tags for KG retrieval"
—
Examples:
required_tags: [] → aggregate KG + no doc tag filter
required_tags: ["tenant:1"] → scoped KG and doc filter requires tenant:1 (consistent)
required_tags: ["tenant:1", "patent"] → scoped KG for tenant:1; docs must also have patent
required_tags: ["patent"] → aggregate KG; docs must have patent
required_tags_mode (and / or) applies only to doc-level eligibility (unchanged from metadata-filters plan). Scope resolution runs before doc matching and is independent of AND/OR — there is at most one KG partition.
Do not combine forbidden_tags with scope-prefixed tags that also appear in required_tags. Scope tags in required_tags select a KG partition; putting related scope tags in forbidden_tags (e.g. required_tags: ["known-in-chapter:03"] + forbidden_tags: ["known-in-chapter:04"]) creates contradictory doc-filter semantics when uploads carry forward-propagating markers — results become hard to reason about. Reserve forbidden_tags for non-scope facets (draft, author-notes, type:appendix, …). For single-chapter doc isolation alongside a scope checkpoint, add non-scope upload tags (e.g. chapter:03) and require those in required_tags next to the single scope tag.
When enable_kg_scope_materialization=false or scope_tag_prefixes empty: resolve_kg_scope_from_required_tags is a no-op (all tags stay doc filters; KG uses today's single-partition behavior).
Graph: node id = graph_node_id; properties include entity_name, tenant_tag.
entity_chunks / relation_chunks KV: keyed by graph_node_id.
entities_vdb / relationships_vdb: extend meta_fields in lightrag/lightrag.py with tenant_tag, graph_node_id (if distinct from hashed id).
No backfill in v1 (same policy as metadata filters): existing nodes without tenant_tag treated as aggregate; reprocess to create scoped variants.
8. WebUI
Graph view scope selector (primary UX)
Add a Scope control to the knowledge-graph panel — not buried in retrieval query settings. Suggested placement: GraphViewer.tsx toolbar area or Settings.tsx popover (alongside depth / max nodes).
Control
Behavior
Scope dropdown
Options: All (aggregate) + scope tags from GET /graph/scope/list
Include graphScopeTag in the fetch signature alongside queryLabel, graphQueryMaxDepth, graphMaxNodes
All (aggregate): required_tags=[] → aggregate KG partition; graph shows cross-scope union view.
tenant:1: required_tags=["tenant:1"] → scoped partition + doc tag filter (harmless for graph-only fetch).
Extend queryGraphs(label, maxDepth, maxNodes, requiredTags?) to append required_tags query params. Types in lightrag.ts.
i18n keys under graphPanel.scope.* (label, aggregate option, tooltip explaining scoped vs all).
Retrieval query panel
Reuse the same required_tags chips UI from the metadata-filters plan — no separate scope field. When a user adds tenant:1 to required tags on a query, KG retrieval automatically uses the scoped partition via resolve_kg_scope_from_required_tags. Tooltip note: scope-prefixed tags (per server config) also select the KG partition.
Optional: when graph scope selector changes, offer to sync retrieval required_tags (off by default) — not required for v1.
Upload UI: document tags already support tenant:1 via metadata form — no new upload fields; document scope tag prefixes in operator docs.
Example: spoiler-aware book knowledge (known-in-chapter:)
A novel is ingested one chapter per upload (10 chapters). Configure scope materialization with:
Facts introduced early stay known for the rest of the book. Tag chapter N with every checkpoint from N through 10 — the first chapter carries all markers; the last carries only its own:
KG scope known-in-chapter:10; doc filter = chapter 10 upload only.
WebUI graph scope selector
Dropdown options: All (full book), known-in-chapter:01 … known-in-chapter:10 from GET /graph/scope/list. Selecting known-in-chapter:05 fetches /graphs?...&required_tags=known-in-chapter:05 — characters and relations as the reader knows them after chapter 5, without a separate query field.
One upload per chapter; keep known-in-chapter: markers chapter-level, not per entity.
Use non-scope chapter:NN when you need a single chapter’s document, not cumulative reader knowledge.
Never put scope-prefixed tags in forbidden_tags while also requiring a related scope tag in required_tags — the forward-propagating tag sets make that especially confusing; use chapter:NN for per-chapter doc boundaries instead.
Document metadata and query filters — design use cases
This document captures why user-controlled document metadata and query-time filters matter for archival and knowledge-work domains, and what requirements each scenario imposes on upload and query APIs.
Ingest (upload):start_date, end_date, tags, meta Query (retrieval scoping):date_filter, required_tags, forbidden_tags only — meta is never a query input
Policy: The meta JSON object is roundtrip-only. It is stored at ingest and returned on document status / detail responses. It is not indexed for retrieval, not accepted on QueryRequest, and will not gain a query API (no JSON path, key match, or numeric filters). To scope search, use tags or date_filter.
Problem statement
LightRAG today scopes retrieval by vector similarity and knowledge-graph structure, not by document-level attributes. Archival corpora—legislation, patents, parliamentary records, newspapers, correspondence—are organized around time, subject tags, and catalog payloads. Users need to:
Attach temporal bounds, filterable tags, and roundtrip meta when documents enter the system.
Restrict retrieval using dates and tags only, without changing the core RAG modes (local, global, hybrid, mix, naive).
Poor date semantics (overlap-only, undifferentiated “partial dates”) fail legal and legislative workflows where how an interval relates to a query range is as important as whether they touch at all.
Broad category facets for query filtering (channel, jurisdiction, doc type, …). Keep cardinality low — see below
meta
JSON object
No
Structured catalog fields for roundtrip only — stored at ingest, returned on read; never used for query filtering
Tags vs meta — when to use which
Tags participate in every filtered query (doc_status scan, post-retrieval pruning, and optional vector pushdown). Large tag sets per document and long required_tags lists slow filtering — tags are for broad, stable categories you routinely scope on (e.g. channel:engineering, jurisdiction:uk), not a mirror of every entity in the document.
Concern
Prefer tags
Prefer meta
Filter retrieval (required_tags / forbidden_tags)
Yes — sparingly, broad facets only
Never — meta is not queryable
Human-readable document list / WebUI details
Short summary tags only (optional)
Yes (rich structure)
Stable machine ids (UUID, DOI, message id)
No — high cardinality hurts query performance
Yes (roundtrip to client / export)
Nested or numeric fields
No
Natural JSON
Long participant / keyword lists (every @mention, every IPC code)
No — do not tag every value
Full list in meta; tag only coarse facets you query often (e.g. one channel:…, type:transcript)
A few principals you often filter on (lead author, primary channel, jurisdiction)
Yes — small, curated set
Duplicate in meta for display if needed
Practical rule: use tags only for broad categories that should narrow retrieval. Put exhaustive lists (all participants, all message ids, all citation fields) in meta. If a field must affect search, promote one coarse tag (e.g. assignee:acme-corp), not every related identifier.
meta is roundtrip-only, permanently. There is no query API on meta. High-cardinality data that must never slow queries belongs in meta, not in tags.
Tag naming convention (recommended): lowercase facet:value prefixes; low facet cardinality (tens of distinct values per facet across the corpus, not thousands per document):
Use person:… (or similar) only for a few named principals you regularly filter on — not every participant in a large thread. Full rosters live in meta.participants.
Normalization at ingest: Unicode NFKC + casefold → charset [a-z0-9:_-] only (see plan). Same normalization on query required_tags / forbidden_tags. Enforce per-document tag count and per-tag length limits at the API boundary.
Tag use cases
Tags are the only non-date dimension for query scoping. Prefer few, broad tags per document; combine with date_filter rather than encoding fine detail as dozens of tags.
Chat and messaging archives
Why interesting: Transcripts are sliced by time window and channel; participants are the primary access path (“what did we decide in #engineering when Ada was involved?”). Vector search alone pulls semantically similar text from other channels and dates.
Tag discipline: one channel, doc type, and source — not every participant. The full participant list is in meta. Add person:ada-lovelace to tagsonly for documents where Ada is a primary subject and you expect frequent required_tags queries on her; otherwise rely on vector search within the channel-scoped corpus.
Optional narrow tagging when a principal is query-critical:
These scenarios explain why meta exists despite not being queryable. Clients ingest rich structures once and read them back on document status / detail APIs without a separate catalog database.
Chat export provenance
Return thread_id, message_count, and participant objects so the WebUI can link to the source export or show avatars. Retrieval is still scoped by tags and date_filter; meta is not consulted during search.
Legal and bibliographic citation
{
"meta": {
"citation": "UK Public General Act 1996 c. 18",
"eli": "http://www.legislation.gov.uk/ukpga/1996/18",
"version_date": "2024-01-01"
}
}
Displayed in document details and exported reports; users filter with tags like jurisdiction:uk and dates, not ELI URLs in meta.
Site/parameter filtering uses tags; DOI and instrument list are display-only roundtrip.
Design constraint for integrators
If a field must narrow retrieval, add a single coarse tag (e.g. form:10-k) at ingest — not every field from meta. Do not assume a future meta query language will exist.
Expiry often missing in metadata; validity may still be inferred as open-ended until expiry is recorded.
Tags: IPC class, assignee, family id.
Interesting queries
Question
Date filter sketch
Patents active at any point in 2010–2015
overlap + has_start
Patents covering the entire 2010–2015 window
contains + has_start
Filing activity in a year (broad)
overlap + has_start
Portfolio tagged semiconductor in period
+ required_tags
Requirements
has_start (filing date is the reliable anchor).
contains vs overlap distinction for clearance / landscape vs mention retrieval.
Forbidden tags to exclude deprecated or withdrawn classifications.
Parliamentary session transcriptions
Corpus characteristics
Often one session date or a short span (start + end).
Tags: chamber, committee, language, document type (transcript, hansard).
Interesting queries
Question
Date filter sketch
Debates during Q1 2024
overlap + has_start
Session wholly within a sitting period
contained_by + has_start
Budget debates in March
Narrow range + required_tags
Requirements
has_start (single sitting date satisfies shape).
contained_by for “session inside term” (precision).
Tag filters for chamber and topic.
Treaties and international agreements
Corpus characteristics
Signed / ratified start; termination or withdrawal end; many open-ended.
Similar temporal logic to legislation.
Interesting queries
Question
Date filter sketch
Agreements in force during a crisis window
overlap + has_start
Treaty valid for entire negotiation period
contains + has_start
Requirements
Same as legislation: has_start, contains, open end.
Newspapers and serials
Corpus characteristics
Usually issue date (effectively start-only or start == end).
Digitized reels may lack per-issue dates → undated batches.
Interesting queries
Question
Date filter sketch
Issues from the 1930s
overlap + dated or has_start
1930s coverage plus undated reel scans
overlap + include_or
Only precisely dated issues
dated + exclude undated
Requirements
include_or for discovery over incomplete digitization.
dated / has_start when catalog quality matters.
Letters, manuscripts, and personal archives
Corpus characteristics
High proportion of undated items.
When dated, often a single letter date.
Interesting queries
Question
Date filter sketch
Correspondence mentioning X (no date filter)
No date_filter
1880–1900 or undated for manual review
overlap + include_or
Strictly dated 19th-century letters
overlap + dated
Requirements
include_or is essential for mixed-quality archives.
Default broad search often has no date filter at all.
Financial and annual reports
Corpus characteristics
Fiscal periods with both bounds well defined.
Tags: entity, report type, GAAP framework.
Interesting queries
Question
Date filter sketch
FY2019 reports only
overlap or contained_by + bounded
Strict complete fiscal intervals
bounded + exclude undated
Requirements
bounded shape for catalog strictness.
contained_by when the fiscal year must fall entirely inside a reporting window.
Court opinions and case law
Corpus characteristics
Decision date (point-like).
Tags: court, jurisdiction, matter type.
Interesting queries
Question
Date filter sketch
Opinions from 2015–2020
overlap + has_start
Jurisdiction-scoped search
+ required_tags
Requirements
Point ranges work with overlap + has_start.
Less need for contains unless querying multi-year precedential “window”.
Environmental monitoring and scientific reports
Corpus characteristics
Monitoring period start/end (usually bounded).
Tags: site, parameter, standard.
Interesting queries
Question
Date filter sketch
Reports covering a contamination event window
overlap or contains
Studies wholly inside a study period
contained_by + bounded
Requirements
bounded when both period bounds are always recorded.
Clinical trials and regulatory filings
Corpus characteristics
Enrollment start; completion often missing for ongoing trials.
Tags: phase, condition, sponsor.
Interesting queries
Question
Date filter sketch
Trials active during a approval review period
overlap + has_start
Trials running through entire calendar year
contains + has_start
Requirements
Same open-end pattern as patents and law.
WebUI presets (suggested)
Presets map to DateFilter fields so users choose intent, not raw enums:
Preset
range_mode
shape
undated
Domains
Broad retrieval
overlap
any
exclude
Default RAG
Archive discovery
overlap
any
include_or
Letters, newspapers
Legislation / in force
contains
has_start
exclude
Law, treaties
Activity in period
overlap
has_start
exclude
Patents, trials, news
Wholly within period
contained_by
has_start
exclude
Sessions, events
Strict catalog
overlap
bounded
exclude
Financial, scientific periods
Uncatalogued only
—
—
only
Admin / cleanup
Advanced UI exposes range_mode, shape, and undated individually.
Implementation requirements (summary)
Upload path
Optional dates, tags, meta on all ingest endpoints.
Dates: timezone-aware only; store with offset preserved (no UTC conversion at persistence — future calendar dates cannot be reliably re-projected through UTC when DST rules change). Comparisons use instant semantics at query time.
All four user_* keys on both_DOC_STATUS_METADATA_CARRY_OVER_KEYS and _DOC_STATUS_METADATA_DIRECTIVE_KEYS (retry reset uses directive whitelist only).
Denormalize filter_start_date, filter_end_date, filter_tags onto chunk vectors only when vector metadata pushdown is enabled (LIGHTRAG_VECTOR_METADATA_FILTERS, default off), at pipeline chunks_vdb.upsert time.
Query path
Single document_matches_filter() implementing shape, range mode (skipped when range is None), undated OR, and tag predicates.
has_active_filters: date active when range set, shape != any, or undated != exclude (not only range / undated=only).
Chunks whose full_doc_id has no doc_status row → undated/untagged; excluded unless undated allows.
Normalize query tags to [a-z0-9:_-] (same as upload).
Answer-cache hashes in kg_query and naive_query must include filter fingerprint when active; keyword-extraction cache stays filter-agnostic.
Vector pushdown when enabled; no filter machinery when inactive.
Out of scope (v1)
Any query API for meta — roundtrip only.
starts_in / ends_in range modes.
Post-upload metadata edit (PATCH); re-upload same filename = duplicate, not metadata update.
Backfill of filter fields on pre-existing chunks/KG rows.
Known modeling limit
One (start_date, end_date) pair per document cannot represent multiple roles (e.g. patent filing vs priority vs expiry). Secondary dates belong in meta for display/export or must be promoted to tags / dates if they need to affect retrieval.