4. Изучение кода поиска JavaScript

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

Исходный код доступен в репозитории GitHub azure-search-javascript-samples .

Пакет SDK Azure @azure/search-documents

Приложение-функция использует пакет AZURE SDK для поиска ИИ Azure:

Приложение-функция проходит проверку подлинности через пакет SDK для облачного API поиска ИИ Azure с помощью имени ресурса, ключа API и имени индекса. Секреты хранятся в параметрах статического веб-приложения и извлекаются в функцию в качестве переменных среды.

Настройка секретов в файле конфигурации

const CONFIG = {
    SearchIndexName: process.env["SearchIndexName"] || "good-books",
    SearchApiQueryKey: process.env["SearchApiKey"] || "",
    SearchServiceName: process.env["SearchServiceName"] || "",
    SearchFacets: process.env["SearchFacets"] || "authors*,language_code", 
}
console.log(CONFIG);
if (!CONFIG.SearchIndexName || !CONFIG.SearchApiQueryKey || !CONFIG.SearchServiceName) throw Error("./config.js::Cognitive Services key is missing");

module.exports = { CONFIG };

Функция Azure: Поиск в каталоге

API поиска принимает условие поиска и выполняет поиск по документам в индексе поиска, возвращая список совпадений.

Функция Azure извлекает сведения о конфигурации поиска и выполняет запрос.

const { app } = require('@azure/functions');
const { CONFIG } = require("../lib/config");
const { readFacets, createFilterExpression } = require('../lib/azure-cognitive-search');
const { SearchClient, AzureKeyCredential } = require("@azure/search-documents");

// Create a SearchClient to send queries
const client = new SearchClient(
    `https://` + CONFIG.SearchServiceName + `.search.windows.net/`,
    CONFIG.SearchIndexName,
    new AzureKeyCredential(CONFIG.SearchApiQueryKey)
);

app.http('search', {
    methods: ['POST'],
    authLevel: 'anonymous',
    handler: async (request, context) => {

        context.log(`Search request for url "${request.url}"`);

        try {

            const body = await request.json();
            console.log(body);

            let q = body.q || "*";
            const top = body.top || 5;
            const skip = parseInt(body.skip || 0);
            const filters = body.filters || undefined;
            const facets = readFacets(CONFIG.SearchFacets);

            const facetNames = Object.keys(facets);
            console.log(facetNames);

            const filtersExpression = (filters && facets) ? createFilterExpression(filters, facets) : undefined;
            console.log(filtersExpression)

            // Creating SearchOptions for query
            let searchOptions = {
                top: top,
                skip: skip,
                includeTotalCount: true,
                facets: facetNames,
                filter: filtersExpression
            };
            console.log(searchOptions);

            // Sending the search request
            const searchResults = await client.search(q, searchOptions);
            console.log(searchResults);

            // Getting results for output
            const output = [];
            for await (const result of searchResults.results) {
                output.push(result);
            }
            console.log(searchResults)

            // Logging search results
            context.log(searchResults.count);

            return {
                headers: {
                    "Content-type": "application/json"
                },
                jsonBody: {
                    count: searchResults.count,
                    results: output,
                    resultsCount: output.length,
                    facets: searchResults.facets,
                    q,
                    top,
                    skip,
                    filters: filters || ''
                }
            };

        } catch (error) {
            return {
                status: 500,
                jsonBody: {
                    innerStatusCode: error.statusCode || error.code,
                    error: error.details || error.message,
                    stack: error.stack
                }
            }
        }
    }
});

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

Вызовите Функцию Azure в клиенте React, используя следующий код:

import React, { useState } from "react";
import request from "../api";
import CircularProgress from "@mui/material/CircularProgress";
import { useLocation, useNavigate } from "react-router-dom";
import styled from "@emotion/styled";
import Grid from "@mui/material/Grid";
import Stack from "@mui/material/Stack";
import Container from "@mui/material/Container";

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

import { useQuery } from "@tanstack/react-query";

const StyledPager = styled(Pager)({
  marginLeft: "auto",
  marginRight: "auto",
  maxWidth: "fit-content",
});
const StyledContainer = styled.div`
  // Uncomment to debug
  // border: 1px solid red;

  // Center body with space around
  margin: 1rem auto;
  margin-top: 5rem;

  min-height: 30em;
  padding-left: 0px;
  padding-right: 0px;
  max-width: 98%;
  outline: 0px;

  display: flex;
`;
const StyledSearchBar = styled(SearchBar)({});
const LeftColumn = styled(Stack)`
  width: 30%;
  border-right: 1px solid #f0f0f0;
  padding: 0 16px 0 16px;
`;

const RightColumn = styled(Container)``;

export default function Search() {
  const location = useLocation();
  const navigate = useNavigate();

  const [currentPage, setCurrentPage] = useState(
    new URLSearchParams(location.search).get("p") ?? 1
  );
  const [searchTerm, setSearchTerm] = 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({});

  let resultsPerPage = top;

  function setNavigation(q, p) {
    navigate(`/search?q=${q}&p=${p}`);
  }

  function changeCurrentPage(newPage) {
    const newSkip = (newPage - 1) * top;
    setNavigation(searchTerm, newPage);
    setCurrentPage(newPage);
    setSkip(newSkip);
  }

  const fiveMinutes = 1000 * 60 * 5;

  /* eslint-disable no-unused-vars */
  const { data, isLoading, dataUpdatedAt, error } = useQuery({
    queryKey: ["search", searchTerm, top, skip, currentPage, filters, facets],
    //refetchOnMount: false,
    //refetchOnWindowFocus: false,
    //refetchOnReconnect: false,
    enabled: searchTerm !== undefined,
    staleTime: fiveMinutes, // time in milliseconds
    cacheTime: fiveMinutes,
    queryFn: async () => {
      setSkip((currentPage - 1) * top);
      return request("/api/search", "POST", {
        q: searchTerm,
        top: top,
        skip: (currentPage - 1) * top,
        filters: filters,
      }).then((response) => {
        setFacets(response.facets);
        setFilters(response.filters);
        return response;
      });
    },
  });

  const postSearchHandler = (searchTerm) => {
    setNavigation(searchTerm, 1);
    setSearchTerm(searchTerm);
    setCurrentPage(1);
    setSkip(0);
  };

  const updateFilters = (filters) => {
    setFilters(filters);
  };
  return (
    <>
      <StyledContainer>
        <LeftColumn>
          <StyledSearchBar
            navigateToSearchPage={postSearchHandler}
            defaultTerm={searchTerm}
          ></StyledSearchBar>
          <Facets
            facets={facets}
            filters={filters}
            setFilters={updateFilters}
          ></Facets>
        </LeftColumn>
        <RightColumn>
          {isLoading ? (
            <CircularProgress />
          ) : (
            <Grid container>
              <Results
                q={searchTerm}
                documents={data.results}
                top={top}
                skip={skip}
                count={data.count}
              ></Results>
              <StyledPager
                className="pager-style"
                currentPage={currentPage}
                resultCount={data.count}
                resultsPerPage={resultsPerPage}
                setCurrentPage={changeCurrentPage}
              ></StyledPager>
            </Grid>
          )}
        </RightColumn>
      </StyledContainer>
    </>
  );
}

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

Этот компонент React включает текстовое поле поиска и аспекты , связанные с результатами поиска. Аспекты необходимо учитывать и разрабатывать как часть схемы поиска при загрузке данных поиска. Затем аспекты используются в поисковом запросе вместе с текстом поиска, чтобы обеспечить фасетную навигацию.

import React, { useEffect, useState } from "react";
import { List, Chip } from "@mui/material";
import CheckboxFacet from "./CheckboxFacet";
import styled from "@emotion/styled";

const StyledFacetComponent = styled.div`
  border-right: "1px solid #f0f0f0";
  height: "100%";
`;
const StyledSelectedFacets = styled.div``;
const StyledFacetList = styled(List)`
  margin: "0.25em";
  margin-top: "32px !important";
  padding-left: "36px !important";
`;
export default function Facets(props) {
  const [filters, setFilters] = useState([]);
  const [facets, setFacets] = useState({});

  useEffect(() => {
    setFilters(props.filters);
    setFacets(props.facets);
  }, [props.filters, props.facets]);

  // Change facet name to be more readable
  // e.g. "author" -> "Author"
  // e.g. "publication_year" -> "Publication Year"
  function mapFacetName(facetName) {
    facetName =
      `${facetName[0].toUpperCase()}${facetName.substring(1)}`.replace(
        "_",
        " "
      ) || ``;
    return facetName;
  }

  function addFilter(name, value) {
    const newFilters = filters.concat({ field: name, value: value });
    props.setFilters(newFilters);
  }

  function removeFilter(filter) {
    const newFilters = filters.filter((item) => item.value !== filter.value);
    props.setFilters(newFilters);
  }

  return (
    <StyledFacetComponent>
      <StyledSelectedFacets>
        <List>
          {filters.map((filter, index) => {
            return (
              <Chip
                key={index}
                label={`${mapFacetName(filter.field)}: ${filter.value}`}
                onDelete={() => removeFilter(filter)}
              />
            );
          })}
        </List>
      </StyledSelectedFacets>
      <StyledFacetList>
        {Object.keys(facets).map((key) => {
          return (
            <CheckboxFacet
              key={key}
              name={key}
              values={facets[key]}
              addFilter={addFilter}
              removeFilter={removeFilter}
              mapFacetName={mapFacetName}
              selectedFacets={filters.filter((f) => f.field === key)}
            />
          );
        })}
      </StyledFacetList>
    </StyledFacetComponent>
  );
}

Клиент: разбиение на страницы из каталога

Когда результаты поиска выходят за рамки тривиальных (8), @mui/material/TablePagination компонент предоставляет разбивку на страницы по результатам.

import React from "react";
import TablePagination from "@mui/material/TablePagination";
import styled from "@emotion/styled";

const StyledTablePagination = styled(TablePagination)`
margin: auto;
max-width: fit-content;
border-bottom: 0;
`;

export default function Pager(props) {
  const pagesInResultSet = Math.round(props.resultCount / props.resultsPerPage);
  const moreThanOnePage = pagesInResultSet > 1;

  if (!moreThanOnePage) return <></>;

  const handleChangePage = (event, newPage) => {
    props.setCurrentPage(newPage + 1);
  };
  return (
    <StyledTablePagination
      className="pager"
      align="center"
      component="div"
      count={props.resultCount}
      page={props.currentPage - 1} // zero-based control from material ui
      onPageChange={handleChangePage}
      rowsPerPage={props.resultsPerPage}
      rowsPerPageOptions={[props.resultsPerPage]} // don't display b/c there is a single value
      showFirstButton={true}
      showLastButton={true}
      labelDisplayedRows={({ from, to, count }) =>
        `${props.currentPage} of ${Math.round(
          props.resultCount / props.resultsPerPage
        )} pages`
      }
    />
  );
}

Когда пользователь изменяет страницу, это значение отправляется на родительскую Search.js страницу из handleChangePage функции. Функция отправляет новый запрос в API поиска для того же запроса и новой страницы. Ответ API обновляет аспекты, результаты и компоненты пейджера.

Функция Azure: предложения из каталога

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

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

const { app } = require('@azure/functions');
const { SearchClient, AzureKeyCredential } = require("@azure/search-documents");
const { CONFIG } = require("../lib/config");

// Create a SearchClient to send queries
const client = new SearchClient(
    `https://` + CONFIG.SearchServiceName + `.search.windows.net/`,
    CONFIG.SearchIndexName,
    new AzureKeyCredential(CONFIG.SearchApiQueryKey)
);

app.http('suggest', {
    methods: ['POST'],
    authLevel: 'anonymous',
    handler: async (request, context) => {
        context.log(`Suggester request for url "${request.url}"`);
        try {
            const body = await request.json();
            console.log(`suggest body ${body}`);

            let q = body.q;
            console.log(`suggest q ${q}`)

            const top = body.top;
            console.log(`suggest top ${top}`)

            const suggester = body.suggester;
            console.log(`suggest suggester ${suggester}`)

            if(!body || !q || !top || !suggester){
                console.log(`No suggester found in body`)
                return {
                    status: 404,
                    body: "No suggester found"
                }
            }

            // Let's get the top 5 suggestions for that search term
            const suggestions = await client.suggest(q, suggester, { top: parseInt(top) });
            //const suggestions = await client.autocomplete(q, suggester, {top: parseInt(top)});

            context.log(suggestions);

            return {
                headers: {
                    "Content-type": "application/json"
                },
                jsonBody: { 
                    suggestions: suggestions.results,
                    q, 
                    top,
                    suggester 

                }
            }
        } catch (error) {
            return {
                status: 400,
                jsonBody: {
                    innerStatusCode: error.statusCode || error.code,
                    error: error.details || error.message
                }
            }
        }
    }
});

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

API функции Suggest вызывается в приложении React \src\components\SearchBar\SearchBar.js в рамках инициализации компонента.

import React, { useState } from "react";
import request from "../api";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Autocomplete from "@mui/material/Autocomplete";
import styled from "@emotion/styled";

import { useQuery } from "@tanstack/react-query";

const StyledContainer = styled.div`
  display: flex;
  flex-flow: row wrap;
  justify-content: space-evenly;
  align-items: center;
  gap: 1em;
  margin-top: 1em;
`;

const StyledAutoComplete = styled(Autocomplete)`
  width: 80%;
`;

const StyledButton = styled(Button)`
  max-height: 40px;
`;

export default function SearchBar({ navigateToSearchPage, defaultTerm = "" }) {

  const [q, setQ] = useState(defaultTerm);
  const [suggestions, setSuggestions] = useState([]);

  const top = 5;
  const suggester = "sg";

  /* eslint-disable no-unused-vars */
  const { data, isLoading, error } = useQuery({
    queryKey: ["suggest", q, top, suggester],
    refetchOnMount: true,
    enabled: q.length > 0,
    queryFn: async () => {
      if (q.length > 0) {
        return request("/api/suggest", "POST", {
          q,
          top,
          suggester,
        }).then((response) => {
          let i = 0;
          const autoCompleteOptions = response.suggestions.map(
            (suggestion) => ({ id: i++, label: suggestion.text })
          );
          setSuggestions(autoCompleteOptions);
          return response;
        });
      }
    },
  });

  const onFormSubmit = () => {
    if (navigateToSearchPage) {
      navigateToSearchPage(q);
    }
  };

  function hasLabelValue (option){
    return (
      option !== undefined &&
      option !== null &&
      option.label !== undefined &&
      option.label !== null
    );
  }

  return (
    <StyledContainer onSubmit={onFormSubmit}>
      <StyledAutoComplete
        key="autocomplete"
        freeSolo // accepts both entered text or selected suggestion
        autoSelect // text in box selected
        autoFocus="autoFocus"
        filterOptions={(x) => x}
        options={suggestions}
        value={q}
        noOptionsText="What are you looking for?"
        onChange={
          (e, value, reason) => setQ(value?.label || "")
        }
        onInputChange={(e, newValue, reason) => {
          if (newValue) {
            setQ(newValue);
          }
        }}
        getOptionLabel={(option) => hasLabelValue(option) ? option.label : q}
        // set key to force re-render when q changes
        renderOption={(props, option) => {
          return hasLabelValue(option) ? (
            <li {...props} key={option.id}>
              {option.label}
            </li>
          ) :  (
            <li {...props} key={q}>
              {q}
            </li>
          )
        }}
        renderInput={(params) => (
          <TextField
            {...params}
            variant="outlined"
            label="What are you looking for?"
            onKeyDown={(e) => {
              if (e.code.toLowerCase() === "enter" && e.target.value) {
                onFormSubmit(e.target.value);
              }
            }}
          />
        )}
      />
      <StyledButton
        key="styledbutton"
        variant="contained"
        onClick={onFormSubmit}
      >
        Search
      </StyledButton>
    </StyledContainer>
  );
}

Этот компонент React использует @mui/material/Autocomplete компонент для предоставления текстового поля поиска, который также поддерживает отображение предложений (с помощью renderInput функции). Автозаполнение начинается после ввода первых нескольких символов. По мере ввода каждого нового символа он отправляется в качестве запроса поисковой системе. Результаты отображаются в виде короткого списка предложений.

Эта функция автозаполнения является распространенным компонентом, но эта конкретная реализация имеет дополнительный вариант использования. Клиент может ввести текст и выбрать из предложений или отправить введенный текст. Входные данные из списка предложений, а также входные данные из текстового поля должны отслеживаться для изменений, которые влияют на то, как форма отрисовывается и что отправляется в API поиска при отправке формы.

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

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

API подстановки принимает идентификатор и возвращает объект документа из индекса поиска.

const { SearchClient, AzureKeyCredential } = require("@azure/search-documents");
const { app } = require('@azure/functions');
const { CONFIG } = require("../lib/config");

// Create a SearchClient to send queries
const client = new SearchClient(
    `https://` + CONFIG.SearchServiceName + `.search.windows.net/`,
    CONFIG.SearchIndexName,
    new AzureKeyCredential(CONFIG.SearchApiQueryKey)
);

app.http('lookup', {
    methods: ['GET'],
    authLevel: 'anonymous',
    handler: async (request, context) => {
        context.log(`Lookup processed request for url "${request.url}"`);

        try {
            const id = request.query.get('id');
            console.log(id);

            if (!id) {
                return {
                    status: 404
                }
            }

            const document = await client.getDocument(id);

            return { jsonBody: { document: document } };

        } catch (error) {
            return {
                status: 400,
                jsonBody: {
                    innerStatusCode: error.statusCode || error.code,
                    error: error.details || error.message
                }
            }
        }
    }
});

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

Этот программный интерфейс функции вызывается в приложении React \src\pages\Details\Detail.js в рамках инициализации компонента:

import React, { useState } from "react";
import { useParams } from 'react-router-dom';
import CircularProgress from '@mui/material/CircularProgress';
import Container from '@mui/material/Container';
import request from '../api';
import BookDetailsTab from "../components/BookDetail/DetailPage/booktab";

import {
  useQuery,
} from '@tanstack/react-query'

export default function Details() {

  let { id } = useParams();
  const [document, setDocument] = useState({});

  const lookupRequest = () => request('/api/lookup?id=' + id, "GET").then(response => {
    const doc = response.document;
    setDocument(doc);
    return response
  });

  const fiveMinutes = 1000 * 60 * 5;

    /* eslint-disable no-unused-vars */
  const { data, isLoading, error } = useQuery({
    queryKey: ["lookup", id], 
    queryFn: async() =>  lookupRequest(),
    enabled: id !== undefined,
    staleTime: fiveMinutes, // time in milliseconds
    cacheTime: fiveMinutes,
    //refetchOnMount: false,
    //refetchOnWindowFocus: false,
    //refetchOnReconnect: false,
  });

  return (
    <Container sx = {{
      padding: 4
    }}>
      { isLoading ? <CircularProgress /> : <BookDetailsTab document={document}/>}
      { error && <div>{error}</div> }
    </Container>
   
  );
}

Если клиентское приложение может использовать предварительно созданное содержимое, эта страница является хорошим кандидатом для автоматического создания, так как содержимое является статическим, извлекается непосредственно из индекса поиска.

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

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

На следующем шаге можно расширить этот пример в нескольких направлениях:

  • Добавьте автозавершение для дополнительных типов.
  • Добавление или изменение аспектов и фильтров.
  • Измените модель проверки подлинности и авторизации, используя идентификатор Microsoft Entra вместо проверки подлинности на основе ключей.
  • Измените методологию индексирования. Вместо отправки JSON в индекс поиска предварительно загрузите контейнер БОЛЬШИХ двоичных объектов с набором данных good-books и настройте индексатор BLOB-объектов для приема данных. Зная, как работать с индексаторами, вы можете использовать дополнительные возможности приема данных и обогащения содержимого во время индексирования.