探索 Azure AI 搜尋服務 在 C# 應用程式中的查詢整合

Note

Azure AI 搜尋服務 可透過 Azure 入口網站、REST API 及 Azure SDK 取得。 它同時也是 Foundry IQ 的基礎,這是一個管理式知識層,能將企業內容轉化為可重複使用、權限感知的知識庫,供 Microsoft Foundry 入口網站中的代理使用。

在前一步,你已經將啟用搜尋功能的網站部署到 Azure 容器應用程式。 本文強調建立搜尋整合的關鍵步驟。 可以把它當作一張將搜尋整合進網頁應用程式的速查表。

Azure SDK Azure Search Documents

該 API 使用 Azure SDK for Azure AI 搜尋服務:

API 透過 SDK 透過搜尋服務名稱與索引名稱,與雲端的 Azure AI 搜尋服務 API 進行認證。 在 Azure 容器應用程式 中,容器環境會提供設定值。 管理身份是預設的憑證路徑。

管理式身份驗證

API 中的每個 Azure 函式都是透過共享SearchClientFactory類別建立的SearchClient,所以每個函式的認證方式都一樣。 預設情況下,工廠會建置 aDefaultAzureCredential,並用它來請求 Azure AI 搜尋服務 的權杖。 在 Azure 容器應用程式 中,DefaultAzureCredential會解析為分配給容器應用程式的受管理身份。

以下方法 SearchClientFactory.cs 會產生該憑證。 當容器應用程式擁有使用者指派的管理身份時,環境變數中的客戶端 ID 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 AI 搜尋服務 資料平面azd up。 此角色指派讓 API good-books 在不儲存查詢金鑰的情況下查詢索引。

本地與已部署憑證解析

在本地,如果AZURE_CLIENT_ID未設定DefaultAzureCredential,會回退到標準的憑證鏈,並解析為你登入的開發者憑證,例如你登入時使用的 Azure CLI 或 Visual Studio Code 帳號。 部署到 Azure 容器應用程式 時,Bicep 基礎架構會設定AZURE_CLIENT_ID為使用者指派的管理身份的客戶端 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 AI 搜尋服務 服務名稱。 結合起來 .search.windows.net 建構服務端點 URI。 永遠
SearchIndexName 查詢的搜尋索引名稱。 預設為 good-books 未設定。 Optional
SEARCH_USE_KEY_AUTH 預設是 false,使用受管理身份。 設定為 true 使用 API 金鑰而非管理身份。 可選的金鑰認證
SearchApiKey Admin key for 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 函式。 用戶端會將搜尋文字、當前頁面 skip 與 top 值,以及 POST 主體中所選的作者或語言篩選器傳送給 /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 Grid from '@mui/material/Grid';
import Box from '@mui/material/Box';
import Container from '@mui/material/Container';
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 { SearchMain, SearchBarColumn, SearchBarResults, SearchBarColumnContainer, SearchResultsContainer, PagerStyle } from './styled';
export default function Search() {
    let location = useLocation();
    const navigate = useNavigate();
    const [results, setResults] = useState([]);
    const [q, setQ] = useState(new URLSearchParams(location.search).get('q') ?? "*");
    const [resultCount, setResultCount] = useState(0);
    const [currentPage, setCurrentPage] = useState(1);
    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 (<Container maxWidth={false} component={SearchMain} sx={{ marginTop: 2 }}>
      <Grid container spacing={2} sx={{ px: 2, marginTop: 2 }}> {/* Added horizontal padding and top margin */}
        <Grid item xs={12} md={3} component={SearchBarColumn} sx={{
            padding: '8px 16px 16px 16px',
            borderRight: '1px solid #f0f0f0'
        }}>
          <SearchBarColumnContainer>
            <SearchBar postSearchHandler={postSearchHandler} query={q} width={false}></SearchBar>
          </SearchBarColumnContainer>
          <Facets facets={facets} filters={filters} setFilters={updateFilterHandler}></Facets>
        </Grid>
        <Grid item xs={12} md={9} component={SearchBarResults}>
          {isLoading ? (<Box display="flex" justifyContent="center" p={2}>
              <CircularProgress />
            </Box>) : (<SearchResultsContainer>
              <Results documents={results} top={top} skip={skip} count={resultCount} query={q}></Results>
              <PagerStyle>
                <Pager currentPage={currentPage} resultCount={resultCount} resultsPerPage={resultsPerPage} onPageChange={handlePageChange}></Pager>
              </PagerStyle>
            </SearchResultsContainer>)}
        </Grid>
      </Grid>
    </Container>);
}

要驗證此整合,請在網站搜尋欄輸入搜尋詞,確認結果列表、結果計數及面向皆已更新。

客戶端:目錄中的建議

建議函式 API 會在 React 應用程式 \client\src\components\SearchBar\SearchBar.jsx 中被呼叫,作為 Material UI 自動補全元件的一部分。 此元件利用輸入文字搜尋符合的作者與書籍。 接著會把這些可能的匹配顯示在下拉選單中,作為可選項目。

import React, { useState, useEffect } from 'react';
import { TextField } from '@mui/material';
import fetchInstance from '../../url-fetch';
import {
  SearchAutocomplete,
  SearchBox,
  SearchButton,
  SearchContainer,
} from './styles';

const suggestionListStyles = {
  padding: 0,
  '& .MuiAutocomplete-option': {
    minHeight: '40px',
    padding: '8px 14px',
    textAlign: 'left',
  },
};

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 (event.key === 'Enter') {
      search(q);
    }
  };

  return (
    <SearchContainer $wide={Boolean(width)}>
      <SearchBox>
        <SearchAutocomplete
          freeSolo
          value={q}
          options={suggestions}
          onInputChange={onInputChangeHandler}
          onChange={onChangeHandler}
          disableClearable
          ListboxProps={{ sx: suggestionListStyles }}
          renderInput={(params) => (
            <TextField
              {...params}
              id="search-box"
              placeholder="What are you looking for?"
              onBlur={() => setSuggestions([])}
              onClick={() => setSuggestions([])}
              onKeyDown={onEnterButton}
            />
          )}
        />
        <SearchButton variant="contained" onClick={() => search(q)}>
          Search
        </SearchButton>
      </SearchBox>
    </SearchContainer>
  );
}

要驗證此整合,請在網站搜尋欄輸入文字,並確認自動補全下拉選單中顯示的書名與作者是否相符。

功能:取得具體文件

文件 查詢 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;
        }
    }
}

若要獨立驗證查詢功能,請用有效的書籍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 { TabPanel, TabPanelValue, CardBody, ImageContainer, CardTitle, CardText, BoxContent, DetailsBoxParent, DetailsTabBoxHeader, DetailsCustomTabPanelJsonDiv } from './styled';
function CustomTabPanel(props) {
    const { children, value, index, ...other } = props;
    return (<TabPanel role="tabpanel" hidden={value !== index} id={`simple-tabpanel-${index}`} aria-labelledby={`simple-tab-${index}`} {...other}>
      {value === index && <TabPanelValue>{children}</TabPanelValue>}
    </TabPanel>);
}
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: 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 (<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '2em' }}>
        <CircularProgress />
        <Box sx={{ mt: 2 }}>Loading...</Box>
      </Box>);
    }
    return (<DetailsBoxParent>
      <DetailsTabBoxHeader>
        <Tabs value={value} onChange={handleChange} aria-label="book-details-tabs">
          <Tab label="Result"/>
          <Tab label="Raw Data"/>
        </Tabs>
      </DetailsTabBoxHeader>
      <CustomTabPanel value={value} index={0} component={BoxContent}>
        <CardBody>
          <CardTitle variant="h5">{document.original_title}</CardTitle>
          <ImageContainer src={document.image_url} alt="Book cover"/>
          <CardText variant="body1">{document.authors?.join('; ')} - {document.original_publication_year}</CardText>
          <CardText variant="body1">ISBN {document.isbn}</CardText>
          <Rating name="half-rating-read" value={parseInt(document.average_rating)} precision={0.1} readOnly></Rating>
          <CardText variant="body1">{document.ratings_count} Ratings</CardText>
        </CardBody>
      </CustomTabPanel>
      <CustomTabPanel value={value} index={1} component={BoxContent}>
        <CardBody>
          <DetailsCustomTabPanelJsonDiv>
            <pre><code>
              {JSON.stringify(document, null, 2)}
            </code></pre>
          </DetailsCustomTabPanelJsonDiv>
        </CardBody>
      </CustomTabPanel>
    </DetailsBoxParent>);
}

要驗證此整合,請從搜尋結果中選擇一本書,並確認其詳細資訊,包括封面圖片、作者及評分,都顯示在詳細資料頁面。

支援 API 的 C# 模型

Azure Functions API 與 bulk import 專案共用一組 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 搜尋服務 的開發,請試試以下關於索引的教學: