# 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: ` + текст, не терять содержимое ### Шаг 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`