Данные и governance
Качество LLM-продукта упирается в данные: что вы распарсили и проиндексировали, что вычистили, что разметили, что храните в логах и кто имеет к этому доступ. Разбираем ingestion документов с сохранением структуры, дедупликацию, PII-редакцию, версии датасетов и разметку, data flywheel, хранение диалогов по GDPR и 152-ФЗ, EU AI Act и документацию системы. Регуляторика здесь — на уровне инженерных решений: применимость к конкретной системе определяет юрист.
Ingestion: от PDF до чанка с метаданными
Ingestion (загрузка и подготовка данных) — всё, что происходит с документом до попадания его чанков в индекс. Многие «галлюцинации RAG» из модуля 5 начинаются здесь: таблица превратилась в кашу из чисел, скан стал пустой страницей, футер «Конфиденциально, стр. 3 из 40» попал в каждый чанк, старая версия политики лежит рядом с новой. Модель честно отвечает по тому, что ей дали.
Пайплайн: сбор (коннекторы к Confluence, Drive, S3, тикетам) → парсинг (текстовый слой, OCR или VLM) → нормализация в структуру (markdown или JSON с заголовками, таблицами, списками и порядком чтения) → очистка → PII-редакция → дедупликация → чанкинг по структуре → метаданные и ACL → эмбеддинги и индекс (модуль 5) → отчёт о качестве. Каждый шаг идемпотентен: повторный запуск на тех же байтах ничего не меняет.
Почему PDF — худший формат для RAG
PDF описывает страницу, а не документ: это глифы по координатам, без абзацев, порядка чтения и таблиц. Типовые поломки:
- Порядок чтения: две колонки читаются построчно поперёк, сноски вклиниваются в абзац.
- Таблицы: ячейки склеиваются в строку «План Срок Комиссия Месяц 3 дня 0%», связь «значение ↔ колонка» теряется, и вопрос «какая комиссия на годовом плане» уже не решается никаким промптом.
- Сканы: текстового слоя нет, парсер возвращает пустую строку, документ молча выпадает из индекса.
- Битые шрифты: без корректной таблицы ToUnicode извлекается мусор вроде «Ïîëèòèêà», хотя визуально PDF идеален.
- Колонтитулы и переносы: «стр. 3 из 40» в каждом чанке, «воз-\nврат» вместо «возврат» ломает и BM25, и эмбеддинги.
Подходы: качество, стоимость, скорость
| Подход | Что даёт | Стоимость и скорость | Где ломается |
|---|---|---|---|
| Текстовый слой (pypdf, pdfminer, PyMuPDF) | Текст без понимания структуры | Дёшево, быстро, без GPU | Таблицы, колонки, сканы, битые шрифты |
Layout-модели (Docling, Unstructured hi_res, MinerU, Marker) | Заголовки, списки, таблицы со структурой ячеек, порядок чтения, OCR растровых областей | На порядок медленнее текстового слоя, на CPU — секунды на страницу; модели в сотни МБ | Нестандартные макеты, «нарисованные» таблицы, рукописный текст |
| OCR (Tesseract; облачные AWS Textract, Google Document AI, Azure Document Intelligence, Mistral OCR) | Текст из сканов; облачные сервисы извлекают ещё таблицы и формы | Tesseract бесплатен, но чувствителен к качеству скана; облако — плата за страницу, документы уходят провайдеру | Путает 0/O, 1/l, кириллицу и латиницу; наклон и низкое разрешение |
| VLM-парсинг (Claude с PDF-блоками, document-VLM вроде granite-docling-258M) | Лучшее понимание сложных макетов, графиков, форм | Самый дорогой: у Claude страница PDF — это текст плюс изображение, тысячи токенов (модуль 2) | Модель может «додумать» цифру в таблице, и эту галлюцинацию не отличить от правды |
Рабочая схема для большого корпуса — маршрутизация по страницам. Страница с нормальным текстовым слоем идёт в layout-парсер. Страница без текста или с мусором (мало букв, много символов вне алфавита) — в OCR. Сложная таблица или график, где важны цифры, — в VLM со сверкой по текстовому слою, если он есть. Когда VLM-парсинг лучше OCR-пайплайна — модуль 16 (факультатив).
Инструменты (по состоянию на октябрь 2026)
| Инструмент | Что делает | Лицензия и особенности |
|---|---|---|
| Docling 2.135 | Layout-модели, таблицы (TableFormer), OCR (RapidOCR, EasyOCR, Tesseract), экспорт в markdown и JSON; проект LF AI & Data | MIT для кода, у моделей свои лицензии. Тянет torch и скачивает модели при первом запуске |
| Unstructured 0.27 | partition_* для десятков форматов, стратегии fast, hi_res, ocr_only, auto | Apache-2.0. VLM-разбор и продвинутый чанкинг — только в коммерческой платформе |
| pymupdf4llm 1.28 | Быстрый PDF → markdown без torch, layout на onnxruntime | AGPL-3.0 или коммерческая лицензия Artifex: AGPL в сетевом сервисе обязывает открыть его исходники |
| Marker 2.0 | PDF → markdown или JSON, опционально с LLM | Код Apache-2.0 (до 2.0 — GPL-3.0); веса под модифицированной OpenRAIL-M, коммерческое использование выше порога выручки платное |
| MinerU 4.0 | PDF → markdown или JSON, силён на статьях и формулах | «MinerU Open Source License» на базе Apache-2.0 с условиями (до весны 2026 — AGPL-3.0) |
| Tesseract 5.5 | Классический OCR, есть русская модель | Apache-2.0 |
Таблицы в индексе
Маленькую таблицу кладите целиком одним чанком в markdown. Большую — группами строк с шапкой в каждом чанке или построчно в виде «План: Год; Срок на возврат: 14 дней; Комиссия: 0%». Справочник с точными значениями (прайс, коды ошибок) лучше положить в SQL и дать модели инструмент поиска (модуль 6): точный ответ не должен зависеть от удачи retrieval.
Парсинг на Python: Docling
Парсеры живут в Python, поэтому это обычно отдельный воркер: GoJava-сервис кладёт файл в очередь, Python-воркер возвращает markdown и отчёт. Скрипт генерирует тестовый PDF с заголовками, списком и таблицей, разбирает его и проверяет, что структура пережила парсинг.
"""PDF → markdown + таблицы + отчёт о парсинге через Docling (модуль 11, урок «Ingestion»).
Запуск без аргументов генерирует тестовый sample.pdf (заголовки, список, таблица) и разбирает его:
uv run --with docling==2.135.0 --with pymupdf==1.28.2 python pdf_to_md.py
Свой файл:
uv run --with docling==2.135.0 python pdf_to_md.py path/to/file.pdf
Первый запуск ставит torch и скачивает модели layout и TableFormer с Hugging Face (сотни МБ, минуты).
PyMuPDF нужен только для генерации тестового PDF; его лицензия AGPL-3.0 — см. урок, прежде чем брать его в продукт.
"""
import hashlib
import json
import sys
import time
from pathlib import Path
# SAMPLE_HTML и make_sample() — генерация тестового PDF через PyMuPDF, см. файл в examples/m11-data
def parse(path: Path) -> tuple[str, dict]:
from docling.document_converter import DocumentConverter
t0 = time.perf_counter()
res = DocumentConverter().convert(path) # OCR растровых областей включён по умолчанию
doc = res.document
md = doc.export_to_markdown()
pages = doc.num_pages()
report = {
"file": path.name,
"sha256": hashlib.sha256(path.read_bytes()).hexdigest()[:16], # версия источника
"status": str(res.status),
"pages": pages,
"chars_per_page": round(len(md) / max(pages, 1)),
"headings": [t.text for t in doc.texts if t.label == "section_header"],
"tables": [t.export_to_dataframe(doc=doc).to_dict(orient="records") for t in doc.tables],
"seconds": round(time.perf_counter() - t0, 1),
}
# Дешёвые проверки качества: пустой текст почти всегда значит скан без OCR или битые шрифты.
report["warnings"] = [w for w, bad in [
("мало текста на страницу: скан или битый ToUnicode", report["chars_per_page"] < 200),
("нет заголовков: структура потеряна, чанкинг по секциям не сработает", not report["headings"]),
] if bad]
return md, report
if __name__ == "__main__":
src = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).with_name("sample.pdf")
if len(sys.argv) == 1:
make_sample(src)
md, report = parse(src)
print(md)
print(json.dumps(report, ensure_ascii=False, indent=2))
if len(sys.argv) == 1: # самопроверка на тестовом PDF
assert "Политика возвратов" in report["headings"], report["headings"]
assert len(report["tables"]) == 1 and len(report["tables"][0]) == 2, report["tables"]
assert "| Год" in md, "таблица должна остаться таблицей в markdown"
print("OK: заголовки и таблица сохранены")
Чанкинг по структуре
Стратегии чанкинга и подбор размера по recall@k — в модуле 5, там же ChunkSection для прозы. Здесь недостающий кусок для проекта A: нарезать markdown после парсера по заголовкам так, чтобы путь заголовков ехал с каждым чанком, а код-блоки и таблицы не резались. Только стандартная библиотека.
/** Markdown-сплиттер модуля 11: секции по заголовкам, блоки не режутся. */
public final class MdSplit {
private MdSplit() {}
/**
* Кусок markdown с путём заголовков. Эмбеддим heading + "\n\n" + text:
* путь заголовков даёт чанку контекст, которого нет в самом тексте.
*/
public record Chunk(String heading, String text) {}
/**
* Режет markdown по ATX-заголовкам (# … ######), затем пакует блоки секции (абзацы,
* списки, таблицы, код) в чанки до maxChars символов. Блок не режется никогда:
* код-блок и таблица уходят целиком, даже если длиннее maxChars.
* ponytail: блок длиннее maxChars остаётся одним чанком; прозу дорезайте chunkSection из модуля 5.
*/
public static List<Chunk> splitMarkdown(String md, int maxChars) {
return new Splitter(maxChars).run(md);
}
private static final class Splitter {
final int max;
final List<Chunk> out = new ArrayList<>();
final List<String> path = new ArrayList<>(); // текущий путь заголовков по уровням
final List<String> blocks = new ArrayList<>(); // блоки текущей секции
final List<String> cur = new ArrayList<>(); // строки текущего блока
String fence = ""; // открытый ``` или ~~~
Splitter(int max) { this.max = max; }
void endBlock() {
String t = String.join("\n", cur).strip();
if (!t.isEmpty()) blocks.add(t);
cur.clear();
}
void endSection() {
endBlock();
String heading = String.join(" › ", path.stream().filter(s -> !s.isEmpty()).toList());
List<String> buf = new ArrayList<>();
int size = 0;
for (String b : blocks) {
int n = b.codePointCount(0, b.length());
if (size > 0 && size + n > max) {
out.add(new Chunk(heading, String.join("\n\n", buf)));
buf.clear();
size = 0;
}
buf.add(b);
size += n;
}
if (!buf.isEmpty()) out.add(new Chunk(heading, String.join("\n\n", buf)));
blocks.clear();
}
List<Chunk> run(String md) {
for (String line : md.split("\n", -1)) {
String trim = line.strip();
int lvl;
if (!fence.isEmpty()) { // внутри код-блока: ни заголовков, ни разрывов
cur.add(line);
if (trim.startsWith(fence)) {
fence = "";
endBlock();
}
} else if (trim.startsWith("```") || trim.startsWith("~~~")) {
endBlock();
fence = trim.substring(0, 3);
cur.add(line);
} else if ((lvl = headingLevel(trim)) > 0) {
endSection();
while (path.size() < lvl) path.add("");
path.subList(lvl - 1, path.size()).clear();
path.add(trim.substring(lvl).strip());
} else if (trim.isEmpty()) {
endBlock();
} else {
cur.add(line);
}
}
endSection();
return out;
}
}
/** "## Title" → 2; "#hashtag" и "####### x" — не заголовки. */
static int headingLevel(String s) {
int n = 0;
while (n < s.length() && s.charAt(n) == '#') n++;
if (n == 0 || n > 6 || (n < s.length() && s.charAt(n) != ' ')) return 0;
return n;
}
}
// Chunk — кусок markdown с путём заголовков. Эмбеддим Heading + "\n\n" + Text:
// путь заголовков даёт чанку контекст, которого нет в самом тексте.
type Chunk struct {
Heading string // "Тарифы › Возвраты › Сроки"
Text string
}
// SplitMarkdown режет markdown по ATX-заголовкам (# … ######), затем пакует блоки
// секции (абзацы, списки, таблицы, код) в чанки до maxRunes. Блок не режется никогда:
// код-блок и таблица уходят целиком, даже если длиннее maxRunes.
// ponytail: блок длиннее maxRunes остаётся одним чанком; прозу дорезайте ChunkSection из модуля 5.
func SplitMarkdown(md string, maxRunes int) []Chunk {
var (
out []Chunk
path []string // текущий путь заголовков по уровням
blocks []string // блоки текущей секции
cur []string // строки текущего блока
fence string // открытый ``` или ~~~
)
endBlock := func() {
if t := strings.TrimSpace(strings.Join(cur, "\n")); t != "" {
blocks = append(blocks, t)
}
cur = nil
}
endSection := func() {
endBlock()
heading := strings.Join(nonEmpty(path), " › ")
var buf []string
size := 0
for _, b := range blocks {
n := utf8.RuneCountInString(b)
if size > 0 && size+n > maxRunes {
out = append(out, Chunk{heading, strings.Join(buf, "\n\n")})
buf, size = nil, 0
}
buf = append(buf, b)
size += n
}
if len(buf) > 0 {
out = append(out, Chunk{heading, strings.Join(buf, "\n\n")})
}
blocks = nil
}
for _, line := range strings.Split(md, "\n") {
trim := strings.TrimSpace(line)
switch {
case fence != "": // внутри код-блока: ни заголовков, ни разрывов
cur = append(cur, line)
if strings.HasPrefix(trim, fence) {
fence = ""
endBlock()
}
case strings.HasPrefix(trim, "```") || strings.HasPrefix(trim, "~~~"):
endBlock()
fence = trim[:3]
cur = append(cur, line)
case headingLevel(trim) > 0:
endSection()
lvl := headingLevel(trim)
for len(path) < lvl {
path = append(path, "")
}
path = append(path[:lvl-1], strings.TrimSpace(trim[lvl:]))
case trim == "":
endBlock()
default:
cur = append(cur, line)
}
}
endSection()
return out
}
// headingLevel: "## Title" → 2; "#hashtag" и "####### x" — не заголовки.
func headingLevel(s string) int {
n := 0
for n < len(s) && s[n] == '#' {
n++
}
if n == 0 || n > 6 || (n < len(s) && s[n] != ' ') {
return 0
}
return n
}
func nonEmpty(ss []string) []string {
var out []string
for _, s := range ss {
if s != "" {
out = append(out, s)
}
}
return out
}
objectMapper.readValue(json, Invoice.class) в типизированный record вместо регулярок по сырой строке. А документ, который парсер вернул пустым, — проглоченное исключение в catch (Exception e) {}: пайплайн «успешно» отработал, данных нет. Пустой результат парсинга должен быть исключением с алертом, а не пустым списком чанков.json.Unmarshal в типизированный struct вместо регулярок по сырым байтам. А документ, который парсер вернул пустым, — проглоченный err: пайплайн «успешно» отработал, данных нет. Пустой результат парсинга должен быть ошибкой с алертом, а не пустым списком чанков.Метаданные и ACL
Чанк без метаданных нельзя отфильтровать, обновить, удалить по запросу и процитировать. Минимум поверх таблицы chunks из модуля 5 (там уже есть tenant_id, acl, embed_model):
ALTER TABLE chunks
ADD COLUMN source_uri text NOT NULL DEFAULT '', -- откуда: для цитаты и удаления
ADD COLUMN doc_version text NOT NULL DEFAULT '', -- etag или ревизия источника
ADD COLUMN content_hash text NOT NULL DEFAULT '', -- sha256 нормализованного текста: дедуп и «не эмбеддить повторно»
ADD COLUMN pages int4range, -- страницы PDF для цитаты «стр. 12–13»
ADD COLUMN lang text NOT NULL DEFAULT 'ru',
ADD COLUMN parser text NOT NULL DEFAULT '', -- "docling 2.135.0": сменили парсер — пересобрали индекс
ADD COLUMN is_current boolean NOT NULL DEFAULT true, -- старые версии документа не ищутся
ADD COLUMN data_class text NOT NULL DEFAULT 'internal'; -- public / internal / confidential
-- ACL проверяется в запросе retrieval, а не в промпте.
-- $1 — эмбеддинг запроса, $2 — tenant, $3 — группы пользователя из IdP.
SELECT id, heading, content, source_uri
FROM chunks
WHERE tenant_id = $2 AND acl && $3 AND is_current
ORDER BY embedding <=> $1
LIMIT 20;
- ACL копируется из источника (группы пространства Confluence или папки Drive). Права меняются чаще текста, поэтому их синхронизация — отдельная, более частая задача.
- Deny by default: документ, права которого не прочитались, не индексируется. Проблемы фильтрованного ANN (post-filtering, iterative scan, партиции) — в модуле 5.
Качество данных и дедупликация
Дубли в RAG-индексе занимают места в top-k (пять копий абзаца вытесняют четыре полезных чанка), смещают ответ к версии, у которой больше копий (часто к старой), и стоят денег на эмбеддинги. В датасетах для evals и обучения дубли дают утечку теста в трейн — это и групповое разбиение разобраны в модуле 10. Здесь фокус на корпусе для RAG.
| Уровень | Как ищем | Что ловит | Подвох |
|---|---|---|---|
| Точные | sha256 нормализованного текста | Копии файлов, повторные выгрузки, чанки-колонтитулы | Изменённая дата — уже другой хэш |
| MinHash + LSH | Сходство Жаккара по шинглам; LSH-корзины из b полос по r хэшей | Версии документа, шаблонные письма, копипаста с правками | Порог задаётся через b и r: перегиб ≈ (1/b)1/r; память на сигнатуры |
| SimHash | 64-битный отпечаток, расстояние Хэмминга ≤ k | То же, 8 байт на документ | Нестабилен на коротких текстах; k = 3 подобран для веб-страниц |
| Семантические | Косинус эмбеддингов > 0.95 | Пересказ тем же смыслом | Дорого; склеивает близкие, но разные факты («возврат за месяц» и «за год») |
SimHash (Charikar, 2002): каждый шингл хэшируется в 64 бита, по каждому биту голосуют все шинглы (+1 за единицу, −1 за ноль), знак суммы даёт бит отпечатка. У похожих текстов почти все шинглы общие, и голосование почти совпадает. Чтобы не сравнивать документ со всем корпусом, Manku и соавторы (Google, 2007) режут отпечаток на блоки: при расстоянии ≤ 3 хотя бы один из четырёх 16-битных блоков совпадает целиком, поэтому сравниваем только с кандидатами из тех же корзин. В их работе на 8 млрд веб-страниц разумными оказались 64 бита и k = 3.
/**
* Ищет точные (sha256 нормализованного текста) и почти-дубли (SimHash ≤ maxDist бит).
* Индекс — 4 таблицы по 16-битным блокам отпечатка: при расстоянии ≤ 3 хотя бы один блок
* совпадает целиком (принцип Дирихле), поэтому сравниваем только с кандидатами из корзин,
* а не со всем корпусом (Manku et al., 2007).
*/
public final class Dedup {
/** Найденный дубль: id и расстояние Хэмминга (0 — точная копия). */
public record Match(String dupOf, int dist) {}
private record Entry(String id, long hash) {}
private final int maxDist; // 3 для 64 бит — значение из статьи Manku et al.; подбирайте на своей разметке
private final Map<String, String> exact = new HashMap<>();
@SuppressWarnings("unchecked")
private final Map<Integer, List<Entry>>[] bands = new Map[4];
public Dedup(int maxDist) {
this.maxDist = maxDist;
for (int i = 0; i < bands.length; i++) bands[i] = new HashMap<>();
}
/**
* Общий шаг для точных и нечётких дублей: нижний регистр, только буквы и цифры,
* пробелы схлопнуты. Даты, id и подписи вырезайте до этого.
*/
public static List<String> normalize(String s) {
List<String> out = new ArrayList<>();
StringBuilder w = new StringBuilder();
s.toLowerCase(Locale.ROOT).codePoints().forEach(cp -> {
if (Character.isLetter(cp) || Character.isDigit(cp)) {
w.appendCodePoint(cp);
} else if (!w.isEmpty()) {
out.add(w.toString());
w.setLength(0);
}
});
if (!w.isEmpty()) out.add(w.toString());
return out;
}
/** 64-битный отпечаток по шинглам из 3 слов (Charikar, 2002). */
public static long simHash(List<String> words) {
final int k = 3;
int[] acc = new int[64];
List<String> shingles = new ArrayList<>();
if (words.size() < k) shingles.add(String.join(" ", words));
for (int i = 0; i + k <= words.size(); i++) shingles.add(String.join(" ", words.subList(i, i + k)));
for (String sh : shingles) {
long x = fnv64a(sh.getBytes(StandardCharsets.UTF_8));
for (int i = 0; i < 64; i++) acc[i] += ((x >>> i) & 1) != 0 ? 1 : -1;
}
long out = 0;
for (int i = 0; i < 64; i++) if (acc[i] > 0) out |= 1L << i;
return out;
}
public static int hamming(long a, long b) { return Long.bitCount(a ^ b); }
/**
* Возвращает найденный дубль или null; если дубля нет, документ добавляется в индекс.
* ponytail: корректно только для maxDist ≤ 3 (4 блока); для большего порога — больше таблиц с перестановками битов.
*/
public Match add(String id, String text) {
List<String> words = normalize(text);
String key = sha256Hex(String.join(" ", words));
String prev = exact.get(key);
if (prev != null) return new Match(prev, 0);
long h = simHash(words);
for (int i = 0; i < bands.length; i++) {
for (Entry e : bands[i].getOrDefault(band(h, i), List.of())) {
int dist = hamming(h, e.hash());
if (dist <= maxDist) return new Match(e.id(), dist);
}
}
exact.put(key, id);
for (int i = 0; i < bands.length; i++) {
bands[i].computeIfAbsent(band(h, i), b -> new ArrayList<>()).add(new Entry(id, h));
}
return null;
}
private static int band(long h, int i) { return (int) ((h >>> (16 * i)) & 0xFFFF); }
private static long fnv64a(byte[] data) {
long h = 0xcbf29ce484222325L;
for (byte b : data) {
h ^= b & 0xff;
h *= 0x100000001b3L;
}
return h;
}
private static String sha256Hex(String s) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
// Normalize — общий шаг для точных и нечётких дублей: нижний регистр,
// только буквы и цифры, пробелы схлопнуты. Даты, id и подписи вырезайте до этого.
func Normalize(s string) []string {
return strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
}
// SimHash — 64-битный отпечаток по шинглам из 3 слов (Charikar, 2002).
// Похожие тексты дают отпечатки с малым расстоянием Хэмминга.
func SimHash(words []string) uint64 {
const k = 3
var acc [64]int
add := func(shingle string) {
h := fnv.New64a()
h.Write([]byte(shingle))
x := h.Sum64()
for i := 0; i < 64; i++ {
if x&(1<<i) != 0 {
acc[i]++
} else {
acc[i]--
}
}
}
if len(words) < k {
add(strings.Join(words, " "))
}
for i := 0; i+k <= len(words); i++ {
add(strings.Join(words[i:i+k], " "))
}
var out uint64
for i, v := range acc {
if v > 0 {
out |= 1 << i
}
}
return out
}
func Hamming(a, b uint64) int { return bits.OnesCount64(a ^ b) }
// Dedup ищет точные (sha256 нормализованного текста) и почти-дубли (SimHash ≤ MaxDist бит).
// Индекс — 4 таблицы по 16-битным блокам отпечатка: при расстоянии ≤ 3 хотя бы один блок
// совпадает целиком (принцип Дирихле), поэтому сравниваем только с кандидатами из корзин,
// а не со всем корпусом (Manku et al., 2007).
type Dedup struct {
MaxDist int // 3 для 64 бит — значение из статьи Manku et al.; подбирайте на своей разметке
exact map[[32]byte]string
bands [4]map[uint16][]entry
}
type entry struct {
id string
hash uint64
}
func NewDedup(maxDist int) *Dedup {
d := &Dedup{MaxDist: maxDist, exact: map[[32]byte]string{}}
for i := range d.bands {
d.bands[i] = map[uint16][]entry{}
}
return d
}
// Add возвращает id найденного дубля и расстояние (0 — точная копия).
// Если дубля нет, документ добавляется в индекс и возвращается "".
// ponytail: корректно только для MaxDist ≤ 3 (4 блока); для большего порога — больше таблиц с перестановками битов.
func (d *Dedup) Add(id, text string) (dupOf string, dist int) {
words := Normalize(text)
key := sha256.Sum256([]byte(strings.Join(words, " ")))
if prev, ok := d.exact[key]; ok {
return prev, 0
}
h := SimHash(words)
for i := range d.bands {
for _, e := range d.bands[i][uint16(h>>(16*i))] {
if dist := Hamming(h, e.hash); dist <= d.MaxDist {
return e.id, dist
}
}
}
d.exact[key] = id
for i := range d.bands {
b := uint16(h >> (16 * i))
d.bands[i][b] = append(d.bands[i][b], entry{id, h})
}
return "", 0
}
В тесте examples/m11-dataexamples/java/m11-data замена одного слова в абзаце из ~60 слов дала расстояние ровно 3, то есть на самом пороге. Порог подбирают на своей разметке (100–200 пар «дубль / не дубль»), а не берут из статьи. Для Python есть MinHash LSH в библиотеке datasketch.
(tenant_id, acl, content).Шум, языки, устаревшие версии
- Boilerplate (колонтитулы, меню и cookie-баннеры из HTML, подписи писем) лучше всего находится по частоте: строка, которая встречается на 30% страниц документа или в 1000 писем, — шаблон. Удаляйте до чанкинга. Мусорные чанки (слишком короткие, без букв, mojibake) отсекаются простыми эвристиками.
- Язык чанка (fastText
lid.176, lingua) пишите в метаданные вместе с уверенностью: от него зависят конфигурация полнотекстового поиска ('russian'или'english'), выбор эмбеддинг-модели и фильтр по языку пользователя. - Устаревшие версии — главный источник ответов «по прошлогодней политике». Новая ревизия документа ставит старой
is_current = falseв той же транзакции; удалённый в источнике документ удаляется из индекса (tombstone), иначе данные, удалённые по требованию, продолжают жить в индексе; дата обновления едет в<source>промпта (модуль 3). Почти-дубли с разными id («копия политики в чужом пространстве») показывайте владельцу корпуса, а не удаляйте автоматически.
PII-редакция
PII (personally identifiable information, персональные данные) приходит в LLM-продукт тремя путями: в документах корпуса (HR-политики, договоры, тикеты), в сообщениях пользователей (в чат вставляют всё: номер карты, паспорт, переписку) и в ответах модели, которая пересказывает контекст. Редактировать нужно там, где данные пересекают границу: индекс, логи, внешний провайдер, разметчики, обучающий датасет.
- Прямые идентификаторы: ФИО, телефон, email, карта, паспорт, СНИЛС, ИНН, адрес.
- Косвенные: дата рождения, город, должность, «дочь учится в 5 «Б» школы № 1234». По отдельности безобидны, вместе указывают на человека: по работе Суини (2000), почтовый индекс, пол и дата рождения уникально определяют 87% жителей США.
- Особые категории (здоровье, биометрия, убеждения) — по GDPR и 152-ФЗ под более жёсткими правилами.
Детекторы
| Детектор | Ловит | Плюсы | Минусы |
|---|---|---|---|
| Регэкспы + контрольные суммы | Карты (алгоритм Луна), ИНН, СНИЛС, телефоны, email, паспорт | Детерминированно, микросекунды, легко тестировать | Не видят имён и адресов; паспорт и телефон — только по формату |
| NER (Presidio, Natasha, spaCy) | Имена, организации, адреса, даты | Видит то, у чего нет формата | Зависит от языка и домена; на чатах без заглавных букв и с опечатками заметно хуже |
| LLM-детектор | Косвенные идентификаторы, PII словами («восемь девятьсот…») | Понимает контекст | Дорого и недетерминированно; слать PII внешнему провайдеру, чтобы найти PII, — порочный круг, годится self-hosted модель (модуль 10) |
Presidio: presidio-analyzer находит сущности (регэкспы с контекстными словами, валидаторы, NER через spaCy, Stanza или transformers), presidio-anonymizer применяет операторы replace, redact, mask, hash, encrypt; обратим только encrypt (через decrypt). Лицензия MIT. Проект перешёл от Microsoft к независимому сообществу (GitHub-организация data-privacy-stack), API не изменился. Из коробки Presidio настроен на английский: встроенных распознавателей российских документов нет. Для русского нужны NER-модель (spaCy, Stanza или transformers через NlpEngineProvider), свои распознаватели ИНН и СНИЛС и русские контекстные слова. Для русского NER есть и отдельные библиотеки, например Natasha.
Код: детекторы, контрольные суммы, псевдонимы
Пересечения решаются приоритетом: сначала то, что подтверждено контрольной суммой, потом форматные детекторы. Так СНИЛС без дефисов не станет «телефоном». Это развитие MaskPII из модуля 9, где регэксп карты маскирует любые 13–19 цифр подряд, включая номера заказов.
/** Найденный фрагмент PII: границы (индексы char) в исходном тексте. */
public record Finding(String kind, int start, int end, String value) {}
/** valid == null — проверки нет, только формат. */
private record Detector(String kind, Pattern re, Predicate<String> valid) {}
// Порядок = приоритет при пересечениях: сначала то, что подтверждено контрольной суммой.
private static final List<Detector> DETECTORS = List.of(
new Detector("CARD", Pattern.compile("\\b(?:\\d{4}[ -]){3}\\d{4}\\b|\\b\\d{13,19}\\b"), Pii::luhn),
new Detector("SNILS", Pattern.compile("\\b\\d{3}-?\\d{3}-?\\d{3}[ -]?\\d{2}\\b"), Pii::snils),
new Detector("INN", Pattern.compile("\\b\\d{12}\\b|\\b\\d{10}\\b"), Pii::inn),
new Detector("EMAIL", Pattern.compile("[\\w.+-]+@[\\w-]+(?:\\.[\\w-]+)+"), null),
new Detector("PHONE", Pattern.compile("(?:\\+7|\\b8)[ (-]*\\d{3}[ )-]*\\d{3}[ -]?\\d{2}[ -]?\\d{2}\\b"), null),
// Паспорт РФ: 4 цифры серии + 6 цифр номера. Контрольной суммы нет — без пробела
// перед номером не ловим, иначе каждое 10-значное число станет «паспортом».
new Detector("PASSPORT", Pattern.compile("\\b\\d{2} ?\\d{2} \\d{6}\\b"), null));
/** Непересекающиеся находки, отсортированные по позиции. */
public static List<Finding> find(String text) {
List<Finding> all = new ArrayList<>();
for (Detector d : DETECTORS) {
Matcher m = d.re().matcher(text);
while (m.find()) {
String v = m.group();
if (d.valid() != null && !d.valid().test(digitsOnly(v))) continue;
all.add(new Finding(d.kind(), m.start(), m.end(), v));
}
}
// all уже упорядочен по приоритету детектора; берём находку, если она не пересекается с принятыми.
// ponytail: O(n²) по числу находок — для сообщений и документов хватает, для гигабайтных дампов нужен interval tree.
List<Finding> out = new ArrayList<>();
for (Finding f : all) {
if (out.stream().noneMatch(o -> f.start() < o.end() && o.start() < f.end())) out.add(f);
}
out.sort(Comparator.comparingInt(Finding::start));
return out;
}// Finding — найденный фрагмент PII: байтовые границы в исходном тексте.
type Finding struct {
Kind string // EMAIL, PHONE, CARD, INN, SNILS, PASSPORT
Start, End int
Value string
}
type detector struct {
kind string
re *regexp.Regexp
valid func(digits string) bool // nil — проверки нет, только формат
}
// Порядок = приоритет при пересечениях: сначала то, что подтверждено контрольной суммой.
var detectors = []detector{
{"CARD", regexp.MustCompile(`\b(?:\d{4}[ -]){3}\d{4}\b|\b\d{13,19}\b`), luhn},
{"SNILS", regexp.MustCompile(`\b\d{3}-?\d{3}-?\d{3}[ -]?\d{2}\b`), snils},
{"INN", regexp.MustCompile(`\b\d{12}\b|\b\d{10}\b`), inn},
{"EMAIL", regexp.MustCompile(`[\w.+-]+@[\w-]+(?:\.[\w-]+)+`), nil},
{"PHONE", regexp.MustCompile(`(?:\+7|\b8)[ (-]*\d{3}[ )-]*\d{3}[ -]?\d{2}[ -]?\d{2}\b`), nil},
// Паспорт РФ: 4 цифры серии + 6 цифр номера. Контрольной суммы нет — без пробела
// перед номером не ловим, иначе каждое 10-значное число станет «паспортом».
{"PASSPORT", regexp.MustCompile(`\b\d{2} ?\d{2} \d{6}\b`), nil},
}
// Find возвращает непересекающиеся находки, отсортированные по позиции.
func Find(text string) []Finding {
var all []Finding
for _, d := range detectors {
for _, m := range d.re.FindAllStringIndex(text, -1) {
v := text[m[0]:m[1]]
if d.valid != nil && !d.valid(digitsOnly(v)) {
continue
}
all = append(all, Finding{Kind: d.kind, Start: m[0], End: m[1], Value: v})
}
}
// all уже упорядочен по приоритету детектора; берём находку, если она не пересекается с принятыми.
// ponytail: O(n²) по числу находок — для сообщений и документов хватает, для гигабайтных дампов нужен interval tree.
var out []Finding
for _, f := range all {
overlap := false
for _, o := range out {
if f.Start < o.End && o.Start < f.End {
overlap = true
break
}
}
if !overlap {
out = append(out, f)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Start < out[j].Start })
return out
}static String digitsOnly(String s) {
StringBuilder b = new StringBuilder();
for (char c : s.toCharArray()) if (c >= '0' && c <= '9') b.append(c);
return b.toString();
}
/** Контрольная сумма номеров карт (ISO/IEC 7812). */
static boolean luhn(String d) {
int sum = 0;
for (int i = d.length() - 1; i >= 0; i--) {
int n = d.charAt(i) - '0';
if ((d.length() - 1 - i) % 2 == 1) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
}
return sum % 10 == 0;
}
/** Контрольные цифры ИНН: 10 знаков (организация) или 12 (физлицо, ИП). */
static boolean inn(String d) {
return switch (d.length()) {
case 10 -> innCheck(d, 2, 4, 10, 3, 5, 9, 4, 6, 8) == d.charAt(9) - '0';
case 12 -> innCheck(d, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8) == d.charAt(10) - '0'
&& innCheck(d, 3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8) == d.charAt(11) - '0';
default -> false;
};
}
private static int innCheck(String d, int... w) {
int s = 0;
for (int i = 0; i < w.length; i++) s += w[i] * (d.charAt(i) - '0');
return s % 11 % 10;
}
/**
* Контрольное число СНИЛС: веса 9…1 по первым 9 цифрам, затем mod 101 (100 и 101 → 00).
* Номера не больше 001-001-998 не проверяются — для детектора считаем их невалидными.
*/
static boolean snils(String d) {
if (d.length() != 11 || d.substring(0, 9).compareTo("001001998") <= 0) return false;
int s = 0;
for (int i = 0; i < 9; i++) s += (d.charAt(i) - '0') * (9 - i);
s %= 101;
if (s == 100) s = 0;
return s == (d.charAt(9) - '0') * 10 + (d.charAt(10) - '0');
}func digitsOnly(s string) string {
return strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
return -1
}, s)
}
// luhn — контрольная сумма номеров карт (ISO/IEC 7812).
func luhn(d string) bool {
sum := 0
for i := len(d) - 1; i >= 0; i-- {
n := int(d[i] - '0')
if (len(d)-1-i)%2 == 1 {
n *= 2
if n > 9 {
n -= 9
}
}
sum += n
}
return sum%10 == 0
}
// inn — контрольные цифры ИНН: 10 знаков (организация) или 12 (физлицо, ИП).
func inn(d string) bool {
check := func(w []int) int {
s := 0
for i, k := range w {
s += k * int(d[i]-'0')
}
return s % 11 % 10
}
switch len(d) {
case 10:
return check([]int{2, 4, 10, 3, 5, 9, 4, 6, 8}) == int(d[9]-'0')
case 12:
return check([]int{7, 2, 4, 10, 3, 5, 9, 4, 6, 8}) == int(d[10]-'0') &&
check([]int{3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8}) == int(d[11]-'0')
}
return false
}
// snils — контрольное число СНИЛС: веса 9…1 по первым 9 цифрам, затем mod 101 (100 и 101 → 00).
// Номера не больше 001-001-998 не проверяются — для детектора считаем их невалидными.
func snils(d string) bool {
if len(d) != 11 || d[:9] <= "001001998" {
return false
}
s := 0
for i := 0; i < 9; i++ {
s += int(d[i]-'0') * (9 - i)
}
s %= 101
if s == 100 {
s = 0
}
return s == int(d[9]-'0')*10+int(d[10]-'0')
}/** Текст без PII и словарь токен → исходное значение. */
public record Redacted(String text, Map<String, String> vault) {}
/**
* Заменяет PII. key == null — маскирование ([CARD]); иначе — псевдонимизация:
* [CARD:3f9a1c2b] = HMAC(key, тип+нормализованное значение). Одно и то же значение даёт
* один и тот же токен во всех документах — можно считать, джойнить и искать по нему, не видя PII.
* Словарь (vault) нужен только для обратной подстановки: храните его отдельно,
* с отдельными правами и сроком жизни, или не храните вовсе, если восстановление не нужно.
*/
public record Redactor(byte[] key) {
public Redacted redact(String text) {
StringBuilder b = new StringBuilder();
Map<String, String> vault = new HashMap<>();
int last = 0;
for (Finding f : find(text)) {
b.append(text, last, f.start());
String tok = "[" + f.kind() + "]";
if (key != null) {
tok = "[" + f.kind() + ":" + pseudonym(f) + "]";
vault.put(tok, f.value());
}
b.append(tok);
last = f.end();
}
b.append(text.substring(last));
return new Redacted(b.toString(), vault);
}
private String pseudonym(Finding f) {
String norm = f.value().toLowerCase(Locale.ROOT);
if (!f.kind().equals("EMAIL")) {
norm = digitsOnly(f.value());
if (f.kind().equals("PHONE") && norm.startsWith("8")) {
norm = "7" + norm.substring(1); // 8 912… и +7 912… — один и тот же номер
}
}
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
byte[] sum = mac.doFinal((f.kind() + ":" + norm).getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(sum, 0, 4); // 32 бита: читаемо; для больших баз берите 8+ байт
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
}
/** Обратная подстановка, например в ответе модели перед показом пользователю. */
public static String restore(String text, Map<String, String> vault) {
for (var e : vault.entrySet()) text = text.replace(e.getKey(), e.getValue());
return text;
}// Redactor заменяет PII. Key == nil — маскирование ([CARD]); иначе — псевдонимизация:
// [CARD:3f9a1c2b] = HMAC(key, тип+нормализованное значение). Одно и то же значение даёт
// один и тот же токен во всех документах — можно считать, джойнить и искать по нему, не видя PII.
type Redactor struct {
Key []byte
}
// Redact возвращает текст без PII и словарь токен → исходное значение.
// Словарь (vault) нужен только для обратной подстановки: храните его отдельно,
// с отдельными правами и сроком жизни, или не храните вовсе, если восстановление не нужно.
func (r Redactor) Redact(text string) (string, map[string]string) {
var b strings.Builder
vault := map[string]string{}
last := 0
for _, f := range Find(text) {
b.WriteString(text[last:f.Start])
tok := "[" + f.Kind + "]"
if r.Key != nil {
tok = "[" + f.Kind + ":" + r.pseudonym(f) + "]"
vault[tok] = f.Value
}
b.WriteString(tok)
last = f.End
}
b.WriteString(text[last:])
return b.String(), vault
}
func (r Redactor) pseudonym(f Finding) string {
norm := strings.ToLower(f.Value)
if f.Kind != "EMAIL" {
norm = digitsOnly(f.Value)
if f.Kind == "PHONE" && strings.HasPrefix(norm, "8") {
norm = "7" + norm[1:] // 8 912… и +7 912… — один и тот же номер
}
}
m := hmac.New(sha256.New, r.Key)
m.Write([]byte(f.Kind + ":" + norm))
return hex.EncodeToString(m.Sum(nil)[:4]) // 32 бита: читаемо; для больших баз берите 8+ байт
}
// Restore — обратная подстановка, например в ответе модели перед показом пользователю.
func Restore(text string, vault map[string]string) string {
pairs := make([]string, 0, 2*len(vault))
for tok, v := range vault {
pairs = append(pairs, tok, v)
}
return strings.NewReplacer(pairs...).Replace(text)
}Маскирование или псевдонимизация
| Способ | Пример | Свойства | Когда |
|---|---|---|---|
| Маскирование | [PHONE] | Необратимо; все телефоны одинаковы, связность теряется | Логи, трейсы, данные для разметки |
| Детерминированная псевдонимизация (HMAC с секретным ключом) | [PHONE:7fa340c5] | Одно значение → один токен: можно считать обращения клиента и связывать сессии, не видя PII | Аналитика, поиск обращений одного человека |
| Обратимая псевдонимизация (vault «токен → значение» или шифрование) | [NAME:1a2b] → в ответе снова «Анна» | Обратима для того, у кого доступ к vault или ключу | Отправка внешнему провайдеру: модель видит токены, приложение подставляет значения |
| Обобщение, синтетическая замена | 34 года → 30–39; «Анна» → «Мария» | Необратимо, текст остаётся естественным | Обучение и few-shot, где [NAME] мешал бы модели |
- Псевдонимизированные данные остаются персональными (GDPR, recital 26): это мера безопасности (ст. 32), а не выход из-под закона. Анонимизация для свободного текста почти недостижима. Как это устроено в 152-ФЗ — в уроке про логи.
- Ключ HMAC — секрет в KMS с ротацией; ротация рвёт связность, планируйте её как миграцию. Короткий токен на десятках миллионов значений даёт коллизии: для джойнов берите 8+ байт.
- Модель портит токены: перефразирует «[NAME:1a2b]» в «клиент 1a2b». Проверяйте, что все токены ответа восстановлены и неизвестных нет.
Где редактировать
| Точка | Что делать | Почему |
|---|---|---|
| До индексации | По классу документа: редактировать, сузить ACL или не индексировать | Из индекса PII уходит в ответ любому, кто прошёл ACL; удалить человека из индекса сложнее, чем не положить |
| До логов и трейсов | Маскировать в приложении или в OTel Collector до выхода из периметра | Логи читает больше людей, и хранятся они дольше |
| До провайдера | Минимизация и обратимая псевдонимизация; zero data retention, регион и договор. Допустима ли передача и снимает ли псевдонимизация требования — решает юрист | Передача третьему лицу, возможно трансграничная |
| До разметки и evals | Маскирование или синтетика, доступ по ролям | Разметчики — часто подрядчики; golden set живёт годами в git |
| До обучения | Удалить PII | Модель запоминает редкие строки; «забыть» человека дообученная модель не умеет |
| На выходе | Детектор PII в ответе (модуль 7) | Модель может пересказать чужие данные из контекста |
Песочница PII-редакции
Те же регэкспы и контрольные суммы, что в GoJava-коде. Редактируйте текст, переключайте режим и детекторы, выключите проверку контрольных сумм и посмотрите, что станет с номером заказа. Пунктиром подчёркнуто то, что похоже на формат, но не прошло проверку. Псевдонимы в демо считаются FNV-хэшем с ключом, в GoJava — HMAC-SHA256.
Что нашли
Что уйдёт дальше
Версии датасетов и разметка
Чтобы ответить, почему вчера recall@5 был 0.82, а сегодня 0.77, нужно знать, что изменилось. Кроме кода, промпта и модели это корпус, парсер, эмбеддинг-модель и golden set. Версионировать нужно всё, от чего зависит метрика: сырой корпус (ревизии и хэши источников), результат парсинга (версия парсера — часть версии данных: обновили Docling — прогоняйте evals), индекс (корпус × чанкер × эмбеддинг-модель), golden set, обучающие наборы и гайдлайн разметки. Отчёт прогона evals хранит версии всех частей (модуль 8), а цепочка «ответ → индекс → парсер → ревизия документа» восстанавливается по трейсу.
| Инструмент (версии на октябрь 2026) | Как устроен | Когда брать | Ограничения |
|---|---|---|---|
| git + JSONL | Файлы в репозитории | Golden set до десятков МБ, дифф кейсов в PR | Большие файлы раздувают репозиторий навсегда |
| git-lfs 3.8 | В git указатели, файлы на LFS-сервере | Средние файлы рядом с кодом | Квоты хостинга, версия хранится целиком, нет пайплайнов |
| DVC 3.67 | Метафайлы .dvc в git, данные в S3, GCS, Azure; пайплайны dvc.yaml с кэшем по хэшу | «Git для данных» без сервера | Apache-2.0; в ноябре 2025 проект перешёл к lakeFS (Treeverse) |
| lakeFS 1.88 | Сервер над объектным хранилищем: ветки, коммиты, merge без копирования | Data lake на терабайтах, атомарная публикация снапшота | Нужен сервер; с 1.87.0 (сентябрь 2026) BUSL-1.1: немодифицированный код бесплатен внутри компании, версия через 4 года становится Apache-2.0 |
Hugging Face Hub + datasets | Git-репозиторий датасета, хранилище Xet; load_dataset(…, revision="<commit>") | Обмен датасетами, обучение | Внешняя платформа: PII и закрытые корпуса — только после решения безопасности и юриста |
gradle.lockfile или checksum-проверка зависимостей в Maven: хэш фиксирует ровно те байты, на которых посчитана метрика. Манифест «файл → sha256» в git плюс сами файлы в объектном хранилище — минимальный рабочий вариант, и DVC, по сути, автоматизирует именно его.go.sum для данных: хэш фиксирует ровно те байты, на которых посчитана метрика. Манифест «файл → sha256» в git плюс сами файлы в объектном хранилище — минимальный рабочий вариант, и DVC, по сути, автоматизирует именно его.# datasets/golden/manifest.yaml — коммитится вместе с кодом evals
name: docrag-golden
version: 2026-10-01.3
guideline: docs/labeling/golden-v4.md # по какой версии гайдлайна размечено
files:
- path: golden.jsonl
sha256: 9f2c…e41a
rows: 186
slices: {table_questions: 31, no_answer: 24, en: 18}
source:
corpus_snapshot: s3://docrag-corpus/snapshots/2026-09-28/
parser: docling 2.135.0
changes: "+12 кейсов из прод-фидбека, 3 эталона обновлены после смены политики возвратов"
Разметка: гайдлайн важнее инструмента
Согласие разметчиков (Cohen's κ) и error analysis разобраны в модуле 8. Низкое согласие почти всегда означает плохой гайдлайн, а не плохих разметчиков. В гайдлайне должны быть: задача и что будет с меткой дальше; метки с определениями и примерами «да» и «нет»; дерево решений для пограничных случаев («ответ верный, но без цитаты → FAIL»; «по устаревшей версии документа → FAIL, тег stale»); выход «не могу оценить»; версия и changelog — изменили определение, перечитайте затронутые метки.
- Контроль качества: контрольные задания с известным ответом вперемешку с обычными, перекрёстная разметка 10–20% и арбитраж эксперта.
- Инструменты (по состоянию на октябрь 2026): Label Studio (Apache-2.0) активно развивается; Argilla (Apache-2.0) после перехода в Hugging Face в 2024 году находится в режиме поддержки: только исправления ошибок, без новых функций; doccano (MIT) развивается медленно. Для старта хватает внутренней страницы с трассой целиком и горячими клавишами PASS/FAIL.
- PII и доступ: редакция до разметки, роли, договор с подрядчиком, запрет выгрузки.
Active learning: что размечать первым
Active learning — выбирать для разметки примеры, которые сильнее всего улучшат систему. Для LLM-продукта это неуверенные случаи (низкий скор retrieval, judge и эвристики разошлись, разные прогоны дали разные ответы), новые кластеры запросов, которых нет в golden set, высокая цена ошибки (деньги, право, безопасность) и сигналы из прода. Активная выборка смещена к трудным случаям, поэтому для честной оценки держите отдельную случайную выборку.
Фидбек и data flywheel
Data flywheel (маховик данных) — замкнутый цикл: прод поставляет примеры ошибок, из них получаются кейсы для evals и данные для улучшений, улучшения через eval-гейт возвращаются в прод. Без маховика golden set застывает на дне запуска, а прод уходит вперёд.
- Явный фидбек (👍/👎, комментарий, исправление оператором) точный, но редкий (обычно меньше процента ответов) и смещённый: его чаще оставляют недовольные.
- Неявный (регенерация, переформулирование, эскалация, копирование, правка черновика) — подробно в модуле 8.
- Привязка к трассе: фидбек без
trace_idбесполезен — нужны запрос, найденные чанки и версии.
CREATE TABLE feedback (
id bigserial PRIMARY KEY,
trace_id text NOT NULL, -- запрос, чанки, версии
user_hmac text NOT NULL, -- HMAC(user_id), не сам id (модуль 9)
kind text NOT NULL CHECK (kind IN ('thumb', 'regenerate', 'copy', 'escalate', 'edit')),
value smallint, -- +1 / -1 для thumb
comment text, -- PII редактируется до записи
prompt_ver text NOT NULL,
index_ver text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
triaged_at timestamptz, -- когда разобрали: скорость маховика
outcome text -- golden_case / doc_fix / prompt_fix / not_a_bug
);
CREATE INDEX feedback_untriaged ON feedback (created_at) WHERE triaged_at IS NULL;
Путь сигнала: отбор (все 👎 и эскалации, случайная выборка 0.5–2% трафика, активная выборка) → PII-редакция → разбор (error analysis: сломались retrieval, документ, промпт, модель или ожидания) → исход (кейс в golden set, задача владельцу документа, правка промпта, пример для обучения в модуле 10, «не баг») → релиз через eval-гейт, где новый кейс сначала падает, потом проходит. Брать логи пользователей для evals и обучения можно не всегда: нужны правовое основание и совместимость с целью сбора (следующий урок), а также условия договора с провайдером.
Метрики маховика: время цикла от 👎 до кейса в CI (медиана в днях — самая честная метрика того, учится ли система); доля разобранных сигналов и бэклог; прирост golden set и покрытие прод-кластеров; доля повторяющихся ошибок (высокая — фиксы не доезжают или кейс не ловит регрессию); тренд качества по релизам на фиксированном срезе; стоимость размеченного кейса.
Маховик данных: где узкое место
Двигайте трафик, долю фидбека, выборку и бюджет разметки. Схема считает недельный поток и подсвечивает узкое место: мало сигналов, не хватает разметки или разобранное не становится кейсами. Модель упрощённая: она показывает баланс, а не прогнозирует ваши цифры.
Логи диалогов: что хранить, сколько и кому показывать
Логи диалогов нужны для отладки, evals, маховика данных и расследования инцидентов. Это же самое опасное хранилище PII в системе: тексты, которые пользователи писали, не думая, где они окажутся. Задача — хранить достаточно для качества и аудита и не больше. Ниже — инженерные следствия GDPR и 152-ФЗ. Применимость к вашей системе и правовые основания определяет юрист.
| Класс | Что внутри | Типичный срок | Доступ |
|---|---|---|---|
| Метаданные | trace_id, HMAC пользователя, модель, версии промпта и индекса, токены, стоимость, латентность, id чанков | Месяцы — год: тренды, биллинг, SLO | Инженеры, аналитики |
| Содержимое | Запросы, ответы, найденные фрагменты | Дни — недели (модуль 9: 14–30 дней), сэмпл 1–5% плюс ошибки и 👎, маскированные | Дежурные и AI-команда, с аудитом |
| Фидбек и кейсы | Оценки, комментарии, трассы-кейсы | Пока кейс нужен; в golden set — после редакции | AI-команда, разметчики по ролям |
| Аудит действий | Вызовы инструментов с побочными эффектами, решения человека | По требованиям безопасности и отрасли; для high-risk по AI Act — не меньше 6 месяцев | Безопасность, комплаенс |
- Срок реализуется механизмом: TTL в ClickHouse, lifecycle-правила S3, партиции по дням с
DROP PARTITION. Срок, который соблюдают «вручную раз в квартал», не соблюдается. - Анонимизация логов: HMAC вместо
user_id, редакция до записи, тексты в отдельном хранилище, в трейсах только ссылки (модуль 9), доступ к сырым данным по заявке с аудитом. - Логи провайдера: у внешнего API свои сроки хранения. Zero data retention, регион обработки и договор — часть архитектуры.
Удаление по запросу
Нужен реестр мест хранения с ключом поиска по пользователю: прод-БД, логи, трейсы и фидбек (по HMAC); векторный индекс, если индексируется пользовательский контент (по owner_id чанка); golden set, очереди разметки и выгрузки подрядчикам (по ссылке на трассу); кэши ответов; бэкапы — через crypto-shredding (данные пользователя шифруются его ключом, удаление ключа делает недоступными и копии); провайдер — по договору. Дообученную модель «разучить» человека нельзя, поэтому сырые персональные данные в обучение не берут. Удаление — скрипт с тестом на синтетическом пользователе и отчётом о выполнении, а не инструкция в wiki.
GDPR: что следует для инженера
| Норма | Решение в системе |
|---|---|
| Ст. 5(1)(a) законность, справедливость, прозрачность; ст. 6 правовые основания | Политика описывает логи и использование для улучшений; в UI видно, что отвечает ИИ |
| Ст. 5(1)(b) ограничение цели | Флаг «можно использовать для evals и обучения» на трассе; маховик берёт только помеченное |
| Ст. 5(1)(c) минимизация; (e) ограничение хранения | Сэмплирование и маскирование текстов, TTL по классам данных |
| Ст. 5(1)(d) точность; (f) целостность и конфиденциальность; ст. 32 | Редактируемая память ассистента, is_current для документов; шифрование, роли, аудит, псевдонимизация |
| Ст. 5(2) подотчётность; ст. 30 реестр обработки | Журналы удаления, реестр мест хранения, system card |
| Ст. 15–22 права субъекта: доступ, удаление (17), переносимость (20), возражение (21), полностью автоматизированные решения (22) | Выгрузка и удаление скриптом; человек в контуре для значимых решений |
| Ст. 25 privacy by design; ст. 28 обработчик | Редакция по умолчанию, opt-in для хранения текстов; DPA с LLM-провайдером |
| Ст. 33 утечка — уведомить в течение 72 часов; ст. 35 DPIA | Алерты и runbook; для LLM-фич с персональными данными в масштабе DPIA ожидаема, её выводы — требования к архитектуре |
| Ст. 44–49 передача за пределы ЕС | Регион обработки у провайдера, договорные гарантии |
Псевдонимизированные данные по общему правилу персональные (recital 26). Суд ЕС в деле C-413/23 P (EDPS v SRB, 4 сентября 2025; дело по Регламенту 2018/1725 для органов ЕС, понятие персональных данных в нём то же, что в GDPR) уточнил, что для получателя, который не может вернуть личность, они могут не быть персональными; для вас, у кого ключ, — персональные. Поправки к определению персональных данных в Digital Omnibus по GDPR (COM(2025) 837) на октябрь 2026 года не приняты.
152-ФЗ: что следует для инженера (по состоянию на октябрь 2026)
| Норма | Суть | Решение в системе |
|---|---|---|
| Локализация, ч. 5 ст. 18 (с 1 июля 2025, 23-ФЗ) | При сборе персональных данных граждан РФ запись, систематизация, накопление, хранение, уточнение и извлечение с использованием баз данных за пределами РФ не допускаются (кроме случаев п. 2, 3, 4, 8 ч. 1 ст. 6) | Первичная БД пользователей и логов — в России; зарубежные LLM API, аналитика и SaaS-трейсинг — только после записи в неё и по ст. 12 |
| Согласие, ст. 9 (с 1 сентября 2025, 156-ФЗ) | Согласие оформляется отдельно от иных документов, которые подписывает или подтверждает субъект | Отдельная форма, а не пункт оферты; в БД — версия текста и время; отзыв запускает уничтожение (30 дней, ч. 5 ст. 21) |
| Трансграничная передача, ст. 12 | Уведомить РКН до начала передачи; в страны из перечня РКН — сразу, в остальные — не раньше чем через 10 рабочих дней; РКН может запретить передачу. С 26 июля 2026 (265-ФЗ) «адекватность» страны определяет перечень РКН, а не членство в Конвенции 108 | Зарубежный LLM API с персональными данными — трансграничная передача: минимизация и псевдонимизация до отправки, провайдер за интерфейсом, чтобы переключиться на российского или self-hosted (модуль 10) |
| Уведомление об обработке, ст. 22 | До начала обработки, включая место нахождения баз данных; с 30 мая 2025 штраф за неуведомление 100–300 тыс. ₽ | Реестр мест хранения — и для уведомления, и для удаления |
| Утечка, ч. 3.1 ст. 21 | В РКН за 24 часа (факт, причины, меры) и за 72 часа (итоги расследования) | Детект аномального доступа и runbook на часы, а не дни |
| Права субъекта, ст. 14, 16, 20, 21 | Сведения об обработке — 10 рабочих дней (+5), исправление или уничтожение — 7; решения только на основе автоматизированной обработки — с письменного согласия или по закону (ст. 16) | Выгрузка и удаление скриптом; человек в контуре |
| Обезличивание, приказ РКН № 140 (с 1 сентября 2025) | Обезличенные данные остаются персональными. Методы: введение идентификаторов, изменение состава или семантики, перемешивание, декомпозиция; ключ сопоставления и локальные акты о методах хранятся отдельно от обезличенных данных | HMAC-псевдонимизация — по сути «введение идентификаторов»; ключ и vault в отдельном контуре |
Ответственность с 30 мая 2025 (420-ФЗ): штрафы для юрлиц за утечку зависят от объёма — до 15 млн ₽, за биометрию до 20 млн ₽; за повторную утечку — 1–3% выручки, но не меньше 20–25 млн и не больше 500 млн ₽. За нарушение локализации — 1–6 млн ₽, повторно 6–18 млн ₽. С 11 декабря 2024 действует ст. 272.1 УК о незаконном использовании и передаче персональных данных, полученных незаконно. Отдельно: 26 июля 2026 подписан Федеральный закон № 243-ФЗ «О поддержке развития технологий искусственного интеллекта в Российской Федерации» (в силе с 1 сентября 2026, часть норм — с 1 марта 2027). 152-ФЗ он не меняет, но вводит требования к «суверенным» и «национальным» большим фундаментальным моделям (от 1 млрд параметров) и с 1 марта 2027 позволяет правительству определить случаи, где допускаются только они; для систем, работающих на этот день, — переходный период до 1 сентября 2032 при обработке и хранении данных в России. Если ваш продукт может оказаться в такой сфере, это ограничение на выбор модели.
EU AI Act
EU AI Act (Regulation (EU) 2024/1689) регулирует ИИ по уровням риска и действует экстерриториально: на всех, кто выводит AI-системы на рынок ЕС, и на провайдеров и эксплуатантов из третьих стран, если результат работы системы используется в ЕС (ст. 2). Российская компания с клиентами в ЕС может под него попасть. Ниже — что закон требует и как это меняет архитектуру. Применимость к конкретной системе определяет юрист, урок не является юридической консультацией.
- Провайдер GPAI-модели (general-purpose AI) — Anthropic, OpenAI, Mistral. Глава V: техническая документация, информация для интеграторов, политика по авторскому праву, публичное описание обучающих данных, для моделей с системным риском — оценка и снижение рисков. Вы становитесь провайдером GPAI, только если выпускаете свою модель общего назначения или значительно модифицируете чужую; по руководству Еврокомиссии обычный LoRA-тюнинг под узкую задачу к этому, как правило, не относится.
- Провайдер AI-системы (provider) разрабатывает систему и выводит её на рынок под своим именем: чат-бот поддержки на Claude, который вы даёте клиентам, — ваша AI-система.
- Эксплуатант (deployer) применяет систему под своей ответственностью: банк, внедривший чужой скоринг, или ваша компания со внутренним инструментом.
Даты (по состоянию на октябрь 2026)
Сроки для high-risk систем перенёс Digital Omnibus on AI. Его предложили 19 ноября 2025 года (COM(2025) 836), политическое соглашение достигнуто 7 мая 2026 года. Итоговый текст — Regulation (EU) 2026/1744 от 8 июля 2026 года, опубликован в Official Journal 24 июля 2026 года, вступил в силу 27 июля 2026 года. В предложении начало high-risk требований зависело от готовности стандартов и имело крайние даты. В принятом тексте даты фиксированные: 2 декабря 2027 года для приложения III и 2 августа 2028 года для приложения I.
| Дата | Что применяется |
|---|---|
| 1 августа 2024 | Регламент вступил в силу |
| 2 февраля 2025 | Определения, AI literacy (ст. 4), запрещённые практики (ст. 5) |
| 2 августа 2025 | Обязательства провайдеров GPAI (глава V), органы надзора, санкции. Code of Practice для GPAI опубликован 10 июля 2025 |
| 2 августа 2026 | Большая часть регламента, включая прозрачность по ст. 50; начало правоприменения; полномочия Еврокомиссии по GPAI и штрафы по ст. 101 |
| 2 декабря 2026 | Новые запреты Omnibus: генерация интимных изображений людей без согласия и материалов сексуального насилия над детьми. Системы генерации, выпущенные до 2 августа 2026, выполняют ст. 50(2) к этой дате |
| 2 августа 2027 | GPAI-модели, выпущенные до 2 августа 2025, соответствуют главе V (ст. 111(3)) |
| 2 декабря 2027 | High-risk системы из приложения III |
| 2 августа 2028 | High-risk системы из приложения I (компоненты безопасности продуктов под отраслевым регулированием) |
Ещё в Omnibus: обязанность по AI literacy (ст. 4) смягчена до «принимать меры для поддержки» грамотности персонала без гарантии уровня; новая ст. 4a разрешает обрабатывать особые категории данных для поиска смещений только при строгой необходимости; упрощения распространены на small mid-caps. Отдельный пакет поправок к GDPR (COM(2025) 837) на октябрь 2026 года не принят.
Категории риска
| Категория | Что это | Последствия |
|---|---|---|
| Запрещённые (ст. 5) | Вредоносная манипуляция и эксплуатация уязвимостей; социальный скоринг; прогноз преступлений только по профилированию; массовый сбор изображений лиц; распознавание эмоций на работе и в учёбе (кроме медицины и безопасности); биометрическая категоризация по чувствительным признакам; удалённая биометрическая идентификация в реальном времени в общедоступных местах для правоохранительных целей (с узкими исключениями); со 2 декабря 2026 — интимный контент без согласия и CSAM | Выпускать нельзя; штраф до 35 млн € или 7% мирового оборота |
| Высокий риск (ст. 6) | Компонент безопасности продукта (приложение I) или одна из 8 областей приложения III: биометрия, критическая инфраструктура, образование, занятость, доступ к важным услугам (кредитный скоринг, страхование жизни и здоровья, льготы, экстренные вызовы), правоохрана, миграция, правосудие и выборы | Требования ниже, оценка соответствия, регистрация в базе ЕС; штраф до 15 млн € или 3% |
| Прозрачность (ст. 50) | (1) общение с людьми; (2) генерация синтетического контента; (3) распознавание эмоций, биометрическая категоризация; (4) дипфейки и сгенерированный текст на общественно значимые темы без редактора | (1) сообщить, что это ИИ, если неочевидно; (2) машиночитаемая маркировка — обязанности провайдера; (3), (4) уведомлять и раскрывать — обязанности эксплуатанта |
| Минимальный | Кодинг-ассистент, внутренний поиск, спам-фильтр | Меры по AI literacy (ст. 4), добровольные кодексы |
Категории совмещаются: high-risk система, которая общается с людьми, выполняет и ст. 50. Для приложения III есть исключение ст. 6(3): система не high-risk, если не создаёт значительного риска и выполняет узкую процедурную или подготовительную задачу, улучшает результат уже завершённой работы человека или ищет отклонения от прежних решений, не заменяя оценку человека. Исключение не работает при профилировании людей. Провайдер, который на него опирается, документирует оценку (ст. 6(4)) и регистрирует систему.
Как определить категорию своей фичи
- Сфера: есть ли пользователи или результаты работы системы в ЕС?
- Роль: провайдер системы, эксплуатант или провайдер GPAI-модели?
- Ст. 5: нет ли запрещённой практики в назначении или в разумно предсказуемом злоупотреблении?
- High-risk: относится ли назначение (intended purpose) к пункту приложения III или к приложению I? Если да — работает ли ст. 6(3), нет ли профилирования?
- Ст. 50: общение с людьми, генерация контента, эмоции, дипфейки?
- Запишите вывод со ссылками на статьи и отдайте юристу. Пересматривайте при смене назначения: «добавим скоринг кандидатов» делает HR-чат-бот high-risk.
Классификатор категории по EU AI Act
Опишите фичу, и схема покажет вероятную категорию, статьи и инженерные последствия. Учебная эвристика по тексту регламента с поправками Regulation (EU) 2026/1744, по состоянию на октябрь 2026. Не юридическая консультация: окончательный вывод делает юрист.
Что меняется в архитектуре
| Требование | Статья | Инженерное решение |
|---|---|---|
| Логирование | 12; хранение не меньше 6 месяцев — 19 (провайдер), 26(6) (эксплуатант) | Append-only журнал событий: время, ссылка на вход, версии модели, промпта и индекса, выход, действие человека, trace_id. Тексты — редактированные, чтобы не спорить с ограничением хранения по GDPR |
| Документация | 11, приложение IV | System card, журнал изменений, отчёты evals и red-team (урок «Документация системы») |
| Human oversight | 14 | Человек видит источники и уверенность, может отклонить или переопределить результат и остановить систему (kill switch, фича-флаг); UI против automation bias |
| Точность и устойчивость | 15 | Заявленные метрики из evals (модуль 8), red-team и защита от инъекций и отравления (модуль 7), fallback (модуль 9) |
| Data governance | 10, 4a | Datasheet, происхождение и версии данных, проверка смещений по группам |
| Риски и качество | 9, 17 | Реестр рисков; eval-гейт и ADR с документированием |
| Мониторинг и инциденты | 72, 73 | Дашборд качества и дрейфа (модуль 9), маховик фидбека, уведомление о серьёзном инциденте не позднее 15 дней |
| Прозрачность | 50 | «Вы общаетесь с ИИ-ассистентом» в UI и в начале звонка; машиночитаемая маркировка контента. Детали — в итоговом Code of Practice по маркировке (июнь 2026) и руководстве Еврокомиссии по ст. 50 (июль 2026) |
@Transactional(isolation = …): решение принимается по требованиям до кода, а менять его потом дорого. Если фича потенциально high-risk, закладывайте журнал событий, точки human oversight и версии данных с первого дня. Переделка готовой системы под ст. 12 и 14 — это миграция схемы на живом трафике.Документация системы
Документация AI-системы отвечает на вопросы аудитора, юриста, дежурного и enterprise-клиента: что система делает и чего не делает, на каких данных и моделях работает, как её проверяли и с каким результатом, кто и как может её остановить. Если ответы собирают по Slack, документации нет.
| Документ | О чём | Кто пишет и когда обновляет |
|---|---|---|
| Model card (Mitchell et al., 2019) | Модель: назначение, данные обучения, метрики по срезам и группам, ограничения | Кто обучает или дообучает модель (модуль 10); для API-модели — ссылка на карточку провайдера |
| System card | Система целиком: назначение, пользователи, архитектура, модели, данные, evals и red-team, ограничения, guardrails, oversight, мониторинг | Команда фичи, с каждым релизом, меняющим модель, промпт, данные или назначение |
| Datasheet для датасета (Gebru et al., 2018) | Мотивация, состав, сбор и согласие, предобработка (PII, дедуп), допустимое использование, сопровождение | Владелец golden set, обучающего набора или корпуса |
| Реестр промптов и моделей | Что активно в проде: версия и хэш, модель, параметры, владелец, прогон evals, дата вывода модели у провайдера | Генерируется из конфигурации (модуль 3) |
| Журнал изменений | Кто, когда, что и зачем поменял, со ссылкой на PR и evals | Из PR и деплоев; крупные решения — ADR (модуль 13) |
Техническая документация high-risk системы по AI Act (ст. 11, приложение IV) — по сути подробная system card с журналом изменений; реестр обработки (ст. 30 GDPR) и DPIA ссылаются на datasheet; анкеты безопасности enterprise-клиентов спрашивают то же самое.
Docs as code
- В репозитории, ревью в одном PR с изменением поведения.
- Генерируемые поля не пишутся руками: версии — из реестра, метрики — из прогона evals; CI падает, если system card расходится с конфигурацией деплоя.
- Ограничения — конкретные: не «модель может ошибаться», а «на вопросах по таблицам тарифов accuracy 81% против 93% в среднем; ответы без цитаты помечаются в UI».
# docs/system-card.yaml — система из проекта A; поля с пометкой generated заполняет CI
system: docrag-ask
version: 2026.10.2
owner: team-support-ai (on-call: #support-ai-oncall)
purpose: ответы на вопросы сотрудников поддержки по внутренней базе знаний, с цитатами
users: сотрудники поддержки (≈ 300), не клиенты; ответ — подсказка, решение принимает оператор
out_of_scope: юридические и медицинские консультации, решения о возвратах, ответы клиентам без проверки
ai_act: # оценка команды, утверждена юристом 2026-09-20
category: transparency # ст. 50(1): UI сообщает, что отвечает ИИ
annex_iii: none # не HR, не кредит, не доступ к услугам
rationale: docs/compliance/ai-act-assessment.md
models: # generated: из реестра
answer: {model: claude-sonnet-5, prompt: answer@v7+3f9a1c2b, effort: low}
rerank: {model: claude-haiku-4-5, prompt: rerank@v2+9e01d4aa}
data:
corpus: confluence:SUPPORT, confluence:BILLING; snapshot 2026-09-28; parser docling 2.135.0
pii: редакция до индексации и логов (examples/m11-data); vault не используется
datasheet: docs/datasheets/corpus.md
evals: # generated: последний прогон CI
golden: docrag-golden@2026-10-01.3 (n=186)
recall_at_5: 0.86
faithfulness: 0.94
answer_accuracy: {overall: 0.91, table_questions: 0.81, no_answer: 0.88}
red_team: 42 атаки, успешных 1 (ASR 2.4%), модуль 7
limitations:
- вопросы по таблицам тарифов: accuracy 0.81, в UI пометка «проверьте по источнику»
- документы старше 2024 года не проиндексированы
oversight: оператор видит источники и может пометить ответ; kill switch — флаг docrag.enabled
monitoring: дашборд качества (judge на 2% трафика), 👎-очередь, SLO из модуля 9
logs: метаданные 13 мес., тексты 30 дней (маскированные), удаление по запросу — runbook docs/dsar.md
changelog: docs/changelog.md
Практика
Задания продолжают сквозные проекты: A — RAG по документации, B — eval-набор и CI. Вы улучшаете свою систему, а не решаете отдельные задачки.
Задание 1: ingestion-пайплайн проекта A с PII и дедупликацией
Добавьте в cmd/ingestingest-модуль проекта A парсинг PDF (Python-воркер), структурный чанкинг, PII-редакцию, дедупликацию и метаданные из урока. Корпус: 5–10 PDF, среди них скан, документ с таблицей и две версии одного документа.
- Отчёт парсинга на каждый документ; скан без OCR даёт предупреждение, а не молча 0 чанков.
- Табличный тестПараметризованный тест (
@ParameterizedTest): ни одна таблица и ни один код-блок не разрезаны; у каждого чанка есть путь заголовков. - PII: на 30 синтетических сообщениях полнота редакции структурных PII ≥ 95%, ни одна валидная карта не попала в индекс и логи (проверка запросом к БД); посчитаны ложные срабатывания на номерах заказов.
- Отчёт «точные / почти-дубли»; старая версия документа не находится поиском; чанки разных тенантов не склеиваются.
- ACL: интеграционный тест — пользователь без группы не получает чанк документа ни в
/search, ни в цитатах/ask. - Recall@5 на golden set проекта B не хуже, чем до; табличные вопросы — отдельным срезом.
Задание 2: версии корпуса и golden set
Заведите манифест golden set проекта B (как в уроке) и снапшот корпуса: git + манифест с sha256 или DVC. Прогон evals пишет в отчёт версии golden set, корпуса, парсера, эмбеддинг-модели и промпта.
- По тегу трёхнедельной давности можно воспроизвести тот прогон и получить те же метрики (с допуском на недетерминизм модели).
- CI падает, если файл golden set изменён без обновления версии в манифесте (sha256 не совпадает).
- Гайдлайн разметки лежит в репозитории с версией; κ на 30 перекрёстно размеченных кейсах посчитан (модуль 8).
Задание 3: маховик — фидбек из прода в golden set
Добавьте в /ask 👍/👎 с комментарием и таблицу feedback. Еженедельный скрипт: неразобранные 👎 и случайная выборка → PII-редакция → очередь разметки → кейсы golden set с тегом source: prod. Нет пользователей — 200 запросов от коллег или синтетики с вашими оценками.
- Минимум 10 новых кейсов в golden set пришли из «прода», и по каждому записан исход: golden_case, doc_fix, prompt_fix или not_a_bug.
- Посчитаны время цикла (от 👎 до кейса в CI) и бэклог разбора; в README есть мини-дашборд или таблица.
- Хотя бы один кейс сначала падал в CI, а после исправления (документа, промпта, чанкинга) стал проходить.
Задание 4: EU AI Act, логи и system card
Классифицируйте две фичи: /ask из проекта A и гипотетическую «оценку резюме кандидатов по базе вакансий». Для каждой пройдите классификатор из урока и выпишите статьи, на которые опирается вывод. Затем напишите для проекта A system card (YAML или markdown) и политику хранения логов.
- По каждой фиче: роль (provider или deployer), проверка запрещённых практик, приложение III (пункт или «нет»), ст. 6(3), обязательства ст. 50, перечень архитектурных последствий. В конце — пометка «требует проверки юристом».
- System card: все поля из урока заполнены, метрики взяты из последнего прогона CI, ограничения сформулированы цифрами.
- Политика логов: таблица «класс данных → что храним → срок → кто имеет доступ». Скрипт удаления по запросу пользователя чистит логи, трейсы, feedback и golden set, и это проверено на тестовом пользователе.
Самопроверка
Почему документ-скан опасен для RAG, даже если парсер не выдал ошибку?
Без текстового слоя парсер возвращает пустой текст, документ даёт 0 чанков и молча выпадает из индекса; ошибки в логах нет. Защита — OCR для страниц без текста и алерт отчёта парсинга на «мало символов на страницу».
Как положить в индекс таблицу на 300 строк?
Группами строк с шапкой в каждом чанке или построчно «колонка: значение». Справочник с точными значениями лучше положить в SQL и дать модели инструмент поиска.
Чем SimHash отличается от MinHash и зачем SimHash режут на блоки?
MinHash оценивает сходство Жаккара шинглов набором минимальных хэшей, кандидатов ищут LSH-корзинами. SimHash сжимает документ в 64-битный отпечаток, похожие тексты близки по Хэммингу. Блоки избавляют от полного перебора: при расстоянии ≤ 3 хотя бы один из четырёх 16-битных блоков совпадает.
Почему дедупликацию нельзя делать глобально по всему индексу?
Одинаковый текст у разных тенантов или групп доступа — это разные документы. Удалив «дубль», вы лишите второго тенанта документа, а склеив ACL — откроете ему чужие данные. Ключ дедупликации включает область доступа.
Номер заказа из 10 цифр прошёл проверку ИНН. Это баг детектора?
Нет, это ожидаемое свойство: у ИНН одна контрольная цифра, и случайное число проходит проверку примерно в одном случае из десяти. Контрольная сумма снижает ложные срабатывания, но не убирает их. Помогают контекстные слова («ИНН» рядом), знание формата ваших номеров заказов и измерение точности на размеченной выборке.
Чем детерминированная псевдонимизация лучше маскирования и в чём её риск?
Одно значение всегда даёт один токен, поэтому сохраняются связность и аналитика: можно посчитать обращения одного клиента, не видя его телефона. Риски: это всё ещё персональные данные (по GDPR псевдонимизированные данные остаются персональными), ключ HMAC — секрет, утечка которого позволяет перебором сопоставить токены с известными значениями, а ротация ключа рвёт связность.
Где в LLM-системе нужно редактировать PII? Назовите минимум четыре точки.
До индексации (или ACL и решение не индексировать), до логов и трейсов, до отправки внешнему провайдеру (минимизация; допустимость самой передачи оценивает юрист), до разметки и evals, до обучения, на выходе модели.
Что должно входить в версию данных, по которой посчитана метрика RAG?
Ревизии исходных документов, версия парсера, чанкер и его параметры, эмбеддинг-модель, версия golden set и гайдлайна разметки. Обновление парсера — это новая версия корпуса и повод прогнать evals.
Почему golden set нельзя пополнять только из 👎?
Явный фидбек редкий и смещённый: оценки оставляют недовольные и определённые сегменты пользователей. Набор только из жалоб завышает долю трудных случаев и не отражает реальное распределение. Нужна случайная выборка трафика для честной оценки и активная — для поиска ошибок.
Какая метрика лучше всего показывает, что маховик данных работает?
Время цикла: сколько дней проходит от 👎 в проде до регрессионного кейса в CI. Дополнительно — бэклог разметки, прирост и покрытие golden set по кластерам прод-запросов, доля повторяющихся ошибок.
Что изменил Digital Omnibus on AI в датах для high-risk систем и каков его статус?
Принят: Regulation (EU) 2026/1744 от 8 июля 2026 года, в силе с 27 июля 2026 года. Требования к high-risk системам из приложения III применяются со 2 декабря 2027 года, из приложения I — со 2 августа 2028 года. В принятом тексте даты фиксированные, без привязки к готовности стандартов, как было в предложении. Прозрачность по ст. 50 — со 2 августа 2026 года, для систем генерации, выпущенных раньше, 50(2) — до 2 декабря 2026 года.
Какие нормы 152-ФЗ затрагивает отправка запросов российских пользователей в зарубежный LLM API?
Локализация (ч. 5 ст. 18): первичная запись и хранение — в базе в России. Трансграничная передача (ст. 12): уведомление Роскомнадзора до начала передачи и ожидание, если страна не в перечне. Правовое основание и согласие (ст. 6, 9), поручение обработки иностранному лицу (ст. 6). Инженерно: минимизация и псевдонимизация до отправки, провайдер за интерфейсом с возможностью переключения. Окончательно применимость оценивает юрист.
Вопросы с собеседований
Спроектируйте ingestion для 2 млн корпоративных PDF: сканы, таблицы, права доступа, документы обновляются.
Очередь и идемпотентные воркеры с ключом (doc_id, revision); неизменившийся по хэшу документ не обрабатывается повторно. Маршрутизация по страницам: текстовый слой → layout-парсер (self-hosted, если документы нельзя отдавать наружу), страницы без текста → OCR, сложные таблицы и графики → VLM только там, где важны цифры, со сверкой по текстовому слою. Выход — markdown или JSON со структурой, чанкинг по заголовкам, таблицы не режутся. Метаданные: источник, ревизия, страницы, язык, версия парсера, ACL из источника с отдельной частой синхронизацией и deny by default. PII-редакция и дедупликация внутри области доступа, старые версии is_current = false, удалённые в источнике — tombstone. Наблюдаемость: отчёт парсинга на документ, доля OCR, документы с нулём чанков, стоимость страницы; качество — golden-вопросы по типам документов и recall@k до и после смены парсера.
На что смотрит интервьюер: маршрутизация по стоимости вместо «прогоним всё через самую сильную VLM», ACL и удаление как часть ingestion, наблюдаемость тихих поломок, инкрементальность.
Банковский чат-бот на внешнем LLM API. Как организуете работу с PII?
Сначала требования: что можно передавать провайдеру по договору и закону, регион, zero data retention. Детекторы на входе: регэкспы с контрольными суммами (карты, счета, ИНН, СНИЛС) и NER для имён и адресов. Для провайдера — обратимая псевдонимизация: модель видит токены, приложение подставляет значения в ответ, vault отдельно и с коротким сроком жизни. В логах — необратимое маскирование до выхода из периметра, HMAC вместо id. На выходе — детектор PII. Полноту детекторов меряю на синтетических диалогах в CI. Реквизиты для операций вообще не идут через LLM: инструмент берёт их из сессии, а не из промпта.
На что смотрит интервьюер: различает маскирование и обратимую псевдонимизацию, меряет полноту, не шлёт PII провайдеру ради поиска PII, держит реквизиты вне промпта.
Пользователь требует удалить все свои данные. Что это значит для LLM-системы?
Нужен реестр мест хранения с ключом поиска: прод-БД, логи, трейсы и фидбек (HMAC), векторный индекс, если индексируется пользовательский контент (owner_id), кэши ответов, golden set и очереди разметки (ссылка на трассу), выгрузки подрядчикам, бэкапы (crypto-shredding), логи провайдера (договор). Дообученную модель точечно не почистить, поэтому сырые персональные данные в обучение не попадают — это решается заранее. Удаление — скрипт с тестом на синтетическом пользователе и отчётом; сроки задаёт закон, значит, процесс автоматизирован.
На что смотрит интервьюер: неочевидные места (индекс, golden set, кэш, бэкапы, провайдер) и понимание, что модель нельзя «разучить».
Как построить data flywheel для RAG-ассистента, который только что запустился?
Инструментирование: трасса с версиями, найденные чанки, явный фидбек с комментарием и неявные сигналы. Отбор на разбор: все 👎 и эскалации, случайная выборка около 1% для честной оценки, активная — неуверенные ответы и новые кластеры. PII-редакция перед очередью. Еженедельный error analysis, у каждого исхода владелец: кейс в golden set, задача владельцу базы знаний, правка промпта. Релиз через eval-гейт, новый кейс сначала падает, потом проходит. Метрики: время цикла от 👎 до кейса в CI, бэклог, покрытие прод-кластеров, повторяемость ошибок. Узкое место почти всегда разметка: приоритизация по цене ошибки и первичная сортировка judge-моделью.
На что смотрит интервьюер: замкнутость цикла, понимание смещения явного фидбека, метрика скорости, а не «собираем лайки».
Как вы определите, подпадает ли фича под high-risk по EU AI Act, и что это изменит в архитектуре?
Начинаю с назначения (intended purpose) и роли: провайдер системы или эксплуатант. Проверяю ст. 5, затем приложение III: важна не отрасль, а использование — отбор резюме (п. 4) да, FAQ-бот для кандидатов, скорее всего, нет. Если попадаем — исключение ст. 6(3) (узкая процедурная или подготовительная задача, без профилирования) с документированной оценкой. Параллельно ст. 50 с 2 августа 2026. High-risk из приложения III применяется со 2 декабря 2027 (Regulation (EU) 2026/1744). Архитектура: append-only журнал событий с версиями и решениями человека, хранение не меньше 6 месяцев; human oversight в UI (источники, отклонить, остановить); техническая документация; datasheet и проверка смещений; заявленная точность из evals и red-team; мониторинг и процесс уведомления об инцидентах. Окончательную классификацию подтверждает юрист.
На что смотрит интервьюер: знает структуру закона и актуальные даты, разделяет роли, переводит статьи в компоненты системы, не выдаёт себя за юриста.
Recall@5 упал с 0.86 до 0.79 после «обновления корпуса». Как найдёте причину?
Проверяю сопоставимость: та же версия golden set, эмбеддинг-модель и код поиска. Затем дифф данных: какие документы добавились, исчезли, изменились; сменилась ли версия парсера; сколько документов дали 0 чанков; как изменились длины чанков и число таблиц. Срезы метрики: упали «табличные» вопросы — парсер или чанкинг таблиц; вопросы по конкретным документам — их выпадение или новые версии рядом со старыми; всё равномерно — эмбеддинги или дубли, вытесняющие полезное из top-k. По каждому упавшему кейсу смотрю, куда делся нужный чанк. Вывод для процесса: обновление корпуса — релиз с прогоном evals.
На что смотрит интервьюер: версии и lineage данных как инструмент отладки, срезы, системный поиск причины вместо «поменяем размер чанка».
Согласие разметчиков κ = 0.4. Что будете делать?
На таких метках нельзя ни калибровать judge, ни обучать. Разбираю расхождения: где и почему спорят. Обычно виноват гайдлайн — размытое определение, нет правил для пограничных случаев, нет «не могу оценить». Переписываю с деревом решений и примерами, составной критерий дроблю на бинарные. Повторная перекрёстная разметка на 30–50 кейсах, арбитраж эксперта, контрольные задания в потоке. Гайдлайн версионирую и пересматриваю метки, затронутые изменением.
На что смотрит интервьюер: лечит инструкцию, а не людей; знает, что judge не согласуется с людьми лучше, чем люди между собой (модуль 8).
Российский продукт хочет перейти на зарубежный LLM API. Какие инженерные последствия по 152-ФЗ вы назовёте?
Персональные данные в запросах — трансграничная передача (ст. 12): уведомление Роскомнадзора до начала, для стран вне перечня РКН — ожидание 10 рабочих дней, риск запрета. Локализация (ч. 5 ст. 18) — отдельное требование: первичная запись и хранение в базе в России, поэтому история диалогов и логи живут у нас, а провайдер получает только текущий запрос. Архитектурно: минимизация контекста и псевдонимизация перед отправкой, маршрутизация по классу данных (чувствительное — на self-hosted или российского провайдера), провайдер за интерфейсом шлюза для переключения конфигурацией, реестр мест хранения для уведомления по ст. 22 и удаления. Плюс проверить 243-ФЗ: не попадает ли продукт в сферы, где допустимы только российские модели. Правовую оценку делает юрист.
На что смотрит интервьюер: различает локализацию и трансграничную передачу, предлагает переключаемого провайдера и минимизацию, а не «запретим всё» или «отправим как есть».
Источники
- EUR-Lex — Regulation (EU) 2024/1689 (AI Act) (ст. 2–6, 9–15, 19, 26, 27, 49, 50, 72, 73, 99, 101, 113, приложения I, III, IV)
- EUR-Lex — Regulation (EU) 2026/1744 (Digital Omnibus on AI) (новые даты high-risk, запреты с 2 декабря 2026, ст. 4 и 4a) и новость Еврокомиссии о вступлении в силу
- AI Act Service Desk — timeline of implementation (сводный календарь с поправками Omnibus) и страница Еврокомиссии об AI Act
- European Commission — General-Purpose AI Code of Practice и руководство для провайдеров GPAI
- European Commission — Code of Practice on marking and labelling of AI-generated content и руководство по ст. 50
- EUR-Lex — Regulation (EU) 2016/679 (GDPR) (ст. 4(5), 5, 6, 15–22, 25, 28, 30, 32, 33, 35, 44–49, recital 26)
- Суд ЕС — C-413/23 P EDPS v SRB, пресс-релиз 107/25 (псевдонимизированные данные с точки зрения получателя)
- КонсультантПлюс — Федеральный закон от 27.07.2006 № 152-ФЗ «О персональных данных» (ст. 3, 6, 9, 12, 14, 16, 18, 20–22, актуальная редакция) и ст. 13.11 КоАП РФ (штрафы)
- Федеральный закон от 28.02.2025 № 23-ФЗ (локализация), от 26.07.2026 № 265-ФЗ (трансграничная передача) и от 26.07.2026 № 243-ФЗ (технологии ИИ) — официальное опубликование
- Docling и Docling Technical Report; IBM — granite-docling-258M
- Unstructured — open source, pymupdf4llm, Marker, MinerU, Tesseract (лицензии и версии — в README и LICENSE)
- Presidio и документация (анализатор, операторы анонимизации, поддержка языков); Natasha (NER для русского)
- Sweeney, 2000 — Simple Demographics Often Identify People Uniquely (87% жителей США уникальны по индексу, полу и дате рождения)
- Charikar, 2002 — Similarity estimation techniques from rounding algorithms (SimHash); Manku, Jain, Das Sarma, 2007 — Detecting near-duplicates for web crawling (64 бита, k = 3, поиск по блокам); Broder, 1997 — On the resemblance and containment of documents (MinHash); datasketch
- DVC и DVC joins lakeFS; lakeFS — переход на BUSL; Git LFS; Hugging Face datasets — загрузка по ревизии
- Label Studio; Settles, 2009 — Active Learning Literature Survey
- Mitchell et al., 2019 — Model Cards for Model Reporting; Gebru et al. — Datasheets for Datasets; Anthropic — system cards (пример system card модели)
- Chip Huyen — AI Engineering (глава 8 «Dataset Engineering», глава 10 о пользовательском фидбеке)