Зачем вообще это нужно?
Вы запускаете Llama 3 или Mistral через Ollama на своем компьютере. Модель работает, отвечает. Но вот беда – RAG (Retrieval-Augmented Generation) с ней не сделать. Нет эмбеддингов. Модель не умеет искать похожие документы в вашей базе знаний.
Типичное решение – ставить отдельный сервис эмбеддингов. Sentence Transformers на Python, какой-нибудь BGE-M3. Но это лишний сервис, лишние зависимости, лишняя память.
EdgeVec предлагает другой путь: эмбеддинги прямо в браузере. Модель весит 20-40 МБ, работает на JavaScript. Вы загружаете ее один раз – и она готова векторизовать тексты.
Проблема в том, что эмбеддинги из 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 chromadb2Серверная часть: FastAPI + ChromaDB
Создаем простой сервер на Python. Он будет делать три вещи:
- Принимать эмбеддинги из браузера
- Искать похожие документы в ChromaDB
- Отправлять контекст в 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, но зато все работает локально, без отправки данных в облако.
Альтернативы: когда 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 на практике: Строим мультимодальный краулер событий».
Что дальше? Куда развивать эту систему
Вы собрали работающий прототип. Что можно улучшить?
- Гибридный поиск: Комбинируйте векторный поиск с ключевыми словами (BM25). ChromaDB это поддерживает.
- Рерайтинг: После поиска документов, используйте маленькую модель (например, tiny-llama) для рерайтинга найденных отрывков, чтобы убрать шум.
- Агентский подход: Вместо одного запроса к 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).