RAG для впровадження: семантичний пошук у дії (Українська (Ukrainian))

RAG для впровадження: семантичний пошук у дії

Wednesday, 24 December 2025

//

15 minute read

Вступ

ведь часть серии RAG: Це можливості пошуку частин 4b і інтерфейсу:

Вхід Частина 4аМи побудували фундамент: вбудовування ONNX і векторний склад Qdrant. інтерфейс справжнього пошуку - включаючи автозавершення типу typeahead, гібридний пошук, що поєднує семантику + повний текст, і додаткове фільтрування.

У цій статті описано реальних користувачів досвіду пошуку, які взаємодіють з цим блогом.

Пошукова історія з Типагед

У полі пошуку, розташованому у верхній частині сайта, ви знайдете результати негайного пошуку у такому вигляді. Ось як це працює:

sequenceDiagram
    participant U as User
    participant A as Alpine.js
    participant API as SearchApi
    participant H as HybridSearch
    participant S as Semantic Search
    participant P as PostgreSQL

    U->>A: Types "docker"
    A->>A: Debounce 300ms
    A->>API: GET /api/search/docker
    API->>H: HybridSearchAsync("docker")
    par Parallel Search
        H->>S: SearchAsync("docker", 20)
        H->>P: GetSearchResultForComplete("docker")
    end
    S-->>H: Semantic results (by meaning)
    P-->>H: Full-text results (by keywords)
    H->>H: Apply RRF scoring
    H-->>API: Combined results
    API-->>A: JSON results
    A->>U: Display dropdown

Компонент TypeaheadName

Використання поля пошуку Альпійський.js для реагентного інтерфейсу користувача без важких оболонок JavaScript. Ось його компонент:

export function typeahead() {
    return {
        query: '',
        results: [],
        highlightedIndex: -1, // Tracks keyboard navigation

        search() {
            // Minimum 2 characters to trigger search
            if (this.query.length < 2) {
                this.results = [];
                this.highlightedIndex = -1;
                return;
            }

            fetch(`/api/search/${encodeURIComponent(this.query)}`, {
                method: 'GET',
                headers: { 'Content-Type': 'application/json' }
            })
            .then(response => {
                if (response.ok) return response.json();
                return Promise.reject(response);
            })
            .then(data => {
                this.results = data;
                this.highlightedIndex = -1;
                // Process HTMX attributes in results
                this.$nextTick(() => {
                    htmx.process(document.getElementById('searchresults'));
                });
            })
            .catch((response) => {
                console.log("Error fetching search results");
            });
        },

        // Keyboard navigation
        moveDown() {
            if (this.highlightedIndex < this.results.length - 1) {
                this.highlightedIndex++;
            }
        },

        moveUp() {
            if (this.highlightedIndex > 0) {
                this.highlightedIndex--;
            }
        },

        selectHighlighted() {
            if (this.highlightedIndex >= 0 && this.highlightedIndex < this.results.length) {
                this.selectResult(this.highlightedIndex);
            }
        },

        selectResult(selectedIndex) {
            // Click the HTMX link to navigate
            let links = document.querySelectorAll('#searchresults a');
            links[selectedIndex].click();
            this.results = [];
            this.highlightedIndex = -1;
            this.query = '';
        }
    }
}

Можливості ключів:

  1. Вирішений ввід: 300мс затримки, щоб сервер не міг пробити
  2. Мінімальна довжина: Потрібні принаймні 2 символи
  3. Навігація клавіатурою: Клавіші стрілок + Ввести для доступності
  4. Інтеграція HTMX: Результати використовують HTMX для плавної навігації

Панель пошуку HTML

<div x-data="window.mostlylucid.typeahead()"
     class="relative"
     x-on:click.outside="results = []">

    <label class="input input-sm bg-white dark:bg-custom-dark-bg input-bordered flex items-center gap-2">
        <input
            type="text"
            x-model="query"
            x-on:input.debounce.300ms="search"
            x-on:keydown.down.prevent="moveDown"
            x-on:keydown.up.prevent="moveUp"
            x-on:keydown.enter.prevent="selectHighlighted"
            placeholder="Search..."
            class="border-0 grow input-sm text-black dark:text-white bg-transparent w-full"/>
        <i class="bx bx-search"></i>
    </label>

    <!-- Dropdown Results -->
    <ul x-show="results.length > 0"
        id="searchresults"
        class="absolute z-10 my-2 w-full bg-white dark:bg-custom-dark-bg border rounded-lg shadow-lg">
        <template x-for="(result, index) in results" :key="result.slug">
            <li :class="{'bg-blue-light dark:bg-blue-dark': index === highlightedIndex}"
                class="cursor-pointer text-sm p-2 m-2 hover:bg-blue-light dark:hover:bg-blue-dark">
                <a hx-boost="true"
                   hx-target="#contentcontainer"
                   hx-swap="innerHTML show:window:top"
                   :href="result.url"
                   x-text="result.title"></a>
            </li>
        </template>
    </ul>
</div>

Чому? x-on:click.outside? Натискання кнопки поза спадним списком закриває її - стандартний шаблон UX для автозавершення.

API пошуку

The /api/search/{query} Контролер:

[ApiController]
[Route("api")]
public class SearchApi(
    BlogSearchService searchService,
    UmamiBackgroundSender umamiBackgroundSender,
    ISemanticSearchService semanticSearchService,
    SemanticSearchConfig semanticSearchConfig) : ControllerBase
{
    private const int RrfConstant = 60; // Reciprocal Rank Fusion constant

    [HttpGet]
    [Route("search/{query}")]
    [OutputCache(Duration = 3600, VaryByQueryKeys = new[] { "query" })]
    public async Task<Results<JsonHttpResult<List<SearchResults>>, BadRequest<string>>> Search(string query)
    {
        using var activity = Log.Logger.StartActivity("Search {query}", query);
        try
        {
            var host = Request.Host.Value;
            List<SearchResults> output;

            // Use hybrid search if semantic search is enabled
            if (semanticSearchConfig.Enabled)
            {
                output = await HybridSearchAsync(query, host);
            }
            else
            {
                // Fallback to full-text search only
                output = await FullTextSearchAsync(query, host);
            }

            // Track search event for analytics
            var encodedQuery = HttpUtility.UrlEncode(query);
            await umamiBackgroundSender.Track("searchEvent", new UmamiEventData { { "query", encodedQuery } });

            return TypedResults.Json(output);
        }
        catch (Exception e)
        {
            Log.Error(e, "Error in search");
            return TypedResults.BadRequest("Error in search");
        }
    }
}

Важливі рішення щодо дизайну:

  1. Прапорець можливостей: semanticSearchConfig.Enabled Надає вам змогу увімкнути або вимкнути семантичний пошук
  2. Кечування виводу: Одногодинний кеш зменшує навантаження на сервер для звичайних запитів
  3. Аналітичний пошук: Кожен пошук слід шукати (допоможіть вам зрозуміти поведінку користувача)
  4. Милосердя: Повертається до PostgreSQL, якщо не вдалося виконати семантичний пошук

Hybrid Search with Reciprocal Rank Fusion

Справжня магія гібридний пошук - Комбінація семантичних результатів з повноцінним текстом. Для їх об' єднання ми використовуємо Reciprocal Rank Fusion (RRF).

Різні підходи до пошуку мають різні переваги:

♪Search TIFF |------------|-----------|------------| | семантика Д. д. д. д. д. д. д. ст. д. д. д. д. д. д. д. д. д. д. д. д. д. д. д. д. д. д. д. д. д. д. д. д. д. д. д. д. проп. | Повний текст ♪ Exact ключові слова, технічні терміни ♪Не має рівного розуміння ♪

Приклад: Пошук "сеансу"

  • Семантика знаходить: " Підручники з Docker," " настанови Kubernetes" (основні концепції)
  • Пошуки з повним текстом: дописи, що містять точно " використання "
  • Гібрид отримує найкраще з обох!

Алгоритм RRF

flowchart TB
    subgraph Semantic[Semantic Results]
        S1[Docker Containers - 0.92]
        S2[Kubernetes Basics - 0.87]
        S3[Container Security - 0.81]
    end

    subgraph FullText[Full-Text Results]
        F1[Container Security - rank 1]
        F2[Docker Containers - rank 2]
        F3[CI/CD Pipelines - rank 3]
    end

    subgraph RRF[RRF Scores]
        R1[Container Security = 0.0327]
        R2[Docker Containers = 0.0325]
        R3[Kubernetes Basics = 0.0161]
        R4[CI/CD Pipelines = 0.0159]
    end

    subgraph Final[Final Ranking]
        O1[Container Security]
        O2[Docker Containers]
        O3[Kubernetes Basics]
        O4[CI/CD Pipelines]
    end

    S1 --> R2
    S2 --> R3
    S3 --> R1
    F1 --> R1
    F2 --> R2
    F3 --> R4
    R1 --> O1
    R2 --> O2
    R3 --> O3
    R4 --> O4

Формула: score = Σ(1 / (k + rank))

Де:

  • k = 60 (Непохитно, щоб ранні лави не домінували)
  • rank = позиція у цьому методі пошуку (1- indexed)

Чому RRF працює:

  • Результати, що з' являються в обидва рахунок джерела вище
  • Жодне з джерел не може домінувати
  • Не потрібно комплексного налаштування

Впровадження

private async Task<List<SearchResults>> HybridSearchAsync(string query, string host)
{
    // Run both searches in parallel
    var fullTextTask = GetFullTextResultsAsync(query);
    var semanticTask = semanticSearchService.SearchAsync(query, limit: 20);

    await Task.WhenAll(fullTextTask, semanticTask);

    var fullTextResults = await fullTextTask;
    var semanticResults = await semanticTask;

    // Apply Reciprocal Rank Fusion to combine results
    var rrfScores = new Dictionary<string, (double Score, string Title, string Slug)>();

    // Score full-text results
    for (int i = 0; i < fullTextResults.Count; i++)
    {
        var (title, slug) = fullTextResults[i];
        var key = slug.ToLowerInvariant();
        var rrfScore = 1.0 / (RrfConstant + i + 1);

        if (rrfScores.TryGetValue(key, out var existing))
        {
            rrfScores[key] = (existing.Score + rrfScore, title, slug);
        }
        else
        {
            rrfScores[key] = (rrfScore, title, slug);
        }
    }

    // Score semantic results
    for (int i = 0; i < semanticResults.Count; i++)
    {
        var result = semanticResults[i];
        var key = result.Slug.ToLowerInvariant();
        var rrfScore = 1.0 / (RrfConstant + i + 1);

        if (rrfScores.TryGetValue(key, out var existing))
        {
            rrfScores[key] = (existing.Score + rrfScore, existing.Title, existing.Slug);
        }
        else
        {
            rrfScores[key] = (rrfScore, result.Title, result.Slug);
        }
    }

    // Sort by combined RRF score and return top results
    return rrfScores.Values
        .OrderByDescending(x => x.Score)
        .Take(15)
        .Select(x => new SearchResults(
            x.Title.Trim(),
            x.Slug,
            Url.ActionLink("Show", "Blog", new { x.Slug }, "https", host)))
        .ToList();
}

Подробиці реалізації ключів:

  1. Паралельна виконання: обидва результати виконуються одночасно (Task.WhenAll)
  2. З урахуванням регістру: Слизи нормалізовані за допомогою ToLowerInvariant()
  3. Нумерація: До обох джерел додається рахунок
  4. Найвищі 15 результатів: Достатньо для макети, щоб не перебільшувати

Зворотний пошук з повним текстом

Якщо семантичний пошук вимкнено або він зазнає невдачі, ми повертаємося до повнотекстового пошуку PostgreSQL.

Обробка запитів

Повнотекстовий пошук може працювати у двох різних випадках:

private async Task<List<(string Title, string Slug)>> GetFullTextResultsAsync(string query)
{
    if (!query.Contains(' '))
        return await searchService.GetSearchResultForComplete(query);  // Wildcard
    else
        return await searchService.GetSearchResultForQuery(query);     // Web search
}

Одно слово (" dacker "): Використання пошуку префіксів заміни docker:* Декілька слів (" контейнери панелі "): Використовувати синтаксис пошуку у мережі PostgreSQL

Запити PostgreSQL

// Single word with wildcard
private IQueryable<BlogPostEntity> QueryForWildCard(string query)
{
    return context.BlogPosts
        .Include(x => x.Categories)
        .Include(x => x.LanguageEntity)
        .AsNoTracking()
        .Where(x =>
            !x.IsHidden
            && (x.ScheduledPublishDate == null || x.ScheduledPublishDate <= now)
            && (x.SearchVector.Matches(EF.Functions.ToTsQuery("english", query + ":*"))
                || x.Categories.Any(c =>
                    EF.Functions.ToTsVector("english", c.Name)
                        .Matches(EF.Functions.ToTsQuery("english", query + ":*"))))
            && x.LanguageEntity.Name == "en")
        .OrderByDescending(x =>
            x.SearchVector.Rank(EF.Functions.ToTsQuery("english", query + ":*")));
}

// Multiple words with web search
private IQueryable<BlogPostEntity> QueryForSpaces(string processedQuery)
{
    return context.BlogPosts
        .Where(x =>
            x.SearchVector.Matches(EF.Functions.WebSearchToTsQuery("english", processedQuery))
            || x.Categories.Any(c =>
                EF.Functions.ToTsVector("english", c.Name)
                    .Matches(EF.Functions.WebSearchToTsQuery("english", processedQuery))))
        .OrderByDescending(x =>
            x.SearchVector.Rank(EF.Functions.WebSearchToTsQuery("english", processedQuery)));
}

Чому? WebSearchToTsQuery? Вона працює з запитами на натуральну мову, як Google:

  • "docker containers" → шукати обидва слова
  • docker OR kubernetes → Логічне АБО
  • docker -compose → Виключає "компонування "

Докладніше про пошук у PostgreSQL можна дізнатися з розділу Повне пошук тексту за допомогою Postgres.

Сторінка повного пошуку

Поза назвою типу ви побачите сторінку результатів пошуку з додатковими фільтраціями:

flowchart LR
    subgraph SearchPage[Search Page]
        A[Query Input] --> B{Filters}
        B --> C[Language Filter]
        B --> D[Date Range Filter]
        C --> E[Search Results]
        D --> E
        E --> F[Paginated List]
    end

    style A stroke:#10b981,stroke-width:2px
    style B stroke:#6366f1,stroke-width:2px
    style E stroke:#ec4899,stroke-width:2px
    style F stroke:#8b5cf6,stroke-width:2px

SearchController

[Route("search")]
public class SearchController(
    BaseControllerService baseControllerService,
    BlogSearchService searchService,
    ISemanticSearchService semanticSearchService,
    ILogger<SearchController> logger)
    : BaseController(baseControllerService, logger)
{
    [HttpGet]
    [Route("")]
    [OutputCache(Duration = 3600, VaryByQueryKeys = new[] { "query", "page", "pageSize", "language", "dateRange", "startDate", "endDate" })]
    public async Task<IActionResult> Search(
        string? query,
        int page = 1,
        int pageSize = 10,
        string? language = null,
        DateRangeOption dateRange = DateRangeOption.AllTime,
        DateTime? startDate = null,
        DateTime? endDate = null,
        [FromHeader] bool pagerequest = false)
    {
        // Calculate date range based on option
        var (calculatedStartDate, calculatedEndDate) = CalculateDateRange(dateRange, startDate, endDate);

        // Get available languages for the filter dropdown
        var availableLanguages = await searchService.GetAvailableLanguagesAsync();

        if (string.IsNullOrEmpty(query?.Trim()))
        {
            var emptyModel = new SearchResultsModel { /* ... */ };
            if (Request.IsHtmx()) return PartialView("SearchResults", emptyModel);
            return View("SearchResults", emptyModel);
        }

        var searchResults = await searchService.HybridSearchWithPagingAsync(
            query,
            language,
            calculatedStartDate,
            calculatedEndDate,
            page,
            pageSize);

        // Build response model...
        if (pagerequest && Request.IsHtmx())
            return PartialView("_SearchResultsPartial", searchModel.SearchResults);
        if (Request.IsHtmx())
            return PartialView("SearchResults", searchModel);
        return View("SearchResults", searchModel);
    }
}

Параметри діапазону дат

public enum DateRangeOption
{
    AllTime,
    LastWeek,
    LastMonth,
    LastYear,
    Custom
}

private static (DateTime? StartDate, DateTime? EndDate) CalculateDateRange(
    DateRangeOption dateRange, DateTime? startDate, DateTime? endDate)
{
    var now = DateTime.UtcNow;
    return dateRange switch
    {
        DateRangeOption.LastWeek => (now.AddDays(-7), now),
        DateRangeOption.LastMonth => (now.AddMonths(-1), now),
        DateRangeOption.LastYear => (now.AddYears(-1), now),
        DateRangeOption.Custom => (startDate, endDate),
        _ => (null, null) // AllTime - no date filter
    };
}

Пов' язані повідомлення з завантаженням " Lazy "

Кожен допис блогу показує семантично подібні дописи на придатній для використання панелі. Цей пункт використовує HTMX для лінивого завантаження:

<!-- In blog post view -->
<div class="print:hidden"
     hx-get="/search/related/@Model.Slug/@Model.Language"
     hx-trigger="load delay:500ms"
     hx-swap="innerHTML">
    <!-- Loading placeholder -->
    <div class="mt-8 mb-8 text-center opacity-50">
        <span class="loading loading-spinner loading-md"></span>
        <p class="text-sm mt-2">Finding related posts...</p>
    </div>
</div>

Почему задерживают 500 мм? Основний вміст спочатку завантажується, а потім завантажує відповідні дописи у тлі. Користувачі негайно бачать вміст.

Пов' язані Posts End точка

[HttpGet]
[Route("related/{slug}/{language}")]
[OutputCache(Duration = 7200, VaryByRouteValueNames = new[] {"slug", "language"})]
public async Task<IActionResult> RelatedPosts(string slug, string language, int limit = 5)
{
    var results = await semanticSearchService.GetRelatedPostsAsync(slug, language, limit);

    if (Request.IsHtmx())
    {
        return PartialView("_RelatedPosts", results);
    }

    return Json(results);
}

Двогодинний кеш: Супутні стовпи не часто змінюються, отже, агресивне кешування безпечне.

Пов' язаний компонент дописів

Компонент компіляції DaisUI, який показує результати подібності радіального прогресу:

@model List<SearchResult>

@if (Model != null && Model.Any())
{
    <div class="mt-8 mb-8">
        <div class="collapse collapse-arrow bg-base-200">
            <input type="checkbox" class="peer" />
            <div class="collapse-title text-xl font-medium">
                <i class='bx bx-brain text-2xl mr-2'></i>
                Related Posts
                <span class="badge badge-secondary badge-sm ml-2">@Model.Count</span>
            </div>
            <div class="collapse-content">
                <div class="divider mt-0"></div>
                <div class="space-y-2">
                    @foreach (var post in Model)
                    {
                        <div class="card bg-base-100 shadow-sm hover:shadow-md transition-shadow">
                            <div class="card-body p-4">
                                <div class="flex items-start justify-between">
                                    <div class="flex-1">
                                        <a hx-boost="true"
                                           hx-target="#contentcontainer"
                                           asp-action="Show"
                                           asp-controller="Blog"
                                           asp-route-slug="@post.Slug"
                                           asp-route-language="@post.Language"
                                           class="card-title text-base hover:text-secondary">
                                            @post.Title
                                        </a>

                                        @if (post.Categories?.Any() == true)
                                        {
                                            <div class="flex flex-wrap gap-1 mt-2">
                                                @foreach (var category in post.Categories.Take(3))
                                                {
                                                    <span class="badge badge-outline badge-sm">@category</span>
                                                }
                                            </div>
                                        }

                                        <div class="flex items-center gap-3 mt-2 text-sm opacity-70">
                                            <span>
                                                <i class='bx bx-calendar'></i>
                                                @post.PublishedDate.ToString("MMM dd, yyyy")
                                            </span>
                                            <span>
                                                <i class='bx bx-planet'></i>
                                                @post.Language.ToUpper()
                                            </span>
                                        </div>
                                    </div>

                                    <!-- Similarity Score -->
                                    <div class="flex flex-col items-end ml-4">
                                        <div class="radial-progress text-primary text-xs"
                                             style="--value:@(post.Score * 100); --size:3rem; --thickness:3px;"
                                             role="progressbar">
                                            @((post.Score * 100).ToString("F0"))%
                                        </div>
                                        <span class="text-xs opacity-60 mt-1">similarity</span>
                                    </div>
                                </div>
                            </div>
                        </div>
                    }
                </div>
            </div>
        </div>
    </div>
}

Радіальний поступ показує подібність у відсотках (0- 100%), допомагаючи користувачам зрозуміти, наскільки пов' язаний кожен допис.

Довідка з програмного інтерфейсу

Пошук у типахедах

GET /api/search/{query}

♪ |-----------|------|-------------| | query ♪ string (шлях) =Search term (мень 2 символів) =

Відповідь: List<SearchResults>

[
  {
    "title": "Docker Containers Explained",
    "slug": "docker-containers",
    "url": "https://example.com/blog/docker-containers"
  }
]

Кечування: 1 година, різниться за запитом

Семантичний пошук

GET /search/semantic?query={query}&limit={limit}

|-----------|------|---------|-------------| | query струна необхідна ♪Search cream ♪ | limit Мається на увазі, що ми маємо на увазі, що ми маємо справу з цією програмою.

Відповідь: List<SearchResult> з оцінками подібності

Супутні повідомлення

GET /search/related/{slug}/{language}?limit={limit}

|-----------|------|---------|-------------| | slug струна необхідна ♪ Blog post} | language струна необхідна #* Код мови (en, es, etc.)) | limit в'язниця 5 * * Макс, пов' язані з малюнком * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * ( * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *

Відповідь: List<SearchResult> впорядковано за подібностями

Кечування: 2 години, різні за допомогою слимаків і мови.

Повний пошук за фільтрами

GET /search?query={query}&page={page}&pageSize={pageSize}&language={language}&dateRange={dateRange}&startDate={startDate}&endDate={endDate}

|-----------|------|---------|-------------| | query струна необхідна ♪Search cream ♪ | page вісі # 1} Номер сторінки ♪ | pageSize в'язниця 10 + 10 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | language # # path- фільтр за мовою} | dateRange ♪ LastMonth, LastYear, tath ♪ | startDate == | endDate

Поради щодо швидкодії

Стратегія кешування

// Typeahead - 1 hour (queries are repeated often)
[OutputCache(Duration = 3600, VaryByQueryKeys = new[] { "query" })]

// Related posts - 2 hours (rarely change)
[OutputCache(Duration = 7200, VaryByRouteValueNames = new[] {"slug", "language"})]

// Full search - 1 hour (many filter combinations)
[OutputCache(Duration = 3600, VaryByQueryKeys = new[] { "query", "page", "pageSize", "language", "dateRange", "startDate", "endDate" })]

Демонстрація

Завжди скидати вхідні дані користувача, щоб запобігти надмірним викликам API:

x-on:input.debounce.300ms="search"

300мс є добрим балансом - достатньо швидким, щоб відчути чуйність, достатньо повільним, щоб зменшити навантаження на сервер.

Завантаження простор

Використовувати HTMX load delay: увімкнути некритичний вміст:

hx-trigger="load delay:500ms"

За допомогою цього пункту можна зробити видимим основний вміст вмісту перед завантаженням вторинного вмісту.

Що наступне

У цій статті описано досвід пошуку - спосіб взаємодії користувачів з семантичним пошуком. Для виробництво разом з автоматичним індексуванням і фоновою службою, продовжіть:

Частина 5: Гібридний пошук і автоматичне інексування - Шаблони інтеграції виробництва:

  • FileSystemWatcher для індексування у режимі реального часу
  • Служба тла для початкового індексування
  • Визначення хешів вмісту для додаткових оновлень

Ресурси

Супутні статті

Використані технології

Повний код

Всі коди наявні у: gitub.com/ scottgal/ methlylucidweb

  • Mostlylucid/API/SearchApi.cs - API Typeahead
  • Mostlylucid/Controllers/SearchController.cs - Повний пошук сторінки
  • Mostlylucid.Services/Blog/BlogSearchService.cs - Гибридська логіка пошуку
  • Mostlylucid/src/js/typeahead.js - Компонент альпійських.js
Finding related posts...
logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.