Phind.com закрылся. Или стал платным. Или просто перестал работать так, как вам нужно. Не важно. Важно, что вы остались без инструмента, который стал частью рабочего процесса.
Хорошая новость: вы можете собрать свой. Лучше. Быстрее. Без ограничений. И он будет работать даже когда интернет отвалится.
Плохая новость: большинство гайдов в интернете — мусор. Они показывают, как подключить SearXNG к Open WebUI и называют это «AI-поиском». Это не поиск. Это медленный, кривой костыль, который сломается через неделю, когда ваш IP попадет в черный список Google.
Если вы просто подключите SearXNG к Open WebUI — вы получите ровно ту же проблему, о которой я писал в статье «Почему AI-поиск с SearXNG перестал работать». Блокировка IP, капчи, пустые ответы. Мы пойдем другим путем.
Что мы на самом деле строим
Phind.com — это не просто «поиск + LLM». Это сложный агентский workflow:
- Анализ запроса пользователя
- Планирование поисковых стратегий
- Параллельный сбор данных из нескольких источников
- Синтез и верификация информации
- Форматирование ответа с ссылками
Наш локальный вариант должен делать все это. Но без облачных API. И без блокировок.
Архитектура: почему один агент — это провал
Типичная ошибка: создать одного «супер-агента», который делает все. Он получает запрос, ищет в интернете, анализирует, отвечает. Звучит логично? На практике это монстр с латентностью в 5+ секунд.
Вместо этого мы используем архитектуру суб-агентов, как в реальных сценариях суб-агентов. Каждый агент делает одну вещь, но делает ее идеально.
| Агент | Задача | Модель | Почему отдельно |
|---|---|---|---|
| Query Planner | Разбивает запрос на подзапросы для поиска | Маленькая (2-3B) | Не нужен контекст, только логика |
| Search Executor | Параллельный поиск по разным движкам | Не нужна LLM | Чистая инженерия, скорость критична |
| Synthesis Agent | Анализ и синтез найденного | Большая (7B+) | Нужен интеллект и контекст |
| Formatter | Подготовка финального ответа | Маленькая (2-3B) | Шаблонная работа |
Эта архитектура снижает общую латентность в 3-4 раза. Query Planner работает за 200 мс, Search Executor — 500-700 мс (если оптимизирован, см. статью про сжатие латентности), Synthesis Agent — 1-2 секунды. Итого 2-3 секунды против 5-8 у монолита.
1 Подготовка: что нужно установить
Не начинайте с Docker. Сначала поймите, что куда идет.
# Базовый стек
sudo apt update
sudo apt install python3-pip git curl wget
# Open WebUI (бывший Ollama WebUI)
pip install open-webui
# Ollama для локальных моделей
curl -fsSL https://ollama.com/install.sh | sh
# Модель для агентов (начнем с чего-то маленького)
ollama pull qwen2.5:3b-instruct
ollama pull llama3.2:3b
Зачем две модели? Qwen2.5:3b — для интеллектуальных задач (анализ, синтез). Llama3.2:3b — для шаблонных (планирование, форматирование). Разделение труда экономит ресурсы и ускоряет работу.
2 Search Executor: обходим блокировки
Вот где большинство падает. Они ставят SearXNG, получают красивый интерфейс, а через три дня поиск перестает работать.
Решение: multiple fallback sources + smart rotation.
Создаем файл search_engine.py:
import asyncio
import aiohttp
from typing import List, Dict
import random
import time
class SearchExecutor:
def __init__(self):
# НЕ используйте публичные инстансы SearXNG
# Поднимите свой или используйте альтернативы
self.search_engines = [
{
'name': 'brave',
'url': 'https://search.brave.com/search',
'params': {'q': '', 'source': 'web'},
'parser': self.parse_brave
},
{
'name': 'duckduckgo',
'url': 'https://html.duckduckgo.com/html/',
'params': {'q': ''},
'method': 'POST',
'parser': self.parse_ddg
},
{
'name': 'mojeek',
'url': 'https://www.mojeek.com/search',
'params': {'q': ''},
'parser': self.parse_mojeek
}
]
# Ротация User-Agent
self.user_agents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36'
]
async def search(self, query: str, num_results: int = 5) -> List[Dict]:
"""Параллельный поиск с fallback"""
tasks = []
for engine in random.sample(self.search_engines, 2): # Берем 2 случайных
tasks.append(self._search_single(engine, query, num_results))
results = await asyncio.gather(*tasks, return_exceptions=True)
# Фильтруем успешные результаты
valid_results = []
for r in results:
if isinstance(r, list) and len(r) > 0:
valid_results.extend(r)
# Дедупликация по URL
seen = set()
unique_results = []
for r in valid_results:
if r['url'] not in seen:
seen.add(r['url'])
unique_results.append(r)
return unique_results[:num_results]
async def _search_single(self, engine: Dict, query: str, num_results: int):
"""Поиск через один движок"""
headers = {
'User-Agent': random.choice(self.user_agents),
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
}
try:
async with aiohttp.ClientSession() as session:
params = engine['params'].copy()
params['q'] = query
if engine.get('method') == 'POST':
async with session.post(engine['url'],
data=params,
headers=headers,
timeout=3) as resp:
html = await resp.text()
else:
async with session.get(engine['url'],
params=params,
headers=headers,
timeout=3) as resp:
html = await resp.text()
return engine['parser'](html)[:num_results]
except Exception as e:
print(f"{engine['name']} failed: {e}")
return []
def parse_brave(self, html: str):
# Упрощенный парсер для примера
# В реальности используйте BeautifulSoup или custom extractor
results = []
# ... логика парсинга ...
return results
# Аналогично для других парсеров
Ключевые моменты:
- Не полагайтесь на один поисковый движок
- Ротация User-Agent — обязательно
- Таймауты 3 секунды максимум
- Параллельные запросы ускоряют поиск в 2 раза
3 Настройка агентов в Open WebUI
Open WebUI поддерживает агентов через систему «Tools». Но их документация… скажем так, написана для идеального мира.
Создаем кастомного агента. В директории Open WebUI создаем файл agents/search_agent.py:
from agents import Agent, Runner, function_tool
from typing import List, Dict
import asyncio
# Импортируем наш SearchExecutor
from search_engine import SearchExecutor
@function_tool
def web_search(query: str) -> str:
"""
Выполняет поиск в интернете по запросу.
Возвращает результаты в формате JSON.
"""
executor = SearchExecutor()
results = asyncio.run(executor.search(query, num_results=5))
# Форматируем для LLM
formatted = ""
for i, r in enumerate(results, 1):
formatted += f"{i}. {r['title']}\n"
formatted += f" URL: {r['url']}\n"
formatted += f" Snippet: {r.get('snippet', '')[:200]}...\n\n"
return formatted if formatted else "Результаты не найдены."
class QueryPlannerAgent(Agent):
def __init__(self):
super().__init__(
name="query_planner",
instructions="""Ты — планировщик поисковых запросов.
Твоя задача: разбить сложный запрос пользователя на 2-3 простых подзапроса для поиска.
Пример:
Запрос: "Как настроить Kubernetes кластер и оптимизировать его для машинного обучения"
Подзапросы:
1. "настройка Kubernetes кластера с нуля"
2. "оптимизация Kubernetes для машинного обучения"
3. "best practices Kubernetes ML workloads"
Возвращай только подзапросы, каждый с новой строки.
""",
model="llama3.2:3b"
)
class SynthesisAgent(Agent):
def __init__(self):
super().__init__(
name="synthesis_agent",
instructions="""Ты — аналитик поисковых результатов.
Твоя задача: проанализировать результаты поиска и синтезировать полный, структурированный ответ.
У тебя есть:
1. Оригинальный запрос пользователя
2. Результаты поиска по подзапросам
Твой ответ должен:
- Отвечать на все аспекты запроса
- Ссылаться на источники (используй URL из результатов)
- Быть структурированным (заголовки, списки)
- Быть объективным (если есть противоречия — укажи их)
Формат:
## Основной ответ
[Текст ответа]
## Источники
- [Название](URL): описание
""",
model="qwen2.5:3b-instruct"
)
# Главный координатор
search_agent = Agent(
name="search_assistant",
instructions="""Ты — интеллектуальный поисковый ассистент.
Ты помогаешь пользователю находить информацию в интернете.
Твой workflow:
1. Получить запрос от пользователя
2. Разбить его на подзапросы через query_planner
3. Выполнить поиск по каждому подзапросу
4. Проанализировать результаты через synthesis_agent
5. Вернуть финальный ответ
Всегда указывай источники информации.
""",
model="qwen2.5:3b-instruct",
tools=[web_search]
)
Теперь регистрируем агента в Open WebUI. Создаем файл config/agents.yaml:
agents:
search_assistant:
path: "agents.search_agent:search_agent"
description: "Интеллектуальный поисковый ассистент с веб-поиском"
enabled: true
tools:
- web_search
query_planner:
path: "agents.search_agent:QueryPlannerAgent"
description: "Планировщик поисковых запросов"
enabled: true
synthesis_agent:
path: "agents.search_agent:SynthesisAgent"
description: "Аналитик и синтезатор результатов"
enabled: true
4 Запуск и оптимизация
Запускаем Open WebUI с нашей конфигурацией:
# Запуск с поддержкой агентов
open-webui serve --config config/agents.yaml \
--ollama-api-url http://localhost:11434 \
--host 0.0.0.0 \
--port 8080
Открываем http://localhost:8080, выбираем агента search_assistant и пробуем:
Запрос: "Какие лучшие практики для мониторинга PostgreSQL в 2024 году?"
Агент должен:
- Разбить запрос на подзапросы (через query_planner)
- Выполнить параллельный поиск
- Проанализировать и синтезировать ответ
- Вернуть структурированный ответ с источниками
Где все ломается (и как чинить)
Проблема 1: Агенты «забывают» вызывать инструменты
Симптом: LLM получает запрос, думает, но не вызывает web_search. Просто генерирует ответ из своих знаний.
Причина: маленькие модели (3B) плохо следуют инструкциям с несколькими шагами.
Решение: явное принуждение в промпте:
# В инструкциях главного агента добавляем:
"""
Ты ДОЛЖЕН вызвать инструмент web_search для получения информации.
Не пытайся ответить из своих знаний.
Шаги:
1. Вызови web_search с подзапросами
2. Дождись результатов
3. Проанализируй их
4. Ответь
Если не вызвал web_search — твой ответ неполный.
"""
Проблема 2: Поиск возвращает мусор
Симптом: результаты поиска содержат рекламу, SEO-тексты, нерелевантный контент.
Решение: фильтрация на уровне парсера. Добавляем в SearchExecutor:
def filter_results(self, results: List[Dict]) -> List[Dict]:
"""Фильтрация низкокачественных результатов"""
filtered = []
blacklist = ['sponsored', 'advertisement', 'best buy', 'amazon', 'wikipedia']
for r in results:
# Пропускаем слишком короткие сниппеты
if len(r.get('snippet', '')) < 50:
continue
# Пропускаем черный список
title_lower = r.get('title', '').lower()
snippet_lower = r.get('snippet', '').lower()
if any(term in title_lower or term in snippet_lower for term in blacklist):
continue
# Приоритет определенных доменов
domain_score = 1.0
good_domains = ['github.com', 'stackoverflow.com', 'docs.python.org', 'kubernetes.io']
for domain in good_domains:
if domain in r['url']:
domain_score = 2.0
break
r['score'] = domain_score
filtered.append(r)
# Сортировка по score
filtered.sort(key=lambda x: x.get('score', 1.0), reverse=True)
return filtered
Проблема 3: Медленный парсинг HTML
Симптом: поиск занимает 3+ секунды, хотя сетевые запросы быстрые.
Решение: переходим на Markdown-экстракцию, как в статье про оптимизацию поиска. BeautifulSoup парсит ВЕСЬ DOM-дерево. Нам нужен только текст.
def html_to_markdown_fast(html: str) -> str:
"""Быстрая конвертация HTML в Markdown"""
# Удаляем скрипты, стили
import re
html = re.sub(r'
Что делать, когда это заработает
Ваш локальный Phind.com работает. Поиск быстрый, ответы качественные. Что дальше?
- Добавьте кэширование. 70% запросов повторяются. Кэшируйте результаты поиска на 24 часа.
- Настройте мониторинг. Отслеживайте успешность поиска, латентность, качество ответов.
- Добавьте специализированных агентов. Для поиска по GitHub, документациям, научным статьям.
- Интегрируйте с другими инструментами. Как в синхронизации cookies для Gmail, но для поиска в закрытых источниках.
Самое главное: теперь у вас есть система, которая не зависит от капризов облачных провайдеров. Она ваша. Вы контролируете каждый байт, каждый запрос, каждую модель.
Phind.com закрылся? Не проблема. Ваш локальный поисковик только начал жить.