Guide · Amazon Kendra · Relevance Tuning · AttributeFilter · Query Expansion

Kendra Relevance Tuning for MCP Servers — AttributeFilter, Boosting, Query Expansion, and UserContext

Kendra's ML relevance model produces good results out of the box for general knowledge retrieval, but MCP server tools almost always need tighter control: a search_workspace tool must scope results to a specific tenant's documents, a search_recent tool should boost documents modified in the last week, and a multi-tenant MCP server must enforce per-user ACLs without leaking documents across tenants. The tuning surface in Kendra's QueryCommand is richer than most developers realize — this guide covers all three main levers (AttributeFilter, DocumentRelevanceOverride, and UserContext), query expansion behavior, faceted search patterns, and the common misconfiguration that causes UserContext to silently have no effect.

TL;DR

Kendra's ML relevance model is good out of the box, but MCP servers often need fine-grained control: scope results to a workspace, boost recent documents, or rank API reference docs above general FAQs. The three tuning levers are: AttributeFilter (pre-filter before ML scoring — hard exclusion), DocumentRelevanceOverride (re-weight specific attributes post-scoring — soft boost), and QueryResultTypeFilter (limit result types to DOCUMENT, QUESTION_ANSWER, or ANSWER). UserContext enforces ACL-based access on top of relevance — it filters which documents the user can see based on _acl_principals metadata, but does not affect relevance scoring at all. Critical: UserContext only takes effect when the index UserContextPolicy is set to USER_TOKEN; the default ATTRIBUTE_FILTER policy ignores UserContext entirely.

AttributeFilter: scoping query results

An AttributeFilter is a predicate tree evaluated before Kendra's ML ranking. Documents that do not match the filter are excluded from results entirely — they never reach the relevance model. This is the right tool for hard boundaries: tenant isolation, content access control, date range exclusions. Filters compose with logical operators: andAllFilters (all children must match), orAllFilters (at least one child must match), and notFilter (negate one child). Leaf predicates cover equality, containment, and comparisons.

import { KendraClient, QueryCommand } from '@aws-sdk/client-kendra';

const kendra = new KendraClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
const INDEX_ID = process.env.KENDRA_INDEX_ID!;

// 1. Simple equality filter: scope results to a specific workspace tenant
async function searchWorkspace(queryText: string, workspaceId: string) {
  return kendra.send(new QueryCommand({
    IndexId: INDEX_ID,
    QueryText: queryText,
    AttributeFilter: {
      EqualsTo: {
        Key: 'workspace',
        Value: { StringValue: workspaceId },
      },
    },
  }));
}

// 2. Date range filter: documents published between two dates
async function searchByDateRange(queryText: string, from: Date, to: Date) {
  return kendra.send(new QueryCommand({
    IndexId: INDEX_ID,
    QueryText: queryText,
    AttributeFilter: {
      AndAllFilters: [
        {
          GreaterThanOrEquals: {
            Key: '_created_at',
            Value: { DateValue: from },  // Date object required, not an ISO string
          },
        },
        {
          LessThanOrEquals: {
            Key: '_created_at',
            Value: { DateValue: to },
          },
        },
      ],
    },
  }));
}

// 3. Multi-category OR filter: match any of several allowed categories
async function searchByCategories(queryText: string, categories: string[]) {
  return kendra.send(new QueryCommand({
    IndexId: INDEX_ID,
    QueryText: queryText,
    AttributeFilter: {
      OrAllFilters: categories.map(cat => ({
        EqualsTo: {
          Key: 'category',
          Value: { StringValue: cat },
        },
      })),
    },
  }));
}

// 4. Combined workspace + date range: nested AndAllFilters
async function searchWorkspaceRecent(
  queryText: string,
  workspaceId: string,
  since: Date
) {
  return kendra.send(new QueryCommand({
    IndexId: INDEX_ID,
    QueryText: queryText,
    AttributeFilter: {
      AndAllFilters: [
        // Tenant isolation — hard boundary
        {
          EqualsTo: {
            Key: 'workspace',
            Value: { StringValue: workspaceId },
          },
        },
        // Recency gate — only documents from the last 30 days
        {
          GreaterThanOrEquals: {
            Key: '_last_updated_at',
            Value: { DateValue: since },
          },
        },
        // Exclude archived documents
        {
          NotFilter: {
            EqualsTo: {
              Key: 'status',
              Value: { StringValue: 'archived' },
            },
          },
        },
      ],
    },
  }));
}

The full set of leaf predicates available in AttributeFilter:

Predicate Attribute types Use case
EqualsTo STRING, LONG, DATE Exact match — workspace ID, status enum, specific date
ContainsAll STRING_LIST Document must have all specified tags (AND semantics on a list attribute)
ContainsAny STRING_LIST Document must have at least one specified tag (OR semantics on a list attribute)
GreaterThan LONG, DATE Strictly after a date, or priority score above a threshold
GreaterThanOrEquals LONG, DATE On or after a date — use for inclusive range boundaries
LessThan LONG, DATE Strictly before a date
LessThanOrEquals LONG, DATE On or before a date — use for inclusive upper bounds

DocumentRelevanceOverride: boosting attributes post-scoring

DocumentRelevanceOverrides is a per-query array of boost instructions that run after Kendra's ML scoring. Unlike AttributeFilter, they do not exclude documents — they adjust the relevance score of documents that match a particular attribute pattern. This is the right tool for surfacing preferred content types (e.g., API reference before blog posts) without hard-excluding the lower-ranked content.

import { KendraClient, QueryCommand } from '@aws-sdk/client-kendra';

// Boost API reference documents to the top of results
// while still including other document types further down
async function searchWithCategoryBoost(queryText: string, workspaceId: string) {
  return kendra.send(new QueryCommand({
    IndexId: INDEX_ID,
    QueryText: queryText,
    AttributeFilter: {
      EqualsTo: { Key: 'workspace', Value: { StringValue: workspaceId } },
    },
    DocumentRelevanceOverrides: [
      {
        Name: 'category',
        Relevance: {
          // Importance: integer 1-10 (default 1). Higher values amplify the
          // attribute's contribution to the final relevance score.
          Importance: 7,

          // ValueImportanceMap: boost specific string values differently.
          // Documents with category='api-reference' get a strong upward push;
          // 'general' documents get a slight downward adjustment.
          ValueImportanceMap: {
            'api-reference': 8,
            'tutorial': 5,
            'faq': 3,
            'general': 1,
          },
        },
      },
      {
        Name: '_last_updated_at',
        Relevance: {
          // Freshness: true instructs Kendra to automatically boost recently
          // modified documents using the _last_updated_at built-in attribute.
          // The boost decays over time — documents updated today rank highest.
          Freshness: true,
          Importance: 4,  // how much freshness affects the final score (1-10)
        },
      },
      {
        Name: 'priority_score',
        Relevance: {
          // RankOrder: for numeric attributes, DESCENDING means higher numbers
          // rank better (e.g., a priority_score of 9 ranks above 2).
          RankOrder: 'DESCENDING',
          Importance: 3,
        },
      },
    ],
  }));
}

The Importance value is an integer between 1 and 10 inclusive — passing 0, a float, or a value outside this range causes a ValidationException. When multiple overrides are specified, Kendra combines their contributions with the base ML score using its internal weighted formula. You cannot observe the intermediate scores directly; use Kendra's query result ScoreAttributes.ScoreConfidence field (VERY_HIGH, HIGH, MEDIUM, LOW, NOT_AVAILABLE) to qualitatively assess how the boosts are affecting ranking. For fine-tuning, compare result ordering with and without specific overrides on representative queries.

// Pattern: build DocumentRelevanceOverrides dynamically based on tool parameters
function buildRelevanceOverrides(opts: {
  boostCategory?: string;
  boostFreshness?: boolean;
  sortByPriority?: boolean;
}) {
  const overrides: Array<{ Name: string; Relevance: Record<string, unknown> }> = [];

  if (opts.boostCategory) {
    overrides.push({
      Name: 'category',
      Relevance: {
        Importance: 8,
        ValueImportanceMap: { [opts.boostCategory]: 10 },
      },
    });
  }

  if (opts.boostFreshness) {
    overrides.push({
      Name: '_last_updated_at',
      Relevance: { Freshness: true, Importance: 5 },
    });
  }

  if (opts.sortByPriority) {
    overrides.push({
      Name: 'priority_score',
      Relevance: { RankOrder: 'DESCENDING', Importance: 6 },
    });
  }

  return overrides.length > 0 ? overrides : undefined;
}

Query expansion and suggested queries

Kendra automatically expands queries using its ML model — a search for "MCP server monitoring" may internally match documents containing "MCP server health check", "MCP endpoint uptime", or "MCP probe latency" even if none of those exact phrases appear in the query. This expansion is invisible and cannot be disabled or inspected; it is part of the base relevance model. What you can control is query suggestions: the autocomplete-style completions that help users refine their query before submitting it.

import {
  KendraClient,
  QueryCommand,
  GetQuerySuggestionsCommand,
} from '@aws-sdk/client-kendra';

const QUERY_SUGGESTIONS_BLOCK_LIST_ID = process.env.KENDRA_SUGGESTIONS_BLOCK_LIST_ID;

// MCP tool: search_suggest — fires before the main search to populate autocomplete
// Returns up to 5 query suggestions based on the partial input
async function getQuerySuggestions(partialQuery: string): Promise<string[]> {
  if (partialQuery.length < 3) return [];  // Kendra requires at least 4 characters for suggestions

  try {
    const resp = await kendra.send(new GetQuerySuggestionsCommand({
      IndexId: INDEX_ID,
      QueryText: partialQuery,
      MaxSuggestionsCount: 5,
    }));

    return (resp.Suggestions ?? [])
      .map(s => s.Value?.Text?.Text ?? '')
      .filter(Boolean);
  } catch (err) {
    // GetQuerySuggestions can fail if suggestion history is insufficient
    // (Kendra needs at least 100 unique queries to generate suggestions)
    console.warn('Query suggestions unavailable', err);
    return [];
  }
}

// MCP server tool registration: two-phase search with suggestions
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';

const server = new McpServer({ name: 'kendra-search', version: '1.0.0' });

// Tool 1: Get suggestions before the user commits to a query
server.tool(
  'search_suggest',
  {
    partial_query: z.string().min(1).describe('Partial query text for autocomplete'),
  },
  async ({ partial_query }) => {
    const suggestions = await getQuerySuggestions(partial_query);
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({ suggestions }, null, 2),
      }],
    };
  }
);

// Tool 2: Execute the full search with the confirmed query
server.tool(
  'search_query',
  {
    query: z.string().min(1).describe('Full search query'),
    workspace_id: z.string().describe('Workspace to scope the search'),
    result_type: z.enum(['DOCUMENT', 'QUESTION_ANSWER', 'ANSWER']).optional(),
  },
  async ({ query, workspace_id, result_type }) => {
    const resp = await kendra.send(new QueryCommand({
      IndexId: INDEX_ID,
      QueryText: query,
      AttributeFilter: {
        EqualsTo: { Key: 'workspace', Value: { StringValue: workspace_id } },
      },
      // Filter to a specific result type if the caller requests it
      QueryResultTypeFilter: result_type,
      PageSize: 10,
    }));

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          total: resp.TotalNumberOfResults,
          items: (resp.ResultItems ?? []).map(item => ({
            id: item.Id,
            type: item.Type,
            title: item.DocumentTitle?.Text,
            excerpt: item.DocumentExcerpt?.Text,
            score: item.ScoreAttributes?.ScoreConfidence,
            uri: item.DocumentURI,
          })),
        }, null, 2),
      }],
    };
  }
);

Query expansion is automatic and ML-driven. Kendra uses its pre-trained domain-specific vocabulary (trained on a large corpus of enterprise content) to identify synonyms and related terms. You cannot view the expanded terms, inject custom synonyms, or disable expansion for specific queries. If expansion is surfacing irrelevant results (e.g., "deployment" is expanding to include unrelated "deployment" contexts), the correct fix is to narrow the result set with AttributeFilter rather than trying to control expansion — that is not exposed as a tunable parameter.

The QuerySuggestionsBlockList feature lets you suppress suggestions for sensitive terms (competitor names, PII patterns, internal codenames). Create a block list via the Kendra console or API, upload a text file with one blocked phrase per line to S3, and reference the block list ID in GetQuerySuggestionsCommand. Block lists do not affect the main QueryCommand — they only suppress entries from the suggestion dropdown.

UserContext and per-user result filtering

Kendra's UserContext parameter applies ACL-based access enforcement on top of relevance scoring. When a document is indexed with an _acl_principals attribute listing allowed users and groups, passing a matching UserContext in the query causes Kendra to filter out documents the user is not authorized to see. This is post-retrieval access control, not relevance scoring — it does not change how results are ranked, only which results appear.

import { KendraClient, QueryCommand } from '@aws-sdk/client-kendra';
import { jwtVerify } from 'jose';  // or your JWT library of choice

const INDEX_ID = process.env.KENDRA_INDEX_ID!;
const JWT_PUBLIC_KEY = process.env.JWT_PUBLIC_KEY!;

// Extract user identity from a JWT claim and pass it to Kendra
// IMPORTANT: This only works if UserContextPolicy on the index is 'USER_TOKEN'
// The default policy is 'ATTRIBUTE_FILTER' which ignores UserContext entirely

interface UserIdentity {
  userId: string;
  groups: string[];
}

async function verifyJwtAndExtractIdentity(token: string): Promise<UserIdentity> {
  const { payload } = await jwtVerify(
    token,
    // Load your public key — from Cognito JWKS, Auth0, or a static key
    new TextEncoder().encode(JWT_PUBLIC_KEY)
  );

  return {
    userId: payload.sub as string,
    groups: (payload['cognito:groups'] as string[] | undefined) ?? [],
  };
}

// MCP tool handler that enforces per-user ACLs via UserContext
async function searchWithUserContext(
  queryText: string,
  authToken: string,
  workspaceId: string
): Promise<object> {
  // Verify JWT and extract user identity — never trust client-provided user IDs
  const identity = await verifyJwtAndExtractIdentity(authToken);

  const resp = await kendra.send(new QueryCommand({
    IndexId: INDEX_ID,
    QueryText: queryText,
    AttributeFilter: {
      EqualsTo: { Key: 'workspace', Value: { StringValue: workspaceId } },
    },
    UserContext: {
      // UserId: Kendra checks _acl_principals for entries matching this user ID
      UserId: identity.userId,
      // Groups: Kendra checks _acl_principals for entries matching any of these groups
      Groups: identity.groups,
      // Token: alternative to UserId/Groups — pass the JWT and configure
      // Kendra to validate it against a Secrets Manager key
      // Token: authToken,  // use this instead of UserId/Groups for token-based policy
    },
    PageSize: 10,
  }));

  return {
    // TotalNumberOfResults reflects only the documents the user can access
    total: resp.TotalNumberOfResults,
    user: identity.userId,
    items: resp.ResultItems?.map(item => ({
      title: item.DocumentTitle?.Text,
      excerpt: item.DocumentExcerpt?.Text,
      score: item.ScoreAttributes?.ScoreConfidence,
    })),
  };
}

How UserContext filtering works: When a document is indexed, its _acl_principals attribute contains a list of principals (user IDs and group names) that are allowed to access it. Documents with no _acl_principals attribute are treated as public within the index — they appear in results for all users. When you pass UserContext in a query, Kendra filters results to: (a) documents where _acl_principals contains the user ID or any of the user's groups, plus (b) documents with no ACL set. Documents where _acl_principals is explicitly set but does not include the user are excluded.

Critical prerequisite: the UserContextPolicy on the Kendra index must be set to USER_TOKEN. The default value is ATTRIBUTE_FILTER, which completely ignores the UserContext field — the query runs as if no UserContext was provided, returning all documents regardless of ACL. Update the policy via UpdateIndexCommand:

import { KendraClient, UpdateIndexCommand, UserContextPolicy } from '@aws-sdk/client-kendra';

// One-time setup: set index to USER_TOKEN policy so UserContext is enforced
// Run this once after index creation — requires kendra:UpdateIndex permission
await kendra.send(new UpdateIndexCommand({
  Id: INDEX_ID,
  UserContextPolicy: UserContextPolicy.USER_TOKEN,
  // If using JWT tokens directly (instead of UserId/Groups), configure the signing key:
  UserTokenConfigurations: [
    {
      JwtTokenTypeConfiguration: {
        KeyLocation: 'SECRET_MANAGER',  // or 'URL' for JWKS endpoint
        SecretManagerArn: process.env.KENDRA_JWT_SECRET_ARN,
        UserNameAttributeField: 'sub',
        GroupAttributeField: 'cognito:groups',
      },
    },
  ],
}));

PageSize, pagination, and facets

Kendra's pagination is 1-indexed: PageNumber: 1 returns the first page of results. The default PageSize is 10; the maximum is 100. Because Kendra returns a maximum of PageNumber 10 × PageSize 10 = 100 results total (page 11 returns nothing), display no more than 100 results to users regardless of TotalNumberOfResults. Facets allow users to drill down by attribute value — the response includes counts per facet value that drive a "filter sidebar" UI pattern.

// Two-phase faceted search pattern for MCP tools:
// Phase 1: Get facet counts + first page of results
// Phase 2: Narrow scope with AttributeFilter based on selected facet

// Phase 1: Initial search with facets
async function searchWithFacets(queryText: string, workspaceId: string) {
  const resp = await kendra.send(new QueryCommand({
    IndexId: INDEX_ID,
    QueryText: queryText,
    AttributeFilter: {
      EqualsTo: { Key: 'workspace', Value: { StringValue: workspaceId } },
    },
    Facets: [
      {
        DocumentAttributeKey: 'category',
        MaxResults: 10,  // return top 10 category values by document count
      },
      {
        DocumentAttributeKey: 'tags',
        MaxResults: 20,
      },
    ],
    PageSize: 10,
    PageNumber: 1,
  }));

  // Parse facet results
  const facets: Record<string, Array<{ value: string; count: number }>> = {};
  for (const facetResult of resp.FacetResults ?? []) {
    const key = facetResult.DocumentAttributeKey ?? 'unknown';
    facets[key] = (facetResult.DocumentAttributeValueCountPairs ?? []).map(pair => ({
      value: pair.DocumentAttributeValue?.StringValue ?? '',
      count: pair.Count ?? 0,
    }));
  }

  return {
    // Cap display at 100 — Kendra cannot return more than 100 results total
    total: Math.min(resp.TotalNumberOfResults ?? 0, 100),
    facets,
    items: resp.ResultItems?.map(item => ({
      id: item.Id,
      title: item.DocumentTitle?.Text,
      excerpt: item.DocumentExcerpt?.Text,
      score: item.ScoreAttributes?.ScoreConfidence,
      type: item.Type,
    })),
  };
}

// Phase 2: Narrowed search after user selects a facet value
async function searchNarrowed(
  queryText: string,
  workspaceId: string,
  selectedCategory: string,
  pageNumber: number = 1
) {
  return kendra.send(new QueryCommand({
    IndexId: INDEX_ID,
    QueryText: queryText,
    AttributeFilter: {
      AndAllFilters: [
        { EqualsTo: { Key: 'workspace', Value: { StringValue: workspaceId } } },
        { EqualsTo: { Key: 'category', Value: { StringValue: selectedCategory } } },
      ],
    },
    // No Facets on narrowed queries — the facet selection is already applied
    PageSize: 10,
    PageNumber: pageNumber,  // 1-indexed; max effective page is 10
  }));
}

// MCP tool: paginated search with facet support
server.tool(
  'search_faceted',
  {
    query: z.string().min(1),
    workspace_id: z.string(),
    category_filter: z.string().optional().describe('Selected category facet value'),
    page: z.number().int().min(1).max(10).default(1),
  },
  async ({ query, workspace_id, category_filter, page }) => {
    if (category_filter) {
      const resp = await searchNarrowed(query, workspace_id, category_filter, page);
      return {
        content: [{
          type: 'text',
          text: JSON.stringify({
            total: Math.min(resp.TotalNumberOfResults ?? 0, 100),
            page,
            category_filter,
            items: resp.ResultItems?.map(item => ({
              title: item.DocumentTitle?.Text,
              excerpt: item.DocumentExcerpt?.Text,
            })),
          }, null, 2),
        }],
      };
    } else {
      const result = await searchWithFacets(query, workspace_id);
      return {
        content: [{
          type: 'text',
          text: JSON.stringify(result, null, 2),
        }],
      };
    }
  }
);

The TotalNumberOfResults field in the query response is Kendra's estimate of matching documents before pagination. It can be large (thousands), but Kendra will only return a maximum of 100 actual result items across all pages. When building search UIs or MCP tool responses, always cap the displayed total at 100 and communicate this limitation to the caller: "showing top 100 of N matching documents". For use cases that need to enumerate all matching documents (e.g., export or bulk processing), use the custom data source with a BatchDeleteDocument + re-index approach rather than trying to paginate through Kendra query results.

Common failures

Error / Symptom Cause Fix
ValidationException: attribute X is not searchable when using it in AttributeFilter The custom attribute was defined in the index schema with Searchable: false in its DocumentAttributeDefinition — only searchable attributes can be used in AttributeFilter Call DescribeIndexCommand to list DocumentMetadataConfigurations and check the Search.Searchable flag; update the attribute definition via UpdateIndexCommand to set Search: { Searchable: true, Facetable: true }; note that changing this triggers an index rebuild which can take 30+ minutes
ValidationException: attribute X does not exist in the index An attribute name used in AttributeFilter or DocumentRelevanceOverrides was never defined in the index schema — it was either never added to DocumentMetadataConfigurations or is misspelled Call DescribeIndexCommand to list all defined DocumentMetadataConfigurations names; use the exact names (case-sensitive); built-in attributes use underscore prefix (_created_at, _last_updated_at, _data_source_id) — these do not need to be declared in schema
UserContext in QueryCommand has no effect — users see all documents including ones they should not access UserContextPolicy on the index is still set to the default value ATTRIBUTE_FILTER — this policy completely ignores the UserContext parameter Call DescribeIndexCommand and check UserContextPolicy; call UpdateIndexCommand to set UserContextPolicy: 'USER_TOKEN'; if using JWT tokens, also configure UserTokenConfigurations with the signing key; the update takes effect within a few minutes without requiring a full index rebuild
ValidationException: Importance must be between 1 and 10 in DocumentRelevanceOverrides Importance was set to 0, a float (e.g., 7.5), or a value above 10 — Kendra requires an integer between 1 and 10 inclusive Use integers only: Importance: 7 not Importance: 7.0 or Importance: 0; if you want to effectively disable an attribute's contribution, set Importance: 1 (the minimum); validate override values before passing them to QueryCommand
Freshness boost in DocumentRelevanceOverrides has no effect — older documents still rank above recent ones The _last_updated_at built-in attribute is not populated on the indexed documents — the freshness boost requires this attribute to have a valid DateValue; for documents ingested via the S3 connector without a metadata JSON sidecar, Kendra does not automatically infer modification time from S3 object metadata Explicitly set _last_updated_at in document metadata JSON files for S3-sourced documents, or set it in the Attributes array when using BatchPutDocumentCommand: { Key: '_last_updated_at', Value: { DateValue: new Date(lastModified) } }; verify population by calling DescribeDataSourceCommand and checking a sample document's attributes in the Kendra console