<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged bedrock</title>
<link>https://coderlegion.com/tag/bedrock</link>
<description>Powered by Question2Answer</description>
<item>
<title>A Field Guide to Beating Amazon Bedrock Throttling</title>
<link>https://coderlegion.com/31889/a-field-guide-to-beating-amazon-bedrock-throttling</link>
<description>&lt;h3 id=&quot;-a-field-guide-to-beating-amazon-bedrock-throttling&quot;&gt;🛡️ A Field Guide to Beating Amazon Bedrock Throttling&lt;/h3&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Know your quota first: check Service Quotas, learn which limits are adjustable (soft) and which are not (hard).&lt;/li&gt;
&lt;li&gt;Retry with exponential backoff and full jitter on throttling errors. This is the floor, not the ceiling.&lt;/li&gt;
&lt;li&gt;Cap concurrency so you stop generating load the quota cannot absorb.&lt;/li&gt;
&lt;li&gt;Add a circuit breaker so a throttle storm backs off instead of hammering.&lt;/li&gt;
&lt;li&gt;Handle timeouts separately: shrink the batch for model timeouts, tune and retry for client read timeouts.&lt;/li&gt;
&lt;li&gt;Use cross-region inference profiles to spread token demand across regions.&lt;/li&gt;
&lt;li&gt;Reserve provisioned throughput only when on-demand plus the above is not enough.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;If you run real LLM traffic through Amazon Bedrock, throttling is not an edge case. It is the normal operating condition at scale. A &lt;code&gt;ThrottlingException&lt;/code&gt; or &lt;code&gt;TooManyRequestsException&lt;/code&gt; is Bedrock telling you that your request rate or token rate crossed a quota for that model in that region. The fix is rarely one setting. It is a layered defense: understand the quota, retry intelligently, cap concurrency, degrade gracefully, handle timeouts, and know what to escalate to AWS.&lt;/p&gt;
&lt;p&gt;This is the set of strategies I run across production LLM workloads, written as a generic guide. No workload specifics, just the patterns and when each one earns its place.&lt;/p&gt;
&lt;h3 id=&quot;step-one-know-the-quota-you-are-hitting&quot;&gt;Step one: know the quota you are hitting&lt;/h3&gt;
&lt;p&gt;Before writing a line of retry code, find the actual limit. Bedrock quotas are per model, per region, and split by type: tokens per minute (TPM), requests per minute (RPM), and for some models separate input/output token quotas.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/bedrock/latest/userguide/quotas.html&quot;&gt;Quotas for Amazon Bedrock&lt;/a&gt; — the overview, including that default quotas vary by account age, usage history, and region.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/bedrock/latest/userguide/quotas-runtime.html&quot;&gt;Quotas for the bedrock-runtime endpoint&lt;/a&gt; — the runtime invoke limits you hit at inference time. View them in the Service Quotas console under Amazon Bedrock.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/bedrock/latest/userguide/scaling-throughput-best-practices.html&quot;&gt;Scaling and throughput best practices&lt;/a&gt; — how per-model TPM and RPM quotas interact.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;soft-limits-vs-hard-limits&quot;&gt;Soft limits vs hard limits&lt;/h4&gt;
&lt;p&gt;This distinction decides your whole strategy.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Soft limits (adjustable).&lt;/strong&gt; In the &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/general/latest/gr/bedrock.html&quot;&gt;Amazon Bedrock endpoints and quotas&lt;/a&gt; table these are marked &lt;code&gt;Adjustable: Yes&lt;/code&gt;. You can raise them. Request an increase through &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/servicequotas/latest/userguide/request-quota-increase.html&quot;&gt;Service Quotas&lt;/a&gt;; smaller increases often auto-approve, larger ones route to AWS Support for review.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hard limits (not adjustable).&lt;/strong&gt; Marked &lt;code&gt;Adjustable: No&lt;/code&gt;. No amount of asking moves these. Your only lever is architectural: reduce demand, spread it across regions, or change the access pattern.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The practical rule: if the limit is soft and you are consistently near it, file the increase and also build the defenses below, because the increase takes time and never makes throttling impossible. If the limit is hard, skip the request entirely and go straight to architecture.&lt;/p&gt;
&lt;p&gt;One often-missed detail: Bedrock reserves TPM quota at the start of a request based on input tokens plus &lt;code&gt;maxTokens&lt;/code&gt;. If you leave &lt;code&gt;maxTokens&lt;/code&gt; unset, it can default to the model maximum and reserve far more quota than your call actually needs, so you throttle earlier than your real token use suggests. Set &lt;code&gt;maxTokens&lt;/code&gt; to a realistic ceiling.&lt;/p&gt;
&lt;h3 id=&quot;layer-one-retry-with-backoff-and-jitter&quot;&gt;Layer one: retry with backoff and jitter&lt;/h3&gt;
&lt;p&gt;The baseline defense. Catch the throttling error codes (&lt;code&gt;ThrottlingException&lt;/code&gt;, &lt;code&gt;TooManyRequestsException&lt;/code&gt;), wait, and try again. Two details separate a good retry from a harmful one:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Exponential backoff.&lt;/strong&gt; Each attempt waits longer: &lt;code&gt;base * 2^attempt&lt;/code&gt;, capped at a ceiling. This gives the quota window time to refill.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Full jitter.&lt;/strong&gt; Randomize the delay inside that window. Without jitter, every throttled caller retries at the same instant and you get a synchronized stampede that throttles again. With jitter, retries spread out.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A compact pattern:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import random, time
from botocore.exceptions import ClientError

THROTTLE = {&quot;ThrottlingException&quot;, &quot;TooManyRequestsException&quot;}

def call_with_backoff(fn, *, base=2.0, cap=15.0, max_retries=5):
    for attempt in range(max_retries + 1):
        try:
            return fn()
        except ClientError as exc:
            code = exc.response.get(&quot;Error&quot;, {}).get(&quot;Code&quot;, &quot;&quot;)
            if code not in THROTTLE or attempt == max_retries:
                raise
            delay = min(base * 2 ** attempt, cap)
            time.sleep(random.uniform(0, delay))  # full jitter
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A note on the boto3 client's own retries. Boto3 has a built-in retry mode (&lt;code&gt;standard&lt;/code&gt; or &lt;code&gt;adaptive&lt;/code&gt;) you set via &lt;code&gt;botocore.config.Config(retries=...)&lt;/code&gt;. You can lean on it, or disable it (&lt;code&gt;max_attempts=0&lt;/code&gt;) and own the retry loop yourself. Owning it is worth the effort when you want the backoff, jitter, and error classification to match the rest of your system rather than two retry layers fighting each other.&lt;/p&gt;
&lt;h4 id=&quot;the-same-retry-in-a-step-functions-workflow&quot;&gt;The same retry in a Step Functions workflow&lt;/h4&gt;
&lt;p&gt;If your Bedrock calls run inside a Step Functions state machine, you do not write the backoff loop by hand. You declare it on the task's &lt;code&gt;Retry&lt;/code&gt; block and the service applies exponential backoff with jitter for you. &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/step-functions/latest/dg/concepts-error-handling.html&quot;&gt;Step Functions error handling&lt;/a&gt; added a &lt;code&gt;JitterStrategy&lt;/code&gt; field so wait times are spread instead of synchronized, the same reason full jitter matters in code.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;&quot;InvokeBedrock&quot;: {
  &quot;Type&quot;: &quot;Task&quot;,
  &quot;Resource&quot;: &quot;arn:aws:states:::bedrock:invokeModel&quot;,
  &quot;Retry&quot;: [
    {
      &quot;ErrorEquals&quot;: [&quot;ThrottlingException&quot;, &quot;TooManyRequestsException&quot;],
      &quot;IntervalSeconds&quot;: 2,
      &quot;MaxAttempts&quot;: 15,
      &quot;BackoffRate&quot;: 2.0,
      &quot;MaxDelaySeconds&quot;: 30,
      &quot;JitterStrategy&quot;: &quot;FULL&quot;
    }
  ],
  &quot;Catch&quot;: [
    { &quot;ErrorEquals&quot;: [&quot;States.ALL&quot;], &quot;Next&quot;: &quot;HumanReview&quot; }
  ],
  &quot;Next&quot;: &quot;NextStep&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The fields map onto the code version: &lt;code&gt;IntervalSeconds&lt;/code&gt; is the base delay, &lt;code&gt;BackoffRate&lt;/code&gt; is the exponential multiplier, &lt;code&gt;MaxDelaySeconds&lt;/code&gt; is the cap, &lt;code&gt;MaxAttempts&lt;/code&gt; is the retry count, and &lt;code&gt;JitterStrategy: FULL&lt;/code&gt; randomizes each wait. The &lt;code&gt;Catch&lt;/code&gt; routes to a fallback once retries are exhausted, for example a human-review step, so an exhausted retry degrades gracefully instead of failing the whole execution.&lt;/p&gt;
&lt;h3 id=&quot;layer-two-cap-concurrency&quot;&gt;Layer two: cap concurrency&lt;/h3&gt;
&lt;p&gt;Retries handle the request that already failed. Concurrency caps stop you from generating more load than the quota can absorb in the first place. If 50 workers all call the same model at once, you will throttle no matter how clever the backoff is.&lt;/p&gt;
&lt;p&gt;Put a ceiling on in-flight Bedrock calls. In async Python a semaphore does it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import asyncio

semaphore = asyncio.Semaphore(10)  # max concurrent Bedrock calls

async def bounded_call(fn):
    async with semaphore:
        return await fn()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Upstream of the application, cap concurrency at the infrastructure layer too. If documents arrive as events, put them on an SQS queue and let the SQS-to-Lambda event source mapping enforce a &lt;code&gt;maximum_concurrency&lt;/code&gt;. Work waits in the queue until a slot frees, with no polling and no custom counters:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-hcl&quot;&gt;resource &quot;aws_lambda_event_source_mapping&quot; &quot;sqs_to_worker&quot; {
  event_source_arn = aws_sqs_queue.work.arn
  function_name    = aws_lambda_function.worker.arn
  batch_size       = 1
  scaling_config {
    maximum_concurrency = 5
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Match the concurrency ceiling to the quota, not to how many workers you could theoretically run. The right number is the one that keeps steady-state token rate under the TPM limit.&lt;/p&gt;
&lt;h3 id=&quot;layer-three-a-circuit-breaker-for-throttle-storms&quot;&gt;Layer three: a circuit breaker for throttle storms&lt;/h3&gt;
&lt;p&gt;Backoff and concurrency caps handle normal pressure. A sustained throttle storm needs something that steps back harder. A circuit breaker watches the throttle rate and, once it crosses a threshold inside a time window, pauses all calls for a cooldown, then resumes at reduced concurrency before restoring full throughput.&lt;/p&gt;
&lt;p&gt;The state machine:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Closed&lt;/strong&gt; (normal): calls flow, throttles are counted.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Open&lt;/strong&gt; (tripped): after N throttles in the window, pause all calls for a cooldown and halve the concurrency limit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Half-open&lt;/strong&gt; (recovering): after the cooldown, resume at the reduced limit; if clean for a while, restore full concurrency; if it throttles again, reopen.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This converts a feedback loop that makes throttling worse (retry, throttle, retry, throttle) into one that actively sheds load until Bedrock recovers. It pairs with the concurrency limiter: the breaker is what turns the limit down and back up.&lt;/p&gt;
&lt;h3 id=&quot;layer-four-handle-timeouts-they-are-not-throttling&quot;&gt;Layer four: handle timeouts, they are not throttling&lt;/h3&gt;
&lt;p&gt;Throttling rarely travels alone, and the loudest neighbor is the timeout. There are two distinct kinds, and they want opposite fixes.&lt;/p&gt;
&lt;h4 id=&quot;model-timeout-shrink-the-work&quot;&gt;Model timeout: shrink the work&lt;/h4&gt;
&lt;p&gt;A &lt;code&gt;ModelTimeoutException&lt;/code&gt; means the request itself was too large to finish in the model's time budget. It is not a rate problem, so retrying the same payload will time it out again. The fix is to reduce the work: halve the batch or chunk the input, then retry smaller.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;async def call_shrinking(fn, items, *, max_retries=3):
    batch = list(items)
    for attempt in range(max_retries + 1):
        try:
            return await fn(batch)
        except Exception as exc:
            code = getattr(exc, &quot;response&quot;, {}).get(&quot;Error&quot;, {}).get(&quot;Code&quot;, &quot;&quot;)
            if code != &quot;ModelTimeoutException&quot; or attempt == max_retries:
                raise
            batch = batch[: max(1, len(batch) // 2)]  # halve and retry
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;client-timeout-tune-the-budget-then-retry-the-transient-ones&quot;&gt;Client timeout: tune the budget, then retry the transient ones&lt;/h4&gt;
&lt;p&gt;A client-side &lt;code&gt;ReadTimeoutError&lt;/code&gt; or &lt;code&gt;ConnectTimeoutError&lt;/code&gt; is different again: the SDK gave up waiting before the model answered. Large prompts and long generations routinely exceed the boto3 default read timeout, which produces timeouts that look like failures but are really an impatient client.&lt;/p&gt;
&lt;p&gt;Two moves:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Set a realistic &lt;code&gt;read_timeout&lt;/code&gt;&lt;/strong&gt; on the boto3 client so a slow-but-valid response is not cut off.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Treat the transient connection timeouts as retryable&lt;/strong&gt;, alongside throttling, rather than letting them bubble up as hard failures.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from botocore.config import Config

client = boto3.client(
    &quot;bedrock-runtime&quot;,
    config=Config(read_timeout=300, retries={&quot;max_attempts&quot;: 0}),
)

RETRYABLE = {&quot;ThrottlingException&quot;, &quot;TooManyRequestsException&quot;}
# also retry ReadTimeoutError / ConnectTimeoutError by exception class name
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Classify before reacting. A throttle wants backoff, a model timeout wants a smaller batch, a client timeout wants a longer budget plus a retry, and a &lt;code&gt;ValidationException&lt;/code&gt; wants no retry at all.&lt;/p&gt;
&lt;h3 id=&quot;layer-five-spread-demand-with-cross-region-inference-profiles&quot;&gt;Layer five: spread demand with cross-region inference profiles&lt;/h3&gt;
&lt;p&gt;Quotas are per region. A &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles.html&quot;&gt;cross-region inference profile&lt;/a&gt; lets a single on-demand call be served from one of several regions, so your token demand is spread across each region's quota instead of piling onto one. For on-demand workloads this is often the highest-leverage change: no capacity to buy, and the effective ceiling goes up because you are drawing from multiple regional pools.&lt;/p&gt;
&lt;p&gt;It is not infinite headroom, and it does not change a hard per-model limit in a single region, but for spiky on-demand traffic it buys real room.&lt;/p&gt;
&lt;h3 id=&quot;layer-six-provisioned-throughput-only-when-you-need-it&quot;&gt;Layer six: provisioned throughput, only when you need it&lt;/h3&gt;
&lt;p&gt;If on-demand plus everything above still throttles under predictable heavy load, Bedrock offers &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/bedrock/latest/userguide/prov-throughput.html&quot;&gt;Provisioned Throughput&lt;/a&gt;: reserved capacity (model units) with a guaranteed token rate, bought by commitment. It removes throttling for that reserved slice because the capacity is yours.&lt;/p&gt;
&lt;p&gt;Treat it as the last layer, not the first. It costs whether or not you use it, so it only makes sense for steady, predictable volume. For bursty or unpredictable traffic, retries, concurrency caps, and cross-region profiles on on-demand are usually the better economics.&lt;/p&gt;
&lt;h3 id=&quot;tuning-in-practice&quot;&gt;Tuning in practice&lt;/h3&gt;
&lt;p&gt;The layers above are the toolbox. How hard you turn each knob depends on the workload, and a couple of patterns are worth stating plainly.&lt;/p&gt;
&lt;p&gt;Match the aggressiveness of retries to the cost of a lost call. Heavy, expensive calls justify more retry attempts, a higher backoff ceiling, and tighter concurrency, because losing one is costly. High-volume, cheaper work is better served by an upstream queue that caps concurrency and a workflow-level self-retry that ends in a manual-review fallback, keeping the per-call retries light. There is no single correct number; the right setting is the one that keeps steady-state token rate under the quota for that workload.&lt;/p&gt;
&lt;p&gt;Two caveats that are easy to get wrong:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A &quot;fallback model&quot; can quietly resolve to the same model.&lt;/strong&gt; If both the primary and fallback point at the same model id through shared config, the fallback functions as extra retries against one model, not as model diversity. Calling it a fallback overstates what it does until the config points at a genuinely different model.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retry, backoff, and concurrency are not the same as reserved capacity.&lt;/strong&gt; A defense built entirely from retries, backoff, concurrency caps, and on-demand cross-region inference profiles has no reserved-capacity floor under a true spike. That can be a deliberate on-demand posture, but name it as one, because on-demand plus retries still has a ceiling.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;how-to-decide-what-to-reach-for&quot;&gt;How to decide what to reach for&lt;/h3&gt;
&lt;p&gt;A short decision order when you see throttling:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Check the quota.&lt;/strong&gt; Is the limit soft or hard? If soft and you are near it, file the increase now, in parallel with the rest.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Confirm retries have backoff and jitter.&lt;/strong&gt; If not, fix that first. It is the cheapest win.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cap concurrency&lt;/strong&gt; to keep steady-state rate under the TPM limit, at both the app and the infrastructure layer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add a circuit breaker&lt;/strong&gt; if throttle storms are recurring rather than occasional.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Separate timeouts from throttles.&lt;/strong&gt; Shrink the batch for model timeouts, tune the read timeout and retry for client timeouts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Spread across regions&lt;/strong&gt; with a cross-region inference profile for on-demand traffic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Buy provisioned throughput&lt;/strong&gt; only for steady, predictable, high volume the above cannot cover.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Throttling is not a wall you hit once and clear. It is a pressure you manage continuously. The systems that stay up are the ones that treat the quota as a first-class design constraint, not an exception to catch.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31889/a-field-guide-to-beating-amazon-bedrock-throttling</guid>
<pubDate>Tue, 20 Oct 2026 20:53:15 +0000</pubDate>
</item>
<item>
<title>Your LLM Keeps Making the Same Extraction Mistake. Here's How to Make It Learn.</title>
<link>https://coderlegion.com/30620/your-llm-keeps-making-the-same-extraction-mistake-heres-how-to-make-it-learn</link>
<description>&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I open-sourced an AWS sample that makes LLM document extraction &lt;em&gt;learn from human corrections&lt;/em&gt;. Each QA fix is captured once and reused, so the pipeline gets &lt;strong&gt;more accurate and cheaper over time&lt;/strong&gt; — with no retraining and no redeployment. Repo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/aws-samples/sample-prompt-correction-memory&quot;&gt;github.com/aws-samples/sample-prompt-correction-memory&lt;/a&gt;. You can run the whole self-healing loop locally in 30 seconds, no AWS account needed.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;the-problem-corrections-that-evaporate&quot;&gt;The problem: corrections that evaporate&lt;/h3&gt;
&lt;p&gt;If you've built an LLM extraction pipeline, you know this pain.&lt;/p&gt;
&lt;p&gt;Your QA analyst opens today's invoice. The model extracted &lt;code&gt;payment_terms&lt;/code&gt; as &lt;strong&gt;&quot;quarterly.&quot;&lt;/strong&gt; That's wrong — &quot;quarterly&quot; is the &lt;em&gt;billing frequency&lt;/em&gt;; the actual payment term is &lt;strong&gt;Net 30&lt;/strong&gt;. The analyst corrects it. Done.&lt;/p&gt;
&lt;p&gt;Tomorrow, an identical invoice from the same vendor arrives. The model extracts... &lt;strong&gt;&quot;quarterly&quot;&lt;/strong&gt; again. Same mistake. The analyst corrects it again.&lt;/p&gt;
&lt;p&gt;The correction &lt;em&gt;evaporated.&lt;/em&gt; It fixed one document and taught the system nothing.&lt;/p&gt;
&lt;p&gt;Multiply that across thousands of documents and dozens of recurring error patterns, and you get the two options most teams settle for:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Pay analysts to re-fix the same patterns forever&lt;/strong&gt; (QA cost never goes down), or&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fine-tune a model&lt;/strong&gt; (expensive, slow, needs ML ops, and you redo it every time the patterns shift).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;There's a third way that needs neither.&lt;/p&gt;
&lt;h3 id=&quot;the-idea-treat-corrections-as-durable-memory&quot;&gt;The idea: treat corrections as durable memory&lt;/h3&gt;
&lt;p&gt;What if every human correction became a permanent, reusable asset?&lt;/p&gt;
&lt;p&gt;That's the pattern behind &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/aws-samples/sample-prompt-correction-memory&quot;&gt;&lt;code&gt;sample-prompt-correction-memory&lt;/code&gt;&lt;/a&gt;. Instead of throwing corrections away, it stores them and feeds them back into future extractions — so the same mistake is never made twice.&lt;/p&gt;
&lt;p&gt;It works as &lt;strong&gt;three tiers&lt;/strong&gt;, and the magic is that traffic gradually shifts from the expensive tier to the free one:&lt;/p&gt;
&lt;h4 id=&quot;-tier-1-graduated-rules-0-no-llm-call-&quot;&gt;🟢 Tier 1 — Graduated rules ($0, no LLM call)&lt;/h4&gt;
&lt;p&gt;When the same correction pattern recurs enough times, the system synthesizes a &lt;strong&gt;deterministic rule&lt;/strong&gt; (a regex, a lookup, a normalization) that handles that field with zero LLM cost and sub-millisecond latency. &quot;Net 30&quot; → &lt;code&gt;30&lt;/code&gt; becomes a rule; it never needs a model again.&lt;/p&gt;
&lt;h4 id=&quot;-tier-2-cloud-llm-extraction-confidence-gated-&quot;&gt;🔵 Tier 2 — Cloud LLM extraction (confidence-gated)&lt;/h4&gt;
&lt;p&gt;Fields with no matching rule go to &lt;strong&gt;Amazon Bedrock (Claude Haiku 4.5)&lt;/strong&gt;. Every field comes back with a &lt;strong&gt;confidence score&lt;/strong&gt;. If confidence clears the threshold, accept it and move on.&lt;/p&gt;
&lt;h4 id=&quot;-tier-3-self-healing-only-when-needed-&quot;&gt;🟠 Tier 3 — Self-healing (only when needed)&lt;/h4&gt;
&lt;p&gt;If confidence is &lt;em&gt;below&lt;/em&gt; threshold, the system retrieves the most relevant past corrections, formats them as &lt;strong&gt;few-shot examples&lt;/strong&gt;, and re-extracts with a stronger model (&lt;strong&gt;Claude Sonnet 4.5&lt;/strong&gt;). The model learns from the specific mistakes it made before — in-context, no training.&lt;/p&gt;
&lt;p&gt;Every QA correction flows into a &lt;strong&gt;correction log&lt;/strong&gt; (DynamoDB) that powers both the self-healing retrieval &lt;em&gt;and&lt;/em&gt; the rule graduation. So the more the system is used, the smarter and cheaper it gets.&lt;/p&gt;
&lt;h3 id=&quot;why-this-matters-the-practical-payoff-&quot;&gt;Why this matters (the practical payoff)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No ML ops.&lt;/strong&gt; No retraining, no GPUs, no deployment cycles. A correction takes effect on the &lt;em&gt;very next&lt;/em&gt; document.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cost drops as quality rises.&lt;/strong&gt; Normally you trade one for the other. Here, recurring fixes graduate to $0 rules — so accuracy climbs while per-document cost falls.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;QA effort compounds instead of repeating.&lt;/strong&gt; Fix it once → fixed forever.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It's a QA action, not an ML project.&lt;/strong&gt; Improving the pipeline no longer requires the ML team. An analyst uploads a correction; the system improves.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The clearest fit is &lt;strong&gt;high-repetition, back-office document processing&lt;/strong&gt; — accounts payable, contracts, compliance filings, supply-chain docs — where the same field errors recur and analyst time is the real expense.&lt;/p&gt;
&lt;h3 id=&quot;the-architecture&quot;&gt;The architecture&lt;/h3&gt;
&lt;p&gt;Fully serverless, deployed with AWS SAM:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Document ──► S3 ──► EventBridge ──► SQS ──► Lambda (extract)
                                              │
                          ┌───────────────────┼───────────────────┐
                          ▼                    ▼                   ▼
                   Tier 1: Rules       Tier 2: Bedrock      Tier 3: Self-heal
                   (deterministic)     (Claude Haiku 4.5)   (Claude Sonnet 4.5)
                          ▲                                        │
                          │                                        ▼
                          └──────── Correction Log (DynamoDB) ◄────┘
                                    (grows from QA feedback)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;QA corrections are uploaded as JSON to an S3 &lt;code&gt;corrections/&lt;/code&gt; prefix; a second Lambda validates and ingests them into the correction log. Everything is encrypted with a customer-managed KMS key, and IAM is scoped to the specific Bedrock model ARNs the sample uses.&lt;/p&gt;
&lt;h3 id=&quot;see-it-in-30-seconds-no-aws-account-&quot;&gt;See it in 30 seconds (no AWS account)&lt;/h3&gt;
&lt;p&gt;The quickstart runs the entire self-healing loop with a mocked Bedrock client, so you can watch the mechanism without deploying anything:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/aws-samples/sample-prompt-correction-memory
cd sample-prompt-correction-memory
pip install -e &quot;.[dev]&quot;
python examples/quickstart.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You'll see output like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Step 1: Initial extraction (no correction memory)
  Field:      effective_date
  Value:      March 2024        Confidence: 0.55   ❌ NO
Step 2: Self-healing triggers (0.55 &amp;lt; threshold 0.70)
  Retrieving corrections for 'effective_date'...
  Found: &quot;March 2024&quot; → &quot;2024-03-01&quot;
Step 3: Re-extraction with correction memory
  Value:       2024-03-01       Confidence: 0.95   ✓ YES
  Self-Healed: True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Low-confidence extraction → retrieve past correction → re-extract → correct answer. No retraining. No redeployment.&lt;/p&gt;
&lt;h3 id=&quot;deploying-to-aws&quot;&gt;Deploying to AWS&lt;/h3&gt;
&lt;p&gt;With the AWS CLI, SAM CLI, and Bedrock model access for Claude Haiku 4.5 + Sonnet 4.5:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;make deploy    # S3, DynamoDB, Lambda, EventBridge, SQS, KMS
make seed      # load sample corrections into the correction log
make trigger   # upload a sample document → triggers real extraction
make verify    # read back results (fields, confidence, self_healed)
make destroy   # empty buckets + delete the stack
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;make verify&lt;/code&gt; prints each extracted field with its confidence and whether self-healing kicked in — your proof it works end-to-end.&lt;/p&gt;
&lt;h3 id=&quot;a-companion-spatial-memory&quot;&gt;A companion: spatial memory&lt;/h3&gt;
&lt;p&gt;This sample provides &lt;strong&gt;semantic&lt;/strong&gt; correction memory (what was extracted wrong, and why). Its companion, &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/aws-samples/sample-textract-field-memory&quot;&gt;&lt;code&gt;sample-textract-field-memory&lt;/code&gt;&lt;/a&gt;, provides &lt;strong&gt;spatial&lt;/strong&gt; memory (where fields appear on a document layout). Together they form a dual memory for document pipelines: use the cheap spatial lookup when you're confident where a field is, and fall back to self-healing extraction when you're not.&lt;/p&gt;
&lt;h3 id=&quot;honest-limitations&quot;&gt;Honest limitations&lt;/h3&gt;
&lt;p&gt;Because it's more useful when it's used well:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It's &lt;strong&gt;sample/reference code&lt;/strong&gt;, not a production library. Harden IAM, add monitoring, and put a human in the loop for high-stakes fields before real use.&lt;/li&gt;
&lt;li&gt;It helps most where the &lt;strong&gt;same field errors recur&lt;/strong&gt;. For wildly heterogeneous, one-off documents, there's less pattern to learn.&lt;/li&gt;
&lt;li&gt;Graduated rules currently take effect &lt;strong&gt;without a human approval step&lt;/strong&gt; — in production you'd add a review gate before a synthesized rule starts answering on its own.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don't use it with regulated data&lt;/strong&gt; (PII/PHI/PCI) as-is; that path needs a proper compliance review.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;try-it-and-tell-me&quot;&gt;Try it and tell me&lt;/h3&gt;
&lt;p&gt;The repo is open source under &lt;code&gt;aws-samples&lt;/code&gt;, MIT-0 licensed, with a benchmark suite, an interactive dashboard, and an offline test harness.&lt;/p&gt;
&lt;p&gt;👉 &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/aws-samples/sample-prompt-correction-memory&quot;&gt;github.com/aws-samples/sample-prompt-correction-memory&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;What repetitive extraction error is your team still fixing by hand? I'd love to hear which patterns you'd want a system like this to learn.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;This is an open-source AWS sample intended for demonstration and non-production use.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30620/your-llm-keeps-making-the-same-extraction-mistake-heres-how-to-make-it-learn</guid>
<pubDate>Sat, 10 Oct 2026 16:09:42 +0000</pubDate>
</item>
<item>
<title>Your RAG copilot can't count — stop letting it try</title>
<link>https://coderlegion.com/23854/your-rag-copilot-cant-count-stop-letting-it-try</link>
<description>&lt;h2 id=&quot;your-rag-copilot-can-t-count-stop-letting-it-try&quot;&gt;Your RAG copilot can't count — stop letting it try&lt;/h2&gt;
&lt;p&gt;A user asked our document-search copilot a completely reasonable question: how many documents a specific person authored. The copilot answered with a number. Confidently. With a nice sentence around it.&lt;/p&gt;
&lt;p&gt;The real answer, computed by the database, was &lt;strong&gt;84&lt;/strong&gt;. The copilot said — because it did what every RAG system does when you ask it an aggregation question: it counted what it could see.&lt;/p&gt;
&lt;p&gt;Here's the claim I want to defend, because I think a lot of us are shipping this bug right now:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 An LLM should never compute an aggregate. Not counts, not sums, not &quot;most recent&quot;. If the question is arithmetic over the corpus, the answer comes from the system of record — the model's only job is to phrase it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;-why-the-model-s-count-was-doomed-before-it-started&quot;&gt;🔢 Why the model's count was doomed before it started&lt;/h3&gt;
&lt;p&gt;This is a multi-agent document-search copilot for a quality-management product, built on LangGraph and Bedrock. When a metadata question comes in (&quot;documents authored by X&quot;), a search agent asks the documents service for matching rows, reranks and permission-gates them, collapses multiple versions of the same document into one card, and surfaces at most 30 results.&lt;/p&gt;
&lt;p&gt;Every one of those steps is correct for &lt;em&gt;search&lt;/em&gt;. Every one of them destroys the &lt;em&gt;count&lt;/em&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Stage &lt;/th&gt;&lt;th&gt;What it does &lt;/th&gt;&lt;th&gt;What &quot;how many&quot; means here&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Documents service (SQL) &lt;/td&gt;&lt;td&gt;runs the filter against the database &lt;/td&gt;&lt;td&gt;true total: &lt;strong&gt;84&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Retrieval &lt;/td&gt;&lt;td&gt;returns the top-k rows &lt;/td&gt;&lt;td&gt;capped&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;View-permission gate &lt;/td&gt;&lt;td&gt;drops rows this user can't see &lt;/td&gt;&lt;td&gt;fewer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Version collapse &lt;/td&gt;&lt;td&gt;5 versions of one doc → 1 card &lt;/td&gt;&lt;td&gt;fewer still&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Surfaced set &lt;/td&gt;&lt;td&gt;at most 30 cards &lt;/td&gt;&lt;td&gt;≤ 30, always&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The model sits at the bottom of that funnel. When it &quot;counts&quot;, it counts the last row of the table. The user asked about the first row. Everything in between is retrieval hygiene that silently turned a database question into a context-window question.&lt;/p&gt;
&lt;p&gt;And this is the part that makes it worse than a crash: &lt;strong&gt;the answer is plausible&lt;/strong&gt;. A count that's capped at 30 and deduped looks like a real number. Nobody squints at &quot;you have 27 documents&quot; the way they'd squint at a stack trace. In a quality-management context, someone might put that number in a report.&lt;/p&gt;
&lt;h3 id=&quot;-the-twist-we-had-the-right-number-the-whole-time-and-overwrote-it&quot;&gt;🪤 The twist: we had the right number the whole time — and overwrote it&lt;/h3&gt;
&lt;p&gt;This is the bit that stung. The documents service &lt;em&gt;returns&lt;/em&gt; the true total with every filtered query. The search node even stored it in agent state, in a field called &lt;code&gt;total_available_results&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Then, a few nodes downstream, the permission-gate node overwrote that same field with the surfaced count — deliberately, and for a good reason:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;return {
    &quot;sources&quot;: diversified,
    # Honest &quot;documents found&quot;: distinct logical documents after the view gate and
    # version-collapse, so the headline count matches the cards (5 versions → 4 docs),
    # not the service's version-row total.
    &quot;total_available_results&quot;: len(diversified_all),
    ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That comment is right! If the UI says &quot;found 12&quot; and shows 12 cards, the numbers should agree. The bug wasn't either write in isolation — it was one state field carrying &lt;strong&gt;two different meanings&lt;/strong&gt; at two different points in the pipeline. Upstream it meant &quot;matches in the database&quot;; downstream it meant &quot;cards on screen&quot;. Whoever read it last got whichever meaning was freshest.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 A state field whose meaning mutates mid-pipeline is a bug factory. If a value is authoritative, give it its own field and never let anything downstream touch it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;-the-fix-the-database-counts-the-model-narrates&quot;&gt;🔧 The fix: the database counts, the model narrates&lt;/h3&gt;
&lt;p&gt;So that's exactly what I did — preserved the service's true total in its own state field, threaded it to the two places that answer the user, and left the surfaced count alone for the card list and pager.&lt;/p&gt;
&lt;p&gt;First, the state gets an untouchable field:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class DocumentsSearchState(OrchestratorState, total=False):
    ...
    total_available_results: int   # surfaced: view-gated, version-collapsed, capped at 30

    # The documents service's true match total for the filter, before the 30-cap and
    # version-collapse. Authoritative for a &quot;how many&quot; count; 0 on a content-only
    # search, which has no exact total.
    total_matching_records: int
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then the outcome node — the one that turns search state into both the user-facing status line and the message the agent reasons over — uses the authoritative number in both:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;total_matching = state.get(&quot;total_matching_records&quot;) or 0
headline_total = total_matching if total_matching &amp;gt; total else total

found_msg = (
    f&quot;Showing {len(sources)} of {headline_total} matching documents&quot;
    if headline_total &amp;gt; len(sources)
    else f&quot;Found {len(sources)} matching documents&quot;
)
...
count_note = (
    f&quot; {total_matching} documents match in total &quot;
    &quot;(authoritative count; use this for a count question).&quot;
    if total_matching &amp;gt; len(sources)
    else &quot;&quot;
)
return f&quot;Returned {len(sources)} document cards to the user.{count_note} ...&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the agent's prompt closes the loop with an explicit instruction:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;For a &quot;how many&quot; question, answer with the authoritative total the tool
reports (&quot;N documents match in total&quot;), never the number of cards shown,
which is capped.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice the belt-and-braces structure, because it matters: the number goes into the &lt;strong&gt;agent's context&lt;/strong&gt; (so the model can phrase the answer around it) &lt;em&gt;and&lt;/em&gt; into the &lt;strong&gt;user-facing headline&lt;/strong&gt; (&quot;Showing 5 of 84 matching documents&quot;). Even on a turn where the model ignores the note and free-styles a count, the UI is showing the true total right next to it. The prompt asks; the headline doesn't have to.&lt;/p&gt;
&lt;p&gt;Three small tests pin the behavior: the count note appears when there are more matches than cards, disappears when everything is shown, and never appears for a content-only semantic search — because a similarity ranking has no crisp &quot;matches&quot; set, so pretending it has an exact total would be the same lie in a new costume.&lt;/p&gt;
&lt;h3 id=&quot;-the-sibling-bug-the-last-10-days-of-what-&quot;&gt;📅 The sibling bug: &quot;the last 10 days&quot; of what?&lt;/h3&gt;
&lt;p&gt;Same release, same root cause wearing a different hat. Users asked for &quot;documents created in the last 10 days&quot; and the copilot had to turn that into a date filter — without knowing what day it is. A model doesn't know today's date. It will happily guess one from its training data, which is exactly as wrong as counting its context window.&lt;/p&gt;
&lt;p&gt;The fix is a tiny LangGraph middleware that injects the current date into the system message on &lt;strong&gt;every model call&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def current_date_context(now: datetime | None = None) -&amp;gt; str:
    day = (now or datetime.now(UTC)).date()
    return (
        f&quot;Today's date is {day.isoformat()} (UTC). Resolve any relative date &quot;
        '(&quot;today&quot;, &quot;yesterday&quot;, &quot;the last 10 days&quot;, &quot;since March&quot;) '
        &quot;to concrete ISO-8601 dates relative to it before building a date filter.&quot;
    )


class CurrentDateMiddleware(AgentMiddleware):
    async def awrap_model_call(self, request, handler):
        base = request.system_message
        line = current_date_context()
        merged = line if base is None else f&quot;{base.content}\n\n{line}&quot;
        return await handler(request.override(system_message=SystemMessage(content=merged)))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One non-obvious detail: it's injected per call and never appended to the persisted conversation. Write &quot;today is 2026-07-30&quot; into the thread history and a conversation resumed next week carries a stale date the model will trust completely.&lt;/p&gt;
&lt;p&gt;Both fixes are the same principle: &lt;strong&gt;the model knows nothing you don't hand it, and it will answer anyway.&lt;/strong&gt; Counts, dates, totals — anything that's a fact about &lt;em&gt;your&lt;/em&gt; system rather than about language has to arrive as context, computed by something that can't hallucinate.&lt;/p&gt;
&lt;h3 id=&quot;-where-i-d-push-back-on-myself&quot;&gt;🤔 Where I'd push back on myself&lt;/h3&gt;
&lt;p&gt;Honest limits of what shipped:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Nothing enforces that the model uses the number.&lt;/strong&gt; The prompt instructs, the tool message supplies, the headline backstops — but there's no output check asserting the count in the model's prose equals the authoritative one. That's an eval I'd like to have and don't yet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Content-only searches still have no real count.&lt;/strong&gt; A semantic ranking over chunks genuinely doesn't have an exact match total, so those turns fall back to the surfaced count. Arguably the honest move there is to refuse to give a number at all. I didn't go that far.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;This only covers counts.&lt;/strong&gt; Sums, averages, &quot;which author has the most&quot; — any of those would need real aggregation endpoints on the service side. The pattern extends; the implementation doesn't yet.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And the design choice I keep turning over: I handed the model the number &lt;strong&gt;inline, as prose in a tool result&lt;/strong&gt;. The agentic-purist alternative is a dedicated &lt;code&gt;count_documents&lt;/code&gt; tool the model calls when it detects an aggregation question — cleaner separation, one more round trip, and a new failure mode where the model doesn't call it. I went with inline because the search already paid for the count; a tool call felt like latency for ideology.&lt;/p&gt;
&lt;p&gt;So: where do you draw that line — hand the model the number, or hand it a tool to fetch the number? If your copilot answers &quot;how many&quot; questions today, I'd genuinely like to know which side you picked and whether it's held up.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Thanks for making it to the end 🙏 If your assistant has ever announced a total that made you go &quot;wait, that can't be right&quot;, I'd love to swap notes on how you fixed it — come find me on &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/rodrigo-diego-67867185/&quot;&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23854/your-rag-copilot-cant-count-stop-letting-it-try</guid>
<pubDate>Fri, 31 Jul 2026 11:05:28 +0000</pubDate>
</item>
<item>
<title>Building a multi-agent document-search copilot - Part 2: adaptive Hybrid, post-rank permission gate</title>
<link>https://coderlegion.com/22632/building-multi-agent-document-search-copilot-part-adaptive-hybrid-post-rank-permission</link>
<description>&lt;h2 id=&quot;building-a-multi-agent-document-search-copilot-part-2-adaptive-hybrid-and-a-permission-gate-after-the-rank&quot;&gt;Building a multi-agent document-search copilot — Part 2: adaptive Hybrid, and a permission gate after the rank&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;This is Part 2 of two. &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/22631/&quot;&gt;Part 1&lt;/a&gt; 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 — &lt;code&gt;MetadataOnly&lt;/code&gt;, &lt;code&gt;ContentOnly&lt;/code&gt;, or &lt;code&gt;Hybrid&lt;/code&gt; — instead of fusing the lanes. We left off at the one strategy that refuses to be tidy: &lt;code&gt;Hybrid&lt;/code&gt;, where the user wants a topic and filters at the same time.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Picking up where Part 1 stopped, we're now in the back half of the same pipeline — the &lt;code&gt;direct_search → rerank → permission_filter → finalize_results&lt;/code&gt; tail of the search graph. Two decisions left: how &lt;code&gt;Hybrid&lt;/code&gt; actually retrieves, and where permissions get enforced.&lt;/p&gt;
&lt;h3 id=&quot;3-adaptive-hybrid-peek-once-then-pick-filter-first-or-rank-then-filter&quot;&gt;3️⃣ Adaptive Hybrid: peek once, then pick filter-first or rank-then-filter&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Hybrid&lt;/code&gt; is the interesting one, because &quot;topic + filters&quot; 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 &lt;strong&gt;parallel&lt;/strong&gt; (fast, but you have to reconcile two sets), or in &lt;strong&gt;sequence&lt;/strong&gt; (precise, but slower)? We resolved it by refusing to pick — and routing on &lt;em&gt;selectivity&lt;/em&gt; instead.&lt;/p&gt;
&lt;p&gt;The trick is one cheap question asked once: &lt;strong&gt;how many documents match the filters?&lt;/strong&gt; &lt;code&gt;direct_search&lt;/code&gt; peeks the filtered-universe size from the Documents service a single time, with a threshold (default &lt;code&gt;1000&lt;/code&gt;), and branches:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;elif strategy == &quot;Hybrid&quot;:
    threshold = cfg.get(&quot;filter_first_threshold&quot;, 1000)
    id_page = cfg.get(&quot;filter_id_page_size&quot;, 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
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Bounded set (≤ threshold) → filter-first.&lt;/strong&gt; The peek returns the actual id set. The semantic query is scoped to exactly those ids via an OpenSearch &lt;code&gt;terms&lt;/code&gt; 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:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 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
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Broad set (&amp;gt; threshold) → rank-then-filter.&lt;/strong&gt; 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 &lt;code&gt;_row_passes_filters&lt;/code&gt; — a faithful mirror of the Documents service's operation semantics across every searchable column (string / number / date / boolean / user_list, with &lt;code&gt;contains&lt;/code&gt; / &lt;code&gt;equal&lt;/code&gt; / &lt;code&gt;greaterThan&lt;/code&gt; / the rest):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _row_passes_filters(row: dict, filters: list[dict]) -&amp;gt; bool:
    &quot;&quot;&quot;Faithful local mirror of the Documents service filter semantics ...
    any hit whose row fails a hard filter is dropped, fail-closed.&quot;&quot;&quot;
    return all(_row_passes_one(row, f) for f in filters)
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The thing that makes this &lt;em&gt;safe&lt;/em&gt; 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 &lt;em&gt;selective&lt;/em&gt; 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.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;filter-first (selective) &lt;/th&gt;&lt;th&gt;rank-then-filter (broad)&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Filtered universe &lt;/td&gt;&lt;td&gt;≤ threshold (default 1000) &lt;/td&gt;&lt;td&gt;&amp;gt; threshold&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Semantic query &lt;/td&gt;&lt;td&gt;scoped to the filtered id set &lt;/td&gt;&lt;td&gt;unscoped&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Filters enforced &lt;/td&gt;&lt;td&gt;at the Documents service (authoritative) &lt;/td&gt;&lt;td&gt;locally, in &lt;code&gt;permission_filter&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Recall risk &lt;/td&gt;&lt;td&gt;none — nothing off-filter can rank &lt;/td&gt;&lt;td&gt;harmless — filter matches most docs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Round trips for the peek &lt;/td&gt;&lt;td&gt;one (read the id set) &lt;/td&gt;&lt;td&gt;one (just the count, then bail)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;One peek, no extra round trips, and the precision/latency tradeoff is made &lt;em&gt;explicit and data-driven&lt;/em&gt; instead of being a coin flip baked into the architecture.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;4-cohere-rerank-then-a-fail-closed-view-gate-and-why-after-is-fine&quot;&gt;4️⃣ Cohere rerank, then a fail-closed view gate — and why &quot;after&quot; is fine&lt;/h3&gt;
&lt;p&gt;The content path is ranked by Cohere (over the top &lt;code&gt;rerank_candidates&lt;/code&gt;, default &lt;code&gt;30&lt;/code&gt;), with the OpenSearch fused score / RRF as the fallback when the reranker is unreachable:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;results = await bedrock_client.rerank(query=query_text, documents=documents, model_id=model_id)
# ... on any failure or empty result -&amp;gt; _fallback() sorts by the OpenSearch raw score
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Metadata results skip the reranker entirely — there's nothing to score — and keep the Documents service order:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;if all(c.get(&quot;match_kind&quot;) == &quot;metadata&quot; for c in candidates):
    return {&quot;reranked&quot;: candidates, &quot;cached_top_30&quot;: candidates}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then comes the part that makes security people lean forward in their chair: the per-document &lt;strong&gt;view-permission gate runs &lt;em&gt;after&lt;/em&gt; rerank&lt;/strong&gt;, not before retrieval. Stay with me — it's safe, and the reason it's safe is the whole point. &lt;code&gt;permission_filter&lt;/code&gt; takes the reranked content hits, confirms each one is viewable via a single bulk &lt;code&gt;export_by_ids&lt;/code&gt; 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:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;rows = await documents_service.export_by_ids(content_ids, jwt)
for row in rows:
    dvid = row.get(&quot;document_version_id&quot;)
    if dvid:
        viewable.add(dvid)
# ... survivors = metadata rows (trusted) + content hits whose id is in `viewable`
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why is gating &lt;em&gt;after&lt;/em&gt; rerank safe? Because there are two different boundaries doing two different jobs:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Tenant isolation is the hard wall, and it's enforced at retrieval.&lt;/strong&gt; Every OpenSearch query is scoped by &lt;code&gt;member_id&lt;/code&gt;, 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.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The post-rerank gate is the soft boundary: per-document &lt;em&gt;view&lt;/em&gt; visibility within your own tenant.&lt;/strong&gt; Drafts, restricted categories, documents shared with three people. That nuance changes hourly, so it does &lt;em&gt;not&lt;/em&gt; live in the index — it's checked live, after retrieval, against the source of record.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Rerank only &lt;em&gt;orders&lt;/em&gt; 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.&lt;/p&gt;
&lt;h3 id=&quot;the-seam-i-owned-on-the-frontend-a-null-relevance-score&quot;&gt;The seam I owned on the frontend: a null relevance score&lt;/h3&gt;
&lt;p&gt;Here's a small but real consequence of &quot;one strategy per query&quot; 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 &quot;barely relevant&quot; for what is actually a perfect match. So a metadata hit emits a &lt;strong&gt;&lt;code&gt;null&lt;/code&gt;&lt;/strong&gt; score in the SSE payload, and the UI has to render that as &quot;matched your filter,&quot; not &quot;0% relevant&quot;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _shape_source_for_emit(s: dict) -&amp;gt; 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(&quot;score_signal&quot;) == &quot;metadata&quot; else (
        s.get(&quot;aggregate_score&quot;) or s.get(&quot;score&quot;) or 0.0
    )
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It's a tiny detail, but it's the kind of thing that breaks if the frontend assumes &quot;every result has a number.&quot; A &lt;code&gt;null&lt;/code&gt; score is a first-class state — &quot;this matched a filter, there's no relevance to show&quot; — not a missing value to default to zero. Defaulting it to &lt;code&gt;0.0&lt;/code&gt; would have quietly buried every exact metadata match at the bottom of the list, sorted &lt;em&gt;below&lt;/em&gt; 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.&lt;/p&gt;
&lt;p&gt;The throughline across all four decisions — the two in Part 1 and the two here — is the same instinct: &lt;strong&gt;make the system commit to one clear shape per turn, and make the tradeoffs explicit instead of averaging them away.&lt;/strong&gt; 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.&lt;/p&gt;
&lt;p&gt; 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. Come say hi on &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/rodrigo-diego-67867185/&quot;&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Missed the setup? &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/22631/&quot;&gt;Part 1&lt;/a&gt; covers the muddy-results problem, the single-call router, and the reframe to one strategy per query.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22632/building-multi-agent-document-search-copilot-part-adaptive-hybrid-post-rank-permission</guid>
<pubDate>Tue, 14 Jul 2026 13:19:33 +0000</pubDate>
</item>
<item>
<title>Building a multi-agent document-search copilot — Part 1: muddy results, and one strategy per query</title>
<link>https://coderlegion.com/22631/building-multi-agent-document-search-copilot-part-muddy-results-and-one-strategy-per-query</link>
<description>&lt;h2 id=&quot;building-a-multi-agent-document-search-copilot-part-1-muddy-results-and-one-strategy-per-query&quot;&gt;Building a multi-agent document-search copilot — Part 1: muddy results, and one strategy per query&lt;/h2&gt;
&lt;p&gt;The first version ranked documents badly — and worse, it ranked them badly in a way that looked &lt;em&gt;fine&lt;/em&gt; on the architecture diagram. Those are the bugs that get under my skin: every box is green, every arrow points the right way, and the answer is still wrong.&lt;/p&gt;
&lt;p&gt;We were building a chat copilot over a regulated document store — the kind where a user types &quot;show me my effective SOPs about equipment cleaning&quot; and expects the right handful of documents back, ranked, with an excerpt and a reason. The v1 design did the obvious thing: run two retrieval lanes in parallel — a structured metadata lane and a semantic content lane — union the hits, rerank the union, render. Clean diagram. Muddy results. We'd open the demo, the pipeline would light up green end to end, and the list that came back was mush: the metadata rows polluted the semantic rank, the relevance scores stopped meaning anything, and there was no clean ordering left to show the user. The architecture was elegant. The experience was not.&lt;/p&gt;
&lt;p&gt;This is a two-part story of how that became v2: &lt;strong&gt;one strategy per query, never mixed&lt;/strong&gt;, a router that's a single structured-output call, and a Hybrid path that peeks at the data before it decides how to retrieve. It's an architecture post, so I'll keep it anchored in the specific decisions that actually moved — not a generic &quot;how to build RAG&quot; walkthrough. &lt;strong&gt;Part 1&lt;/strong&gt; (this post) is the problem and the first two reframes. &lt;strong&gt;Part 2&lt;/strong&gt; is the hard case — &lt;code&gt;Hybrid&lt;/code&gt; — and the permission model.&lt;/p&gt;
&lt;h3 id=&quot;-the-series-at-a-glance&quot;&gt;️ The series at a glance&lt;/h3&gt;
&lt;p&gt;This is &lt;strong&gt;Part 1 of 2.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Part 1 (this post) — the problem and the first two reframes:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;️ &lt;strong&gt;v1 fused two parallel lanes into one rerank → muddy.&lt;/strong&gt; Metadata rows have no text; a reranker scores text; fusing them corrupts the one number the UI depends on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The router is one Bedrock structured-output call&lt;/strong&gt;, not three sequential hops. Route + rewritten query + strategy + filters come back together, with a deterministic fallback. The catch: the merged task got too hard for a small model, so it runs on a bigger one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;v2 picks exactly one shape per query:&lt;/strong&gt; &lt;code&gt;MetadataOnly&lt;/code&gt;, &lt;code&gt;ContentOnly&lt;/code&gt;, or &lt;code&gt;Hybrid&lt;/code&gt;. The lanes are never unioned or cross-scored.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Part 2 — the hard case and the safety model:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;⚖️ &lt;strong&gt;Hybrid is adaptive.&lt;/strong&gt; It peeks the filtered-universe size &lt;em&gt;once&lt;/em&gt; (threshold &lt;code&gt;1000&lt;/code&gt;) and routes on selectivity: small set → scope the semantic query to those ids (filter-first, authoritative); big set → rank unscoped and drop off-filter hits locally (rank-then-filter, harmless recall ceiling).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The view-permission gate runs &lt;em&gt;after&lt;/em&gt; rerank&lt;/strong&gt;, and that's safe — because tenant isolation is enforced earlier, at retrieval, from the token.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-flow-end-to-end&quot;&gt;The flow, end to end&lt;/h3&gt;
&lt;p&gt;Here's the whole turn. A message comes in, the supervisor routes it, the search graph runs one retrieval shape, reranks, gates on permissions, and finalizes for the UI:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;                 ┌─────────────────────────────────────┐
   user turn ──► │ PLANNER (one Bedrock call)           │
                 │ route + rewrite + strategy + filters │
                 └──────────────┬──────────────────────┘
                                │ deterministic fallback if it fails
                                ▼
         ┌─────────────────── retrieval graph ───────────────────┐
         │   classify_intent                                     │
         │        │  (NoMatch / NeedsClarification skip ahead)   │
         │        ▼                                              │
         │   retrieve        ── picks ONE: Metadata / Content /  │
         │                      Hybrid (adaptive on selectivity) │
         │        ▼                                              │
         │   rank_results    ── Cohere over content; metadata    │
         │                      passes through unscored          │
         │        ▼                                              │
         │   access_gate     ── fail-closed VIEW gate on the     │
         │                      content lane only                │
         │        ▼                                              │
         │   format_response ── floor, shape, SSE                │
         └───────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The node order is exactly that, straight from the graph definition:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;builder.add_edge(START, &quot;query_rewriting&quot;)
builder.add_edge(&quot;query_rewriting&quot;, &quot;intent_detection&quot;)
builder.add_conditional_edges(
    &quot;intent_detection&quot;,
    _route_after_intent_detection,
    {&quot;retrieve&quot;: &quot;retrieve&quot;, &quot;finalize_results&quot;: &quot;finalize_results&quot;},
)
builder.add_edge(&quot;retrieve&quot;, &quot;rerank&quot;)
builder.add_edge(&quot;rerank&quot;, &quot;permission_filter&quot;)
builder.add_edge(&quot;permission_filter&quot;, &quot;finalize_results&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four decisions made this work. Two of them are in this post; the other two are Part 2. I'll take them in order.&lt;/p&gt;
&lt;h3 id=&quot;1-the-router-is-one-call-not-three&quot;&gt;1️⃣ The router is one call, not three&lt;/h3&gt;
&lt;p&gt;The v1 router ran three sequential Bedrock hops per turn: one to pick a route, one to rewrite the user's query into a clean retrieval string, one to classify intent and extract filters. Three round trips, in series, before any retrieval even started. Each one waits on the last — and the user just watches a spinner the whole time.&lt;/p&gt;
&lt;p&gt;My pushback in review was simple: &lt;strong&gt;don't run sequential model calls when one call can return the whole decision.&lt;/strong&gt; A router's job is to emit a structured plan. There's no reason &lt;code&gt;route&lt;/code&gt;, &lt;code&gt;rewrite&lt;/code&gt;, and &lt;code&gt;intent&lt;/code&gt; need to be three separate inferences — they're three fields of one object. So we collapsed them. The supervisor now makes a single structured-output call that returns a typed plan, which gets adapted into the downstream routing contract:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;result = await bedrock_client.invoke_with_structured_response_with_fallback_async(
    messages=[SystemMessage(content=system), HumanMessage(content=message)],
    response_structure=Plan,
    chat_model_chain=model_chain,
    max_tokens=1024,
    temperature=0.0,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One call, &lt;code&gt;temperature=0.0&lt;/code&gt;, and everything the downstream graph needs comes back together: the route, the rewritten query, the strategy, the structured filters. The &lt;code&gt;RouterDecision&lt;/code&gt; it adapts into is the contract the search graph consumes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class RouterDecision(BaseModel):
    route: Literal[&quot;documents.search&quot;, &quot;documents.doc_context&quot;, &quot;general.help&quot;]
    rewritten_query: str = Field(default=&quot;&quot;)
    strategy: SearchStrategy  # MetadataOnly | ContentOnly | Hybrid | NoMatch | NeedsClarification
    search_value: str = Field(default=&quot;&quot;)
    filters: list[DocumentFilter] = Field(default_factory=list)
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The twist that bit back.&lt;/strong&gt; Merging three easy classifications into one harder one means the model now has to do all of it in a single pass — and the small/cheap model we wanted started getting it wrong. So the router moved up to a stronger (and slower) model. The &quot;3 calls → 1 call&quot; math promised a big latency win; the model upgrade promptly taxed a chunk of it back. (Presenting a &quot;latency win&quot; that your own model bump immediately claws into is a humbling little moment — I recommend the experience to no one.) We shipped anyway, because correctness moved the right way and the architecture got dramatically simpler — and because of the second non-negotiable below.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt; The reusable lesson: a call-merge latency win can be partly clawed back by the accuracy upgrade the merge &lt;em&gt;forces&lt;/em&gt;. Budget for that. And always keep a deterministic fallback.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The deterministic fallback is the part I'd flag for anyone copying this. The structured call returns &lt;code&gt;None&lt;/code&gt; on any failure (the model is down, the output won't parse, the plan isn't exactly one step), and the caller drops to a non-LLM router:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;fallback = route_request(body, intent_resolver=resolve_doc_intent)
plan, router_usage = await supervise_turn(...)
routing = plan_to_router_decision(plan)
# routing is None -&amp;gt; use fallback
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The copilot never hangs on a flaky router call. If the smart path can't answer, a dumb-but-reliable path does. That's what lets you run the router on a heavier model without making it a single point of failure.&lt;/p&gt;
&lt;h3 id=&quot;2-one-strategy-per-query-the-reframe-that-fixed-the-muddy-results&quot;&gt;2️⃣ One strategy per query — the reframe that fixed the muddy results&lt;/h3&gt;
&lt;p&gt;This is the heart of the v2 change, and the part I argued hardest for — loudly, in more than one meeting.&lt;/p&gt;
&lt;p&gt;The v1 retrieval ran two member-scoped lanes in parallel — an OpenSearch hybrid lane (vector + BM25) and a structured metadata lane — and fused them into one set before reranking. The intuition was &quot;more recall is better, let the reranker sort it out.&quot; It doesn't work, and the reason is specific: &lt;strong&gt;a metadata hit and a content hit are not the same kind of object.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A cross-encoder reranker scores text. You hand it a query and a list of passages, it returns a relevance number per passage. A content chunk has text. A metadata row — &quot;status = effective, author = me&quot; — has no passage. When you stuff that row into the reranker by stringifying a title or a key-id, you get back a number that means nothing, on the same 0-1 scale as the real text scores. Nothing downstream can tell the calibrated score from the garbage one. The rank looks plausible and is quietly wrong.&lt;/p&gt;
&lt;p&gt;So v2 picks &lt;strong&gt;exactly one&lt;/strong&gt; retrieval shape per query and runs only that. The strategy comes from the router as a literal:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;SearchStrategy = Literal[
    &quot;MetadataOnly&quot;, &quot;ContentOnly&quot;, &quot;Hybrid&quot;, &quot;NoMatch&quot;, &quot;NeedsClarification&quot;
]
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Strategy &lt;/th&gt;&lt;th&gt;What it means &lt;/th&gt;&lt;th&gt;How it's retrieved &lt;/th&gt;&lt;th&gt;Has a relevance rank?&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;MetadataOnly&lt;/code&gt; &lt;/td&gt;&lt;td&gt;structured filters only (&quot;my effective SOPs&quot;) &lt;/td&gt;&lt;td&gt;Documents service, filtered rows in service order &lt;/td&gt;&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt; — a satisfied filter isn't a relevance signal&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;ContentOnly&lt;/code&gt; &lt;/td&gt;&lt;td&gt;topic search (&quot;policy on cleaning between batches&quot;) &lt;/td&gt;&lt;td&gt;OpenSearch hybrid over chunks, unscoped &lt;/td&gt;&lt;td&gt;Yes — Cohere rerank&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Hybrid&lt;/code&gt; &lt;/td&gt;&lt;td&gt;topic &lt;strong&gt;and&lt;/strong&gt; filters (&quot;effective docs about cleaning&quot;) &lt;/td&gt;&lt;td&gt;adaptive (Part 2) &lt;/td&gt;&lt;td&gt;Yes — Cohere rerank&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;NoMatch&lt;/code&gt; &lt;/td&gt;&lt;td&gt;not a document query (greeting, capability question) &lt;/td&gt;&lt;td&gt;skips retrieval entirely &lt;/td&gt;&lt;td&gt;n/a&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;NeedsClarification&lt;/code&gt; &lt;/td&gt;&lt;td&gt;a document query too vague to retrieve (&quot;show me stuff&quot;) &lt;/td&gt;&lt;td&gt;skips retrieval, asks a clarifying question &lt;/td&gt;&lt;td&gt;n/a&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The lanes are never unioned or cross-scored anymore — &lt;code&gt;retrieve&lt;/code&gt; says so in plain terms:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# The lanes are never unioned or cross-scored: a content turn returns content
# candidates, a metadata turn returns metadata candidates.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There's a nice side effect in the graph: &lt;code&gt;NoMatch&lt;/code&gt; and &lt;code&gt;NeedsClarification&lt;/code&gt; short-circuit straight to &lt;code&gt;finalize_results&lt;/code&gt;, skipping retrieval, rerank, and the permission gate. A greeting shouldn't make the UI flash &quot;Searching... / No matches&quot; and shouldn't cost three round trips.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _route_after_intent_detection(state) -&amp;gt; str:
    return (&quot;finalize_results&quot;
            if state.get(&quot;intent_strategy&quot;) in (&quot;NoMatch&quot;, &quot;NeedsClarification&quot;)
            else &quot;retrieve&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The honest cost.&lt;/strong&gt; Going single-strategy meant deprecating the old global keyword lane entirely. The practical fallout: custom-field values are no longer reachable as a filter, because the content-keyword fold that used to (badly) cover them is gone. That's a real regression on a narrow feature, parked until a dedicated Documents endpoint for custom fields lands. It stings to ship a regression on purpose — but I'd take a clean rank with one honest, documented gap over a muddy rank that hides a dozen.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt; If you take one thing from this post: don't fuse object types into a single rerank set just to maximize recall. Pick the retrieval shape that matches the query, and run only that. Recall you can't rank cleanly is recall you can't show.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;to-be-continued&quot;&gt;To be continued&lt;/h3&gt;
&lt;p&gt;So far the story has a tidy shape. One router call instead of three. One retrieval strategy per query instead of a fused blend. Pick &lt;code&gt;MetadataOnly&lt;/code&gt;, or &lt;code&gt;ContentOnly&lt;/code&gt;, and run only that. Clean.&lt;/p&gt;
&lt;p&gt;But I skipped the strategy that refuses to be tidy — the one where the user genuinely wants &lt;strong&gt;both at once&lt;/strong&gt;. &quot;Effective SOPs about equipment cleaning&quot; is a topic &lt;em&gt;and&lt;/em&gt; a filter, and you can't honor it by picking a single lane. Running the filter and the rank in parallel is fast, but you have to reconcile two sets. Running them in sequence is precise, but slow. There's no single right answer — which is exactly why it's the interesting part.&lt;/p&gt;
&lt;p&gt;That's &lt;code&gt;Hybrid&lt;/code&gt;, and in Part 2 a single cheap question turns that coin-flip into a data-driven decision. Then there's the permission gate that runs in a spot that makes security people flinch on first read — &lt;em&gt;after&lt;/em&gt; the rank — and why it's actually safe. Finally, the one place all of this landed on my side of the stack: a &lt;code&gt;null&lt;/code&gt; relevance score that the frontend has to treat as a first-class state, not a missing number.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Part 2 is live:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/22632/&quot;&gt;adaptive Hybrid, and the permission gate that runs &lt;em&gt;after&lt;/em&gt; the rank&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Thanks for reading all the way through 🙌 If you've hit the same muddy-rank problem — a pipeline that's green end to end and &lt;em&gt;still&lt;/em&gt; returns mush — I'd love to compare notes on how you untangled yours. Come say hi on &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/rodrigo-diego-67867185/&quot;&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22631/building-multi-agent-document-search-copilot-part-muddy-results-and-one-strategy-per-query</guid>
<pubDate>Tue, 14 Jul 2026 13:19:14 +0000</pubDate>
</item>
<item>
<title>Building a Conversational Chatbot with AWS Bedrock (Amazon Titan)</title>
<link>https://coderlegion.com/8959/building-a-conversational-chatbot-with-aws-bedrock-amazon-titan</link>
<description>&lt;h2 id=&quot;building-a-conversational-chatbot-with-aws-bedrock-amazon-titan-&quot;&gt;Building a Conversational Chatbot with AWS Bedrock (Amazon Titan)&lt;/h2&gt;
&lt;h3 id=&quot;overview&quot;&gt;Overview&lt;/h3&gt;
&lt;p&gt;Large Language Models don’t magically “remember” conversations.&lt;br&gt;
In real-world systems, &lt;strong&gt;conversation state must be explicitly managed&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In this project, we build a &lt;strong&gt;deterministic, production-style conversational chatbot&lt;/strong&gt; using:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AWS Bedrock&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Amazon Titan Text (&lt;code&gt;amazon.titan-text-express-v1&lt;/code&gt;)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Python (boto3)&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This project demonstrates how teams can safely integrate &lt;strong&gt;foundation models into enterprise workflows&lt;/strong&gt; without giving up control, observability, or reproducibility.&lt;/p&gt;
&lt;h3 id=&quot;what-is-aws-bedrock-&quot;&gt;What Is AWS Bedrock?&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Amazon Bedrock&lt;/strong&gt; is a &lt;strong&gt;fully managed service&lt;/strong&gt; that provides access to multiple&lt;br&gt;
&lt;strong&gt;foundation models (FMs)&lt;/strong&gt; via a single API — without requiring you to manage infrastructure.&lt;/p&gt;
&lt;p&gt;With Bedrock, you can:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Invoke models securely using IAM&lt;/li&gt;
&lt;li&gt;Choose models from different providers&lt;/li&gt;
&lt;li&gt;Keep data within AWS (no model training on your prompts by default)&lt;/li&gt;
&lt;li&gt;Integrate generative AI directly into existing AWS architectures&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;key-bedrock-characteristics&quot;&gt;Key Bedrock Characteristics&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Serverless (no infrastructure management)&lt;/li&gt;
&lt;li&gt;Model-agnostic API&lt;/li&gt;
&lt;li&gt;Enterprise-grade security&lt;/li&gt;
&lt;li&gt;Pay-per-use pricing&lt;/li&gt;
&lt;li&gt;Native AWS integration&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;business-use-cases-of-aws-bedrock&quot;&gt;Business Use Cases of AWS Bedrock&lt;/h3&gt;
&lt;p&gt;AWS Bedrock is designed for &lt;strong&gt;real business workloads&lt;/strong&gt;, not just demos.&lt;/p&gt;
&lt;h4 id=&quot;common-enterprise-use-cases&quot;&gt;Common Enterprise Use Cases&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Internal chatbots &amp;amp; AI copilots  &lt;/li&gt;
&lt;li&gt;Document summarization &amp;amp; analysis  &lt;/li&gt;
&lt;li&gt;Automated reporting &amp;amp; insight generation  &lt;/li&gt;
&lt;li&gt;Semantic search over internal data  &lt;/li&gt;
&lt;li&gt;AI-assisted debugging &amp;amp; data quality analysis  &lt;/li&gt;
&lt;li&gt;Analytics narrative generation  &lt;/li&gt;
&lt;li&gt;Customer support automation  &lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;why-companies-choose-bedrock&quot;&gt;Why Companies Choose Bedrock&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Data &lt;strong&gt;never leaves AWS&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;IAM-controlled access&lt;/li&gt;
&lt;li&gt;Works seamlessly with &lt;strong&gt;S3, Lambda, Glue, Databricks, Redshift&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;No lock-in to a single model provider&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;amazon-titan-text-amazon-titan-text-express-v1-&quot;&gt;Amazon Titan Text (&lt;code&gt;amazon.titan-text-express-v1&lt;/code&gt;)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Amazon Titan Text Express&lt;/strong&gt; is a fast, cost-efficient text generation model built by AWS.&lt;/p&gt;
&lt;h4 id=&quot;key-characteristics&quot;&gt;Key Characteristics&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Optimized for &lt;strong&gt;low-latency text generation&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Ideal for &lt;strong&gt;chatbots, summarization, and explanations&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Deterministic behavior when temperature is low&lt;/li&gt;
&lt;li&gt;Fully managed and secured by AWS&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;when-to-use-titan-text-express&quot;&gt;When to Use Titan Text Express&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Conversational assistants&lt;/li&gt;
&lt;li&gt;Structured responses&lt;/li&gt;
&lt;li&gt;Enterprise-safe workloads&lt;/li&gt;
&lt;li&gt;Cost-sensitive applications&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ Titan does &lt;strong&gt;not&lt;/strong&gt; manage conversation state — which is why explicit memory handling (as shown in this project) is essential.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;architecture-overview&quot;&gt;Architecture Overview&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=12936474398430260952&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;strong&gt;Important:&lt;/strong&gt;&lt;br&gt;
The entire conversation history is sent &lt;strong&gt;on every request&lt;/strong&gt;.&lt;/p&gt;
&lt;h3 id=&quot;core-design-decisions&quot;&gt;Core Design Decisions&lt;/h3&gt;
&lt;h4 id=&quot;1-explicit-conversation-memory&quot;&gt;1️⃣ Explicit Conversation Memory&lt;/h4&gt;
&lt;p&gt;Amazon Titan does not track sessions.&lt;/p&gt;
&lt;p&gt;We:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Store user and assistant messages&lt;/li&gt;
&lt;li&gt;Append them to a history list&lt;/li&gt;
&lt;li&gt;Inject the full history into each prompt&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This makes the system:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Predictable&lt;/li&gt;
&lt;li&gt;Auditable&lt;/li&gt;
&lt;li&gt;Easy to debug&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;2-role-based-prompt-formatting&quot;&gt;2️⃣ Role-Based Prompt Formatting&lt;/h4&gt;
&lt;p&gt;Conversation is formatted as:&lt;/p&gt;
&lt;p&gt;User: ...&lt;br&gt;
Assistant: ...&lt;/p&gt;
&lt;p&gt;This significantly improves response quality and consistency.&lt;/p&gt;
&lt;h4 id=&quot;3-stop-sequences&quot;&gt;3️⃣ Stop Sequences&lt;/h4&gt;
&lt;p&gt;We configure:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;&quot;stopSequences&quot;: [&quot;User:&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This prevents the model from hallucinating the next user message.&lt;/p&gt;
&lt;p&gt;4️⃣ Deterministic Generation&lt;/p&gt;
&lt;p&gt;Low temperature&lt;/p&gt;
&lt;p&gt;Explicit assistant cue&lt;/p&gt;
&lt;p&gt;Token limits&lt;/p&gt;
&lt;p&gt;How to Run the Project&lt;/p&gt;
&lt;p&gt;Clone the Github repo &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/isaacotengdev/Amazon_Bedrock_Chatbot&quot;&gt;AWS Bedrock Chatbot(Titan)&lt;/a&gt;&lt;br&gt;
Prerequisites&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Python 3.9+&lt;/li&gt;
&lt;li&gt;AWS credentials configured&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-code&quot;&gt;Install Dependencies
pip install boto3

Run the Chatbot
python chatbot.py


Type exit to quit
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can also experiment with other models available on the AWS Bedrock service page.&lt;/p&gt;
&lt;p&gt;On the AWS Bedrock service page &lt;strong&gt;Click on Model Catalog&lt;/strong&gt; . Here you have access to other model providers like Meta, Anthropic, Mistral AI etc. You can search for different models from different or same providers.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=11987431971801943371&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Click on a Model, read it's documentation to understand how to use it in your project and copy the model's ID&lt;/strong&gt; &lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=8209219561238574973&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/8959/building-a-conversational-chatbot-with-aws-bedrock-amazon-titan</guid>
<pubDate>Sat, 27 Dec 2025 01:06:17 +0000</pubDate>
</item>
<item>
<title>Evaluating Amazon Bedrock Knowledge Base integration</title>
<link>https://coderlegion.com/3357/evaluating-amazon-bedrock-knowledge-base-integration</link>
<description>&lt;p&gt;Amazon Bedrock &lt;strong&gt;Knowledge Base&lt;/strong&gt; pricing is based on the following components:&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-storage-and-ingestion&quot;&gt;&lt;strong&gt;1. Storage and Ingestion&lt;/strong&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Storage&lt;/strong&gt;: You are charged for the storage of vector embeddings in the underlying vector database (e.g., Amazon OpenSearch, Pinecone, or Redis Enterprise Cloud).  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ingestion&lt;/strong&gt;: When you ingest (sync) documents into the knowledge base, you are charged for the number of tokens processed to create embeddings.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The actual storage and ingestion costs depend on the vector database you select. Bedrock itself does not charge a separate fee for the knowledge base feature, but you pay for the underlying vector store and the compute used for embedding generation.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-embedding-model-usage&quot;&gt;&lt;strong&gt;2. Embedding Model Usage&lt;/strong&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;When you ingest documents, Bedrock uses an embedding model to convert your text into vectors.  &lt;/li&gt;
&lt;li&gt;You are charged for the number of input tokens processed by the embedding model during ingestion.  &lt;/li&gt;
&lt;li&gt;The price per 1,000 tokens depends on the embedding model you select (e.g., Amazon Titan Embeddings, Cohere Embed, etc.).&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-vector-database-vector-store-costs&quot;&gt;&lt;strong&gt;3. Vector Database (Vector Store) Costs&lt;/strong&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;You pay for the storage and retrieval operations in the vector database you choose (e.g., OpenSearch, Pinecone, Redis).  &lt;/li&gt;
&lt;li&gt;These costs are billed separately by the respective service (e.g., OpenSearch Service pricing, Pinecone pricing, etc.).&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-retrieval-query-costs&quot;&gt;&lt;strong&gt;4. Retrieval (Query) Costs&lt;/strong&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;When you query the knowledge base (e.g., via an agent), you are charged for:&lt;ul&gt;
&lt;li&gt;The number of tokens processed by the embedding model (if your query is embedded).&lt;/li&gt;
&lt;li&gt;The inference cost of the foundation model used to generate the answer (standard Bedrock model inference pricing).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;example-cost-flow&quot;&gt;&lt;strong&gt;Example Cost Flow&lt;/strong&gt;&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Ingest 10,000 tokens&lt;/strong&gt; into the knowledge base:&lt;ul&gt;
&lt;li&gt;Charged for 10,000 tokens by the embedding model.&lt;/li&gt;
&lt;li&gt;Charged for storage in the vector database.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Query the knowledge base&lt;/strong&gt;:&lt;ul&gt;
&lt;li&gt;Charged for embedding the query (if applicable).&lt;/li&gt;
&lt;li&gt;Charged for retrieval from the vector database.&lt;/li&gt;
&lt;li&gt;Charged for the foundation model inference to generate the answer.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;where-to-see-pricing&quot;&gt;&lt;strong&gt;Where to See Pricing&lt;/strong&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://aws.amazon.com/bedrock/pricing/&quot;&gt;Amazon Bedrock Pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://aws.amazon.com/opensearch-service/pricing/&quot;&gt;Amazon OpenSearch Pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.pinecone.io/pricing/&quot;&gt;Pinecone Pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://redis.com/redis-enterprise-cloud/pricing/&quot;&gt;Redis Enterprise Cloud Pricing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;summary-table&quot;&gt;&lt;strong&gt;Summary Table&lt;/strong&gt;&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Component                &lt;/th&gt;&lt;th&gt;How Charged&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Embedding Model          &lt;/td&gt;&lt;td&gt;Per 1,000 tokens ingested/queried&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Vector Database Storage  &lt;/td&gt;&lt;td&gt;Per GB/month (varies by provider)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Vector Database Retrieval&lt;/td&gt;&lt;td&gt;Per query (varies by provider)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Model Inference          &lt;/td&gt;&lt;td&gt;Per 1,000 tokens generated (standard Bedrock FM)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;References:&lt;/strong&gt;  &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://aws.amazon.com/bedrock/pricing/&quot;&gt;Amazon Bedrock Pricing&lt;/a&gt;  &lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/bedrock/latest/userguide/knowledge-base.html&quot;&gt;Bedrock Knowledge Base Documentation&lt;/a&gt;  &lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.aws.amazon.com/bedrock/latest/userguide/s3-data-source-connector.html&quot;&gt;S3 Data Source Connector&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/3357/evaluating-amazon-bedrock-knowledge-base-integration</guid>
<pubDate>Wed, 18 Jun 2025 12:51:19 +0000</pubDate>
</item>
</channel>
</rss>