Реалізація AI-пошуку по контенту сайту (Semantic Search)

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.
Розробка та обслуговування будь-яких видів сайтів:
Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

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

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація AI-пошуку по контенту сайту (Semantic Search)
Складна
~1-2 тижні
Часті питання
Наші компетенції:
Етапи розробки
Останні роботи
  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Реалізація AI-пошуку за контентом сайту (Semantic Search)

Звичайний повнотекстовий пошук (PostgreSQL tsvector, Elasticsearch) шукає за співпаданням слів. Користувач шукає «як оплатити» — знаходить статті зі словом «оплатити», але не знаходить статтю «способи розрахунку» або «поповнення балансу». Семантичний пошук працює інакше: порівнюються смислові вектори, а не рядки.

Як працює векторний пошук

Текст перетворюється на вектор (embedding) — числовий масив із 768–3072 вимірів, де близькі за смислом тексти мають подібні вектори. Відстань між векторами (косинусна або евклідова) = семантична близькість.

"способи оплати"   → [0.12, -0.87, 0.34, ...]
"як заплатити"     → [0.11, -0.85, 0.31, ...]  ← близько
"рецепт борщу"     → [0.91,  0.23, -0.67, ...] ← далеко

Вибір embedding-моделі

Модель Розмір вектора Контекст Швидкість Вартість
OpenAI text-embedding-3-small 1536 8K токенів Швидко $0.02/1M токенів
OpenAI text-embedding-3-large 3072 8K токенів Повільніше $0.13/1M токенів
Cohere embed-multilingual-v3 1024 512 токенів Швидко $0.10/1M токенів
BGE-M3 (self-hosted) 1024 8K токенів Залежить від GPU Безплатно
nomic-embed-text (Ollama) 768 8K токенів CPU-можливо Безплатно

Для багатомовного контенту text-embedding-3-large або Cohere multilingual дають кращі результати.

Векторна база даних

pgvector — розширення PostgreSQL. Ідеально, якщо вже використовується Postgres:

CREATE EXTENSION vector;

CREATE TABLE content_chunks (
  id         BIGSERIAL PRIMARY KEY,
  content_id BIGINT REFERENCES content(id),
  chunk_text TEXT NOT NULL,
  chunk_index INT,
  embedding  vector(1536),
  metadata   JSONB
);

-- Індекс для ANN-пошуку (approximate nearest neighbor)
CREATE INDEX ON content_chunks USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);
-- Або HNSW (краще для більшості випадків):
CREATE INDEX ON content_chunks USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

Qdrant — спеціалізована векторна БД з фільтрацією:

const qdrant = new QdrantClient({ url: 'http://localhost:6333' });

await qdrant.createCollection('content', {
  vectors: { size: 1536, distance: 'Cosine' },
  optimizers_config: { indexing_threshold: 20000 },
  hnsw_config: { m: 16, ef_construct: 100 },
});

Індексація контенту

import OpenAI from 'openai';

const openai = new OpenAI();

function chunkText(text, options = { maxTokens: 400, overlap: 50 }) {
  // Розділяємо за абзацами, потім об'єднуємо до maxTokens
  const paragraphs = text.split(/\n{2,}/);
  const chunks = [];
  let current = '';
  let currentTokens = 0;

  for (const para of paragraphs) {
    const paraTokens = estimateTokens(para);

    if (currentTokens + paraTokens > options.maxTokens && current) {
      chunks.push(current.trim());
      // Перекриття: беремо останні N слів
      const words = current.split(' ');
      current = words.slice(-options.overlap).join(' ') + ' ' + para;
      currentTokens = estimateTokens(current);
    } else {
      current += (current ? '\n\n' : '') + para;
      currentTokens += paraTokens;
    }
  }

  if (current) chunks.push(current.trim());
  return chunks;
}

async function indexContent(contentItem) {
  const chunks = chunkText(contentItem.body);

  // Batch embeddings (до 2048 входів за раз)
  const batchSize = 100;
  for (let i = 0; i < chunks.length; i += batchSize) {
    const batch = chunks.slice(i, i + batchSize);

    const { data: embeddings } = await openai.embeddings.create({
      model: 'text-embedding-3-small',
      input: batch,
      encoding_format: 'float',
    });

    // Зберігаємо в pgvector
    await db.query(`
      INSERT INTO content_chunks (content_id, chunk_text, chunk_index, embedding, metadata)
      SELECT $1, unnest($2::text[]), generate_series(0, $3), unnest($4::vector[]), $5
    `, [
      contentItem.id,
      batch,
      batch.length - 1,
      embeddings.map(e => `[${e.embedding.join(',')}]`),
      JSON.stringify({ title: contentItem.title, url: contentItem.url, type: contentItem.type }),
    ]);
  }
}

Пошук

async function semanticSearch(query, options = {}) {
  const {
    limit = 10,
    threshold = 0.7,
    filter = {},    // { type: 'article', lang: 'uk' }
    hybrid = true,  // Комбінувати з повнотекстовим
  } = options;

  // Векторизуємо запит
  const { data: [{ embedding }] } = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: query,
  });

  let results;

  if (hybrid) {
    // Гібридний пошук: вектор + повнотекст, RRF (Reciprocal Rank Fusion)
    results = await db.query(`
      WITH semantic AS (
        SELECT
          content_id,
          chunk_text,
          1 - (embedding <=> $1::vector) AS score,
          ROW_NUMBER() OVER (ORDER BY embedding <=> $1::vector) AS rank
        FROM content_chunks
        WHERE metadata->>'type' = ANY($3::text[])
        ORDER BY embedding <=> $1::vector
        LIMIT 20
      ),
      fulltext AS (
        SELECT
          id AS content_id,
          body AS chunk_text,
          ts_rank(to_tsvector('ukrainian', body), plainto_tsquery('ukrainian', $2)) AS score,
          ROW_NUMBER() OVER (ORDER BY ts_rank(to_tsvector('ukrainian', body), plainto_tsquery('ukrainian', $2)) DESC) AS rank
        FROM content
        WHERE to_tsvector('ukrainian', body) @@ plainto_tsquery('ukrainian', $2)
        LIMIT 20
      )
      SELECT
        COALESCE(s.content_id, f.content_id) AS id,
        COALESCE(s.chunk_text, f.chunk_text) AS text,
        (
          COALESCE(1.0 / (60 + s.rank), 0) +
          COALESCE(1.0 / (60 + f.rank), 0)
        ) AS rrf_score
      FROM semantic s
      FULL OUTER JOIN fulltext f ON s.content_id = f.content_id
      ORDER BY rrf_score DESC
      LIMIT $4
    `, [`[${embedding.join(',')}]`, query, Object.values(filter), limit]);
  } else {
    // Чистий семантичний пошук
    results = await db.query(`
      SELECT DISTINCT ON (content_id)
        content_id,
        chunk_text,
        1 - (embedding <=> $1::vector) AS score
      FROM content_chunks
      WHERE 1 - (embedding <=> $1::vector) > $2
      ORDER BY content_id, score DESC
      LIMIT $3
    `, [`[${embedding.join(',')}]`, threshold, limit]);
  }

  return results.rows;
}

UI компонент пошуку

function SemanticSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  // Debounce запитів
  useEffect(() => {
    if (query.length < 3) { setResults([]); return; }

    const timer = setTimeout(async () => {
      setLoading(true);
      const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
      const data = await res.json();
      setResults(data.results);
      setLoading(false);
    }, 300);

    return () => clearTimeout(timer);
  }, [query]);

  return (
    <div className="search-wrapper">
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Пошук за документацією..."
        className="search-input"
      />
      {loading && <Spinner />}
      <ul className="search-results">
        {results.map(r => (
          <li key={r.id}>
            <a href={r.url}>
              <strong>{r.title}</strong>
              <p>{highlightMatch(r.snippet, query)}</p>
              <span className="score">{(r.score * 100).toFixed(0)}% співпадіння</span>
            </a>
          </li>
        ))}
      </ul>
    </div>
  );
}

Переранжирування (Reranking)

Після векторного пошуку точність можна підвищити cross-encoder-моделлю:

import { CohereClient } from 'cohere-ai';

const cohere = new CohereClient({ token: process.env.COHERE_API_KEY });

async function rerank(query, documents) {
  const response = await cohere.rerank({
    model: 'rerank-multilingual-v3.0',
    query,
    documents: documents.map(d => d.text),
    topN: 5,
  });

  return response.results.map(r => ({
    ...documents[r.index],
    rerankScore: r.relevanceScore,
  }));
}

Пошук за зображеннями

Для візуального контенту — мультимодальні embeddings (CLIP, OpenAI Vision):

// Індексація зображення
async function indexImage(imageUrl) {
  const response = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: await generateImageCaption(imageUrl), // Vision API → текст
  });
  // Зберігаємо в ту ж колекцію
}

Моніторинг якості

-- Запити без результатів (розширити базу знань)
SELECT query, COUNT(*) as count
FROM search_logs
WHERE results_count = 0
GROUP BY query ORDER BY count DESC LIMIT 20;

-- Запити з низьким CTR (результати нерелевантні)
SELECT query, clicks / impressions AS ctr
FROM search_metrics
WHERE impressions > 100
ORDER BY ctr ASC LIMIT 20;

Терміни

  • Семантичний пошук по 10K документам з pgvector — 4–5 днів
  • Гібридний пошук (вектор + повнотекст) — плюс 1–2 дні
  • Переранжирування через Cohere Rerank — плюс 1 день
  • UI з підсвіткою результатів, аналітикою — плюс 2–3 дні
  • Інкрементальна переіндексація при оновленні контенту — плюс 1–2 дні