forked from zovos/vk_hackathon
329 lines
13 KiB
Markdown
329 lines
13 KiB
Markdown
# Report: Go Nova Data Audit
|
||
|
||
Дата: 2026-04-18
|
||
|
||
## Что анализировал
|
||
|
||
Под "Go Data.js" интерпретировал файл [data/Go Nova.json](/home/q/doc/hackaton/data/Go%20Nova.json), потому что в репозитории это единственный релевантный датасет для `index` и `search`.
|
||
|
||
## Краткая статистика по данным
|
||
|
||
- всего сообщений: `25`
|
||
- сообщений с пустым верхнеуровневым `text`: `15`
|
||
- сообщений с `parts`: `14`
|
||
- сообщений с `mentions`: `4`
|
||
- сообщений с `member_event`: `1`
|
||
- сообщений с `file_snippets`: `1`
|
||
- сообщений с `is_forward = true`: `2`
|
||
- сообщений с `is_quote = true`: `5`
|
||
- сообщений с `thread_sn`: `0`
|
||
- сообщений с zero-width символом `\u200b`: `1`
|
||
|
||
## Что это значит для индексации
|
||
|
||
Текущий `index` теряет заметную часть смысла, потому что:
|
||
|
||
- слишком сильно полагается на `message.text`
|
||
- не различает `mediaType` внутри `parts`
|
||
- не превращает `member_event` в индексируемый текст
|
||
- не разбирает JSON в `file_snippets`
|
||
- не нормализует артефакты вроде zero-width символов
|
||
|
||
На этом датасете это критично: существенная доля сообщений живет целиком внутри `parts[*].text`.
|
||
|
||
## Наблюдаемые паттерны в сообщениях
|
||
|
||
### 1. Системные сообщения
|
||
|
||
Есть системное сообщение без текста, но с `member_event`:
|
||
|
||
- `type = addMembers`
|
||
- список участников лежит в `members`
|
||
|
||
Такое сообщение нельзя отбрасывать. Его нужно материализовать в текст вроде:
|
||
|
||
```text
|
||
system event: add members
|
||
actor: n.lebedev@corp.example
|
||
members: l.smirnova@corp.example, m.orlova@corp.example, v.baranova@corp.example, n.lebedev@corp.example
|
||
```
|
||
|
||
### 2. Сообщения, где весь смысл в `parts`
|
||
|
||
Во многих сообщениях `text == ""`, а контент лежит в `parts[*].text`.
|
||
|
||
Наблюдаемые `mediaType`:
|
||
|
||
- `text`
|
||
- `quote`
|
||
- `forward`
|
||
|
||
Следствие:
|
||
|
||
- `parts` должны быть первичным источником текста, а не вторичным придатком к `text`
|
||
|
||
### 3. Цитаты
|
||
|
||
У quote-part встречаются:
|
||
|
||
- `mediaType = quote`
|
||
- `sn` как источник цитаты
|
||
- `time`
|
||
- `text`
|
||
|
||
Quote нельзя просто склеивать с ответом. Нужна разметка, например:
|
||
|
||
```text
|
||
quote_from: n.ermakova@team.example
|
||
quote_text: ...
|
||
reply_text: ...
|
||
```
|
||
|
||
Иначе dense/sparse видят просто один большой комок текста и теряют отношение "на что отвечали".
|
||
|
||
### 4. Forward-сообщения
|
||
|
||
Forward приходит как `parts[*].mediaType = forward`, часто с длинным телом анонса.
|
||
|
||
Для них полезно явно сохранять:
|
||
|
||
- что это пересланное сообщение
|
||
- источник `sn`
|
||
- текст forwarded-блока
|
||
|
||
Пример нормализованного вида:
|
||
|
||
```text
|
||
forwarded_from: 48377@chat.example
|
||
forward_text: ...
|
||
```
|
||
|
||
### 5. Файлы и ссылки
|
||
|
||
В `file_snippets` лежит JSON-строка, внутри которой есть полезные поля:
|
||
|
||
- `name`
|
||
- `mime`
|
||
- `original_url`
|
||
- `date_create`
|
||
|
||
Это нужно разбирать локально и добавлять в нормализованный текст, а не хранить сырой JSON.
|
||
|
||
Минимально:
|
||
|
||
```text
|
||
attachment_name: IMG_8471.webp
|
||
attachment_mime: image/webp
|
||
attachment_url: https://redacted.example/resource/001
|
||
```
|
||
|
||
Ссылки из текста тоже нельзя выбрасывать полностью. Их нужно:
|
||
|
||
- сохранять в `page_content`
|
||
- извлекать как отдельные токены/сигналы в `sparse_content`
|
||
|
||
### 6. Технический шум
|
||
|
||
В данных уже видны артефакты:
|
||
|
||
- zero-width символ `\u200b`
|
||
- лишние пустые строки
|
||
- неравномерные пробелы
|
||
|
||
Но чистить нужно осторожно, чтобы не повредить:
|
||
|
||
- email
|
||
- URL
|
||
- имена файлов
|
||
- термины вроде `CGO`, `Go 1.18`, `Mutex.TryLock`
|
||
|
||
## Предлагаемая локальная логика очистки сообщений
|
||
|
||
Вся очистка должна жить локально внутри `index`, без внешних API.
|
||
|
||
### Шаг 1. Извлечение сигналов из raw message
|
||
|
||
Из каждого сообщения собрать:
|
||
|
||
- `message.text`
|
||
- `parts[*]`
|
||
- `mentions`
|
||
- `member_event`
|
||
- `file_snippets`
|
||
- `sender_id`
|
||
- флаги `is_system`, `is_forward`, `is_quote`
|
||
|
||
### Шаг 2. Нормализация Unicode и whitespace
|
||
|
||
Безопасная очистка:
|
||
|
||
- удалить `\u200b`, `\u200c`, `\u200d`, `\ufeff`
|
||
- заменить `\r\n` на `\n`
|
||
- схлопнуть повторяющиеся пробелы внутри строки
|
||
- схлопнуть `3+` пустых строк до `2`
|
||
- обрезать пробелы по краям строк
|
||
|
||
Не делать агрессивную очистку:
|
||
|
||
- не удалять email
|
||
- не удалять URL
|
||
- не переводить все в lower
|
||
- не выкидывать цифры и версии
|
||
|
||
### Шаг 3. Нормализация `parts`
|
||
|
||
Правила:
|
||
|
||
- `mediaType = text`: добавить как обычный текстовый блок
|
||
- `mediaType = quote`: добавить маркеры `quote_from` и `quote_text`
|
||
- `mediaType = forward`: добавить маркеры `forwarded_from` и `forward_text`
|
||
- неизвестный `mediaType`: сохранять как `part_type: <value>` + текст, не терять содержимое
|
||
|
||
### Шаг 4. Нормализация системных событий
|
||
|
||
Для `member_event` генерировать текстовую форму.
|
||
|
||
Минимум поддержать:
|
||
|
||
- `addMembers`
|
||
- любые неизвестные события сохранять как `system_event_type: ...`
|
||
|
||
### Шаг 5. Нормализация файлов
|
||
|
||
`file_snippets` распарсить из JSON-строки локально.
|
||
|
||
Из каждого файла вытаскивать:
|
||
|
||
- имя
|
||
- mime
|
||
- url
|
||
- дату
|
||
|
||
Если JSON битый:
|
||
|
||
- не падать
|
||
- сохранить исходную строку как `attachment_raw`
|
||
|
||
### Шаг 6. Сборка трех текстовых представлений
|
||
|
||
`page_content`:
|
||
|
||
- читабельный текст для payload
|
||
- с маркерами quote/forward/system/file
|
||
|
||
`dense_content`:
|
||
|
||
- нормализованный текст с ролями и источниками
|
||
- без мусорных повторов и с понятной структурой
|
||
|
||
`sparse_content`:
|
||
|
||
- keyword-heavy версия
|
||
- email, mentions, file names, MIME, URL host/path, технические термины
|
||
|
||
### Шаг 7. Правила пропуска
|
||
|
||
Сообщение можно пропускать только если после нормализации одновременно пусты:
|
||
|
||
- основной текст
|
||
- `parts`
|
||
- `member_event`
|
||
- `file_snippets`
|
||
|
||
Иначе его нужно индексировать.
|
||
|
||
## Что обновил в документации
|
||
|
||
- [doc/prompt.md](/home/q/doc/hackaton/doc/prompt.md): добавил локальную логику очистки сообщений и требование логировать каждую правку
|
||
- [doc/output.md](/home/q/doc/hackaton/doc/output.md): создал этот отчет
|
||
|
||
## Что делать следующим шагом
|
||
|
||
1. Реализовать `index/rendering.py` и `index/cleaning.py` по этим правилам.
|
||
2. Добавить unit tests на системные, quote, forward и file-based сообщения.
|
||
3. Только после этого менять chunking и retrieval, чтобы не тюнить поиск на грязном тексте.
|
||
|
||
---
|
||
|
||
# Отчёт: Рефакторинг search и index (2026-04-18)
|
||
|
||
## Что изменено
|
||
|
||
### P0 — исправлен критический баг в search
|
||
|
||
**Файл:** `search/main.py` (до рефакторинга)
|
||
**Баг:** строка `must_conditions: []` была type annotation, а не присваивание. Любой запрос с `date_range` или `asker` вызывал `NameError` на `.append()`.
|
||
**Исправление:** присваивание `must_conditions = []` перенесено в `search/retrieval.py` корректно.
|
||
|
||
### search — модульная декомпозиция
|
||
|
||
**Было:** монолит `search/main.py` (~390 строк)
|
||
**Стало:** 6 модулей + тонкий main
|
||
|
||
| Модуль | Назначение |
|
||
|---|---|
|
||
| `search/config.py` | env vars, validate_required_env (теперь в lifespan, не при импорте) |
|
||
| `search/schemas.py` | pydantic модели |
|
||
| `search/query_builder.py` | построение dense/sparse запросов из question |
|
||
| `search/retrieval.py` | qdrant prefetch с multi-query + фильтры |
|
||
| `search/rerank.py` | reranker + сохранение хвоста |
|
||
| `search/aggregation.py` | dedup, top-50 |
|
||
| `search/main.py` | только FastAPI wiring |
|
||
|
||
**Логические изменения:**
|
||
- primary dense query: `search_text` с fallback на `text`
|
||
- дополнительные dense queries: `variants`, `hyde` — отдельные Prefetch
|
||
- sparse query: `keywords` или primary query при их отсутствии
|
||
- rerank: сортирует top-60, хвост retrieval сохраняется
|
||
- финал: dedup + top-50 из head+tail
|
||
- timeout=30s, retry до 2 раз на 5xx/сеть
|
||
|
||
**Параметры:** DENSE_PREFETCH_K=50, SPARSE_PREFETCH_K=100, RETRIEVE_K=80, RERANK_LIMIT=60, TOP_K=50
|
||
|
||
### index — модульная декомпозиция
|
||
|
||
**Было:** монолит `index/main.py` (~268 строк, char-based chunking)
|
||
**Стало:** 5 модулей + тонкий main
|
||
|
||
| Модуль | Назначение |
|
||
|---|---|
|
||
| `index/schemas.py` | pydantic модели |
|
||
| `index/cleaning.py` | локальная очистка, без внешних API |
|
||
| `index/rendering.py` | три представления: page/dense/sparse |
|
||
| `index/chunking.py` | message-based windowing + overlap |
|
||
| `index/sparse.py` | sparse embedding |
|
||
| `index/main.py` | только FastAPI wiring |
|
||
|
||
**Логические изменения:**
|
||
- **Базовая единица чанка**: сообщение, не символ
|
||
- **Окно**: ≤10 сообщений И ≤2048 символов И без time gap >1h
|
||
- **Overlap**: последние 3 сообщения из предыдущего окна
|
||
- **page_content**: читабельный текст с `sender: текст`
|
||
- **dense_content**: timestamp + role markers + mentions + файлы
|
||
- **sparse_content**: sender + mentions + filenames + url + текст
|
||
|
||
**Очистка (cleaning.py):**
|
||
- удаление zero-width chars (`\u200b`, `\u200c`, `\u200d`, `\ufeff`)
|
||
- mediaType-aware нормализация parts (text/quote/forward/unknown)
|
||
- member_event → человекочитаемый текст
|
||
- file_snippets → safe JSON parse + extract (name/mime/url/date)
|
||
- сообщение пропускается только если пусты text+parts+member_event+file_snippets
|
||
|
||
## Файлы изменены
|
||
|
||
**Изменены:** `search/main.py`, `index/main.py`
|
||
**Созданы:** `search/config.py`, `search/schemas.py`, `search/query_builder.py`, `search/retrieval.py`, `search/rerank.py`, `search/aggregation.py`, `search/__init__.py`, `index/schemas.py`, `index/cleaning.py`, `index/rendering.py`, `index/chunking.py`, `index/sparse.py`, `index/__init__.py`, `tests/` (5 test files)
|
||
|
||
## Проверка
|
||
|
||
- `python3 -m py_compile` — пройден на всех 13 новых/изменённых Python-файлах
|
||
- `pytest tests/ -q` — 68 тестов, все прошли
|
||
- API контракты не изменены: `POST /index`, `POST /sparse_embedding`, `POST /search`
|
||
|
||
## Что осталось
|
||
|
||
- metadata-aware boost/filter (participants, mentions, contains_quote, contains_forward, thread_sn)
|
||
- тюнинг параметров DENSE_PREFETCH_K / RETRIEVE_K / RERANK_LIMIT под реальные запросы
|
||
- regression test file с контрольными вопросами по Go Nova.json
|
||
- docker-compose / Makefile / README alignment (`--platform linux/amd64`)
|
||
- решение про `.ai_update/` в `.gitignore`
|