Изучение интеграции запросов Поиск с использованием ИИ Azure в приложении C#

Note

Поиск с использованием ИИ Azure доступна через портал Azure, REST API и Azure SDKs. Он также лежит в основе Foundry IQ — управляемого слоя знаний, который преобразует корпоративный контент в многократно используемые базы знаний с учетом разрешений доступа для агентов на портале Microsoft Foundry.

На предыдущем шаге вы развернули веб-сайт с поддержкой поиска для Контейнеры приложений Azure. В этой статье описаны основные шаги, которые устанавливают интеграцию поиска. Думайте об этом как памятку для интеграции поиска в веб-приложение.

Пакет SDK Azure Azure.Search.Documents

API использует Azure SDK для Поиск с использованием ИИ Azure:

API проходит проверку подлинности через пакет SDK для облачного api Поиск с использованием ИИ Azure с помощью имени службы поиска и имени индекса. В Контейнеры приложений Azure среда контейнера предоставляет значения конфигурации. Управляемое удостоверение — это путь учетных данных по умолчанию.

Аутентификация управляемой идентичности

Каждая функция Azure в API создает его SearchClient через общий SearchClientFactory класс, поэтому каждая функция проходит проверку подлинности так же. По умолчанию фабрика создает и DefaultAzureCredential использует его для запроса маркеров для Поиск с использованием ИИ Azure. В Контейнеры приложений Azure разрешается управляемому удостоверению, DefaultAzureCredential назначенному приложению-контейнеру.

Приведенный ниже метод SearchClientFactory.cs создает эти учетные данные. Если у приложения-контейнера есть управляемое удостоверение, назначаемое пользователем, идентификатор клиента из AZURE_CLIENT_ID переменной среды передается DefaultAzureCredentialOptions таким образом, что получение маркера не является неоднозначным.

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

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

    return new DefaultAzureCredential(options);
}

Инфраструктура Bicep назначает управляемому удостоверению доступ к плоскости данных Поиск с использованием ИИ Azure во времяazd up. Это назначение роли позволяет API запрашивать good-books индекс без хранения ключа запроса в среде контейнера.

Локальное разрешение учетных данных и разрешение развернутых учетных данных

Локально, если AZURE_CLIENT_ID он не настроен, DefaultAzureCredential возвращается через стандартную цепочку учетных данных и разрешает учетные данные разработчика, выполнившего вход, например учетную запись Azure CLI или Visual Studio Code, которую вы использовали для входа. При развертывании в Контейнеры приложений Azure инфраструктура Bicep задает AZURE_CLIENT_ID идентификатор клиента управляемого удостоверения, назначаемого пользователем, поэтому DefaultAzureCredential он предназначен специально для идентификации вместо разрешения неоднозначно между несколькими удостоверениями, которые может предоставлять узел.

Чтобы использовать ключи API, задайте USE_KEYLESS_AUTH значение false перед развертыванием:

azd env set USE_KEYLESS_AUTH false
azd up

Используйте проверку подлинности ключа только в том случае, если для вашей среды требуется.

Параметры локальной разработки

Для локальной разработки в примере sample.local.settings.json файла показаны ожидаемые значения API. Используйте только локальные параметры для разработки. В Контейнеры приложений Azure конфигурация развертывания предоставляет эквивалентные значения среды контейнера.

Setting Purpose Обязательный, если
SearchServiceName Имя службы Поиск с использованием ИИ Azure. Объединяется с .search.windows.net сборкой URI конечной точки службы. Всегда
SearchIndexName Имя индекса поиска для запроса. Значение по умолчанию, если не задано good-books . Optional
SEARCH_USE_KEY_AUTH Значение по умолчанию равно false, использует управляемое удостоверение. Установите для true использования ключа API вместо управляемого удостоверения. Необязательная проверка подлинности ключа
SearchApiKey Ключ администратора для Поиск с использованием ИИ Azure. Требуется, если SEARCH_USE_KEY_AUTH имеет значение true
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
    "SearchServiceName": "",
    "SearchIndexName": "good-books"
  },
  "Host": {
    "CORS": "*"
  }
}

Функция: поиск каталога

API поиска принимает условие поиска и выполняет поиск по документам в индексе поиска, возвращая список совпадений. Через API предложения частичные строки отправляются в поисковую систему в качестве типов пользователей. API предлагает условия поиска, такие как названия книг и авторы, на основе документов в индексе поиска и возвращает небольшой список совпадений.

Функция Azure извлекает сведения о конфигурации поиска из среды контейнера, создает клиент Поиск с использованием ИИ Azure и выполняет запрос.

Средство поиска, sg, определяется в файле схемы, используемом при массовой загрузке.

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);
        }
    }
}

Чтобы проверить функцию независимо, вызовите /api/search термин поиска в тексте запроса и подтвердите, что ответ содержит соответствующие документы книги, общее количество и значения аспектов.

Клиент: поиск каталога

Страница поиска клиента React вызывает search функцию Azure всякий раз, когда пользователь вводит запрос, изменяет фильтр аспектов или перемещается на новую страницу результатов. Клиент отправляет текст поиска, значения текущей страницы skip и top любые выбранные фильтры автора или языка в текст /api/searchPOST. Функция возвращает список соответствующих документов книги, общее количество и значения аспектов, которые страница использует для отображения списка результатов, пейджера и фильтров аспектов. Следующий код в \client\src\pages\Search\Search.jsx сборках, которые запрашивают и сохраняют ответ в состоянии компонента:

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>
  );
}

Чтобы проверить эту интеграцию, введите поисковый термин в строке поиска веб-сайта и убедитесь, что список результатов, счетчик результатов и аспекты всех обновлений.

Клиент: предложения из каталога

API функции "Предложение" вызывается в приложении \client\src\components\SearchBar\SearchBar.jsx React в составе компонента автозавершения пользовательского интерфейса материала. Этот компонент использует входной текст для поиска авторов и книг, которые соответствуют. Затем в раскрывающемся списке отображаются возможные совпадения как доступные для выбора элементы.

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>
  );
}

Чтобы проверить эту интеграцию, введите текст в строке поиска веб-сайта и убедитесь, что соответствующие названия книг и авторы отображаются в раскрывающемся списке автозаполнения.

Функция: получение определенного документа

API поиска документов извлекает полный документ для одной книги после того, как пользователь выбирает его из результатов поиска. Функция считывает книгу id из строки запроса, используется SearchClientFactory для создания аутентифицированного SearchClientключа и вызовов GetDocumentAsync для поиска этого ключа в индексе good-books . Он возвращает полученный документ, завернутый LookupOutput в объект.

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;
        }
    }
}

Чтобы проверить функцию Поиска независимо, вызовите /api/lookup допустимую книгу id и подтвердите, что ответ возвращает полный документ этой книги.

Клиент: получение конкретного документа

Когда пользователь выбирает книгу из результатов поиска, страница сведений нуждается в полном документе для этой книги, включая поля, не отображаемые в списке сводок. Страница сведений считывает книгу id из параметров маршрута и вызывает API /api/lookup подстановки документов при подключении компонента. Он сохраняет возвращенный документ в состоянии компонента и отображает его на вкладках "Результат " и " Необработанные данные ". Следующий код выполняет \client\src\pages\Details\Details.jsx этот поиск во время инициализации компонента:

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>
  );
}

Чтобы проверить эту интеграцию, выберите книгу из результатов поиска и убедитесь, что ее сведения, включая изображение обложки, авторы и рейтинг, отображаются на странице сведений.

Модели C#, поддерживающие API

API Функции Azure и проект массового импорта совместно используют набор классов модели C#. Эти классы определяют тела запроса, отправляемые клиентом, такие как текст поиска, значения разбиения по страницам и фильтры. Они также определяют фигуры ответа, ожидаемые клиентом, такие как результаты поиска, значения аспектов и один документ с поиском. Сохранение этих моделей в одном файле гарантирует, что конечные точки поиска, предложения и подстановки документов остаются в соответствии с ожиданиями клиента React. Следующие модели, определенные в Models.cs, поддерживают функции в этом приложении:

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; }
    }
}

Следующий шаг

Чтобы продолжить изучение Поиск с использованием ИИ Azure разработки, воспользуйтесь следующим руководством по индексации: