Explore a integração de consultas do Pesquisa de IA do Azure numa aplicação C#

Note

O Pesquisa de IA do Azure está disponível através do portal Azure, APIs REST e SDKs do Azure. Também sustenta o Foundry IQ, a camada de conhecimento gerida que transforma conteúdos empresariais em bases de conhecimento reutilizáveis e conscientes de permissões para agentes no portal Microsoft Foundry.

No passo anterior, implementou o site com capacidade de pesquisa para o Azure Container Apps. Este artigo destaca os passos essenciais que estabelecem a integração da pesquisa. Pensa nisso como uma folha de dicas para integrar a pesquisa na tua aplicação web.

SDK do Azure Azure. Pesquisar.Documentos

A API utiliza o SDK do Azure para Pesquisa de IA do Azure:

A API autentica-se através do SDK para a API cloud do Pesquisa de IA do Azure, utilizando o nome do serviço de pesquisa e o nome do índice. No Azure Container Apps, o ambiente do contentor fornece os valores de configuração. A identidade gerida é o caminho padrão das credenciais.

Autenticação de identidade gerida

Cada função do Azure na API cria-o SearchClient através de uma classe partilhadaSearchClientFactory, por isso todas as funções autenticam da mesma forma. Por defeito, a fábrica constrói um DefaultAzureCredential e usa-o para pedir tokens para o Pesquisa de IA do Azure. No Azure Container Apps, DefaultAzureCredential resolve para a identidade gerida atribuída à aplicação container.

O método seguinte cria SearchClientFactory.cs essa credencial. Quando a aplicação container tem uma identidade gerida atribuída pelo utilizador, o ID do cliente da AZURE_CLIENT_ID variável de ambiente é passado para DefaultAzureCredentialOptions que a aquisição de tokens não seja ambígua.

private static DefaultAzureCredential CreateManagedIdentityCredential()
{
    var options = new DefaultAzureCredentialOptions();

    if (!string.IsNullOrWhiteSpace(ManagedIdentityClientId))
    {
        options.ManagedIdentityClientId = ManagedIdentityClientId;
    }

    return new DefaultAzureCredential(options);
}

A infraestrutura Bicep atribui à identidade gerida acesso ao plano de dados do Pesquisa de IA do Azure durante azd up. Esta atribuição de funções permite à API consultar o good-books índice sem armazenar uma chave de consulta no ambiente do contentor.

Resolução de credenciais local vs. implementada

Localmente, se AZURE_CLIENT_ID não estiver definido, DefaultAzureCredential recorre à sua cadeia de credenciais padrão e resolve para a credencial de programador com sessão iniciada, como a conta do CLI do Azure ou do Visual Studio Code que usou para iniciar sessão. Quando implementado no Azure Container Apps, a infraestrutura Bicep define AZURE_CLIENT_ID para o ID de cliente da identidade gerida atribuída pelo utilizador, direcionando DefaultAzureCredential essa identidade especificamente em vez de resolver de forma ambígua entre múltiplas identidades que um host pode expor.

Para usar chaves API em vez disso, defina USE_KEYLESS_AUTH para false antes da implantação:

azd env set USE_KEYLESS_AUTH false
azd up

Use autenticação por chave apenas quando o seu ambiente o exigir.

Contextos de desenvolvimento local

Para desenvolvimento local, o ficheiro de exemplo sample.local.settings.json mostra os valores que a API espera. Use as definições locais apenas para desenvolvimento. No Azure Container Apps, a configuração de implementação fornece os valores equivalentes do ambiente do contentor.

Setting Purpose Obrigatório quando
SearchServiceName Nome do serviço Pesquisa de IA do Azure. Combina-se com .search.windows.net para construir o URI do endpoint de serviço. Sempre
SearchIndexName Nome do índice de pesquisa a consultar. Por defeito é good-books se estiver desdefinido. Opcional
SEARCH_USE_KEY_AUTH O padrão é falso, usa identidade gerida. Defina para true usar uma chave API em vez de uma identidade gerida. Autenticação opcional por chave
SearchApiKey Chave de administração para Pesquisa de IA do Azure. Necessário quando SEARCH_USE_KEY_AUTH é true
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
    "SearchServiceName": "",
    "SearchIndexName": "good-books"
  },
  "Host": {
    "CORS": "*"
  }
}

Função: Pesquisar no catálogo

A API de Pesquisa usa um termo de pesquisa e pesquisa os documentos no índice de pesquisa, retornando uma lista de correspondências. Através da API Suggest, strings parciais são enviadas ao motor de busca à medida que o utilizador escreve. A API sugere termos de pesquisa, como títulos de livros e autores, com base em documentos do índice de pesquisa e devolve uma pequena lista de correspondências.

A função Azure recolhe a informação de configuração de pesquisa do ambiente do contentor, cria o cliente Pesquisa de IA do Azure e satisfaz a consulta.

O sugeridor de pesquisa, sg, é definido no arquivo de esquema usado durante o carregamento em massa.

using Azure;
using Azure.Core.Serialization;
using Azure.Identity;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using WebSearch.Models;
using SearchFilter = WebSearch.Models.SearchFilter;

namespace WebSearch.Function
{
    public class Search
    {
        private readonly ILogger<Lookup> _logger;

        public Search(ILogger<Lookup> logger)
        {
            _logger = logger;
        }

        [Function("search")]
        public async Task<HttpResponseData> RunAsync(
            [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req, 
            FunctionContext executionContext)
        {
            string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
            var data = JsonSerializer.Deserialize<RequestBodySearch>(requestBody);

            // Azure AI Search (managed identity by default; API key only when SEARCH_USE_KEY_AUTH=true)
            SearchClient searchClient = SearchClientFactory.CreateSearchClient();

            SearchOptions options = new()

            {
                Size = data.Size,
                Skip = data.Skip,
                IncludeTotalCount = true,
                Filter = CreateFilterExpression(data.Filters)
            };
            options.Facets.Add("authors");
            options.Facets.Add("language_code");

            SearchResults<SearchDocument> searchResults = searchClient.Search<SearchDocument>(data.SearchText, options);

            var facetOutput = new Dictionary<string, IList<FacetValue>>();
            foreach (var facetResult in searchResults.Facets)
            {
                facetOutput[facetResult.Key] = facetResult.Value
                           .Select(x => new FacetValue { value = x.Value.ToString(), count = x.Count })

                           .ToList();
            }

            // Data to return 
            var output = new SearchOutput
            {
                Count = searchResults.TotalCount,
                Results = searchResults.GetResults().ToList(),
                Facets = facetOutput
            };
            
            var response = req.CreateResponse(HttpStatusCode.Found);

            // Serialize data
            var serializer = new JsonObjectSerializer(
                new JsonSerializerOptions(JsonSerializerDefaults.Web));
            await response.WriteAsJsonAsync(output, serializer);

            return response;
        }

        public static string CreateFilterExpression(List<SearchFilter> filters)
        {
            if (filters is null or { Count: <= 0 })
            {
                return null;
            }

            List<string> filterExpressions = new();


            List<SearchFilter> authorFilters = filters.Where(f => f.field == "authors").ToList();
            List<SearchFilter> languageFilters = filters.Where(f => f.field == "language_code").ToList();

            List<string> authorFilterValues = authorFilters.Select(f => f.value).ToList();

            if (authorFilterValues.Count > 0)
            {
                string filterStr = string.Join(",", authorFilterValues);
                filterExpressions.Add($"{"authors"}/any(t: search.in(t, '{filterStr}', ','))");
            }

            List<string> languageFilterValues = languageFilters.Select(f => f.value).ToList();
            foreach (var value in languageFilterValues)
            {
                filterExpressions.Add($"language_code eq '{value}'");
            }

            return string.Join(" and ", filterExpressions);
        }
    }
}

Para verificar a função de forma independente, ligue /api/search com um termo de pesquisa no corpo do pedido e confirme que a resposta inclui documentos de livro correspondentes, uma contagem total e valores de facetas.

Cliente: Pesquise no catálogo

A página de Pesquisa do cliente React chama a search função Azure sempre que o utilizador insere uma consulta, altera um filtro de facetas ou passa para uma nova página de resultados. O cliente envia o texto da pesquisa, as páginas skip e top valores atuais, e quaisquer filtros de autor ou linguagem selecionados no corpo do POST para /api/search. A função devolve uma lista de documentos de livro correspondentes, uma contagem total e valores de faceta, que a página utiliza para renderizar a lista de resultados, o pager e os filtros de facetas. O código seguinte em \client\src\pages\Search\Search.jsx compila que solicita e armazena a resposta no estado do componente:

import React, { useEffect, useState, Suspense } from 'react';
import fetchInstance from '../../url-fetch';
import CircularProgress from '@mui/material/CircularProgress';
import { useLocation, useNavigate } from "react-router-dom";

import Results from '../../components/Results/Results';
import Pager from '../../components/Pager/Pager';
import Facets from '../../components/Facets/Facets';
import SearchBar from '../../components/SearchBar/SearchBar';

import "./Search.css";

export default function Search() {

  let location = useLocation();
  const navigate = useNavigate();

  const [results, setResults] = useState([]);
  const [resultCount, setResultCount] = useState(0);
  const [currentPage, setCurrentPage] = useState(1);
  const [q, setQ] = useState(new URLSearchParams(location.search).get('q') ?? "*");
  const [top] = useState(new URLSearchParams(location.search).get('top') ?? 8);
  const [skip, setSkip] = useState(new URLSearchParams(location.search).get('skip') ?? 0);
  const [filters, setFilters] = useState([]);
  const [facets, setFacets] = useState({});
  const [isLoading, setIsLoading] = useState(true);

  let resultsPerPage = top;

  // Handle page changes in a controlled manner
  function handlePageChange(newPage) {
    setCurrentPage(newPage);
  }

  // Calculate skip value and fetch results when relevant parameters change
  useEffect(() => {
    // Calculate skip based on current page
    const calculatedSkip = (currentPage - 1) * top;
    
    // Only update if skip has actually changed
    if (calculatedSkip !== skip) {
      setSkip(calculatedSkip);
      return; // Skip the fetch since skip will change and trigger another useEffect
    }
    
    // Proceed with fetch
    setIsLoading(true);
    
    const body = {
      q: q,
      top: top,
      skip: skip,
      filters: filters
    };

    
    fetchInstance('/api/search', { body, method: 'POST' })
      .then(response => {
        setResults(response.results);
        setFacets(response.facets);
        setResultCount(response.count);
        setIsLoading(false);
      })
      .catch(error => {
        console.log(error);
        setIsLoading(false);
      });
  }, [q, top, skip, filters, currentPage]);

  // pushing the new search term to history when q is updated
  // allows the back button to work as expected when coming back from the details page
  useEffect(() => {
    navigate('/search?q=' + q);
    setCurrentPage(1);
    setFilters([]);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [q]);


  let postSearchHandler = (searchTerm) => {
    setQ(searchTerm);
  }


  // filters should be applied across entire result set, 
  // not just within the current page
  const updateFilterHandler = (newFilters) => {

    // Reset paging
    setSkip(0);
    setCurrentPage(1);

    // Set filters
    setFilters(newFilters);
  };

  return (
    <main className="main main--search container-fluid">
      <div className="row">
        <div className="search-bar-column col-md-3">
          <div className="search-bar-column-container">
            <SearchBar postSearchHandler={postSearchHandler} query={q} width={false}></SearchBar>
          </div>
          <Facets facets={facets} filters={filters} setFilters={updateFilterHandler}></Facets>
        </div>
        <div className="search-bar-results">
          {isLoading ? (
            <div className="col-md-9">
              <CircularProgress />
            </div>
          ) : (
            <div className="search-results-container">
              <Results documents={results} top={top} skip={skip} count={resultCount} query={q}></Results>
              <Pager className="pager-style" currentPage={currentPage} resultCount={resultCount} resultsPerPage={resultsPerPage} onPageChange={handlePageChange}></Pager>
            </div>
          )}
        </div>
      </div>
    </main>
  );
}

Para verificar esta integração, introduza um termo de pesquisa na barra de pesquisa do site e confirme que a lista de resultados, a contagem de resultados e as facetas estão todas atualizadas.

Cliente: Sugestões do catálogo

A API da função Suggest é chamada na aplicação React como \client\src\components\SearchBar\SearchBar.jsx parte do componente de Autocomplete da Material UI. Este componente utiliza o texto de entrada para procurar autores e livros que correspondam. Depois, apresenta essas possíveis correspondências como itens selecionáveis na lista suspensa.

import React, { useState, useEffect } from 'react';
import { TextField, Autocomplete, Button, Box } from '@mui/material';
import fetchInstance from '../../url-fetch';
import './SearchBar.css';

export default function SearchBar({ postSearchHandler, query, width }) {
  const [q, setQ] = useState(() => query || '');
  const [suggestions, setSuggestions] = useState([]);

  const search = (value) => {
    postSearchHandler(value);
  };

  useEffect(() => {
    if (q) {

      const body = { q, top: 5, suggester: 'sg' };

      fetchInstance('/api/suggest', { body, method: 'POST' })
      .then(response => {
        setSuggestions(response.suggestions.map(s => s.text));
      })
      .catch(error => {
        console.log(error);
        setSuggestions([]);
      });
    }
  }, [q]);


  const onInputChangeHandler = (event, value) => {
    setQ(value);
  };


  const onChangeHandler = (event, value) => {

    setQ(value);
    search(value);
  };

  const onEnterButton = (event) => {
    // if enter key is pressed
    if (event.key === 'Enter') {
      search(q);
    }
  };

  return (
    <div
      className={width ? "search-bar search-bar-wide" : "search-bar search-bar-narrow"}
    >
      <Box className="search-bar-box">
        <Autocomplete
          className="autocomplete"
          freeSolo
          value={q}
          options={suggestions}
          onInputChange={onInputChangeHandler}
          onChange={onChangeHandler}
          disableClearable
          renderInput={(params) => (
            <TextField
              {...params}
              id="search-box"
              className="form-control rounded-0"
              placeholder="What are you looking for?"
              onBlur={() => setSuggestions([])}
              onClick={() => setSuggestions([])}
              onKeyDown={onEnterButton}
            />
          )}
        />
        <div className="search-button" >
          <Button variant="contained" color="primary" onClick={() => {
            search(q)
          }
          }>
            Search
          </Button>
        </div>
      </Box>
    </div>
  );
}

Para verificar esta integração, introduza texto na barra de pesquisa do site e confirme que os títulos e autores correspondentes aparecem no menu suspenso de autocompletamento.

Função: Obter um documento específico

A API de Pesquisa de Documentos recupera o documento completo de um único livro depois de o utilizador o selecionar nos resultados de pesquisa. A função lê um livro id a partir da cadeia de consulta do pedido, é usada SearchClientFactory para criar um , autenticado SearchCliente chama GetDocumentAsync para consultar essa chave no good-books índice. Devolve o documento resultante embrulhado num LookupOutput objeto.

using Azure;
using Azure.Core.Serialization;
using Azure.Identity;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using System.Net;
using System.Text.Json;
using WebSearch.Models;

namespace WebSearch.Function
{
    public class Lookup
    {
        private readonly ILogger<Lookup> _logger;

        public Lookup(ILogger<Lookup> logger)
        {
            _logger = logger;
        }


        [Function("lookup")]
        public async Task<HttpResponseData> RunAsync(
            [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req, 
            FunctionContext executionContext)
        {

            // Get Document Id
            var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
            string documentId = query["id"].ToString();

            // Azure AI Search (managed identity by default; API key only when SEARCH_USE_KEY_AUTH=true)
            SearchClient searchClient = SearchClientFactory.CreateSearchClient();

            var getDocumentResponse = await searchClient.GetDocumentAsync<SearchDocument>(documentId);

            // Data to return 
            var output = new LookupOutput
            {
                Document = getDocumentResponse.Value
            };

            var response = req.CreateResponse(HttpStatusCode.Found);

            // Serialize data
            var serializer = new JsonObjectSerializer(
                new JsonSerializerOptions(JsonSerializerDefaults.Web));
            await response.WriteAsJsonAsync(output, serializer);

            return response;
        }
    }
}

Para verificar a função de Pesquisa de forma independente, ligue /api/lookup para um livro id válido e confirme que a resposta devolve o documento completo desse livro.

Cliente: Obter documento específico

Quando um utilizador seleciona um livro nos resultados da pesquisa, a página de Detalhes necessita do documento completo desse livro, incluindo os campos não mostrados na lista de resumo. A página de Detalhes lê o livro id a partir dos parâmetros de rota e chama a API de Pesquisa de Documentos quando /api/lookup o componente é montado. Armazena o documento devolvido no estado de componente e renderiza-o nos separadores de Resultados e Dados Brutos . O código seguinte em \client\src\pages\Details\Details.jsx realiza esta consulta durante a inicialização dos componentes:

import React, { useState, useEffect } from "react";
import { useParams } from 'react-router-dom';
import Rating from '@mui/material/Rating';
import CircularProgress from '@mui/material/CircularProgress';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Box from '@mui/material/Box';

import fetchInstance from '../../url-fetch';

import "./Details.css";


function CustomTabPanel(props) {
  const { children, value, index, ...other } = props;

  return (
    <div
      className="tab-panel"
      role="tabpanel"
      hidden={value !== index}
      id={`simple-tabpanel-${index}`}
      aria-labelledby={`simple-tab-${index}`}
      {...other}
       // Ensure it takes full width
    >
      {value === index && <Box className="tab-panel-value">{children}</Box>}
    </div>
  );
}

export default function BasicTabs() {
  const { id } = useParams();
  const [document, setDocument] = useState({});
  const [value, setValue] = React.useState(0);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    setIsLoading(true);
    fetchInstance('/api/lookup', { query: { id } })
      .then(response => {
        console.log(JSON.stringify(response))
        const doc = response.document;
        setDocument(doc);
        setIsLoading(false);
      })
      .catch(error => {
        console.log(error);
        setIsLoading(false);
      });

  }, [id]);

  const handleChange = (event, newValue) => {
    setValue(newValue);
  };


  if (isLoading || !id || Object.keys(document).length === 0) {
    return (
      <div className="loading-container">
        <CircularProgress />
        <p>Loading...</p>
      </div>
    );
  }

  return (
    <Box className="details-box-parent">
      <Box className="details-tab-box-header">
        <Tabs value={value} onChange={handleChange} aria-label="book-details-tabs">
          <Tab label="Result" />
          <Tab label="Raw Data" />
        </Tabs>
      </Box>
      <CustomTabPanel value={value} index={0} className="tab-panel box-content">
        <div className="card-body">
          <h5 className="card-title">{document.original_title}</h5>
          <img className="image" src={document.image_url} alt="Book cover"></img>
          <p className="card-text">{document.authors?.join('; ')} - {document.original_publication_year}</p>
          <p className="card-text">ISBN {document.isbn}</p>
          <Rating name="half-rating-read" value={parseInt(document.average_rating)} precision={0.1} readOnly></Rating>
          <p className="card-text">{document.ratings_count} Ratings</p>
        </div>
      </CustomTabPanel>
      <CustomTabPanel value={value} index={1} className="tab-panel">
        <div className="card-body text-left card-text details-custom-tab-panel-json-div" >
          <pre><code>
            {JSON.stringify(document, null, 2)}
          </code></pre>
        </div>
      </CustomTabPanel>
    </Box>
  );
}

Para verificar esta integração, selecione um livro nos resultados da pesquisa e confirme que os seus detalhes, incluindo a imagem da capa, autores e classificação, aparecem na página de Detalhes.

Modelos C# que suportam a API

A API Funções do Azure e o projeto de importação em massa partilham um conjunto de classes de modelo C#. Estas classes definem os corpos de pedido que o cliente envia, como texto de pesquisa, valores de paginação e filtros. Também definem as formas de resposta que o cliente espera, como resultados de pesquisa, valores de facetas e um único documento pesquisado. Manter estes modelos num único ficheiro garante que os endpoints de pesquisa, sugestão e pesquisa de documentos se mantêm consistentes com as expectativas do cliente React. Os seguintes modelos, definidos em Models.cs, suportam as funções desta aplicação:

using Azure.Search.Documents.Models;
using System.Text.Json.Serialization;

namespace WebSearch.Models
{
    public class RequestBodyLookUp
    {
        [JsonPropertyName("id")]
        public string Id { get; set; }
    }

    public class RequestBodySuggest
    {
        [JsonPropertyName("q")]
        public string SearchText { get; set; }

        [JsonPropertyName("top")]
        public int Size { get; set; }

        [JsonPropertyName("suggester")]
        public string SuggesterName { get; set; }
    }

    public class RequestBodySearch
    {
        [JsonPropertyName("q")]
        public string SearchText { get; set; }

        [JsonPropertyName("skip")]
        public int Skip { get; set; }

        [JsonPropertyName("top")]
        public int Size { get; set; }

        [JsonPropertyName("filters")]
        public List<SearchFilter> Filters { get; set; }
    }

    public class SearchFilter
    {
        public string field { get; set; }
        public string value { get; set; }
    }

    public class FacetValue
    {
        public string value { get; set; }
        public long? count { get; set; }
    }

    class SearchOutput
    {
        [JsonPropertyName("count")]
        public long? Count { get; set; }
        [JsonPropertyName("results")]
        public List<SearchResult<SearchDocument>> Results { get; set; }
        [JsonPropertyName("facets")]
        public Dictionary<String, IList<FacetValue>> Facets { get; set; }
    }
    class LookupOutput
    {
        [JsonPropertyName("document")]
        public SearchDocument Document { get; set; }
    }
    public class BookModel
    {
        public string id { get; set; }
        public decimal? goodreads_book_id { get; set; }
        public decimal? best_book_id { get; set; }
        public decimal? work_id { get; set; }
        public decimal? books_count { get; set; }
        public string isbn { get; set; }
        public string isbn13 { get; set; }
        public string[] authors { get; set; }
        public decimal? original_publication_year { get; set; }
        public string original_title { get; set; }
        public string title { get; set; }
        public string language_code { get; set; }
        public double? average_rating { get; set; }
        public decimal? ratings_count { get; set; }
        public decimal? work_ratings_count { get; set; }
        public decimal? work_text_reviews_count { get; set; }
        public decimal? ratings_1 { get; set; }
        public decimal? ratings_2 { get; set; }
        public decimal? ratings_3 { get; set; }
        public decimal? ratings_4 { get; set; }
        public decimal? ratings_5 { get; set; }
        public string image_url { get; set; }
        public string small_image_url { get; set; }
    }
}

Passo seguinte

Para continuar a aprender sobre o desenvolvimento do Pesquisa de IA do Azure, experimente este próximo tutorial sobre indexação: