Перейти к содержанию
Публикация AiManual

EdgeVec + локальная LLM: заставьте браузер говорить с Ollama через Transformers.js

Пошаговый туториал по интеграции EdgeVec с локальными LLM через Transformers.js и Ollama. Примеры кода, настройка, частые ошибки.

Коротко

Что будет в материале

  1. 01

    Зачем вообще это нужно?

  2. 02

    Архитектура, которая работает (и та, что не работает)

  3. 03

    Ошибки, которые сломают вашу систему (и как их избежать)

  4. 04

    Производительность: чего ожидать

Зачем вообще это нужно?

Вы запускаете Llama 3 или Mistral через Ollama на своем компьютере. Модель работает, отвечает. Но вот беда – RAG (Retrieval-Augmented Generation) с ней не сделать. Нет эмбеддингов. Модель не умеет искать похожие документы в вашей базе знаний.

Типичное решение – ставить отдельный сервис эмбеддингов. Sentence Transformers на Python, какой-нибудь BGE-M3. Но это лишний сервис, лишние зависимости, лишняя память.

EdgeVec предлагает другой путь: эмбеддинги прямо в браузере. Модель весит 20-40 МБ, работает на JavaScript. Вы загружаете ее один раз – и она готова векторизовать тексты.

💡
EdgeVec – это библиотека для создания векторных представлений текста на стороне клиента. Она использует модели типа all-MiniLM-L6-v2 или gte-small, но в формате ONNX, оптимизированном для браузера.

Проблема в том, что эмбеддинги из EdgeVec и запросы к локальной LLM живут в разных мирах. Браузер и локальный сервер Ollama. Нужен мост.

Архитектура, которая работает (и та, что не работает)

Сначала покажу, как НЕ надо делать. Типичная ошибка новичков:

// ПЛОХОЙ ПРИМЕР – не делайте так
async function badExample() {
  // 1. Получаем эмбеддинг в браузере
  const embedding = await edgevec.embed("Мой запрос");
  
  // 2. Ищем похожие документы в базе
  const similarDocs = findSimilar(embedding);
  
  // 3. Формируем промпт
  const prompt = `Контекст: ${similarDocs}\nВопрос: Мой запрос`;
  
  // 4. Шлем промпт на Ollama
  const response = await fetch('http://localhost:11434/api/generate', {
    method: 'POST',
    body: JSON.stringify({
      model: 'llama3',
      prompt: prompt
    })
  });
  
  // Ожидание: 10 секунд... 20 секунд...
  // Результат: таймаут или странный ответ
}

Проблема в шаге 2. База документов где? В браузере? В IndexedDB? А если документов 10 000? Браузер упадет. Искать похожие векторы – тяжелая операция. Браузер для этого не предназначен.

Правильная архитектура выглядит так:

ЭтапГде выполняетсяИнструмент
Векторизация запросаБраузерEdgeVec + Transformers.js
Поиск похожих документовСервер (локальный)ChromaDB / Qdrant / простой FAISS
Генерация ответаСервер (локальный)Ollama + LLM

1Подготовка: что нужно установить

Начнем с серверной части. Если у вас еще нет Ollama – установите. Это займет 2 минуты:

# Установка Ollama (Linux/Mac)
curl -fsSL https://ollama.com/install.sh | sh

# Запуск сервиса
ollama serve &

# Скачиваем модель (например, Llama 3.1 8B)
ollama pull llama3.1:8b

Теперь нужна векторная база. Я рекомендую ChromaDB – она простая и работает из коробки:

# Устанавливаем ChromaDB
pip install chromadb

# Или через pipx, если хотите изолированно
pipx install chromadb

Для браузера нужны две библиотеки:

# В вашем проекте на JavaScript
npm install @xenova/transformers chromadb
💡
Transformers.js – это порфолио библиотеки Hugging Face Transformers на JavaScript. Она умеет загружать модели из Hugging Face Hub и выполнять их в браузере. EdgeVec построена поверх нее.

2Серверная часть: FastAPI + ChromaDB

Создаем простой сервер на Python. Он будет делать три вещи:

  1. Принимать эмбеддинги из браузера
  2. Искать похожие документы в ChromaDB
  3. Отправлять контекст в Ollama и возвращать ответ
# server.py
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
import chromadb
from chromadb.config import Settings
import requests
import json

app = FastAPI()

# Разрешаем запросы из браузера
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],  # Ваш фронтенд
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Подключаем ChromaDB (персистентная база)
chroma_client = chromadb.PersistentClient(path="./chroma_db")
collection = chroma_client.get_or_create_collection("documents")

@app.post("/search")
async def search_documents(embedding: list[float], top_k: int = 3):
    """Ищем документы по вектору из браузера"""
    try:
        results = collection.query(
            query_embeddings=[embedding],
            n_results=top_k
        )
        
        # results содержит документы и метаданные
        documents = results['documents'][0] if results['documents'] else []
        return {"documents": documents}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.post("/ask")
async def ask_llama(context: str, question: str):
    """Отправляем вопрос и контекст в Ollama"""
    prompt = f"""Используй следующий контекст для ответа на вопрос.

Контекст:
{context}

Вопрос: {question}

Ответ:"""
    
    try:
        response = requests.post(
            'http://localhost:11434/api/generate',
            json={
                "model": "llama3.1:8b",
                "prompt": prompt,
                "stream": False,
                "options": {"temperature": 0.7}
            }
        )
        
        if response.status_code == 200:
            result = response.json()
            return {"answer": result["response"]}
        else:
            raise HTTPException(status_code=500, detail="Ollama error")
    except requests.exceptions.ConnectionError:
        raise HTTPException(status_code=503, detail="Ollama не запущен")

# Запуск: uvicorn server:app --reload --port 8000

Этот сервер работает на порту 8000. ChromaDB сохраняет данные в папку chroma_db. Перед первым использованием нужно заполнить базу документами.

Важный момент: модель эмбеддингов в EdgeVec и в ChromaDB должна быть одинаковой. Если в браузере используете all-MiniLM-L6-v2, то при индексации документов на сервере нужно использовать ту же модель. Иначе векторы будут в разных пространствах.

Скрипт для индексации документов

# index_documents.py
from sentence_transformers import SentenceTransformer
import chromadb

# Используем ТУ ЖЕ модель, что и в EdgeVec
model = SentenceTransformer('all-MiniLM-L6-v2')

# Ваши документы
documents = [
    "Документ 1: ...",
    "Документ 2: ...",
    # ...
]

# Генерируем эмбеддинги
embeddings = model.encode(documents).tolist()

# Сохраняем в ChromaDB
chroma_client = chromadb.PersistentClient(path="./chroma_db")
collection = chroma_client.get_or_create_collection("documents")

# Добавляем с ID
for i, (doc, emb) in enumerate(zip(documents, embeddings)):
    collection.add(
        documents=[doc],
        embeddings=[emb],
        ids=[str(i)]
    )

print(f"Проиндексировано {len(documents)} документов")

3Клиентская часть: EdgeVec в действии

Теперь самое интересное – браузерный код. Устанавливаем EdgeVec через CDN или npm:



Или через npm в вашем проекте:

npm install edgevec

Основной код приложения:

// app.js
import { pipeline } from '@xenova/transformers';
import { EdgeVec } from 'edgevec';

class RAGSystem {
  constructor() {
    this.embedder = null;
    this.initialized = false;
  }
  
  async initialize() {
    console.log('Загружаем модель для эмбеддингов...');
    
    // Инициализируем EdgeVec с конкретной моделью
    this.embedder = await EdgeVec.load(
      'Xenova/all-MiniLM-L6-v2', // Та же модель, что на сервере
      {
        quantized: true, // Квантованная версия, меньше весит
        progress_callback: (progress) => {
          console.log(`Загружено: ${(progress * 100).toFixed(1)}%`);
        }
      }
    );
    
    this.initialized = true;
    console.log('Модель загружена');
  }
  
  async getEmbedding(text) {
    if (!this.initialized) {
      throw new Error('Сначала вызовите initialize()');
    }
    
    // EdgeVec возвращает эмбеддинг как Float32Array
    const embedding = await this.embedder.embed(text);
    
    // Преобразуем в обычный массив для JSON
    return Array.from(embedding);
  }
  
  async searchSimilar(embedding, topK = 3) {
    // Отправляем эмбеддинг на наш сервер
    const response = await fetch('http://localhost:8000/search', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        embedding: embedding,
        top_k: topK
      })
    });
    
    if (!response.ok) {
      throw new Error(`Ошибка поиска: ${response.status}`);
    }
    
    return await response.json();
  }
  
  async askQuestion(question, context) {
    const response = await fetch('http://localhost:8000/ask', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        question: question,
        context: context.join('\n\n') // Объединяем документы
      })
    });
    
    if (!response.ok) {
      throw new Error(`Ошибка LLM: ${response.status}`);
    }
    
    return await response.json();
  }
  
  async processQuery(question) {
    try {
      // 1. Векторизуем вопрос
      console.log('Создаем эмбеддинг для вопроса...');
      const embedding = await this.getEmbedding(question);
      
      // 2. Ищем похожие документы
      console.log('Ищем похожие документы...');
      const searchResult = await this.searchSimilar(embedding);
      
      if (!searchResult.documents || searchResult.documents.length === 0) {
        return {
          answer: 'Не найдено подходящих документов для ответа',
          sources: []
        };
      }
      
      // 3. Задаем вопрос LLM с контекстом
      console.log('Отправляем запрос в LLM...');
      const llmResult = await this.askQuestion(question, searchResult.documents);
      
      return {
        answer: llmResult.answer,
        sources: searchResult.documents
      };
      
    } catch (error) {
      console.error('Ошибка:', error);
      throw error;
    }
  }
}

// Использование
async function main() {
  const rag = new RAGSystem();
  await rag.initialize();
  
  // Пример запроса
  const result = await rag.processQuery(
    "Какие требования к системе для запуска локальных LLM?"
  );
  
  console.log('Ответ:', result.answer);
  console.log('Источники:', result.sources.length);
}

// Запускаем при загрузке страницы
window.addEventListener('DOMContentLoaded', main);

Ошибки, которые сломают вашу систему (и как их избежать)

Ошибка 1: Разные размерности векторов

EdgeVec загружает квантованную версию модели. Иногда размерность эмбеддингов может отличаться от оригинальной модели. all-MiniLM-L6-v2 дает 384 измерения. Убедитесь, что на сервере используется точно такая же размерность.

// Проверка размерности
const embedding = await rag.getEmbedding("тест");
console.log(`Размерность: ${embedding.length}`); // Должно быть 384

Ошибка 2: CORS при работе с Ollama

Браузер не может напрямую обращаться к Ollama (порт 11434) из-за CORS. Поэтому мы используем промежуточный сервер (наш FastAPI на порту 8000). Не пытайтесь обойти CORS – это нарушение безопасности.

Ошибка 3: Слишком большие документы

EdgeVec имеет ограничение на длину текста – обычно 512 токенов. Если отправлять огромные документы, они будут обрезаны. Решение: разбивать документы на чанки перед индексацией.

# На сервере, перед индексацией
def chunk_text(text, chunk_size=500):
    words = text.split()
    chunks = []
    for i in range(0, len(words), chunk_size):
        chunk = ' '.join(words[i:i+chunk_size])
        chunks.append(chunk)
    return chunks

Ошибка 4: Память в браузере

Модель all-MiniLM-L6-v2 в квантованном виде весит ~20 МБ. Это немало для мобильных устройств. Если ваше приложение должно работать на слабых устройствах, используйте еще меньшую модель, например Xenova/all-MiniLM-L6-v2-quantized.

Производительность: чего ожидать

Я тестировал эту связку на MacBook M1 Pro:

  • Загрузка модели EdgeVec: 3-5 секунд (после кэширования – мгновенно)
  • Создание эмбеддинга для одного предложения: 50-100 мс
  • Поиск в ChromaDB (10к документов): 10-20 мс
  • Генерация ответа Llama 3.1 8B: 2-5 секунд в зависимости от длины

Общее время от вопроса до ответа: 3-8 секунд. Это медленнее, чем ChatGPT, но зато все работает локально, без отправки данных в облако.

💡
Если производительность критична, замените Ollama на llama.cpp с более оптимизированными настройками. В статье «Оптимизация llama.cpp под AMD видеокарты» есть подробности по настройке.

Альтернативы: когда EdgeVec не подходит

EdgeVec – не единственный вариант. Если ваш проект уже использует другие инструменты, рассмотрите альтернативы:

СценарийАльтернатива EdgeVecПлюсы/Минусы
Только серверное приложениеSentence Transformers (Python)+ Больше моделей, + Выше точность, - Зависит от Python
Мобильное приложениеTensorFlow Lite / Core ML+ Нативная производительность, - Сложнее внедрить
Максимальная скоростьONNX Runtime в WebAssembly+ Быстрее EdgeVec в 2-3 раза, - Больше размер бандла

Для большинства веб-приложений EdgeVec – оптимальный выбор. Баланс между простотой, размером и производительностью.

Что делать, если хочется больше контроля?

Эта архитектура – базовый вариант. В реальных проектах часто нужны доработки:

1. Кэширование эмбеддингов в браузере

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

class CachedEmbedder {
  constructor() {
    this.cache = new Map(); // Простой in-memory кэш
  }
  
  async getEmbeddingCached(text) {
    const key = text.toLowerCase().trim();
    
    if (this.cache.has(key)) {
      console.log('Используем кэшированный эмбеддинг');
      return this.cache.get(key);
    }
    
    const embedding = await this.getEmbedding(text);
    this.cache.set(key, embedding);
    return embedding;
  }
}

2. Стриминг ответов от Ollama

В нашем примере мы ждем полный ответ от LLM. Это создает задержку. Лучше использовать стриминг:

// На сервере (Python)
@app.post("/ask_stream")
async def ask_llama_stream(context: str, question: str):
    prompt = f"Контекст:\n{context}\n\nВопрос: {question}\n\nОтвет:"
    
    response = requests.post(
        'http://localhost:11434/api/generate',
        json={
            "model": "llama3.1:8b",
            "prompt": prompt,
            "stream": True  # Включаем стриминг!
        },
        stream=True
    )
    
    # Возвращаем стрим
    return StreamingResponse(response.iter_content(), media_type="text/plain")

В браузере обрабатываем потоковые данные через EventSource или Fetch API.

3. Мультимодальность

Хотите работать не только с текстом, но и с изображениями? EdgeVec поддерживает только текст, но Transformers.js умеет работать с CLIP моделями. Можно создать систему, где пользователь загружает изображение, получает текстовое описание через BLIP, а потом ищет похожие документы. Об этом подходе я писал в статье «Локальные LLM на практике: Строим мультимодальный краулер событий».

Что дальше? Куда развивать эту систему

Вы собрали работающий прототип. Что можно улучшить?

  1. Гибридный поиск: Комбинируйте векторный поиск с ключевыми словами (BM25). ChromaDB это поддерживает.
  2. Рерайтинг: После поиска документов, используйте маленькую модель (например, tiny-llama) для рерайтинга найденных отрывков, чтобы убрать шум.
  3. Агентский подход: Вместо одного запроса к LLM, создайте цепочку агентов. Один ищет документы, другой анализирует, третий формулирует ответ. Интересный пример – в статье про Kaggle по AI-агентам.

Главное преимущество этой архитектуры – ее модульность. Можно заменить любую часть:

  • EdgeVec → другая браузерная модель
  • ChromaDB → Qdrant, Weaviate, Pinecone
  • Ollama → llama.cpp, vLLM, TensorRT-LLM
  • FastAPI → Flask, Express.js, Rust сервер

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

И последний совет: всегда тестируйте с реальными пользователями. Технически красивое решение может оказаться неудобным на практике. Иногда простая поисковая строка работает лучше, чем сложная RAG система с тремя моделями.

Удачи с локальными LLM. Когда все работает на вашем железе – это особое удовольствие. (И экономия $20 в месяц на ChatGPT Plus).