이전 단원에서는 정적 웹앱에 검색을 추가했습니다. 이 단원에서는 통합을 설정하는 필수 단계를 중점적으로 설명합니다. 검색을 웹앱에 통합하는 방법에 대한 치트 시트를 찾고 있는 경우 이 문서에서는 알아야 할 사항을 설명합니다.
Azure SDK Azure.Search.Documents
함수 앱은 Azure AI 검색용 Azure SDK를 사용합니다.
- NuGet: Azure.Search.Documents
- 참조 설명서: 클라이언트 라이브러리
함수 앱은 리소스 이름, 리소스 키 및 인덱스 이름을 사용하여 SDK를 통해 클라우드 기반 Azure AI 검색 API에 인증합니다. 비밀은 정적 웹앱 설정에 저장되고 함수에 환경 변수로 끌어옵니다.
local.settings.json 파일에서 비밀 구성
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "",
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"SearchApiKey": "",
"SearchServiceName": "",
"SearchIndexName": "good-books"
},
"Host": {
"CORS": "*"
}
}
Azure 함수: 카탈로그 검색
검색 API는 검색어를 사용하여 검색 인덱스의 문서를 검색하고 일치하는 항목 목록을 반환합니다. 제안 API를 통해 부분 문자열이 사용자 유형으로 검색 엔진에 전송되고, 검색 인덱스의 문서에서 책 제목 및 작성자와 같은 검색 용어를 제안하고, 일치하는 항목의 작은 목록을 반환합니다.
Azure 함수는 검색 구성 정보를 가져와서 쿼리를 충족합니다.
검색 제안기 sg
는 대량 업로드 중에 사용되는 스키마 파일에 정의되어 있습니다.
using Azure;
using Azure.Core.Serialization;
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 static string searchApiKey = Environment.GetEnvironmentVariable("SearchApiKey", EnvironmentVariableTarget.Process);
private static string searchServiceName = Environment.GetEnvironmentVariable("SearchServiceName", EnvironmentVariableTarget.Process);
private static string searchIndexName = Environment.GetEnvironmentVariable("SearchIndexName", EnvironmentVariableTarget.Process) ?? "good-books";
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
Uri serviceEndpoint = new($"https://{searchServiceName}.search.windows.net/");
SearchClient searchClient = new(
serviceEndpoint,
searchIndexName,
new AzureKeyCredential(searchApiKey)
);
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);
}
}
}
클라이언트: 카탈로그에서 검색
다음 코드를 사용하여 React 클라이언트에서 \client\src\pages\Search\Search.jsx
Azure Function을 호출하여 책을 검색합니다.
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([])}
/>
)}
/>
<div className="search-button" >
<Button variant="contained" color="primary" onClick={() => {
search(q)
}
}>
Search
</Button>
</div>
</Box>
</div>
);
}
Azure 함수: 특정 문서 가져오기
문서 조회 API는 ID를 가져와 검색 인덱스에서 문서 개체를 반환합니다.
using Azure;
using Azure.Core.Serialization;
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 static string searchApiKey = Environment.GetEnvironmentVariable("SearchApiKey", EnvironmentVariableTarget.Process);
private static string searchServiceName = Environment.GetEnvironmentVariable("SearchServiceName", EnvironmentVariableTarget.Process);
private static string searchIndexName = Environment.GetEnvironmentVariable("SearchIndexName", EnvironmentVariableTarget.Process) ?? "good-books";
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
Uri serviceEndpoint = new($"https://{searchServiceName}.search.windows.net/");
SearchClient searchClient = new(
serviceEndpoint,
searchIndexName,
new AzureKeyCredential(searchApiKey)
);
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는 구성 요소 초기화의 일환으로 \client\src\pages\Details\Details.jsx
의 React 앱에서 호출됩니다.
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# 모델
이 앱에서 함수를 지원하는 데 사용되는 모델은 다음과 같습니다.
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 검색 개발에 대한 자세한 내용을 계속 알아보려면 인덱싱에 대한 다음 자습서를 활용해 보세요.