4.4 · Векторная БД + загрузка базы знаний
Неделя 4 · RAG · ⏱ ~8 ч (2 дня) · Проект
Зачем это AI-инженеру
Заголовок раздела «Зачем это AI-инженеру»В гл. 4.3 стратегия нарезки определена на 10–20 страницах kb/, но каждый прогон заново резал и эмбеддил всё в памяти. Теперь игрушечный поиск на numpy из гл. 4.1 превращается в настоящий индекс: сотни и тысячи чанков, метаданные, фильтры, повторная загрузка без дублей. Ingestion-пайплайн — это 60% реальной работы над RAG в компаниях: источники меняются, ломаются, дублируются. На собеседовании спрашивают «как вы обновляете индекс», «как фильтруете по тенанту/источнику», «чем pgvector отличается от отдельной векторной БД» — после этой задачи у нас будут ответы из собственного опыта.
Что даёт векторная БД сверх numpy
Заголовок раздела «Что даёт векторная БД сверх numpy»Векторная БД — хранилище, которое держит векторы вместе с текстом и метаданными и умеет искать ближайшие к запросу. Сравним её с тем, что уже умеет numpy:
| Задача | numpy (гл. 4.1) | Векторная БД |
|---|---|---|
| Хранение | .npy в памяти |
На диске, транзакции |
| Текст и метаданные | Отдельный JSON рядом | В той же записи |
| Фильтр «только гарантия» | Руками маской | WHERE category = 'warranty' |
| Обновление одного документа | Пересобрать массив | DELETE + INSERT |
| Миллионы векторов | Медленно и не влезает в память | ANN-индексы (HNSW, IVF) |
На нашем масштабе (сотни — десятки тысяч чанков) точный перебор (brute force) занимает миллисекунды. Поэтому выбор хранилища здесь — вопрос удобства и знакомости, а не скорости.
ANN: приближённый поиск, коротко
Заголовок раздела «ANN: приближённый поиск, коротко»Нам 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, остальные — читаем для сравнения.
sqlite-vec: схема
Заголовок раздела «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 versionCREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0( embedding float[1024] -- bge-m3; must equal your model's dimension);Подключение из Python (иллюстративно, сверяем с README):
import sqlite3import 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, их в базе больше всего), и для гарантии получим ноль. Варианты:
- Метаданные/partition key прямо в
vec0— в свежих версиях sqlite-vec можно объявить колонки вродеcategory text(илиcategory text partition key) иarchived integerв виртуальной таблице и фильтровать по ним в том же KNN-запросе. Проверяем в документации своей версии. - Отдельная
vec0-таблица на категорию — грубо, но надёжно. - Большой k + фильтр + обрезка (взять k=200, отфильтровать, оставить 10) — простой компромисс на малом объёме.
- Точный поиск в numpy по подмножеству: вытащить векторы нужной категории и посчитать как в гл. 4.1 — на нашем объёме тоже вариант.
Выбираем 1, если поддерживается; иначе 3. Главное — тест: «поиск с фильтром warranty всегда возвращает k результатов из категории warranty, если они есть».
Второй фильтр — archived. Старая версия правил возврата (returns-2025.md, archived: true) по тексту почти совпадает с актуальной, и без фильтра поиск охотно поднимет устаревшие сроки. По умолчанию архив исключаем (archived = 0 тем же способом, что и категорию), включаем только явным флагом. Из индекса архив не удаляем: на нём позже проверяется, что retrieval не путает версии.
Ingestion-пайплайн
Заголовок раздела «Ingestion-пайплайн»Схема и поиск готовы; теперь — как наполнять индекс и обновлять его, когда база знаний меняется. Этим занимается 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: returnsupdated: 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 и Ollama: как это сделать у нас
Заголовок раздела «DeepSeek и Ollama: как это сделать у нас»Индексация — единственное место проекта, где модель вызывается сотни и тысячи раз подряд, и это только эмбеддинги. У DeepSeek их нет — это разобрано в главе 4.1 · Эмбеддинги руками на numpy. Поэтому весь ingestion работает локально и бесплатно: Ollama или Sentence Transformers. Облачный ключ для ingest.py не нужен вообще.
embed.py с выбором бэкенда
Заголовок раздела «embed.py с выбором бэкенда»# Illustrative: one interface, backend chosen by env vars (EMBED_BACKEND, EMBED_MODEL)import osimport numpy as np
BACKEND = os.environ.get("EMBED_BACKEND", "ollama") # ollama | st | openaiMODEL = 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-m31024 (по карточке 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/ в облако можно: база знаний публичная.
Маршрут изучения
Заголовок раздела «Маршрут изучения»- Открываем sqlite-vec → README целиком, затем по ссылке из README — документацию: установка для Python,
vec0, KNN-запросы, метаданные/partition keys, метрики расстояния (≈45 мин). Выносим точный синтаксис для своей версии и как в ней фильтровать по колонке в KNN-запросе. - Открываем pgvector → README: разделы про создание колонки
vector, операторы расстояния, HNSW и IVFFlat, «Filtering» (≈30 мин). Обратим внимание, как здесь описана проблема фильтрации с ANN-индексом и способы её решения — это то же, что в теории выше. - Открываем Qdrant → документация: Concepts → Collections, Points, Payload, Filtering, Indexing (≈30 мин). Выносим, чем payload-индекс помогает фильтрам и почему Qdrant фильтрует во время поиска по графу, а не после.
- Сверяем страницы
kb/из гл. 4.3 с макетом из «Практики»: front matter, явные якоря у заголовков, целые таблицы (≈20 мин). Для необязательного пункта открываем публичную страницу правил доставки реальной службы и в devtools находим контейнер текста и id заголовков (≈10 мин). - Документация конвертера HTML → Markdown, если делаем веб-страницу (
markdownifyилиhtml2textна PyPI, илиtrafilatura) — параметры для заголовков, таблиц и списков (≈15 мин). - Перечитываем справку 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. Как сортировать, чтобы самые похожие были первыми?
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?
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?
b). Генерация в ingest.py не участвует, а эмбеддинги делает Ollama или Sentence Transformers локально.
Что дальше
Заголовок раздела «Что дальше»Индекс готов, и у нас есть базовая точка качества. Неделя 5 начинается с 5.1 · Переформулировка запроса и метаданные: учим систему понимать вопросы так, как их задают клиенты, и автоматически ставить фильтр по категории.