Zum Hauptinhalt springen
tsecurity.de LIVE
Echtzeit-Radar & Feeds
Alle RSS Feeds
👥 Community & Social
Windows Tipps & SecurityNighthawk M7 Pro im Test: Flexibler, aber teurer 5G-Router(21.09.2026 um 10:30 Uhr)
Sichere ProgrammierungNeue Gmail-Funktion: So sparst du jetzt Zeit bei Einmalcodes(21.09.2026 um 10:00 Uhr)
Sichere ProgrammierungYour GIF exporter is fine — the container is the problem(21.09.2026 um 10:01 Uhr)
Sichere ProgrammierungCSS, Motion, or GSAP? I Choose by Who Owns the Animation(21.09.2026 um 10:12 Uhr)
Windows Tipps & SecurityNighthawk M7 Pro im Test: Flexibler, aber teurer 5G-Router(21.09.2026 um 10:30 Uhr)
Sichere ProgrammierungNeue Gmail-Funktion: So sparst du jetzt Zeit bei Einmalcodes(21.09.2026 um 10:00 Uhr)
Sichere ProgrammierungYour GIF exporter is fine — the container is the problem(21.09.2026 um 10:01 Uhr)
Sichere ProgrammierungCSS, Motion, or GSAP? I Choose by Who Owns the Animation(21.09.2026 um 10:12 Uhr)
Intelligence View
⚡ tsecurity.de Intelligence

Building a multi-agent document-search copilot — Part 2: adaptive Hybrid, and a permission gate after the rank

Building a multi-agent document-search copilot — Part 2: adaptive Hybrid, and a permission gate after the rank This is Part 2 of two. Part 1 opened on a document-search copilot whose v1 produced muddy results — two retrieval lanes fused i…

0
↗ Quelle (dev.to)
Reagiere als Erste:r — dein Feedback zählt!




Building a multi-agent document-search copilot — Part 2: adaptive Hybrid, and a permission gate after the rank



This is Part 2 of two. Part 1 opened on a document-search copilot whose v1 produced muddy results — two retrieval lanes fused into one rerank, where metadata rows that carry no text corrupted the relevance scores. The fix was two reframes: collapse the router into one structured Bedrock call (with a deterministic fallback), and pick exactly one retrieval strategy per query — MetadataOnly, ContentOnly, or Hybrid — instead of fusing the lanes. We left off at the one strategy that refuses to be tidy: Hybrid, where the user wants a topic and filters at the same time.



Picking up where Part 1 stopped, we're now in the back half of the same pipeline — the direct_search → rerank → permission_filter → finalize_results tail of the search graph. Two decisions left: how Hybrid actually retrieves, and where permissions get enforced.






3️⃣ Adaptive Hybrid: peek once, then pick filter-first or rank-then-filter



Hybrid is the interesting one, because "topic + filters" is genuinely a tradeoff and not a single right answer. There was a real debate about it — the kind that goes a few rounds on a whiteboard: run the filter and the rank in parallel (fast, but you have to reconcile two sets), or in sequence (precise, but slower)? We resolved it by refusing to pick — and routing on selectivity instead.



The trick is one cheap question asked once: how many documents match the filters? direct_search peeks the filtered-universe size from the Documents service a single time, with a threshold (default 1000), and branches:




elif strategy == "Hybrid":
threshold = cfg.get("filter_first_threshold", 1000)
id_page = cfg.get("filter_id_page_size", 100)
scope_ids, total_records = await _fetch_filtered_ids(metadata_filters, threshold, id_page)
if scope_ids is not None:
candidates = await _content_hits(scope_ids=scope_ids) # filter-first
filters_enforced_upstream = True
else:
candidates = await _content_hits() # rank-then-filter








  • Bounded set (≤ threshold) → filter-first. The peek returns the actual id set. The semantic query is scoped to exactly those ids via an OpenSearch terms id-scope (over the version field on chunks, the parent field on attachment chunks). Every filter is enforced authoritatively at the source — nothing off-filter can possibly rank, and nothing on-filter is missed. It sets a flag so the later permission gate knows not to re-filter:




  # filter-first scoped the topic ranking to the service-filtered id set, so the filters
# are already enforced authoritatively; permission_filter must NOT re-enforce them.
filters_enforced_upstream = True








  • Broad set (> threshold) → rank-then-filter. Scoping a semantic query to thousands of ids would be unbounded and slow, so instead we rank the topic unscoped and drop the off-filter hits afterward, locally, with _row_passes_filters — a faithful mirror of the Documents service's operation semantics across every searchable column (string / number / date / boolean / user_list, with contains / equal / greaterThan / the rest):




  def _row_passes_filters(row: dict, filters: list[dict]) -> bool:
"""Faithful local mirror of the Documents service filter semantics ...
any hit whose row fails a hard filter is dropped, fail-closed.
"""
return all(_row_passes_one(row, f) for f in filters)






The thing that makes this safe rather than a sloppy approximation: when the filtered set is huge, the filter is nearly a no-op anyway (it matches most of the library), so the recall ceiling from ranking-first-then-dropping is harmless. The case where dropping would lose good matches — a selective filter — is exactly the case that takes the filter-first path, where nothing is dropped. The branch picks the strategy where its own weakness doesn't apply. I'll be honest: it took me a beat to trust this — it feels like a cheat until you sit with it — but the two failure modes genuinely cancel out.






































filter-first (selective) rank-then-filter (broad)
Filtered universe ≤ threshold (default 1000) > threshold
Semantic query scoped to the filtered id set unscoped
Filters enforced at the Documents service (authoritative) locally, in permission_filter
Recall risk none — nothing off-filter can rank harmless — filter matches most docs
Round trips for the peek one (read the id set) one (just the count, then bail)


One peek, no extra round trips, and the precision/latency tradeoff is made explicit and data-driven instead of being a coin flip baked into the architecture.




💡 The pattern, generalized: when you can't decide between two retrieval strategies, measure the one property that distinguishes them (here, filter selectivity) with a single cheap probe, and route on it. Each branch is chosen precisely where its failure mode is benign.







4️⃣ Cohere rerank, then a fail-closed view gate — and why "after" is fine



The content path is ranked by Cohere (over the top rerank_candidates, default 30), with the OpenSearch fused score / RRF as the fallback when the reranker is unreachable:




results = await bedrock_client.rerank(query=query_text, documents=documents, model_id=model_id)
# ... on any failure or empty result -> _fallback() sorts by the OpenSearch raw score






Metadata results skip the reranker entirely — there's nothing to score — and keep the Documents service order:




if all(c.get("match_kind") == "metadata" for c in candidates):
return {"reranked": candidates, "cached_top_30": candidates}






Then comes the part that makes security people lean forward in their chair: the per-document view-permission gate runs after rerank, not before retrieval. Stay with me — it's safe, and the reason it's safe is the whole point. permission_filter takes the reranked content hits, confirms each one is viewable via a single bulk export_by_ids call against the system of record, and drops the ones that aren't — fail-closed, so if the service is unreachable, every content hit is dropped rather than leaked:




rows = await documents_service.export_by_ids(content_ids, jwt)
for row in rows:
dvid = row.get("document_version_id")
if dvid:
viewable.add(dvid)
# ... survivors = metadata rows (trusted) + content hits whose id is in `viewable`






Why is gating after rerank safe? Because there are two different boundaries doing two different jobs:





  1. Tenant isolation is the hard wall, and it's enforced at retrieval. Every OpenSearch query is scoped by member_id, and that id comes from the JWT — never from the request body, never from the index. A user physically cannot retrieve another tenant's vectors. That boundary is upstream and absolute.


  2. The post-rerank gate is the soft boundary: per-document view visibility within your own tenant. Drafts, restricted categories, documents shared with three people. That nuance changes hourly, so it does not live in the index — it's checked live, after retrieval, against the source of record.



Rerank only orders the candidates. It exposes nothing — every candidate it sees already passed the tenant wall, and the set it's ordering is bounded (the reranked top set), so the view gate is one bounded bulk call, not a fan-out. The metadata lane skips the gate entirely because its rows came back already view-filtered by the service's own SQL — re-checking trusted rows would be wasted work. Identity from the token, tenant at retrieval, view-visibility after, fail-closed throughout.






🧵 The seam I owned on the frontend: a null relevance score



Here's a small but real consequence of "one strategy per query" that landed squarely on my side of the stack — and nearly slipped past me. Content hits carry a relevance score. Metadata hits don't — a satisfied filter is not a relevance measurement, and showing the OpenSearch fused score (which for an exact field match is ~0) would read as "barely relevant" for what is actually a perfect match. So a metadata hit emits a null score in the SSE payload, and the UI has to render that as "matched your filter," not "0% relevant":




def _shape_source_for_emit(s: dict) -> dict:
# A metadata match is a satisfied filter, not a relevance measure, so its score stays null
# (the card shows just the match reason); content matches keep their fused/rerank score.
score = None if s.get("score_signal") == "metadata" else (
s.get("aggregate_score") or s.get("score") or 0.0
)
...






It's a tiny detail, but it's the kind of thing that breaks if the frontend assumes "every result has a number." A null score is a first-class state — "this matched a filter, there's no relevance to show" — not a missing value to default to zero. Defaulting it to 0.0 would have quietly buried every exact metadata match at the bottom of the list, sorted below loosely-relevant content — a perfect match dead last, and no error anywhere to tell you why. The contract had to carry the absence on purpose.



The throughline across all four decisions — the two in Part 1 and the two here — is the same instinct: make the system commit to one clear shape per turn, and make the tradeoffs explicit instead of averaging them away. One router call instead of three. One retrieval strategy instead of a fused blend. One selectivity probe instead of a parallel-vs-sequential guess. One bounded permission check after a bounded rerank. The muddy-results version tried to do everything at once and let the reranker sort it out. It couldn't. Picking, on purpose, is what made the rank mean something again.



👋 Thanks for sticking through both parts. If you've fought the same muddy-rank fight, I'd genuinely love to hear how you cut yours — the failure modes are weirdly universal.






Missed the setup? Part 1 covers the muddy-results problem, the single-call router, and the reframe to one strategy per query.






Thanks for reading all the way through 🙌 If you're building agents and fighting the same "is this finding even real?" problem, I'd genuinely like to compare notes — come say hi on LinkedIn.

Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten Building a multi-agent document-search copilot — Part 2: adaptive Hybrid, and a permission gate after the rank

Thematisch verwandte Begriffe: Building, multiagent, documentsearch, copilot · 6 Treffer

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Zum Aktualisieren ziehen
ZERO-DAY CVE-2026-94030 | A security vulnerability has been detected in SerenityOS up to 3d83e4509…
Advisory →
TTS Reader • tsecurity.de Voice
tsecurity.de Icon
tsecurity.de App
Offline-Lesen, Eilmeldungen & 0ms Ladezeit

Installiere tsecurity.de direkt auf deinen Home-Bildschirm für das ultimative Vollbild-Magazinerlebnis ohne Browser-Leisten.

Nächster Beitrag
Themen-Radar & Intelligence Matrix
Echtzeit-Taxonomie nach Angriffsvektoren & Plattformen

tsecurity.de Live Threat Radar

🔴 LIVE RADAR
MONITORING
AKTIV
CVE-DATENBANK
LIVE
🔍
Community Radar & Live Chat
Sentinel Bot online • Live-Stream
Dein Cluster: Security Explorer
Match:
lädt…
Verbindung zum Community-Stream wird aufgebaut...
Bearbeitungsmodus — Senden überschreibt deine Nachricht
Community-Puls — was gerade passiert
lädt…
Aktivitäten deiner Analysten
lädt…
Neues Thema oder Eilmeldung einreichen

Reiche interessante Links, Zero-Days oder Debatten ein. Die Community entscheidet per Upvote über die Veröffentlichung.

Heiß diskutierte Einreichungen
🔖 Gespeicherte Artikel
📂 Keine gespeicherten Artikel vorhanden.
Zurück Ziehen Vor
Links: vorheriger Artikel Rechts: nächster Artikel unten: schließen
News NIS-2 Frühwarnung Tier-1 Intel ⏱️ 3 Min vor 10 Min
Artikeldaten werden geladen...

Zurück: vorheriger Vor: nächster
↗ Original-Quelle
Social Reaktionen Deine Reaktion zählt
Einstufung & Relevanz-Poll 0 Stimmen
In sozialen Netzwerken teilen 1-Klick