Flat top-k similarity over one undifferentiated index fails predictably in the enterprise: it can't scope by source, recency, or permissions; it retrieves fragments when the generator needs whole sections; and it can't answer multi-hop analytical questions whose evidence never co-occurs in one chunk.
Tier: Advanced · Workflow area: RAG & Retrieval Engineering
This playbook adds the three structural upgrades — metadata-filtered retrieval, parent-document retrieval, and graph-augmented retrieval — each gated by the same eval discipline as pb-22–24.
networkx (or Neo4j), and an entity/relation extraction schema.Design the payload schema like a database, not an afterthought. Every chunk carries: doc_id, parent_section_id, source, doc_type, effective_date, version, acl_groups: list[str], plus domain facets (product, region). Index the filterable fields (Qdrant payload indexes / pgvector b-tree columns) — unindexed filters silently become post-filters that wreck recall at scale.
Enforce scope as pre-filter, ACLs server-side. Filters must constrain the ANN search itself, and permissions must be non-bypassable by prompt content:
hits = qdrant.query_points("corpus", query=qvec, limit=50,
query_filter=Filter(must=[
FieldCondition(key="acl_groups", match=MatchAny(any=user.groups)),
FieldCondition(key="version", match=MatchValue(value="current")),
]))
The acl_groups condition is injected by the serving layer from the authenticated session — never model-generated. Model-chosen filters (self-query: "in 2024 reports" → effective_date range) are fine for scoping, never for authorization.
Retrieve small, read big — parent-document retrieval. Embed fine-grained chunks (they match queries better) but hand the generator the parent section:
matched = search(qvec, k=30) # child chunks
parents = dedup([c.payload["parent_section_id"] for c in matched])[:5]
context = [section_store[p] for p in parents] # full sections, with headings
Cap parent size (~1,500 tokens; split giant sections at ingestion). Measure against pb-24: context_precision often dips slightly while faithfulness and answer completeness rise — decide on the answer metrics.
Build the graph arm for multi-hop. Extract entities/relations at ingestion with a structured call per chunk ((entity_a, relation, entity_b) triples with types from a closed vocabulary + source chunk ID), and load into a graph:
class Triple(BaseModel):
head: str; head_type: Literal["org","person","product","event"]
relation: Literal["supplies","owns","audited_by","partnered_with", "competes_with"]
tail: str; tail_type: Literal["org","person","product","event"]
G.add_edge(normalize(t.head), normalize(t.tail), rel=t.relation, src=chunk_id)
Entity normalization (case, aliases, the pb-17 dedup trick on entity-name embeddings) is where graph quality is won or lost. Query time: extract entities from the question → k-hop neighborhood → pull the source chunks for those edges → merge with vector results before reranking.
Route, don't default, to the graph. Graph retrieval is expensive to build and maintain; send it only queries classified as multi-hop/relational (hops_required field in the pb-24 query classifier). Single-hop questions stay on the vector path.
Evaluate on a multi-hop slice. Extend the grounding set with 15–20 genuine multi-hop questions (evidence spread across ≥2 documents). Report the ablation: pb-24 stack vs +parents vs +graph, on single-hop and multi-hop slices separately — the graph should transform the multi-hop slice and leave single-hop untouched.
An ACL bug in RAG is a data breach with a friendly chat interface. Treat permission filters as security code: reviewed, tested with a dedicated adversarial suite, and enforced in the retrieval service — never in the prompt.
AutoRetriever/self-query for model-chosen filters, HierarchicalNodeParser + AutoMergingRetriever for parent-document, Property Graph Index for the graph arm.acl_groups.