Перейти к содержимому

4.4 · Векторная БД + загрузка базы знаний

Неделя 4 · RAG · ⏱ ~8 ч (2 дня) · Проект

В гл. 4.3 стратегия нарезки определена на 10–20 страницах kb/, но каждый прогон заново резал и эмбеддил всё в памяти. Теперь игрушечный поиск на numpy из гл. 4.1 превращается в настоящий индекс: сотни и тысячи чанков, метаданные, фильтры, повторная загрузка без дублей. Ingestion-пайплайн — это 60% реальной работы над RAG в компаниях: источники меняются, ломаются, дублируются. На собеседовании спрашивают «как вы обновляете индекс», «как фильтруете по тенанту/источнику», «чем pgvector отличается от отдельной векторной БД» — после этой задачи у нас будут ответы из собственного опыта.

Векторная БД — хранилище, которое держит векторы вместе с текстом и метаданными и умеет искать ближайшие к запросу. Сравним её с тем, что уже умеет numpy:

Задача numpy (гл. 4.1) Векторная БД
Хранение .npy в памяти На диске, транзакции
Текст и метаданные Отдельный JSON рядом В той же записи
Фильтр «только гарантия» Руками маской WHERE category = 'warranty'
Обновление одного документа Пересобрать массив DELETE + INSERT
Миллионы векторов Медленно и не влезает в память ANN-индексы (HNSW, IVF)

На нашем масштабе (сотни — десятки тысяч чанков) точный перебор (brute force) занимает миллисекунды. Поэтому выбор хранилища здесь — вопрос удобства и знакомости, а не скорости.

Нам ANN не понадобится, но именно он отличает большие векторные БД и о нём спрашивают на собеседованиях. Точный поиск сравнивает запрос со всеми векторами: O(N). При миллионах векторов это медленно, поэтому используют приближённый поиск ближайших соседей (ANN):

  • HNSW — многоуровневый граф «соседей»; поиск идёт жадно от дальних связей к ближним. Быстрый, точный, но жрёт память. Стандарт в Qdrant, pgvector, многих других.
  • IVF — векторы разбиты на кластеры; ищем только в нескольких ближайших кластерах.

Цена ANN — recall < 100%: иногда настоящий ближайший сосед не находится. Важная практическая ловушка — фильтр + ANN: если сначала найти top-10 по вектору, а потом отфильтровать «только гарантия», может остаться 0 результатов (post-filtering). Хорошие БД умеют фильтровать внутри поиска или до него (pre-filtering, partition). На нашем объёме с точным поиском этой проблемы почти нет, но знать её обязательно.

Выбор хранилища на нашем объёме — вопрос удобства, поэтому сравниваем три типичных варианта: расширение к SQLite, расширение к Postgres и отдельный сервис.

sqlite-vec — расширение SQLite. Вектор хранится в виртуальной таблице vec0, поиск — обычным SQL. Один файл, ноль серверов, знакомый инструмент (базы магазина в курсе тоже SQLite с WAL). Делает точный поиск (brute force), чего на нашем масштабе более чем хватает. Проект молодой: синтаксис и возможности (метаданные-колонки, partition keys, выбор метрики) развиваются — сверяемся с README и документацией той версии, которую ставим.

pgvector — расширение PostgreSQL: тип vector, операторы расстояния (<-> L2, <=> косинусное расстояние, <#> отрицательное скалярное произведение), индексы HNSW и IVFFlat. Лучший выбор, если в проекте уже есть Postgres: векторы рядом с бизнес-данными, транзакции, JOIN.

Qdrant — отдельная векторная БД (Rust), с REST/gRPC API, коллекциями, payload-фильтрами и HNSW. Удобна, когда векторов много, нужен масштаб и продвинутые фильтры. Плата — ещё один сервис в инфраструктуре.

Для проекта берём sqlite-vec, остальные — читаем для сравнения.

Идея: обычная таблица с текстом и метаданными + виртуальная таблица с векторами, связанные по rowid.

CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY,
chunk_key TEXT UNIQUE NOT NULL, -- sha256(url + section + text)
category TEXT NOT NULL, -- delivery | payment | returns | warranty | promo | orders | products
archived INTEGER NOT NULL DEFAULT 0, -- 1 = old version, excluded from search by default
doc TEXT NOT NULL, -- page: kb/returns.md (or the web page URL)
url TEXT NOT NULL, -- citation target: kb/returns.md#opened-tea-returns
section TEXT,
text TEXT NOT NULL,
doc_hash TEXT NOT NULL, -- hash of the whole page content
embed_model TEXT NOT NULL,
loaded_at TEXT NOT NULL
);
-- Check the sqlite-vec docs for the exact vec0 options of your version
CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(
embedding float[1024] -- bge-m3; must equal your model's dimension
);

Подключение из Python (иллюстративно, сверяем с README):

import sqlite3
import sqlite_vec
db = sqlite3.connect("index.db")
db.enable_load_extension(True)
sqlite_vec.load(db)
db.enable_load_extension(False)

Если enable_load_extension отсутствует — Python собран без поддержки расширений SQLite (бывает с системным Python на macOS); ставим Python через Homebrew/pyenv/uv.

Вставка и KNN-запрос (k nearest neighbors — найти k ближайших к запросу векторов). Чанк вставляем в chunks, а его вектор — в vec_chunks с тем же rowid:

from sqlite_vec import serialize_float32
cur = db.execute(
"INSERT INTO chunks (chunk_key, category, archived, doc, url, section, text, "
"doc_hash, embed_model, loaded_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(chunk_key) DO NOTHING",
(key, category, archived, doc, url, section, text, doc_hash, MODEL, now),
)
if cur.rowcount == 1: # inserted, not a duplicate
db.execute("INSERT INTO vec_chunks (rowid, embedding) VALUES (?, ?)",
(cur.lastrowid, serialize_float32(vec)))
rows = db.execute(
"""
SELECT c.category, c.url, c.section, c.text, v.distance
FROM vec_chunks v JOIN chunks c ON c.id = v.rowid
WHERE v.embedding MATCH ? AND k = ?
ORDER BY v.distance
""",
(serialize_float32(qvec), 10),
).fetchall()

Обратим внимание на distance: это расстояние (меньше = ближе), а не сходство. По умолчанию — L2; для нормализованных векторов порядок тот же, что у косинуса (гл. 4.1).

KNN выше ищет по всей базе, а ассистенту часто нужна одна категория: на вопрос о гарантии — только куски про гарантию. Наивный вариант — найти top-k по всей базе, потом WHERE c.category = 'warranty' в JOIN. Это post-filtering: при k=10 может оказаться, что все 10 — из карточек товаров (products, их в базе больше всего), и для гарантии получим ноль. Варианты:

  1. Метаданные/partition key прямо в vec0 — в свежих версиях sqlite-vec можно объявить колонки вроде category text (или category text partition key) и archived integer в виртуальной таблице и фильтровать по ним в том же KNN-запросе. Проверяем в документации своей версии.
  2. Отдельная vec0-таблица на категорию — грубо, но надёжно.
  3. Большой k + фильтр + обрезка (взять k=200, отфильтровать, оставить 10) — простой компромисс на малом объёме.
  4. Точный поиск в numpy по подмножеству: вытащить векторы нужной категории и посчитать как в гл. 4.1 — на нашем объёме тоже вариант.

Выбираем 1, если поддерживается; иначе 3. Главное — тест: «поиск с фильтром warranty всегда возвращает k результатов из категории warranty, если они есть».

Второй фильтр — archived. Старая версия правил возврата (returns-2025.md, archived: true) по тексту почти совпадает с актуальной, и без фильтра поиск охотно поднимет устаревшие сроки. По умолчанию архив исключаем (archived = 0 тем же способом, что и категорию), включаем только явным флагом. Из индекса архив не удаляем: на нём позже проверяется, что retrieval не путает версии.

Схема и поиск готовы; теперь — как наполнять индекс и обновлять его, когда база знаний меняется. Этим занимается ingestion-пайплайн (загрузка):

sources.yaml ─► load (kb/*.md: front matter → метаданные)
└► [по желанию] fetch (GET, кэш на диск) ─► clean (HTML → Markdown)
─► chunk (стратегия из AIL-19) ─► dedupe (chunk_key) ─► embed (батчами)
─► upsert в SQLite ─► отчёт: страниц, чанков новых/старых/удалённых

Принципы из бэкенда здесь работают так же:

  • Идемпотентность. Повторный запуск без изменений в базе знаний не меняет индекс. Достигается детерминированным ключом чанка: sha256(url + section + text), и UNIQUE в схеме.
  • Инкрементальность. Храним doc_hash страницы (файл вместе с front matter: смена archived тоже переиндексирует страницу). Если хэш не изменился — страницу пропускаем целиком (ни chunking, ни эмбеддинги). Если изменился — удаляем старые чанки этой страницы (DELETE ... WHERE doc = ? и соответствующие строки vec_chunks) и вставляем новые. Файл удалили из kb/ — его чанки тоже удаляются.
  • Кэш сырья (для веб-страниц). Скачанный HTML сохраняем в raw/web/<slug>.html. Отлаживать чистку придётся много раз, а качать заново — невежливо к сайту. Для kb/ кэш не нужен.
  • Батчи эмбеддингов и кэш (гл. 4.1).
  • Транзакции. Обработка одной страницы — одна транзакция: либо все её чанки обновились, либо ничего.
  • Записываем embed_model. Сменили модель — по этому полю видно, что индекс надо перестроить.

Первый шаг пайплайна — load. Корпус проекта — справочный центр магазина «Полка» в папке kb/: Markdown-файлы с заголовками, источник истины, как документация в репозитории. Первые 10–20 страниц появились в гл. 4.3, здесь дописываем базу до 30–50.

Источник Где Особенности
Правила магазина kb/delivery.md, payment.md, returns.md, warranty.md, promo.md, orders.md Длинные страницы с таблицами; ссылка раздела = файл + якорь
Архив kb/returns-2025.md (archived: true) Старая версия правил возврата — ловушка для retrieval
Карточки товаров kb/products/*.md Много коротких страниц; есть похожие (lamp-light.md и lamp-light-mini.md с разной гарантией)

Каждый файл начинается с front matter — отсюда load.py берёт метаданные:

---
title: Возврат товара
category: returns
updated: 2026-03-01
---
# Возврат товара
## Возврат вскрытого чая {#opened-tea-returns}

load.py разбирает front matter (yaml.safe_load), проверяет category по допустимому списку и падает с именем файла, если front matter нет или он битый; в текст чанков front matter не попадает. Список источников держим в sources.yaml, а не в коде.

Тексты выдуманы, поэтому черновики пишет облачная модель по брифу, а мы правим их руками. Встроенные сложности оставляем сознательно, на них построены будущие evals: архивные правила возврата; таблицы тарифов и гарантий; похожие товары; числа, которые расходятся между страницами (порог бесплатной доставки в delivery.md и в promo.md); вопросы, ответ на которые требует двух разделов.

Веб-страница (по желанию). Сбор публичных страниц пригодится на работе, поэтому одну реальную справочную страницу (правила доставки службы) добавляем через fetch.py → clean.py с category: delivery, source: web. Вежливость к сайтам: пауза между запросами (0.5–1 с), понятный User-Agent без личных данных, уважаем robots.txt, кэшируем. Если страница рендерится JavaScript’ом и в HTML пусто — сначала ищем, нет ли исходников в виде Markdown (например, в публичном GitHub-репозитории сервиса), и лишь потом думаем о headless-браузере.

HTML → Markdown: сначала вырезаем основной контент (контейнер статьи; CSS-селектор находим в devtools), потом конвертируем (markdownify, html2text или trafilatura). Проверяем глазами: таблицы и списки должны уцелеть.

Последнее поле чанка, которое надо продумать до загрузки, — url. Для цитат (гл. 5.3) у чанка должна быть ссылка, ведущая как можно ближе к месту: kb/returns.md#opened-tea-returns, а не просто kb/returns.md. Заголовкам даём стабильные id: явный {#anchor} (так сделано на страницах из гл. 4.3) или детерминированный slug из текста заголовка. Явный id лучше — он не меняется, когда заголовок переформулируют. У веб-страницы якоря обычно есть в id у заголовков HTML — сохраняем их на этапе чистки. Якорь раздела записываем в url чанка.

Индексация — единственное место проекта, где модель вызывается сотни и тысячи раз подряд, и это только эмбеддинги. У DeepSeek их нет — это разобрано в главе 4.1 · Эмбеддинги руками на numpy. Поэтому весь ingestion работает локально и бесплатно: Ollama или Sentence Transformers. Облачный ключ для ingest.py не нужен вообще.

# Illustrative: one interface, backend chosen by env vars (EMBED_BACKEND, EMBED_MODEL)
import os
import numpy as np
BACKEND = os.environ.get("EMBED_BACKEND", "ollama") # ollama | st | openai
MODEL = os.environ["EMBED_MODEL"] # e.g. bge-m3 / BAAI/bge-m3
def embed(texts: list[str]) -> np.ndarray:
if BACKEND == "ollama":
import ollama
res = ollama.embed(model=MODEL, input=texts, truncate=False, keep_alive="10m")
return np.asarray(res["embeddings"], dtype=np.float32)
if BACKEND == "st":
from sentence_transformers import SentenceTransformer
st = _cached_st(MODEL) # TODO: load once, reuse
return st.encode(texts, normalize_embeddings=True, batch_size=32).astype(np.float32)
raise ValueError(f"unknown backend {BACKEND}") # 'openai' is optional, for comparison
def model_tag() -> str:
return f"{BACKEND}:{MODEL}" # store this in chunks.embed_model

Что это меняет в схеме и пайплайне:

  • Размерность vec0 = размерность модели. У bge-m3 1024 (по карточке BAAI/bge-m3), поэтому в схеме выше embedding float[1024]; у другой модели число будет другим (например, 384 у paraphrase-multilingual-MiniLM-L12-v2 из гл. 4.1). Число не берём из памяти: вызываем len(embed(["test"])[0]) и подставляем результат.
  • embed_model хранит и бэкенд: ollama:bge-m3 и st:BAAI/bge-m3 — разные индексы, даже если имя модели одно (глава 4.1).
  • Батчи. input у /api/embed принимает список. Размер батча (16–64 чанка) подбираем по скорости своей машины: слишком большой батч не ускоряет и ест память.
  • Модель в памяти. Первый вызов грузит модель (поле load_duration в ответе). Параметр keep_alive держит её загруженной между запросами — важно для search.py, иначе каждый поиск начинается с загрузки. Обратная сторона на машине с 8 GB, где рядом открыты браузер и IDE: загруженная модель держит память, пока не истечёт keep_alive. Для ingest.py хватает короткого keep_alive; после индексации проверяем ollama ps и выгружаем модель (ollama stop <модель>). Не держим одновременно эмбеддинг-модель, генератор и реранкер, не прикинув их суммарный размер.
  • Ollama должен быть запущен. Если сервер не отвечает на localhost:11434, ingest.py должен падать с понятным сообщением («Ollama не запущен: ollama serve»), а не с трейсбеком на 40 строк.
  • Латентность поиска теперь без сети: эмбеддинг вопроса локальной моделью на обычном ноутбуке (например, Apple Silicon) занимает порядка десятков миллисекунд, если модель уже загружена. Этой цифре не верим на слово — измеряем.
Что нужно индексу Claude OpenAI DeepSeek Ollama / Sentence Transformers
Эмбеддинги тысяч чанков Нет своих (Voyage AI) Да, платно за токены, rate limits Нет Да, бесплатно; ограничено скоростью машины
Батч в одном запросе — Да (список в input) — Да (список в input; у ST — batch_size)
Сеть при поиске — Запрос к API на каждый вопрос — Нет, всё локально
Ключ в .env для ingest.py — OPENAI_API_KEY — Не нужен

Практика этой главы делается целиком на локальном бэкенде. OpenAI-эмбеддинги — только «для сравнения» и только в отдельный index_openai.db (смешивать векторы разных моделей в одной таблице нельзя). Отправлять kb/ в облако можно: база знаний публичная.

  1. Открываем sqlite-vec → README целиком, затем по ссылке из README — документацию: установка для Python, vec0, KNN-запросы, метаданные/partition keys, метрики расстояния (≈45 мин). Выносим точный синтаксис для своей версии и как в ней фильтровать по колонке в KNN-запросе.
  2. Открываем pgvector → README: разделы про создание колонки vector, операторы расстояния, HNSW и IVFFlat, «Filtering» (≈30 мин). Обратим внимание, как здесь описана проблема фильтрации с ANN-индексом и способы её решения — это то же, что в теории выше.
  3. Открываем Qdrant → документация: Concepts → Collections, Points, Payload, Filtering, Indexing (≈30 мин). Выносим, чем payload-индекс помогает фильтрам и почему Qdrant фильтрует во время поиска по графу, а не после.
  4. Сверяем страницы kb/ из гл. 4.3 с макетом из «Практики»: front matter, явные якоря у заголовков, целые таблицы (≈20 мин). Для необязательного пункта открываем публичную страницу правил доставки реальной службы и в devtools находим контейнер текста и id заголовков (≈10 мин).
  5. Документация конвертера HTML → Markdown, если делаем веб-страницу (markdownify или html2text на PyPI, или trafilatura) — параметры для заголовков, таблиц и списков (≈15 мин).
  6. Перечитываем справку Ollama /api/embed (≈10 мин): input списком, truncate, keep_alive, поля load_duration и prompt_eval_count. Выносим, как держать модель загруженной во время ingest.py и search.py.
  • Несовпадение размерности float[N] в vec0 и модели → ошибка вставки (это хорошо, лучше, чем молча).
  • distance ≠ similarity. Меньше — ближе. Не сортируем по убыванию по привычке.
  • Post-filtering возвращает меньше k результатов или ноль. Фильтр тестируем на редкой категории.
  • Архив в выдаче: забыли фильтр archived — и бот уверенно цитирует отменённые правила возврата.
  • Дубли при повторной загрузке: ключ не детерминирован (включает время или случайный id). Ключ только из содержимого.
  • Осиротевшие векторы: удалили строки из chunks, забыли vec_chunks → в выдаче JOIN пропадает строка, а k фактически меньше. Удаляем в одной транзакции.
  • Мусор в тексте чанков — главный враг качества (см. гл. 4.3): front matter, навигация веб-страницы. Проверяем 10 случайных чанков глазами.
  • Таблицы режутся пополам при chunking, а HTML-таблицы с colspan ломают конвертеры. Таблицу тарифов держим в одном чанке или превращаем в список «зона: вес, цена, срок».
  • Одна ссылка на весь файл kb/delivery.md — цитата будет бесполезной. Нужны якоря.
  • Блокировки и капча при агрессивном скачивании веб-страниц. Паузы и кэш.
  • Безопасность: проект не ходит в базы магазина; в .env только ключ провайдера LLM (DeepSeek; OpenAI/Anthropic — если делаем сравнение), .env в .gitignore. При локальных эмбеддингах индексации ключ не нужен вовсе. Индекс (index.db) и raw/ тоже в .gitignore — их можно пересобрать скриптом; kb/ — наоборот, в git: это исходники. Настоящие сообщения клиентов в облако не уходят; тестовые вопросы выдумываем.

Структура проекта projects/02-support-kb-rag/:

02-support-kb-rag/
sources.yaml # kb/ path + optional web pages (URL, content selector, delay)
kb/ # shop help center: Markdown with front matter, source of truth
rag/
load.py # read kb/*.md: front matter -> metadata, keep heading anchors
fetch.py # optional: GET + disk cache in raw/web/
clean.py # optional: HTML -> Markdown, keep anchors, tables
chunk.py # strategy chosen in AIL-19
embed.py # batch embeddings + cache
store.py # sqlite-vec schema, upsert, search
ingest.py # CLI: python ingest.py [--category returns]
search.py # CLI: python search.py "query" --category returns -k 5
tests/test_store.py
  • kb/ до 30–50 страниц: к страницам из гл. 4.3 дописываем недостающие — delivery.md (зоны, сроки, таблица тарифов по весу и зоне), payment.md, returns.md (14 дней; вскрытый чай и кофе не возвращаются; кто платит за обратную доставку), returns-2025.md (archived: true), warranty.md, promo.md, orders.md и products/ (описание, уход, инструкция, FAQ). У каждого файла front matter: title, category, updated, при необходимости archived: true. Встроенные сложности из «Теории» на месте.
  • sources.yaml: путь к kb/; для веб-страницы — URL, селектор контента, пауза, category, source: web.
  • load.py: функция load_pages("kb") читает kb/**/*.md, front matter → title, category, updated, archived (по умолчанию false); проверяет category; на файле без front matter падает с его именем; якоря заголовков сохраняются.
  • (По желанию) fetch.py + clean.py для одной публичной справочной страницы реальной службы доставки: GET с учётом robots.txt, файл в raw/web/, повторно не качается, если свежее N часов (--refresh — принудительно); контент по селектору → Markdown с якорями. Проверяем глазами.
  • chunk.py: стратегия-победитель из гл. 4.3 (скорее всего по заголовкам с breadcrumbs), у каждого чанка: category, archived, doc, url (kb/<file>.md#<anchor>), section, text.
  • embed.py: бэкенд из переменных окружения (EMBED_BACKEND=ollama, EMBED_MODEL=bge-m3 — основной путь; st — альтернатива), батчи, truncate=False, кэш по хэшу текста + model_tag(). Понятная ошибка, если Ollama не запущен.
  • store.py: схема из «Теории» (размерность vec0 — из реального вектора своей модели), upsert_page(doc, category, archived, doc_hash, chunks, vectors) в одной транзакции (удалить старые чанки страницы, если doc_hash изменился; вставить новые), search(qvec, k, category=None, include_archived=False).
  • ingest.py: прогон пайплайна, в конце отчёт: pages: 42 (unchanged 35, updated 5, new 2) | chunks: +38 −27 | total 410.
  • Идемпотентность: запускаем ingest.py два раза подряд — второй раз +0 −0, общее число чанков то же. Проверяем SQL-запросом: SELECT chunk_key, COUNT(*) FROM chunks GROUP BY 1 HAVING COUNT(*) > 1 — пусто.
  • Фильтр: python search.py "можно ли вернуть вскрытый чай" --category returns -k 5 возвращает 5 результатов, все из категории returns и ни одного из архивного returns-2025.md. Пишем тест в tests/test_store.py на :memory:-базе с фейковыми векторами (без сети): 50 чанков products, 2 чанка warranty, поиск с фильтром warranty возвращает именно эти 2; плюс архивный чанк, который находится только с include_archived=True.
  • Скорость: замеряем time.perf_counter() вокруг поиска (эмбеддинг запроса + SQL) — цель < 1 с. Отдельно записываем, сколько занимает эмбеддинг запроса, а сколько SQL, и отдельно — первый запрос (с загрузкой модели в Ollama) и последующие.
  • (Для сравнения, по желанию) Строим index_openai.db на облачных эмбеддингах OpenAI и сравниваем время индексации, стоимость (по usage) и результат sanity check на тех же 5 вопросах.
  • Sanity check на 5 вопросах из golden set (golden.jsonl из гл. 4.3, черновик — в notes/rag.md, гл. 4.2): нужный кусок в топ-5? Записываем результат — это будет базовая точка для гл. 5.1.
  • Индекс по всей базе знаний (30–50 страниц kb/, все категории) в одном index.db, с метаданными category, archived, url (kb/<file>.md#<anchor>), section.
  • Поиск с фильтром по категории работает и всегда отдаёт k результатов нужной категории; архивные страницы по умолчанию не попадают в выдачу; поиск отвечает быстрее секунды.
  • Повторный запуск загрузки не создаёт дубликатов; изменённая страница заменяет свои чанки.
  • Тест фильтра проходит офлайн; .env, index.db, raw/ не в git, kb/ — в git.

Вопрос 1

Зачем векторная БД, если numpy ищет по 5000 векторам за миллисекунды?

Ради хранения на диске, метаданных и фильтров, обновления отдельных документов, транзакций и масштабирования (ANN) — а не ради скорости на малом объёме.

Вопрос 2

Что такое HNSW и какова цена ANN-поиска?

Граф ближайших соседей в несколько уровней, по которому поиск идёт жадно. Цена — приближённость: recall меньше 100%, плюс память на граф и сложности с фильтрами.

Вопрос 3

В чём проблема post-filtering на примере «найти чанки только из категории warranty»?

Если сначала взять top-10 по всей базе, а потом отфильтровать warranty, среди десяти может не оказаться ни одного чанка про гарантию (все места займут многочисленные карточки товаров), хотя они есть в базе: результат пустой или неполный. Нужен фильтр внутри/до поиска или больший k.

Вопрос 4

В sqlite-vec результат KNN содержит distance. Как сортировать, чтобы самые похожие были первыми?

  • a) по убыванию
  • b) по возрастанию
  • c) неважно
  • d) по rowid

b) по возрастанию: это расстояние, меньше — ближе.

Вопрос 5

Как сделать загрузку идемпотентной?

Детерминированный ключ чанка из его содержимого (sha256(url + section + text)) с UNIQUE и ON CONFLICT DO NOTHING, плюс пропуск страниц с неизменным хэшем.

Вопрос 6

Что не так в этой функции генерации ключа чанка?

def chunk_key(chunk):
return f"{chunk['url']}-{uuid.uuid4()}"

uuid4() случаен: каждый запуск даёт новый ключ, и UNIQUE никогда не срабатывает — каждый прогон дублирует все чанки. Ключ должен зависеть только от содержимого.

Вопрос 7

Зачем хранить doc_hash страницы отдельно от chunk_key?

Чтобы целиком пропускать неизменённые страницы (без chunking и эмбеддингов) и понимать, что страница изменилась и её старые чанки надо удалить.

Вопрос 8

Когда стоит выбрать pgvector, а не sqlite-vec?

  • a) всегда
  • b) когда в проекте уже есть Postgres и нужны векторы рядом с бизнес-данными
  • c) когда векторов меньше 1000
  • d) когда нет интернета

b).

Вопрос 9

Почему у чанков страницы kb/returns.md нужна ссылка с якорем, а не просто путь к файлу?

returns.md — длинная страница со многими разделами; без якоря ссылка в ответе ведёт в начало файла, и проверить утверждение (например, про вскрытый чай) невозможно.

Вопрос 10

Почему удаление старых чанков и вставка новых для одной страницы должны идти в одной транзакции?

Иначе сбой посередине оставит страницу без чанков или с дублями старой и новой версии, а vec_chunks разойдётся с chunks (осиротевшие векторы).

Вопрос 11

Что вероятнее всего занимает больше времени в поиске на нашем индексе: SQL-запрос KNN или эмбеддинг вопроса через API?

Эмбеддинг вопроса через API (сетевой запрос, десятки-сотни мс); точный KNN по нескольким тысячам векторов в SQLite — миллисекунды. С локальной Ollama сети нет, и эмбеддинг загруженной моделью обычно быстрее облачного, но всё равно, как правило, дольше KNN — нужно измерить.

Вопрос 12

После перехода с облачных эмбеддингов на Ollama bge-m3 схема осталась embedding float[1536]. Что произойдёт и как правильно выбрать размерность?

Вставка упадёт с ошибкой несовпадения размерности: у bge-m3 1024 измерения. Размерность берут из реального вектора (len(embed(["test"])[0])), а при смене модели индекс перестраивают целиком.

Вопрос 13

Первый search.py после перерыва отвечает 3 секунды, следующие — 80 мс. Что происходит и как это исправить в Ollama?

Ollama выгрузила модель из памяти, и первый запрос включает её загрузку (видно по load_duration). Модель держат загруженной через keep_alive и прогревают одним запросом при старте; в замерах прогрев отделяют.

Вопрос 14

Почему для ingest.py в нашем стеке не нужен ключ DeepSeek?

  • a) DeepSeek бесплатный
  • b) у DeepSeek нет эндпоинта эмбеддингов, а индексации нужны только эмбеддинги — они локальные
  • c) ключ берётся из Ollama
  • d) DeepSeek сам строит индекс

b). Генерация в ingest.py не участвует, а эмбеддинги делает Ollama или Sentence Transformers локально.

Индекс готов, и у нас есть базовая точка качества. Неделя 5 начинается с 5.1 · Переформулировка запроса и метаданные: учим систему понимать вопросы так, как их задают клиенты, и автоматически ставить фильтр по категории.