API веб-поиска Ollama в Go

AI-поисковые агенты с использованием Go и Ollama

Содержимое страницы

Ollama’s Web Search API позволяет дополнять локальные LLMs актуальной информацией из интернета. Это руководство показывает, как реализовать возможности веб-поиска на Go, от простых API-запросов до полнофункциональных поисковых агентов.

gopher biking

Начало работы

Есть ли у Ollama официальная библиотека для Go для веб-поиска? Ollama предоставляет REST API для веб-поиска, который работает с любым клиентом HTTP на Go. Хотя пока нет официального SDK для Go, вы легко можете реализовать API-запросы с использованием стандартных пакетов библиотеки.

Сначала создайте API ключ в вашем аккаунте Ollama. Для полной справки по командам и использованию Ollama ознакомьтесь с шпаргалкой Ollama.

Установите ваш API ключ как переменную окружения:

export OLLAMA_API_KEY="your_api_key"

В Windows PowerShell:

$env:OLLAMA_API_KEY = "your_api_key"

Настройка проекта

Создайте новый модуль Go:

mkdir ollama-search
cd ollama-search
go mod init ollama-search

Базовый веб-поиск

Как аутентифицироваться в API веб-поиска Ollama на Go? Установите заголовок Authorization с вашим API ключом как Bearer токен. Создайте API ключ в вашем аккаунте Ollama и передайте его в заголовке запроса.

Вот полная реализация API веб-поиска:

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
)

// Типы запросов/ответов для web_search
type WebSearchRequest struct {
	Query      string `json:"query"`
	MaxResults int    `json:"max_results,omitempty"`
}

type WebSearchResult struct {
	Title   string `json:"title"`
	URL     string `json:"url"`
	Content string `json:"content"`
}

type WebSearchResponse struct {
	Results []WebSearchResult `json:"results"`
}

func webSearch(query string, maxResults int) (*WebSearchResponse, error) {
	apiKey := os.Getenv("OLLAMA_API_KEY")
	if apiKey == "" {
		return nil, fmt.Errorf("переменная окружения OLLAMA_API_KEY не установлена")
	}

	reqBody := WebSearchRequest{
		Query:      query,
		MaxResults: maxResults,
	}
	jsonData, err := json.Marshal(reqBody)
	if err != nil {
		return nil, fmt.Errorf("не удалось сериализовать запрос: %w", err)
	}

	req, err := http.NewRequest("POST", "https://ollama.com/api/web_search", bytes.NewBuffer(jsonData))
	if err != nil {
		return nil, fmt.Errorf("не удалось создать запрос: %w", err)
	}

	req.Header.Set("Authorization", "Bearer "+apiKey)
	req.Header.Set("Content-Type", "application/json")

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("запрос не удался: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("ошибка API (статус %d): %s", resp.StatusCode, string(body))
	}

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("не удалось прочитать ответ: %w", err)
	}

	var searchResp WebSearchResponse
	if err := json.Unmarshal(body, &searchResp); err != nil {
		return nil, fmt.Errorf("не удалось десериализовать ответ: %w", err)
	}

	return &searchResp, nil
}

func main() {
	results, err := webSearch("Что такое Ollama?", 5)
	if err != nil {
		fmt.Printf("Ошибка: %v\n", err)
		return
	}

	fmt.Println("Результаты поиска:")
	fmt.Println("===============")
	for i, result := range results.Results {
		fmt.Printf("\n%d. %s\n", i+1, result.Title)
		fmt.Printf("   URL: %s\n", result.URL)
		fmt.Printf("   %s\n", truncate(result.Content, 150))
	}
}

func truncate(s string, maxLen int) string {
	if len(s) <= maxLen {
		return s
	}
	return s[:maxLen] + "..."
}

Реализация Web Fetch

В чем разница между эндпоинтами web_search и web_fetch? Эндпоинт web_search выполняет поиск в интернете и возвращает несколько результатов с заголовками, URL и выдержками. Эндпоинт web_fetch получает полное содержимое конкретного URL, возвращая заголовок страницы, содержимое и ссылки.

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
)

// Типы запросов/ответов для web_fetch
type WebFetchRequest struct {
	URL string `json:"url"`
}

type WebFetchResponse struct {
	Title   string   `json:"title"`
	Content string   `json:"content"`
	Links   []string `json:"links"`
}

func webFetch(url string) (*WebFetchResponse, error) {
	apiKey := os.Getenv("OLLAMA_API_KEY")
	if apiKey == "" {
		return nil, fmt.Errorf("переменная окружения OLLAMA_API_KEY не установлена")
	}

	reqBody := WebFetchRequest{URL: url}
	jsonData, err := json.Marshal(reqBody)
	if err != nil {
		return nil, fmt.Errorf("не удалось сериализовать запрос: %w", err)
	}

	req, err := http.NewRequest("POST", "https://ollama.com/api/web_fetch", bytes.NewBuffer(jsonData))
	if err != nil {
		return nil, fmt.Errorf("не удалось создать запрос: %w", err)
	}

	req.Header.Set("Authorization", "Bearer "+apiKey)
	req.Header.Set("Content-Type", "application/json")

	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("запрос не удался: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("ошибка API (статус %d): %s", resp.StatusCode, string(body))
	}

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("не удалось прочитать ответ: %w", err)
	}

	var fetchResp WebFetchResponse
	if err := json.Unmarshal(body, &fetchResp); err != nil {
		return nil, fmt.Errorf("не удалось десериализовать ответ: %w", err)
	}

	return &fetchResp, nil
}

func main() {
	result, err := webFetch("https://ollama.com")
	if err != nil {
		fmt.Printf("Ошибка: %v\n", err)
		return
	}

	fmt.Printf("Заголовок: %s\n\n", result.Title)
	fmt.Printf("Содержимое:\n%s\n\n", result.Content)
	fmt.Printf("Найдено ссылок: %d\n", len(result.Links))
	for i, link := range result.Links {
		if i >= 5 {
			fmt.Printf("  ... и еще %d\n", len(result.Links)-5)
			break
		}
		fmt.Printf("  - %s\n", link)
	}
}

Переиспользуемый клиентский пакет

Создайте переиспользуемый пакет клиента Ollama для более чистого кода:

// ollama/client.go
package ollama

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"
)

type Client struct {
	apiKey     string
	httpClient *http.Client
	baseURL    string
}

func NewClient() (*Client, error) {
	apiKey := os.Getenv("OLLAMA_API_KEY")
	if apiKey == "" {
		return nil, fmt.Errorf("переменная окружения OLLAMA_API_KEY не установлена")
	}

	return &Client{
		apiKey: apiKey,
		httpClient: &http.Client{
			Timeout: 30 * time.Second,
		},
		baseURL: "https://ollama.com/api",
	}, nil
}

type SearchResult struct {
	Title   string `json:"title"`
	URL     string `json:"url"`
	Content string `json:"content"`
}

type SearchResponse struct {
	Results []SearchResult `json:"results"`
}

type FetchResponse struct {
	Title   string   `json:"title"`
	Content string   `json:"content"`
	Links   []string `json:"links"`
}

func (c *Client) WebSearch(query string, maxResults int) (*SearchResponse, error) {
	payload := map[string]interface{}{
		"query":       query,
		"max_results": maxResults,
	}
	return doRequest[SearchResponse](c, "/web_search", payload)
}

func (c *Client) WebFetch(url string) (*FetchResponse, error) {
	payload := map[string]string{"url": url}
	return doRequest[FetchResponse](c, "/web_fetch", payload)
}

func doRequest[T any](c *Client, endpoint string, payload interface{}) (*T, error) {
	jsonData, err := json.Marshal(payload)
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequest("POST", c.baseURL+endpoint, bytes.NewBuffer(jsonData))
	if err != nil {
		return nil, err
	}

	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("Content-Type", "application/json")

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("ошибка API (статус %d): %s", resp.StatusCode, string(body))
	}

	var result T
	if err := json.Unmarshal(body, &result); err != nil {
		return nil, err
	}

	return &result, nil
}

Использование:

package main

import (
	"fmt"
	"log"
	"ollama-search/ollama"
)

func main() {
	client, err := ollama.NewClient()
	if err != nil {
		log.Fatal(err)
	}

	// Поиск
	results, err := client.WebSearch("новые функции Ollama", 5)
	if err != nil {
		log.Fatal(err)
	}

	for _, r := range results.Results {
		fmt.Printf("- %s\n  %s\n\n", r.Title, r.URL)
	}

	// Получение
	page, err := client.WebFetch("https://ollama.com")
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("Страница: %s\n", page.Title)
}

Построение поискового агента

Какие модели лучше всего подходят для поисковых агентов на Go с использованием Ollama? Лучше всего работают модели с сильными возможностями использования инструментов, включая qwen3, gpt-oss и облачные модели, такие как qwen3:480b-cloud и deepseek-v3.1-cloud. Если вы работаете с моделями Qwen3 и вам нужно обрабатывать или переранжировать результаты поиска, ознакомьтесь с нашим руководством по переранжировке текстовых документов с использованием Ollama и модели Qwen3 Embedding в Go.

Вот полная реализация поискового агента:

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
)

// Типы чата
type Message struct {
	Role      string     `json:"role"`
	Content   string     `json:"content,omitempty"`
	ToolCalls []ToolCall `json:"tool_calls,omitempty"`
	ToolName  string     `json:"tool_name,omitempty"`
}

type ToolCall struct {
	Function FunctionCall `json:"function"`
}

type FunctionCall struct {
	Name      string                 `json:"name"`
	Arguments map[string]interface{} `json:"arguments"`
}

type Tool struct {
	Type     string       `json:"type"`
	Function ToolFunction `json:"function"`
}

type ToolFunction struct {
	Name        string                 `json:"name"`
	Description string                 `json:"description"`
	Parameters  map[string]interface{} `json:"parameters"`
}

type ChatRequest struct {
	Model    string    `json:"model"`
	Messages []Message `json:"messages"`
	Tools    []Tool    `json:"tools,omitempty"`
	Stream   bool      `json:"stream"`
}

type ChatResponse struct {
	Message Message `json:"message"`
}

// SearchAgent управляет веб-поиском с использованием LLM
type SearchAgent struct {
	model      string
	ollamaURL  string
	apiKey     string
	maxIter    int
	httpClient *http.Client
}

func NewSearchAgent(model string) *SearchAgent {
	return &SearchAgent{
		model:      model,
		ollamaURL:  "http://localhost:11434/api/chat",
		apiKey:     os.Getenv("OLLAMA_API_KEY"),
		maxIter:    10,
		httpClient: &http.Client{},
	}
}

func (a *SearchAgent) Query(question string) (string, error) {
	tools := []Tool{
		{
			Type: "function",
			Function: ToolFunction{
				Name:        "web_search",
				Description: "Поиск в интернете актуальной информации",
				Parameters: map[string]interface{}{
					"type": "object",
					"properties": map[string]interface{}{
						"query": map[string]string{
							"type":        "string",
							"description": "Поисковый запрос",
						},
					},
					"required": []string{"query"},
				},
			},
		},
		{
			Type: "function",
			Function: ToolFunction{
				Name:        "web_fetch",
				Description: "Получение полного содержимого веб-страницы",
				Parameters: map[string]interface{}{
					"type": "object",
					"properties": map[string]interface{}{
						"url": map[string]string{
							"type":        "string",
							"description": "URL для получения",
						},
					},
					"required": []string{"url"},
				},
			},
		},
	}

	messages := []Message{
		{Role: "user", Content: question},
	}

	for i := 0; i < a.maxIter; i++ {
		response, err := a.chat(messages, tools)
		if err != nil {
			return "", fmt.Errorf("ошибка чата: %w", err)
		}

		messages = append(messages, response.Message)

		// Отсутствие вызовов инструментов означает, что у нас есть окончательный ответ
		if len(response.Message.ToolCalls) == 0 {
			return response.Message.Content, nil
		}

		// Выполнение вызовов инструментов
		for _, toolCall := range response.Message.ToolCalls {
			fmt.Printf("🔧 Вызов: %s\n", toolCall.Function.Name)

			result, err := a.executeTool(toolCall)
			if err != nil {
				result = fmt.Sprintf("Ошибка: %v", err)
			}

			// Обрезка для ограничений контекста
			if len(result) > 8000 {
				result = result[:8000] + "... [обрезано]"
			}

			messages = append(messages, Message{
				Role:     "tool",
				Content:  result,
				ToolName: toolCall.Function.Name,
			})
		}
	}

	return "", fmt.Errorf("достигнуто максимальное количество итераций")
}

func (a *SearchAgent) chat(messages []Message, tools []Tool) (*ChatResponse, error) {
	reqBody := ChatRequest{
		Model:    a.model,
		Messages: messages,
		Tools:    tools,
		Stream:   false,
	}

	jsonData, _ := json.Marshal(reqBody)

	resp, err := a.httpClient.Post(a.ollamaURL, "application/json", bytes.NewBuffer(jsonData))
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)

	var chatResp ChatResponse
	if err := json.Unmarshal(body, &chatResp); err != nil {
		return nil, err
	}

	return &chatResp, nil
}

func (a *SearchAgent) executeTool(toolCall ToolCall) (string, error) {
	switch toolCall.Function.Name {
	case "web_search":
		query, ok := toolCall.Function.Arguments["query"].(string)
		if !ok {
			return "", fmt.Errorf("неверный аргумент запроса")
		}
		return a.webSearch(query)

	case "web_fetch":
		url, ok := toolCall.Function.Arguments["url"].(string)
		if !ok {
			return "", fmt.Errorf("неверный аргумент URL")
		}
		return a.webFetch(url)

	default:
		return "", fmt.Errorf("неизвестный инструмент: %s", toolCall.Function.Name)
	}
}

func (a *SearchAgent) webSearch(query string) (string, error) {
	payload := map[string]interface{}{"query": query, "max_results": 5}
	jsonData, _ := json.Marshal(payload)

	req, _ := http.NewRequest("POST", "https://ollama.com/api/web_search", bytes.NewBuffer(jsonData))
	req.Header.Set("Authorization", "Bearer "+a.apiKey)
	req.Header.Set("Content-Type", "application/json")

	resp, err := a.httpClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	return string(body), nil
}

func (a *SearchAgent) webFetch(url string) (string, error) {
	payload := map[string]string{"url": url}
	jsonData, _ := json.Marshal(payload)

	req, _ := http.NewRequest("POST", "https://ollama.com/api/web_fetch", bytes.NewBuffer(jsonData))
	req.Header.Set("Authorization", "Bearer "+a.apiKey)
	req.Header.Set("Content-Type", "application/json")

	resp, err := a.httpClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	return string(body), nil
}

func main() {
	agent := NewSearchAgent("qwen3:4b")

	answer, err := agent.Query("Какие последние функции в Ollama?")
	if err != nil {
		fmt.Printf("Ошибка: %v\n", err)
		return
	}

	fmt.Println("\n📝 Ответ:")
	fmt.Println(answer)
}

Как обрабатывать большие ответы веб-поиска в Go? Обрезайте содержимое ответа перед передачей его в контекст модели. Используйте срезы строк для ограничения содержимого примерно до 8000 символов, чтобы оно помещалось в ограничения контекста.

Параллельный поиск

Go отлично подходит для параллельных операций. Вот как выполнять несколько поисков одновременно. Понимание как Ollama обрабатывает параллельные запросы может помочь вам оптимизировать свои параллельные реализации поиска.

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"sync"
)

type SearchResult struct {
	Query   string
	Results []struct {
		Title   string `json:"title"`
		URL     string `json:"url"`
		Content string `json:"content"`
	} `json:"results"`
	Error error
}

func concurrentSearch(queries []string) []SearchResult {
	results := make([]SearchResult, len(queries))
	var wg sync.WaitGroup

	for i, query := range queries {
		wg.Add(1)
		go func(idx int, q string) {
			defer wg.Done()

			result := SearchResult{Query: q}

			payload := map[string]string{"query": q}
			jsonData, _ := json.Marshal(payload)

			req, _ := http.NewRequest("POST", "https://ollama.com/api/web_search", bytes.NewBuffer(jsonData))
			req.Header.Set("Authorization", "Bearer "+os.Getenv("OLLAMA_API_KEY"))
			req.Header.Set("Content-Type", "application/json")

			resp, err := http.DefaultClient.Do(req)
			if err != nil {
				result.Error = err
				results[idx] = result
				return
			}
			defer resp.Body.Close()

			body, _ := io.ReadAll(resp.Body)
			json.Unmarshal(body, &result)
			results[idx] = result
		}(i, query)
	}

	wg.Wait()
	return results
}

func main() {
	queries := []string{
		"Последние функции Ollama",
		"Локальное развертывание LLM",
		"AI поисковые агенты Go",
	}

	results := concurrentSearch(queries)

	for _, r := range results {
		fmt.Printf("\n🔍 Запрос: %s\n", r.Query)
		if r.Error != nil {
			fmt.Printf("   Ошибка: %v\n", r.Error)
			continue
		}
		for _, item := range r.Results[:min(3, len(r.Results))] {
			fmt.Printf("   • %s\n", item.Title)
		}
	}
}

func min(a, b int) int {
	if a < b {
		return a
	}
	return b
}

Контекст и отмена

Добавьте поддержку контекста для таймаутов и отмены:

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"
)

func webSearchWithContext(ctx context.Context, query string) (string, error) {
	payload := map[string]string{"query": query}
	jsonData, _ := json.Marshal(payload)

	req, err := http.NewRequestWithContext(ctx, "POST", "https://ollama.com/api/web_search", bytes.NewBuffer(jsonData))
	if err != nil {
		return "", err
	}

	req.Header.Set("Authorization", "Bearer "+os.Getenv("OLLAMA_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}

	return string(body), nil
}

func main() {
	// Создание контекста с таймаутом 10 секунд
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	result, err := webSearchWithContext(ctx, "API веб-поиска Ollama")
	if err != nil {
		if ctx.Err() == context.DeadlineExceeded {
			fmt.Println("Запрос истек по времени")
		} else {
			fmt.Printf("Ошибка: %v\n", err)
		}
		return
	}

	fmt.Println(result)
}

Рекомендуемые модели

Какую длину контекста следует использовать для поисковых агентов на Go? Установите длину контекста примерно на 32000 токенов для разумной производительности. Поисковые агенты работают лучше всего с полной длиной контекста, так как результаты веб-поиска могут быть обширными. Если вам нужно управлять вашими моделями Ollama или перемещать их в разные места, ознакомьтесь с нашим руководством по как переместить модели Ollama на другой диск или папку.

Модель Параметры Лучше всего для
qwen3:4b 4B Быстрые локальные поиски
qwen3 8B Общее назначение агента
gpt-oss Разные Исследовательские задачи
qwen3:480b-cloud 480B Сложные рассуждения (облако)
gpt-oss:120b-cloud 120B Долгосрочные исследования (облако)
deepseek-v3.1-cloud - Продвинутый анализ (облако)

Для продвинутых приложений ИИ, которые сочетают текстовое и визуальное содержимое, рассмотрите возможность изучения кросс-модальных вложений для расширения ваших поисковых возможностей за рамками текстовых запросов.

Лучшие практики

  1. Обработка ошибок: Всегда проверяйте ошибки и обрабатывайте сбои API корректно
  2. Тайм-ауты: Используйте контекст с тайм-аутами для сетевых запросов
  3. Ограничение скорости: Уважайте ограничения скорости API Ollama. Будьте осведомлены о возможных изменениях в API Ollama, как обсуждалось в первые признаки деградации Ollama
  4. Обрезка результатов: Обрезайте результаты до ~8000 символов из-за ограничений контекста
  5. Параллельные запросы: Используйте горутины для параллельных поисков
  6. Переиспользование соединений: Переиспользуйте HTTP-клиент для лучшей производительности
  7. Тестирование: Пишите всесторонние модульные тесты для своих реализаций поиска. Следуйте лучшим практикам модульного тестирования на Go

Полезные ссылки