EdgeVec + локальная LLM: гайд для Transformers.js и Ollama | AiManual
AiManual Logo Ai / Manual.
02 Янв 2026 Гайд

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

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

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

Вы запускаете 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).