Зачем RAG нужны метаданные
Представьте корпоративную базу знаний: 10 000 документов, часть из которых — устаревшие версии регламентов. Векторный поиск по запросу «порядок согласования договора» вернёт топ-5 похожих чанков — но из каких документов? 2019 года или 2024-го? Из HR-отдела или юридического? Без метаданных эти вопросы неразрешимы.
Метаданные в RAG решают три задачи:
- Pre-retrieval фильтрация — ищем только в подмножестве документов
(
WHERE department='legal' AND year=2024) до векторного поиска. - Post-retrieval ранжирование — свежий документ от главного автора поднимается выше устаревшей копии.
- Атрибуция в ответе — LLM может написать «Согласно регламенту №ORG-42 от 15.03.2024, подписанному Ивановым И.И…»
Три источника метаданных
Метаданные документа живут в трёх разных местах — и у каждого своя надёжность.
Приоритет: Уровень 1 всегда достоверен (файловая система не врёт), Уровень 2 почти всегда достоверен (хотя автор мог не заполнить поля), Уровень 3 — эвристика, требующая проверки. Стратегия: берём из Уровня 2, если поле заполнено; иначе пробуем Уровень 3; FS-метаданные добавляем всегда.
Пайплайн извлечения метаданных
Уровень 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: метаданные, встроенные в форматы
Большинство документальных форматов имеют собственные стандарты хранения метаданных — заполненные редакторами или системой при сохранении файла.
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.
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-пайплайном: какие поля гарантированно присутствуют, какие опциональны,
и в каком формате ожидать значения.
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.
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).
Финальный пайплайн: 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())
Типичные ошибки
metadata объекта Document. В page_content — только смысловой текст._confidenceШпаргалка
- Format Metadata (PDF DocInfo/XMP, DOCX core.xml, HTML og:, MD frontmatter) — берём первым, уверенность 0.9–0.95
- Content Heuristics (первый h1 → title, regex дат, «Автор:» паттерны) — только если Уровень 2 пуст, уверенность 0.45–0.6
- LLM Fallback (claude-haiku, head+tail документа) — только если всё остальное не сработало, уверенность 0.65
ПРИОРИТЕТ ИСТОЧНИКОВ (от высокого к низкому):
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, ...}
Практические задания
-
Обогатитель документов. Напишите скрипт, который обходит директорию
с PDF-файлами, извлекает метаданные через
MetadataExtractorи сохраняет результат вmetadata.jsonl(одна JSON-строка на документ). Добавьте прогресс-бар черезtqdmи статистику: сколько документов имеют title / author / published_at. -
Детектор дат. Расширьте функцию
extract_date_from_text()добавив поддержку паттерна«от <DD> <месяц> <YYYY> года»(типичный для российских договоров и приказов). Напишите pytest-тесты на 10 разных форматов дат. -
Фильтрация по метаданным в Qdrant. Проиндексируйте 20+ документов
с метаданными в Qdrant. Реализуйте функцию
search_recent(query, days=90), которая используетqdrant_clientфильтрFieldCondition(key="published_at", range=DatetimeRange(gte=...))для поиска только среди документов не старше N дней.