Зачем RAG нужны метаданные

Представьте корпоративную базу знаний: 10 000 документов, часть из которых — устаревшие версии регламентов. Векторный поиск по запросу «порядок согласования договора» вернёт топ-5 похожих чанков — но из каких документов? 2019 года или 2024-го? Из HR-отдела или юридического? Без метаданных эти вопросы неразрешимы.

Метаданные в RAG решают три задачи:

  • Pre-retrieval фильтрация — ищем только в подмножестве документов (WHERE department='legal' AND year=2024) до векторного поиска.
  • Post-retrieval ранжирование — свежий документ от главного автора поднимается выше устаревшей копии.
  • Атрибуция в ответе — LLM может написать «Согласно регламенту №ORG-42 от 15.03.2024, подписанному Ивановым И.И…»
Правило thumb: хорошие метаданные — это те поля, по которым пользователь хотел бы отфильтровать или отсортировать результаты. Если поле не используется ни для фильтрации, ни для ранжирования, ни для отображения — оно лишнее.

Три источника метаданных

Метаданные документа живут в трёх разных местах — и у каждого своя надёжность.

Уровень 1 · Файловая система
FS Metadata
📁Путь и имя файла
📅mtime / ctime
📦Размер в байтах
🔑SHA-256 хеш
Уровень 2 · Встроенные в формат
Format Metadata
📄PDF: DocInfo + XMP
📝DOCX: core_properties
🌐HTML: <meta> + Open Graph
📋MD: YAML frontmatter
Уровень 3 · Извлечённые из содержимого
Content Metadata
🏷️Первый h1 → title
📆Regex для дат в тексте
✍️«Автор: …» эвристики
🤖LLM-экстракция

Приоритет: Уровень 1 всегда достоверен (файловая система не врёт), Уровень 2 почти всегда достоверен (хотя автор мог не заполнить поля), Уровень 3 — эвристика, требующая проверки. Стратегия: берём из Уровня 2, если поле заполнено; иначе пробуем Уровень 3; FS-метаданные добавляем всегда.

Пайплайн извлечения метаданных

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Документ PDF / DOCX HTML / MD MetadataExtractor FS Extractor Format Extractor Content Extractor Normalizer parse_date() clean_author() Merger priority merge validate() DocumentMetadata title, author, date source, file_hash language, keywords confidence scores LLM Extractor (fallback) INPUT OUTPUT

Уровень 1: файловые метаданные

Файловая система даёт самые надёжные данные — они не зависят от формата документа и доступны для любого файла. os.stat() возвращает структуру с временными метками, размером и правами. pathlib.Path добавляет удобный API.

Два ключевых поля: st_mtime — время последнего изменения содержимого (modification time), st_ctime — на Linux это время изменения метаданных inode (не создания файла!), на Windows — время создания. Для RAG нас интересует mtime как прокси «когда документ обновлялся».

import hashlib
import os
from datetime import datetime, timezone
from pathlib import Path


def extract_fs_metadata(path: str | Path) -> dict:
    """Извлекает метаданные уровня файловой системы."""
    p = Path(path)
    stat = p.stat()

    # SHA-256 для дедупликации и change-detection
    sha256 = hashlib.sha256(p.read_bytes()).hexdigest()

    return {
        "source":      str(p.resolve()),      # абсолютный путь
        "filename":    p.name,
        "extension":   p.suffix.lower(),
        "file_size":   stat.st_size,          # байты
        "file_hash":   sha256,
        # mtime надёжнее ctime на Linux
        "modified_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
        # Директория → департамент/категория (если структура папок семантична)
        "directory":   str(p.parent),
    }


# Пример:
# {
#   "source": "/docs/legal/contract_v3.pdf",
#   "filename": "contract_v3.pdf",
#   "extension": ".pdf",
#   "file_size": 245872,
#   "file_hash": "a3f2...",
#   "modified_at": "2024-03-15T10:22:00+00:00",
#   "directory": "/docs/legal",
# }
Семантика папок: если ваши документы организованы по принципу docs/{department}/{year}/{file}, можно автоматически парсить путь — p.parts[-3] → department, p.parts[-2] → year. Это бесплатный источник структурированных метаданных.

Уровень 2: метаданные, встроенные в форматы

Большинство документальных форматов имеют собственные стандарты хранения метаданных — заполненные редакторами или системой при сохранении файла.

Формат
Стандарт / API
Ключевые поля
Надёжность
PDF
DocInfo dict
XMP (ISO 16684)
Title, Author, Subject, Keywords, CreationDate, ModDate, Producer
Средняя
DOCX
OPC core.xml
python-docx .core_properties
title, author, last_modified_by, created, modified, subject, keywords, category
Высокая
HTML
<meta> tags
Open Graph / Schema.org
og:title, og:description, author, article:published_time, article:author
Средняя
Markdown
YAML frontmatter
title, author, date, tags, category (произвольные поля)
Высокая

PDF: DocInfo и XMP

PDF хранит метаданные в двух местах. DocInfo — словарь в трейлере PDF, ключи которого заданы стандартом (Title, Author, Subject, Keywords, CreationDate, ModDate, Producer, Creator). XMP (Extensible Metadata Platform) — XML-стрим, добавленный в PDF 1.4 как расширяемая альтернатива DocInfo. Оба могут быть заполнены одновременно, но с разными значениями — берём XMP приоритетнее как более современный стандарт.

import re
from datetime import datetime, timezone

import fitz  # PyMuPDF


def extract_pdf_metadata(path: str) -> dict:
    """Извлекает DocInfo + XMP из PDF, нормализует даты."""
    doc = fitz.open(path)
    info = doc.metadata or {}

    result: dict = {}

    # DocInfo → title, author, subject, keywords
    for key in ("title", "author", "subject", "keywords"):
        val = info.get(key, "").strip()
        if val:
            result[key] = val

    # DocInfo даты — формат "D:20240315102200+03'00'"
    for src_key, dst_key in (("creationDate", "created_at"), ("modDate", "modified_at")):
        raw = info.get(src_key, "")
        if raw:
            parsed = _parse_pdf_date(raw)
            if parsed:
                result[dst_key] = parsed

    # XMP — богаче, перезаписываем DocInfo если XMP заполнен
    xmp_raw = doc.get_xml_metadata()
    if xmp_raw:
        xmp_meta = _parse_xmp(xmp_raw)
        result.update({k: v for k, v in xmp_meta.items() if v})

    doc.close()
    return result


# PDF DateString: "D:YYYYMMDDHHmmSSOHH'mm'"
_PDF_DATE_RE = re.compile(
    r"D:(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})"
    r"([Z+-])(\d{2})?'?(\d{2})?'?"
)

def _parse_pdf_date(raw: str) -> str | None:
    m = _PDF_DATE_RE.match(raw.strip())
    if not m:
        return None
    Y, Mo, D, H, Mi, S, sign, oh, om = m.groups()
    tz_offset = 0
    if sign in ("+", "-") and oh:
        tz_offset = (int(oh) * 60 + int(om or 0)) * 60
        if sign == "-":
            tz_offset = -tz_offset
    from datetime import timezone as tz, timedelta
    tzinfo = tz(timedelta(seconds=tz_offset))
    try:
        dt = datetime(int(Y), int(Mo), int(D), int(H), int(Mi), int(S), tzinfo=tzinfo)
        return dt.astimezone(timezone.utc).isoformat()
    except ValueError:
        return None


def _parse_xmp(xmp: str) -> dict:
    """Достаём dc:title, dc:creator, xmp:CreateDate из XMP-XML."""
    from xml.etree import ElementTree as ET
    NS = {
        "dc":  "http://purl.org/dc/elements/1.1/",
        "xmp": "http://ns.adobe.com/xap/1.0/",
        "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
    }
    result = {}
    try:
        root = ET.fromstring(xmp)
        # dc:title
        t = root.find(".//{dc}title/{rdf}Alt/{rdf}li".format(**NS), NS)
        if t is not None and t.text:
            result["title"] = t.text.strip()
        # dc:creator (список авторов)
        creators = root.findall(".//{dc}creator/{rdf}Seq/{rdf}li".format(**NS), NS)
        if creators:
            result["author"] = "; ".join(c.text.strip() for c in creators if c.text)
        # xmp:CreateDate
        cd = root.find(".//{xmp}CreateDate".format(**NS), NS)
        if cd is not None and cd.text:
            result["created_at"] = cd.text.strip()
    except ET.ParseError:
        pass
    return result

DOCX: OPC core properties

DOCX (Open Packaging Convention) хранит метаданные в файле docProps/core.xml внутри ZIP-архива. python-docx читает их через атрибут core_properties, который возвращает типизированные объекты: строки для title/author, объекты datetime для дат — нормализация уже встроена.

from datetime import datetime, timezone

from docx import Document


def extract_docx_metadata(path: str) -> dict:
    doc = Document(path)
    cp = doc.core_properties

    result = {}

    # Строки
    for attr, key in (
        ("title",            "title"),
        ("author",           "author"),
        ("last_modified_by", "last_modified_by"),
        ("subject",          "subject"),
        ("keywords",         "keywords"),
        ("category",         "category"),
        ("description",      "description"),
    ):
        val = getattr(cp, attr, None)
        if val and str(val).strip():
            result[key] = str(val).strip()

    # Даты — python-docx возвращает datetime или None
    for attr, key in (("created", "created_at"), ("modified", "modified_at")):
        dt: datetime | None = getattr(cp, attr, None)
        if isinstance(dt, datetime):
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=timezone.utc)
            result[key] = dt.astimezone(timezone.utc).isoformat()

    return result

HTML: <meta>, Open Graph, schema.org

HTML-страницы имеют три слоя метаданных: стандартные <meta name="...">, Open Graph (og:) для соцсетей и JSON-LD / Schema.org для структурированных данных. Каждый следующий слой информативнее предыдущего — JSON-LD часто содержит точную дату публикации, имя автора и категорию.

import json

from bs4 import BeautifulSoup


def extract_html_metadata(html: str, url: str = "") -> dict:
    """Извлекает метаданные из HTML: meta, OG, JSON-LD."""
    soup = BeautifulSoup(html, "lxml")
    result: dict = {}

    if url:
        result["source_url"] = url

    # ── 1. Заголовок: <title> (запасной вариант)
    title_tag = soup.find("title")
    if title_tag and title_tag.string:
        result["title"] = title_tag.string.strip()

    # ── 2. Стандартные <meta>
    META_MAP = {
        "author":      "author",
        "description": "description",
        "keywords":    "keywords",
        "date":        "published_at",
        "article:published_time": "published_at",
    }
    for meta in soup.find_all("meta"):
        name = (meta.get("name") or meta.get("property") or "").lower()
        content = meta.get("content", "").strip()
        if name in META_MAP and content:
            result[META_MAP[name]] = content

    # ── 3. Open Graph — перезаписывает стандартные meta
    OG_MAP = {
        "og:title":            "title",
        "og:description":      "description",
        "og:type":             "content_type",
        "og:site_name":        "site_name",
        "article:author":      "author",
        "article:published_time": "published_at",
        "article:modified_time":  "modified_at",
        "article:section":     "category",
        "article:tag":         "tags",
    }
    for meta in soup.find_all("meta", property=True):
        prop = meta["property"].lower()
        content = meta.get("content", "").strip()
        if prop in OG_MAP and content:
            key = OG_MAP[prop]
            if key == "tags":
                result.setdefault("tags", []).append(content)
            else:
                result[key] = content

    # ── 4. JSON-LD (высший приоритет — структурированные данные)
    for script in soup.find_all("script", type="application/ld+json"):
        try:
            data = json.loads(script.string or "")
            _merge_json_ld(data, result)
        except (json.JSONDecodeError, AttributeError):
            pass

    return result


def _merge_json_ld(data: dict, result: dict) -> None:
    """Парсит Article, BlogPosting, NewsArticle из JSON-LD."""
    schema_type = data.get("@type", "")
    if schema_type not in (
        "Article", "BlogPosting", "NewsArticle",
        "TechArticle", "WebPage", "FAQPage",
    ):
        return

    if h := data.get("headline") or data.get("name"):
        result["title"] = str(h)
    if d := data.get("description"):
        result["description"] = str(d)
    if p := data.get("datePublished"):
        result["published_at"] = str(p)
    if m := data.get("dateModified"):
        result["modified_at"] = str(m)

    # author — может быть строка, объект {"@type":"Person","name":"..."} или список
    author_raw = data.get("author")
    if isinstance(author_raw, str):
        result["author"] = author_raw
    elif isinstance(author_raw, dict):
        result["author"] = author_raw.get("name", "")
    elif isinstance(author_raw, list):
        names = [
            a.get("name", "") if isinstance(a, dict) else str(a)
            for a in author_raw
        ]
        result["author"] = "; ".join(n for n in names if n)

    if kw := data.get("keywords"):
        result["keywords"] = str(kw)

Уровень 3: эвристики по содержимому

Когда форматные метаданные отсутствуют или не заполнены, смотрим в сам текст. Три самых ценных поля — заголовок, дата и автор — часто присутствуют в начале документа в узнаваемых паттернах.

Заголовок: первый h1 или жирный абзац

import re


def extract_title_from_text(text: str, max_len: int = 200) -> str | None:
    """
    Ищет заголовок в первых строках документа.
    Стратегии (по убыванию надёжности):
    1. Строка в ВЕРХНЕМ РЕГИСТРЕ (типичный стиль Word-документов)
    2. Строка короче max_len без точки в конце (заголовок не оканчивается точкой)
    3. Первая непустая строка (fallback)
    """
    lines = [l.strip() for l in text.split("\n") if l.strip()][:20]

    # 1. Полностью верхний регистр, минимум 5 символов
    for line in lines[:5]:
        if len(line) >= 5 and line == line.upper() and not line.isdigit():
            return line[:max_len]

    # 2. Первая строка без точки в конце (не предложение)
    for line in lines[:5]:
        if 5 <= len(line) <= max_len and not line.endswith("."):
            # Исключаем строки с датой — они не заголовки
            if not re.search(r"\d{2}[./-]\d{2}[./-]\d{2,4}", line):
                return line

    # 3. Первая непустая строка обрезается до max_len
    if lines:
        return lines[0][:max_len]

    return None

Дата: regex по 15+ паттернам

Даты в русскоязычных документах встречаются в десятках форматов. Задача — найти первую дату в документе (как правило, дата публикации располагается в начале), распарсить её и привести к ISO 8601.

\d{2}\.\d{2}\.\d{4}
15.03.2024
Самый распространённый в RU
\d{1,2}\s+(января|февраля|...)\s+\d{4}
15 марта 2024
Месяц прописью (русский)
\d{4}-\d{2}-\d{2}
2024-03-15
ISO 8601 (технические доки)
\d{1,2}/\d{1,2}/\d{4}
15/03/2024 или 3/15/2024
Неоднозначный — нужна локаль
\d{1,2}\s+(Jan|Feb|Mar|...)\s+\d{4}
15 Mar 2024
Английский месяц прописью
import re
from datetime import date, datetime, timezone

# Русские названия месяцев → номер
RU_MONTHS = {
    "января": 1, "февраля": 2, "марта": 3, "апреля": 4,
    "мая": 5, "июня": 6, "июля": 7, "августа": 8,
    "сентября": 9, "октября": 10, "ноября": 11, "декабря": 12,
}
EN_MONTHS = {
    "jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
    "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
}

# Паттерны в порядке приоритета (специфичные → общие)
DATE_PATTERNS = [
    # ISO 8601 с временем: 2024-03-15T10:22:00
    (re.compile(r"\b(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}"), "iso_dt"),
    # ISO date: 2024-03-15
    (re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b"), "iso"),
    # Русский прописью: 15 марта 2024
    (re.compile(
        r"\b(\d{1,2})\s+(" + "|".join(RU_MONTHS) + r")\s+(\d{4})\b",
        re.IGNORECASE
    ), "ru_text"),
    # Английский прописью: 15 Mar 2024 / March 15, 2024
    (re.compile(
        r"\b(\d{1,2})\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\s+(\d{4})\b",
        re.IGNORECASE
    ), "en_text"),
    # DD.MM.YYYY или DD/MM/YYYY
    (re.compile(r"\b(\d{2})[./](\d{2})[./](\d{4})\b"), "dmy"),
]


def extract_date_from_text(text: str, search_chars: int = 3000) -> str | None:
    """
    Ищет первую дату в первых search_chars символах текста.
    Возвращает ISO 8601 строку или None.
    """
    snippet = text[:search_chars]

    for pattern, fmt in DATE_PATTERNS:
        m = pattern.search(snippet)
        if not m:
            continue

        try:
            d = _parse_match(m, fmt)
            if d and 1990 <= d.year <= datetime.now().year + 1:
                return d.isoformat()
        except (ValueError, KeyError):
            continue

    return None


def _parse_match(m: re.Match, fmt: str) -> date | None:
    if fmt in ("iso", "iso_dt"):
        return date(int(m.group(1)), int(m.group(2)), int(m.group(3)))

    if fmt == "ru_text":
        day, month_name, year = m.group(1), m.group(2).lower(), m.group(3)
        return date(int(year), RU_MONTHS[month_name], int(day))

    if fmt == "en_text":
        day = int(m.group(1))
        month = EN_MONTHS[m.group(2).lower()[:3]]
        year = int(m.group(3))
        return date(year, month, day)

    if fmt == "dmy":
        d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))
        if 1 <= mo <= 12:  # DD.MM.YYYY
            return date(y, mo, d)

    return None

Автор: ключевые слова и паттерны

import re


# Метки, после которых идёт имя автора (RU + EN)
_AUTHOR_PREFIXES = re.compile(
    r"(?:Автор|Составитель|Разработал|Подготовил|Author|By|Written by)"
    r"\s*[:\-–]?\s*",
    re.IGNORECASE,
)

# Стандартный ФИО паттерн: Иванов И.И. / Иванов Иван Иванович
_RU_NAME_RE = re.compile(
    r"[А-ЯЁ][а-яё]+\s+[А-ЯЁ][а-яё]+(?:\s+[А-ЯЁ][а-яё]+)?"  # Полное имя
    r"|[А-ЯЁ][а-яё]+\s+[А-ЯЁ]\.[А-ЯЁ]\."                     # Фамилия И.О.
)

# Английское имя: John Doe / J. Doe
_EN_NAME_RE = re.compile(r"[A-Z][a-z]+\s+(?:[A-Z]\.\s*)?[A-Z][a-z]+")


def extract_author_from_text(text: str, search_chars: int = 2000) -> str | None:
    """
    Ищет имя автора в первых search_chars символах.
    Приоритет: явный префикс «Автор:» → имя после него.
    Fallback: первое найденное ФИО в документе.
    """
    snippet = text[:search_chars]

    # 1. Явный маркер «Автор: Иванов И.И.»
    m = _AUTHOR_PREFIXES.search(snippet)
    if m:
        rest = snippet[m.end():m.end() + 80].split("\n")[0].strip()
        # Берём до запятой или конца строки
        candidate = rest.split(",")[0].strip()
        if 3 <= len(candidate) <= 60:
            return candidate

    # 2. Первое ФИО по паттерну в RU
    m = _RU_NAME_RE.search(snippet)
    if m:
        return m.group(0).strip()

    # 3. Английское имя
    m = _EN_NAME_RE.search(snippet)
    if m:
        return m.group(0).strip()

    return None

Нормализация: привести всё к единой схеме

Даты из разных источников приходят в разных форматах: PDF даёт D:20240315102200+03'00', HTML — 2024-03-15T10:22:00+03:00, FS — Unix timestamp. Нужен единый нормализатор, который принимает любой формат и возвращает UTC ISO 8601.

import re
from datetime import datetime, timezone


# Форматы, которые пробуем по очереди через strptime
_DATE_FORMATS = [
    "%Y-%m-%dT%H:%M:%S%z",    # ISO с timezone: 2024-03-15T10:22:00+03:00
    "%Y-%m-%dT%H:%M:%SZ",     # ISO UTC: 2024-03-15T10:22:00Z
    "%Y-%m-%dT%H:%M:%S",      # ISO без TZ (считаем UTC)
    "%Y-%m-%d",                # Только дата
    "%d.%m.%Y",                # DD.MM.YYYY
    "%d/%m/%Y",                # DD/MM/YYYY
    "%B %d, %Y",               # March 15, 2024 (EN)
    "%d %B %Y",                # 15 March 2024 (EN)
    "%Y%m%d",                  # YYYYMMDD (компактный)
]


def normalize_date(raw: str | None) -> str | None:
    """Приводит дату в любом формате к UTC ISO 8601 (YYYY-MM-DD)."""
    if not raw:
        return None
    raw = raw.strip()

    for fmt in _DATE_FORMATS:
        try:
            dt = datetime.strptime(raw, fmt)
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=timezone.utc)
            return dt.astimezone(timezone.utc).date().isoformat()
        except ValueError:
            continue

    # Пробуем извлечь из строки как последний шанс
    m = re.search(r"\b(\d{4})-(\d{2})-(\d{2})\b", raw)
    if m:
        return f"{m.group(1)}-{m.group(2)}-{m.group(3)}"

    return None


def normalize_author(raw: str | None) -> str | None:
    """
    Нормализует строку автора:
    - Убирает лишние пробелы и знаки препинания
    - Обрезает «Автор:» / «Author:» префикс если остался
    - Капитализирует слова
    """
    if not raw:
        return None
    # Убираем префиксы-маркеры если они попали в значение
    cleaned = re.sub(
        r"^(?:автор|автор:?|author:?|by:?)\s*",
        "", raw.strip(), flags=re.IGNORECASE
    )
    # Убираем незначимые символы в начале/конце
    cleaned = cleaned.strip(" .,;:-–—")
    if not cleaned or len(cleaned) < 2:
        return None
    return cleaned


def normalize_keywords(raw: str | None) -> list[str]:
    """Разбивает строку ключевых слов на список."""
    if not raw:
        return []
    # Разделители: запятая, точка с запятой, вертикальная черта
    parts = re.split(r"[,;|]+", raw)
    return [p.strip() for p in parts if p.strip()]

Унифицированная схема DocumentMetadata

Все извлечённые и нормализованные поля собираются в единый датакласс DocumentMetadata. Он определяет контракт между загрузчиком и RAG-пайплайном: какие поля гарантированно присутствуют, какие опциональны, и в каком формате ожидать значения.

Обязательные поля
source
str
Абсолютный путь или URL
file_hash
str
SHA-256 содержимого
doc_type
str
pdf / docx / html / md / db
indexed_at
str
UTC ISO 8601 — когда проиндексировали
Опциональные поля
title
str | None
Заголовок документа
author
str | None
Автор или список через «; »
published_at
str | None
Дата публикации YYYY-MM-DD
modified_at
str | None
Дата последнего изменения
language
str | None
ISO 639-1: «ru», «en»
keywords
list[str]
Список тегов / ключевых слов
word_count
int | None
Для оценки объёма
_confidence
dict
Уверенность в каждом поле 0–1
from dataclasses import dataclass, field
from datetime import datetime, timezone


@dataclass
class DocumentMetadata:
    # ── Обязательные ──────────────────────────────────────────────────
    source:     str          # путь / URL
    file_hash:  str          # SHA-256
    doc_type:   str          # pdf | docx | html | md | db
    indexed_at: str = field(
        default_factory=lambda: datetime.now(timezone.utc).isoformat()
    )

    # ── Описательные (опциональные) ───────────────────────────────────
    title:        str | None = None
    author:       str | None = None
    published_at: str | None = None   # YYYY-MM-DD
    modified_at:  str | None = None   # YYYY-MM-DD
    description:  str | None = None
    language:     str | None = None   # ISO 639-1
    keywords:     list[str]  = field(default_factory=list)
    category:     str | None = None
    word_count:   int | None = None

    # ── Технические ───────────────────────────────────────────────────
    file_size:  int | None = None     # байты
    filename:   str | None = None
    directory:  str | None = None

    # ── Уверенность в полях (0.0 – 1.0) ──────────────────────────────
    _confidence: dict[str, float] = field(default_factory=dict)

    def to_dict(self) -> dict:
        """Конвертирует в словарь для хранения в векторной БД."""
        d = {
            k: v for k, v in self.__dict__.items()
            if not k.startswith("_") and v is not None and v != []
        }
        return d

    def is_fresh(self, days: int = 365) -> bool:
        """Проверяет, не устарел ли документ (по дате публикации или изменения)."""
        from datetime import timedelta
        date_str = self.modified_at or self.published_at
        if not date_str:
            return True  # нет данных — считаем актуальным
        try:
            from datetime import date
            doc_date = date.fromisoformat(date_str[:10])
            return (date.today() - doc_date) <= timedelta(days=days)
        except ValueError:
            return True

LLM-экстракция: когда эвристики не справляются

Документы бывают нестандартными: договор начинается с реквизитов, а имя автора спрятано в подписи на последней странице; технический отчёт имеет дату только в колонтитуле. Для таких случаев — LLM-экстрактор как fallback.

Принцип: подаём первые и последние N символов документа (там обычно и название, и подпись), просим вернуть JSON. Модель haiku справляется за ≈200 мс и стоит дёшево — это не тот случай, где нужен Claude Opus.

Когда запускать LLM: только если основные поля (title, author, published_at) не удалось извлечь через Уровни 1–3. LLM-экстракция добавляет задержку и стоимость — не делайте её дефолтным шагом.
import asyncio
import json

import anthropic

client = anthropic.AsyncAnthropic()

EXTRACT_PROMPT = """Проанализируй фрагменты документа и извлеки метаданные.

НАЧАЛО ДОКУМЕНТА:
{head}

КОНЕЦ ДОКУМЕНТА:
{tail}

Верни JSON с полями (используй null если не удалось найти):
{{
  "title": "заголовок документа",
  "author": "имя автора или null",
  "published_at": "дата в формате YYYY-MM-DD или null",
  "language": "ru или en или null",
  "keywords": ["ключевое слово", ...],
  "description": "одно предложение о чём документ или null"
}}

Только JSON, без пояснений."""


async def llm_extract_metadata(text: str, head_chars: int = 1500, tail_chars: int = 500) -> dict:
    """
    Извлекает метаданные через LLM из начала и конца документа.
    Используется как fallback когда структурированные метаданные отсутствуют.
    """
    head = text[:head_chars].strip()
    tail = text[-tail_chars:].strip() if len(text) > head_chars else ""

    prompt = EXTRACT_PROMPT.format(head=head, tail=tail)

    try:
        response = await client.messages.create(
            model="claude-haiku-4-5-20251001",
            max_tokens=400,
            messages=[{"role": "user", "content": prompt}],
        )
        raw = response.content[0].text.strip()

        # Вырезаем JSON если он обёрнут в ```json ... ```
        if raw.startswith("```"):
            raw = raw.split("```")[1]
            if raw.startswith("json"):
                raw = raw[4:]
            raw = raw.strip()

        data = json.loads(raw)
        return {k: v for k, v in data.items() if v is not None}

    except (json.JSONDecodeError, anthropic.APIError, IndexError):
        return {}


# Пример использования с таймаутом
async def safe_llm_extract(text: str, timeout: float = 5.0) -> dict:
    try:
        return await asyncio.wait_for(llm_extract_metadata(text), timeout=timeout)
    except asyncio.TimeoutError:
        return {}

Оценка уверенности в полях

Разные источники дают разную степень надёжности. Поле _confidence в схеме позволяет нижестоящим компонентам учитывать это при ранжировании: документ с датой из DocInfo (уверенность 0.95) приоритетнее документа с датой из regex в тексте (уверенность 0.5).

Дата из DocInfo/XMP (PDF)
0.95
Дата из DOCX core_properties
0.95
Дата из HTML article:published_time
0.90
Дата из JSON-LD datePublished
0.90
Дата из mtime файловой системы
0.70
Дата из LLM-экстракции
0.65
Дата из ISO regex в тексте
0.55
Дата из DD.MM.YYYY regex в тексте
0.45

Финальный пайплайн: MetadataExtractor

Соберём все компоненты в класс MetadataExtractor, который принимает документ с текстом и возвращает заполненный DocumentMetadata. Логика: сначала FS-метаданные (всегда), потом форматные (если доступны), потом контентные эвристики для незаполненных полей, наконец LLM как fallback.

import hashlib
from dataclasses import dataclass
from pathlib import Path

# Предполагаем что Document из предыдущего урока
@dataclass
class Document:
    page_content: str
    metadata: dict


class MetadataExtractor:
    """
    Многоуровневый экстрактор метаданных.
    Приоритет: Format Metadata > Content Heuristics > LLM Fallback.
    FS Metadata добавляется всегда поверх всего.
    """

    def __init__(self, use_llm_fallback: bool = True):
        self.use_llm_fallback = use_llm_fallback

    async def extract(self, doc: Document) -> DocumentMetadata:
        source = doc.metadata.get("source", "")
        text   = doc.page_content
        ext    = Path(source).suffix.lower() if source else ""

        # ── Шаг 1: FS метаданные (базовые — всегда) ─────────────────
        fs_meta = extract_fs_metadata(source) if source and Path(source).exists() else {}

        file_hash = fs_meta.get(
            "file_hash",
            hashlib.sha256(text.encode()).hexdigest()
        )

        # ── Шаг 2: Форматные метаданные ──────────────────────────────
        fmt_meta: dict = {}
        if ext == ".pdf":
            fmt_meta = extract_pdf_metadata(source)
        elif ext == ".docx":
            fmt_meta = extract_docx_metadata(source)
        elif ext in (".html", ".htm"):
            fmt_meta = extract_html_metadata(
                text, url=doc.metadata.get("source_url", "")
            )
        elif ext in (".md", ".markdown"):
            # YAML frontmatter уже должен быть в doc.metadata
            fmt_meta = {
                k: v for k, v in doc.metadata.items()
                if k in ("title", "author", "date", "tags", "category")
            }
            if "date" in fmt_meta:
                fmt_meta["published_at"] = fmt_meta.pop("date")

        # ── Шаг 3: Контентные эвристики для незаполненных полей ──────
        content_meta: dict = {}
        confidence: dict[str, float] = {}

        if not fmt_meta.get("title"):
            t = extract_title_from_text(text)
            if t:
                content_meta["title"] = t
                confidence["title"] = 0.6

        date_src = (
            fmt_meta.get("published_at") or fmt_meta.get("created_at")
            or fmt_meta.get("modified_at")
        )
        if not date_src:
            d = extract_date_from_text(text)
            if d:
                content_meta["published_at"] = d
                confidence["published_at"] = 0.5

        if not fmt_meta.get("author"):
            a = extract_author_from_text(text)
            if a:
                content_meta["author"] = a
                confidence["author"] = 0.55

        # ── Шаг 4: Слияние (Format > Content > FS fallback) ──────────
        merged = {**content_meta, **fmt_meta}  # Format перебивает Content

        # Нормализация
        merged["title"]        = merged.get("title")
        merged["author"]       = normalize_author(merged.get("author"))
        merged["published_at"] = normalize_date(
            merged.get("published_at") or merged.get("created_at")
        )
        merged["modified_at"]  = normalize_date(
            merged.get("modified_at") or fs_meta.get("modified_at")
        )
        merged["keywords"] = normalize_keywords(merged.get("keywords", ""))

        # ── Шаг 5: LLM fallback если ключевые поля пусты ─────────────
        needs_llm = (
            self.use_llm_fallback
            and not merged.get("title")
            and not merged.get("author")
            and not merged.get("published_at")
        )
        if needs_llm:
            llm_meta = await safe_llm_extract(text)
            for key in ("title", "author", "published_at", "language", "keywords", "description"):
                if llm_meta.get(key) and not merged.get(key):
                    merged[key] = llm_meta[key]
                    confidence[key] = 0.65  # LLM confidence

        # ── Шаг 6: Определение языка (если не установлен) ─────────────
        if not merged.get("language"):
            merged["language"] = _detect_language(text[:500])

        # ── Сборка DocumentMetadata ───────────────────────────────────
        meta = DocumentMetadata(
            source      = source,
            file_hash   = file_hash,
            doc_type    = ext.lstrip(".") or "unknown",
            title       = merged.get("title"),
            author      = merged.get("author"),
            published_at= merged.get("published_at"),
            modified_at = merged.get("modified_at"),
            description = merged.get("description"),
            language    = merged.get("language"),
            keywords    = merged.get("keywords") or [],
            category    = merged.get("category"),
            word_count  = len(text.split()),
            file_size   = fs_meta.get("file_size"),
            filename    = fs_meta.get("filename"),
            directory   = fs_meta.get("directory"),
            _confidence = confidence,
        )
        return meta


def _detect_language(text: str) -> str:
    """Простая эвристика: считаем кириллицу и латиницу."""
    cyrillic = sum(1 for c in text if "\u0400" <= c <= "\u04FF")
    latin    = sum(1 for c in text if "a" <= c.lower() <= "z")
    if cyrillic == 0 and latin == 0:
        return "unknown"
    return "ru" if cyrillic >= latin else "en"

Интеграция с документным пайплайном

import asyncio


async def process_documents(paths: list[str]) -> list[Document]:
    """Полный пайплайн: загрузить документ + обогатить метаданными."""
    extractor = MetadataExtractor(use_llm_fallback=True)
    results = []

    for path in paths:
        # 1. Загружаем текст через подходящий загрузчик
        ext = Path(path).suffix.lower()
        if ext == ".pdf":
            docs = PDFLoader(path).load()
        elif ext == ".docx":
            docs = DocxLoader(path).load()
        else:
            continue

        for doc in docs:
            # 2. Извлекаем метаданные
            meta = await extractor.extract(doc)

            # 3. Обновляем поле metadata в документе
            doc.metadata.update(meta.to_dict())
            results.append(doc)

    return results


async def demo():
    paths = [
        "/docs/legal/contract_2024.pdf",
        "/docs/hr/policy_remote_work.docx",
    ]
    docs = await process_documents(paths)
    for doc in docs:
        print(f"[{doc.metadata['doc_type'].upper()}] {doc.metadata.get('title', '—')}")
        print(f"  Author:  {doc.metadata.get('author', '—')}")
        print(f"  Date:    {doc.metadata.get('published_at', '—')}")
        print(f"  Words:   {doc.metadata.get('word_count', 0)}")
        print(f"  Lang:    {doc.metadata.get('language', '—')}")
        print()


if __name__ == "__main__":
    asyncio.run(demo())

Типичные ошибки

Слепое доверие mtime как дате документа
mtime меняется при каждом копировании файла. Документ 2018 года, скопированный сегодня, получит mtime = сегодня — и будет отображаться как свежий.
✓ mtime используем только как fallback. Предпочитаем DocInfo/XMP дату или дату из контента.
Regex дат — первое совпадение ≠ дата документа
Документ может начинаться с таблицы, где первая дата — срок действия договора или дата рождения сотрудника, а не дата публикации.
✓ Ищем дату после ключевых слов: «от», «дата», «утверждён», «опубликован». Ограничиваем поиск первыми 500 символами.
Хранение метаданных в page_content
Если заголовок и автор попадают в текст чанка — они засоряют векторное пространство и дублируются в каждом чанке документа.
✓ Метаданные — только в поле metadata объекта Document. В page_content — только смысловой текст.
Запускать LLM-экстрактор на каждый документ
При 10 000 документов и стоимости ≈$0.001 за вызов — это $10 при каждом переиндексировании. Плюс задержка и rate limits.
✓ LLM — только fallback. Запускаем если и только если title + author + date == None после всех уровней.
Не нормализовать даты к единому формату
В векторной БД окажется: «15.03.2024», «2024-03-15», «March 15, 2024» — фильтрация по диапазону дат перестаёт работать.
✓ Всегда нормализуем к YYYY-MM-DD (ISO 8601 date). Время и timezone → UTC перед сохранением.
Игнорировать _confidence
Ранжировать документ с датой 0.45 (DD.MM.YYYY regex) так же, как документ с датой 0.95 (DocInfo) — ошибка. Поисковик не знает, насколько дата надёжна.
✓ Храните confidence в метаданных. При ранжировании умножайте score на коэффициент уверенности.

Шпаргалка

Три уровня метаданных — три приоритета:
  1. Format Metadata (PDF DocInfo/XMP, DOCX core.xml, HTML og:, MD frontmatter) — берём первым, уверенность 0.9–0.95
  2. Content Heuristics (первый h1 → title, regex дат, «Автор:» паттерны) — только если Уровень 2 пуст, уверенность 0.45–0.6
  3. LLM Fallback (claude-haiku, head+tail документа) — только если всё остальное не сработало, уверенность 0.65
FS Metadata добавляется всегда — source, file_hash, modified_at как fallback даты.
ПРИОРИТЕТ ИСТОЧНИКОВ (от высокого к низкому):

title:        XMP dc:title → og:title → JSON-LD headline → <title> → first h1 → LLM
author:       XMP dc:creator → DocInfo Author → og:article:author → JSON-LD author
              → DOCX core_properties.author → «Автор:» regex → LLM
published_at: JSON-LD datePublished → og:article:published_time → DocInfo creationDate
              → DOCX created → MD date → DD.MM.YYYY regex → mtime fallback → LLM
language:     HTML lang attr → og:locale → символы кириллица/латиница

НОРМАЛИЗАЦИЯ:
  Все даты → UTC ISO 8601 (YYYY-MM-DD)
  Все авторы → strip(".,;:-–—"), remove prefix "Автор:"
  keywords → list[str] через split(",;|")

ХРАНЕНИЕ:
  Метаданные → Document.metadata (не в page_content!)
  Обязательно: source, file_hash, doc_type, indexed_at
  Опционально: title, author, published_at, language, keywords
        
# Быстрый старт: PDF с LLM fallback
import asyncio
from pathlib import Path

async def enrich_pdf(path: str) -> dict:
    doc = Document(
        page_content=extract_pdf_text(path),
        metadata={"source": path}
    )
    extractor = MetadataExtractor(use_llm_fallback=True)
    meta = await extractor.extract(doc)
    return meta.to_dict()

# result: {"title": "...", "author": "...", "published_at": "2024-03-15",
#          "language": "ru", "file_hash": "a3f2...", "word_count": 4521, ...}

Практические задания

  1. Обогатитель документов. Напишите скрипт, который обходит директорию с PDF-файлами, извлекает метаданные через MetadataExtractor и сохраняет результат в metadata.jsonl (одна JSON-строка на документ). Добавьте прогресс-бар через tqdm и статистику: сколько документов имеют title / author / published_at.
  2. Детектор дат. Расширьте функцию extract_date_from_text() добавив поддержку паттерна «от <DD> <месяц> <YYYY> года» (типичный для российских договоров и приказов). Напишите pytest-тесты на 10 разных форматов дат.
  3. Фильтрация по метаданным в Qdrant. Проиндексируйте 20+ документов с метаданными в Qdrant. Реализуйте функцию search_recent(query, days=90), которая использует qdrant_client фильтр FieldCondition(key="published_at", range=DatetimeRange(gte=...)) для поиска только среди документов не старше N дней.