Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 

README.md

RAG по аудио/видео-записям: рецепт end-to-end

Из набора записей (mp3-ссылки, RSS-фид подкаста, локальные файлы, видео) получаем агентский RAG, который отвечает на вопросы со ссылкой на секунду звука («момент»: запись · MM:SS · спикер, клик — и слышно, как это было сказано). Референс-деплой: RAG по подкасту «Капитанский мостик» (53 выпуска, 70 часов) — история решений в docs/adr/0015-0018.

Что понадобится

  • Транскрайб-бэкенд — services/asr-adaptor (диаризация + двухпроходный Whisper с LLM-глоссарием + имена спикеров по голосу; см. ADR-0017/0018). Аудио-модели Apple-Silicon-bound — primary-деплой нативный на Mac (services/asr-adaptor/deploy/mac, ставится одной командой); оркестратор без аудио можно в Docker, указав ему внешние аудио-URL.
  • LLM-ключ (OpenAI-совместимый провайдер) — для глоссария/правки при транскрипции и для агента/реранкера при ответах.
  • Эмбеддинги: dense (напр. Qwen3-Embedding через любого провайдера) + sparse GTE (services/embedder_gte, локальный сервис — публичных провайдеров нет).
  • Qdrant, ffmpeg (для видео и длительностей), python 3.12.

Шаг 1. Поднять транскрайб-бэкенд

На Apple Silicon ставится установщиком из этого репозитория — четыре сервиса, четыре venv, состояние в ~/asr-stack:

cd services/asr-adaptor/deploy/mac
./install.sh            # создаст ~/.asr-stack.env и остановится — впишите OR_KEY и HF_TOKEN
./install.sh            # venv-ы и зависимости (~10 мин, ~4.6 ГБ на диске)
./fetch-models.sh       # модели из сети (~1.6 ГБ): Whisper, CAM++, pyannote
./stack.sh up           # диаризатор грузит модель ~90 с
./stack.sh health       # downstream должен быть ok у всех трёх

Свободно качается всё, кроме двух gated-репозиториев pyannote, для которых понадобится HF-токен с принятыми условиями на pyannote/segmentation-3.0 и pyannote/speaker-diarization-3.1. Требования, таблица происхождения моделей и грабли — в deploy/mac/README.md.

Шаг 2. Настроить корпус

Корпус — это ваш набор записей целиком, а не отдельный файл. Конвейеру не нужно знать, встреча перед ним, лекция или подкаст: стадии одинаковы, а имена он либо найдёт в представлениях, либо честно оставит Speaker_N. Настраивается корпус окружением адаптера:

переменная что писать на что влияет если не заполнить
OR_KEY, ASR_LLM_BASE_URL, ASR_LLM_MODEL ключ и модель LLM (reasoning off) глоссарий, правка терминов, наминг сервис не стартует вовсе — клиент LLM строится на импорте модуля
ASR_CORPUS_DESC чем является материал, родительный падеж: "рабочей записи: встречи, митапа или обучающего материала по разработке" подсказка домена в промптах правки и наминга останется дефолт, и правка будет искать в записи чужой домен
ASR_ALWAYS_TERMS имена участников и постоянные названия через запятую идут в подсказку распознавания ВСЕГДА и первыми устойчивые искажения фамилий останутся: глоссарий переоткрывает термины на каждой записи и на стабильном гарбле срывается (замерено: одна фамилия искажалась 14 раз по корпусу)
ASR_REGISTRY_PATH свой файл на каждый корпус где копятся голоса — по ним имена узнаются между записями голоса разных проектов смешаются в одном пространстве Speaker_N, и ложный матч подпишет человека чужим именем
ASR_MAX_SPEAKERS сколько людей бывает в записи (дефолт 10) верхняя граница диаризации на совещании с десятком участников лишние голоса склеятся с чужими
ASR_MIN_GUEST_MIN сколько минут голос должен наговорить, чтобы стать отдельным человеком (дефолт 2.0) разделение докладчика и коротких реплик на записи с вопросами из зала все сведутся в одного человека — расшифровка при этом выглядит удавшейся
ASR_ENABLE_NAMING 1, только если каждая запись начинается с представлений два вызова LLM на запись дефолт 0 — имён не ищем. Вне «ведущий представляет гостя в начале» стадия подписывает не того: на митапе докладчика благодарят по имени в КОНЦЕ, и имя достаётся тому, кто это произнёс
HTTPS_PROXY если провайдер LLM не пускает ваш регион доступ к LLM-стадиям вызовы упрутся в отказ провайдера

Доменные значения держите в репозитории своего проекта (файл в git, без секретов), а секреты и пути — в ~/.asr-stack.env на машине; см. ADR-0023 и раздел «Профили» в deploy/mac/README.md.

⚠️ Настройки голосов задайте до первого прогона — подробности, два типовых профиля с числами и способ проверить результат в разделе «Первая настройка корпуса: голоса». На записи «один докладчик + вопросы из зала» дефолты сводят всех участников в одного Speaker_N.

Шаг 3. Расшифровать записи

Папка со смешанными записями — аудио и видео вперемешку, имена произвольные:

export ASR_BASE=http://127.0.0.1:8082
services/asr-adaptor/client/run_folder.sh ~/Records --dry   # показать, что будет сделано
services/asr-adaptor/client/run_folder.sh ~/Records

Результат ложится рядом с исходником: Планёрка 12 марта.mp4 → Планёрка 12 марта.md и .json. Идемпотентно (готовое пропускается, папку можно доливать), последовательно (реестр голосов общий).

Пронумерованные выпуски по URL-шаблону — если это подкаст:

export ASR_BASE=http://127.0.0.1:8082
export MP3_URL_TEMPLATE='https://site/episodes/ep{n}.mp3' # {n} — номер, {pfx} — сезонный префикс
export TITLE_TEMPLATE='Мой подкаст №{pfx}{n}'
export OUT_DIR=./transcripts/season1 CACHE_DIR=./media_cache/season1
services/asr-adaptor/client/run_corpus.sh 1 2 3 4 5      # последовательно! (реестр голосов)
# одиночная запись, прямой URL или локальный файл (видео → дорожка извлечётся ffmpeg'ом):
services/asr-adaptor/client/transcribe_one.sh 6 /path/to/lecture.mp4

На выходе — .md (front-matter + [Имя] <!-- t:сек --> текст) и .json (raw-сайдкар; пословные тайминги внутри, в x_enriched.words). Реестр голосов стартует пустым: участников именует LLM из представлений, дальше голоса узнаются между записями автоматически (ADR-0018).

Цена и время (замер на записи 91 минута, ноутбук с 16 ГБ): около 20 минут и $0.03 на запись, 166 вызовов LLM. Аудио-стадии дают 84% времени — упирается в машину, не в сеть. Померить свою: deploy/mac/bench.sh <файл>.

Проверить, что получилось — без прослушивания

Адаптер сам считает покрытие звука речью и кладёт отчёт в .json — отдельный инструмент не нужен:

python3 - <<'EOF'
import json, pathlib, sys
for f in sorted(pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else '.').glob('*.json')):
    xe = (json.load(open(f)).get('x_enriched') or {})
    cov, words = xe.get('coverage') or {}, (xe.get('words') or {}).get('turns') or []
    unheard = sum(b - a for a, b in cov.get('unheard', []))
    flat = [w for t in words for w in t.get('words', [])]
    ordered = all(flat[i][1] <= flat[i + 1][1] for i in range(len(flat) - 1))
    print(f"{f.name}: звука {cov.get('audio_sec', 0):.0f} с | не услышано {unheard:.1f} с "
          f"| слов {len(flat)} | порядок {'ок' if ordered else 'НАРУШЕН'}")
EOF

Приёмка по числам: не услышано — единицы секунд на запись; метки говорящих совпадают между записями с одними и теми же людьми; пословные тайминги строго по возрастанию (на неотсортированных караоке и перемотка по слову врут молча). Если перегоняете записи повторно и сравниваете с прежней расшифровкой, сравнивайте по словам и с autojunk=False: difflib на репликах длиннее 200 элементов занижает сходство вдвое и покажет расхождение там, где отличается одно слово.

Шаг 4. Обогащение front-matter (опционально, если есть RSS/страницы выпусков)

export MP3_URL_TEMPLATE='https://site/episodes/ep{n}.mp3'
export RSS_URL='https://site/feed.xml'                    # даты публикации
export EPISODE_PAGE_TEMPLATE='https://site/ep-{n}.html'   # темы в title из meta description
export TITLE_TEMPLATE='Мой подкаст №{pfx}{n}'
export TRANSCRIPTS_DIR=./transcripts/season1 MEDIA_CACHE_DIR=./media_cache/season1
python tools/enrich_frontmatter.py 1 2 3 4 5

Появятся date, duration_sec, темы в title, speakers — на них опираются каталог выпусков и фильтры retrieval.

Шаг 5. Конфиг и индексация

config.example.yml рядом — скопируйте, впишите ключи и участников. Ключевое: chunker.mode: transcript (тематические чанки с сохранением секунд и спикеров), retrieval.features.timestamp_citations: true (цитаты-моменты, ADR-0015), разговорный режим ответа в prompts.section_overrides (ADR-0016).

docker run -d --name qdrant -p 6333:6333 -v "$PWD/qdrant-data:/qdrant/storage" qdrant/qdrant
docker run -d --name gte -p 8081:8081 morag-embedder-gte   # или нативно, см. deploy-macos/
python -m cli.main index --config ./config.yml

Шаг 6. Ответы

OWUI + pipelines (см. корневой docker-compose) либо нативный стек без Docker — deploy-macos/ содержит launchd-плисты нашего публичного деплоя (qdrant-бинарь + GTE + pipelines + OWUI без авторизации, всё на loopback за реверс-прокси).

Обновление корпуса новой записью = Шаг 3 (один файл или номер) → Шаг 4 → cli.main index без --reset (инкрементально, старые записи скипаются) — ~15 минут на свежий выпуск.

Грабли, собранные за вас

  • Whisper-бэкенд обязан honor-ить prompt (стоковые сервер-обвязки игнорируют — потому свой).
  • Python 3.12 для нативного стека: OWUI требует <3.13, пины GTE без cp313-wheels.
  • OWUI не работает под URL-подпутём — публикуйте на поддомене.
  • WEBUI_AUTH=False валиден только на чистой data/ OWUI.
  • Реестр голосов — биометрия: держите локально, в git не кладите.
  • Транскрипты — производная вашего контента; чужие записи публикуйте только с разрешения.