C# 앱에서 Azure AI 검색 쿼리 통합 살펴보기

메모

Azure AI 검색 Azure 포털, REST API 및 Azure SDK 통해 사용할 수 있습니다. 또한 엔터프라이즈 콘텐츠를 Microsoft Foundry 포털의 에이전트에 대해 재사용 가능한 사용 권한 인식 기술 자료로 변환하는 관리되는 기술 계층인 Foundry IQ를 뒷받침합니다.

이전 단계에서는 검색 지원 웹 사이트를 배포하여 Azure Container Apps. 이 문서에서는 검색 통합을 설정하는 필수 단계를 강조 표시합니다. 검색을 웹앱에 통합하기 위한 치트 시트로 간주합니다.

Azure SDK Azure Search.Documents

API는 Azure AI 검색 위해 Azure SDK 사용합니다.

API는 검색 서비스 이름 및 인덱스 이름을 사용하여 SDK를 통해 클라우드 기반 Azure AI 검색 API에 인증합니다. Azure Container Apps 컨테이너 환경에서는 구성 값을 제공합니다. 관리 ID는 기본 자격 증명 경로입니다.

관리 ID 인증

API의 각 Azure 함수는 SearchClient 공유 SearchClientFactory 클래스를 통해 생성되므로 모든 함수가 동일한 방식으로 인증됩니다. 기본적으로 팩터리는 a DefaultAzureCredential 를 빌드하고 이를 사용하여 Azure AI 검색 토큰을 요청합니다. Azure Container Apps DefaultAzureCredential 컨테이너 앱에 할당된 관리 ID를 확인합니다.

다음 메서드는 SearchClientFactory.cs 해당 자격 증명을 만듭니다. 컨테이너 앱에 사용자 할당 관리 ID가 있는 경우 환경 변수의 AZURE_CLIENT_ID 클라이언트 ID가 전달 DefaultAzureCredentialOptions 되므로 토큰 획득이 모호하지 않습니다.

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

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

    return new DefaultAzureCredential(options);
}

Bicep 인프라는 관리 ID 액세스를 Azure AI 검색 데이터 평면에 azd up할당합니다. 이 역할 할당을 통해 API는 컨테이너 환경에 쿼리 good-books 키를 저장하지 않고도 인덱스 쿼리를 수행할 수 있습니다.

로컬 및 배포된 자격 증명 확인

로컬에서 설정 DefaultAzureCredential 되지 않은 경우 AZURE_CLIENT_ID 표준 자격 증명 체인을 통과하여 로그인하는 데 사용한 Azure CLI 또는 Visual Studio Code 계정과 같은 로그인한 개발자 자격 증명으로 확인합니다. Azure Container Apps 배포되는 경우 Bicep 인프라는 사용자 할당 관리 ID의 클라이언트 ID로 설정 AZURE_CLIENT_ID 되므로 DefaultAzureCredential 호스트가 노출할 수 있는 여러 ID 간에 모호하게 해결하는 대신 해당 ID를 대상으로 합니다.

대신 API 키를 사용하려면 배포 전으로 false 설정합니다USE_KEYLESS_AUTH.

azd env set USE_KEYLESS_AUTH false
azd up

사용자 환경에 필요한 경우에만 키 인증을 사용합니다.

로컬 개발 설정

로컬 개발의 경우 샘플 sample.local.settings.json 파일은 API에서 예상하는 값을 보여 줍니다. 개발에만 로컬 설정을 사용합니다. Azure Container Apps 배포 구성은 동등한 컨테이너 환경 값을 제공합니다.

Setting Purpose 필요한 경우
SearchServiceName Azure AI 검색 서비스의 이름입니다. 서비스 엔드포인트 URI를 빌드하기 위해 결합합니다 .search.windows.net .
SearchIndexName 쿼리할 검색 인덱스의 이름입니다. 기본값은 good-books 설정되지 않은 경우입니다. Optional
SEARCH_USE_KEY_AUTH 기본값은 false이며 관리 ID를 사용합니다. true 관리 ID 대신 API 키를 사용하도록 설정합니다. 선택적 키 인증
SearchApiKey Azure AI 검색 대한 관리 키입니다. SEARCH_USE_KEY_AUTH이(가) true인 경우 필요합니다.
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
    "SearchServiceName": "",
    "SearchIndexName": "good-books"
  },
  "Host": {
    "CORS": "*"
  }
}

함수: 카탈로그 검색

검색 API는 검색어를 사용하여 검색 인덱스의 문서를 검색하고 일치하는 항목 목록을 반환합니다. Suggest API를 통해 부분 문자열은 사용자가 입력할 때 검색 엔진으로 전송됩니다. API는 검색 인덱스의 문서를 기반으로 책 제목 및 작성자와 같은 검색 용어를 제안하고 일치하는 항목의 작은 목록을 반환합니다.

Azure 함수는 컨테이너 환경에서 검색 구성 정보를 가져오고, Azure AI 검색 클라이언트를 만들고, 쿼리를 수행합니다.

검색 제안기 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 함수를 호출합니다. 클라이언트는 검색 텍스트, 현재 페이지 및 값, top POST 본문에서 skip 선택한 작성자 또는 언어 필터를 /api/search보냅니다. 이 함수는 일치하는 책 문서 목록, 총 개수 및 패싯 값을 반환하며, 이 값은 페이지가 결과 목록, 호출기 및 패싯 필터를 렌더링하는 데 사용합니다. 빌드의 다음 코드는 \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>
  );
}

이 통합을 확인하려면 웹 사이트의 검색 창에 검색어를 입력하고 결과 목록, 결과 수 및 패싯이 모두 업데이트되는지 확인합니다.

클라이언트: 카탈로그의 제안

Suggest 함수 API는 Material UI 자동 완성 구성 요소의 일부로 React 앱에서 \client\src\components\SearchBar\SearchBar.jsx 호출됩니다. 이 구성 요소는 입력 텍스트를 사용하여 일치하는 작성자 및 책을 검색합니다. 그런 다음 가능한 일치 항목을 드롭다운 목록에 선택 가능한 항목으로 표시합니다.

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 읽고, 인증된 SearchClient키를 만들고, 인덱스에서 해당 키를 조회하는 호출 GetDocumentAsyncgood-books 사용합니다SearchClientFactory. 개체에 래핑된 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;
        }
    }
}

Lookup 함수를 독립적으로 확인하려면 유효한 책 id 으로 호출 /api/lookup 하고 응답이 해당 책의 전체 문서를 반환했는지 확인합니다.

클라이언트: 특정 문서 가져오기

사용자가 검색 결과에서 책을 선택하면 요약 목록에 표시되지 않는 필드를 포함하여 세부 정보 페이지에 해당 책의 전체 문서가 필요합니다. 세부 정보 페이지는 경로 매개 변수에서 책을 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>
  );
}

이 통합을 확인하려면 검색 결과에서 책을 선택하고 표지 이미지, 작성자 및 등급을 포함한 세부 정보가 세부 정보 페이지에 표시되는지 확인합니다.

API를 지원하는 C# 모델

Azure Functions API 및 대량 가져오기 프로젝트는 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 AI 검색 개발에 대해 계속 알아보려면 인덱싱에 대한 다음 자습서를 시도해 보세요.