forked from zovos/vk_hackathon
Compare commits
28 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19af82ba11 | ||
|
|
dc3a5b9dfd | ||
|
|
6f122eabd0 | ||
|
|
3561a064d5 | ||
|
|
f9f1b332bd | ||
|
|
7247248b27 | ||
|
|
a4475abfb2 | ||
|
|
91aae61b65 | ||
|
|
24505baa1c | ||
|
|
deb422d03e | ||
|
|
d2e531cc07 | ||
|
|
965fc906f3 | ||
|
|
9750868ea8 | ||
|
|
0fbf69e359 | ||
|
|
f4b14edabf | ||
|
|
d5ed8c7764 | ||
|
|
bd4c7c24a3 | ||
|
|
143efd6531 | ||
|
|
1e276512fb | ||
|
|
e244b8bf30 | ||
|
|
d28964aa7e | ||
|
|
b15c59a579 | ||
|
|
3423200625 | ||
|
|
e95ca82a4a | ||
|
|
9db7d3544c | ||
|
|
6c75cc8376 | ||
|
|
aad36f0ac5 | ||
|
|
e49e3417eb |
29 changed files with 740 additions and 1751 deletions
|
|
@ -1,280 +0,0 @@
|
||||||
---
|
|
||||||
name: team-sync-hackathon
|
|
||||||
description: Use this skill for any work inside this repository when the task involves collaborative development, continuing previous work, restoring project context, updating shared progress logs, handing off work to another Codex or human, or bootstrapping the local dev stack. Do not use for unrelated one-off questions outside the repo.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Team Sync Hackathon Skill
|
|
||||||
|
|
||||||
This skill makes Codex behave like a persistent teammate inside the repository.
|
|
||||||
|
|
||||||
Its purpose is to:
|
|
||||||
- restore context at the start of every session
|
|
||||||
- keep a shared machine-readable and human-readable progress trail
|
|
||||||
- reduce repeated analysis
|
|
||||||
- make handoff between humans and Codex reliable
|
|
||||||
- automatically orient to the current repo state before coding
|
|
||||||
- keep the project runnable locally whenever validation is needed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Core rule
|
|
||||||
|
|
||||||
Do not start changing code blindly.
|
|
||||||
|
|
||||||
First restore context, then inspect git state, then inspect runtime state, then work, then write handoff.
|
|
||||||
|
|
||||||
If context is missing, create it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Shared state directory
|
|
||||||
|
|
||||||
Use `.ai_update/` as the canonical shared state area.
|
|
||||||
|
|
||||||
Required files:
|
|
||||||
|
|
||||||
- `.ai_update/current_status.md`
|
|
||||||
- `.ai_update/handoff.md`
|
|
||||||
- `.ai_update/changelog.md`
|
|
||||||
- `.ai_update/touched_files.md`
|
|
||||||
- `.ai_update/sessions/` (directory with per-session notes)
|
|
||||||
|
|
||||||
These files are part of the collaboration workflow and should be committed unless the team explicitly decides otherwise.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Mandatory startup workflow
|
|
||||||
|
|
||||||
At the beginning of each repo task, do this in order:
|
|
||||||
|
|
||||||
1. Read:
|
|
||||||
- `AGENTS.md`
|
|
||||||
- `README.md`
|
|
||||||
- relevant docs/config files
|
|
||||||
- `.ai_update/current_status.md`
|
|
||||||
- `.ai_update/handoff.md`
|
|
||||||
- latest 3 to 5 files from `.ai_update/sessions/`
|
|
||||||
- `.ai_update/changelog.md`
|
|
||||||
- `.ai_update/touched_files.md`
|
|
||||||
|
|
||||||
2. Inspect repository state:
|
|
||||||
- `git status --short --branch`
|
|
||||||
- `git log --oneline --decorate -n 15`
|
|
||||||
- inspect main app entrypoints and service layout
|
|
||||||
- identify current branch
|
|
||||||
- identify uncommitted work
|
|
||||||
- identify likely active area of development
|
|
||||||
|
|
||||||
3. If `.ai_update/` files are missing:
|
|
||||||
- create them immediately
|
|
||||||
- infer current project state from repository files and git history
|
|
||||||
- write a minimal baseline before making new code changes
|
|
||||||
|
|
||||||
4. Create a new session note:
|
|
||||||
- `.ai_update/sessions/YYYY-MM-DD_HH-MM-SS.md`
|
|
||||||
- include:
|
|
||||||
- task requested
|
|
||||||
- starting branch
|
|
||||||
- starting commit
|
|
||||||
- initial repo observations
|
|
||||||
- assumptions
|
|
||||||
- risks/blockers
|
|
||||||
|
|
||||||
5. Only after that begin implementation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Runtime bootstrapping workflow
|
|
||||||
|
|
||||||
When local validation is required, use the least destructive startup path.
|
|
||||||
|
|
||||||
Try in this order:
|
|
||||||
|
|
||||||
1. If `./bin/codex-start` exists, use it.
|
|
||||||
2. Else if `docker-compose.yml` exists:
|
|
||||||
- check whether services are already up
|
|
||||||
- if not, run `docker compose up -d --build`
|
|
||||||
3. Else if `compose.yaml` or `compose.yml` exists:
|
|
||||||
- run `docker compose up -d --build`
|
|
||||||
4. Else if `Makefile` exists and has a relevant target:
|
|
||||||
- try `make dev`
|
|
||||||
- otherwise `make up`
|
|
||||||
- otherwise `make run`
|
|
||||||
5. Else inspect project docs for the correct startup command.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- do not run destructive cleanup automatically
|
|
||||||
- do not remove volumes automatically
|
|
||||||
- do not rebuild everything if a simple start is enough
|
|
||||||
- if startup fails, record the failure and exact reason in `.ai_update/current_status.md` and the current session note
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Required logging during work
|
|
||||||
|
|
||||||
For each meaningful step, keep `.ai_update/` current.
|
|
||||||
|
|
||||||
### Update `.ai_update/current_status.md`
|
|
||||||
|
|
||||||
This file is the canonical current snapshot.
|
|
||||||
|
|
||||||
It must always contain:
|
|
||||||
|
|
||||||
- current goal
|
|
||||||
- done
|
|
||||||
- in progress
|
|
||||||
- blocked
|
|
||||||
- next actions
|
|
||||||
- current branch
|
|
||||||
- validation status
|
|
||||||
- known risks
|
|
||||||
|
|
||||||
### Append to `.ai_update/changelog.md`
|
|
||||||
|
|
||||||
Append a short entry for every meaningful change:
|
|
||||||
- timestamp
|
|
||||||
- what changed
|
|
||||||
- why
|
|
||||||
- files
|
|
||||||
- verification result
|
|
||||||
|
|
||||||
### Update `.ai_update/touched_files.md`
|
|
||||||
|
|
||||||
Maintain a concise list:
|
|
||||||
- file path
|
|
||||||
- purpose
|
|
||||||
- why touched
|
|
||||||
- whether complete/incomplete
|
|
||||||
- whether needs review
|
|
||||||
|
|
||||||
### Write session notes
|
|
||||||
|
|
||||||
Each session file should capture:
|
|
||||||
- objective
|
|
||||||
- context read
|
|
||||||
- commands run
|
|
||||||
- findings
|
|
||||||
- code changes
|
|
||||||
- test results
|
|
||||||
- unresolved issues
|
|
||||||
- handoff notes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Mandatory handoff before stopping
|
|
||||||
|
|
||||||
Before ending the session:
|
|
||||||
|
|
||||||
1. Update `.ai_update/current_status.md`
|
|
||||||
2. Update `.ai_update/handoff.md`
|
|
||||||
3. Append `.ai_update/changelog.md`
|
|
||||||
4. Update `.ai_update/touched_files.md`
|
|
||||||
5. Finalize current session note
|
|
||||||
|
|
||||||
`handoff.md` must answer:
|
|
||||||
|
|
||||||
- what was completed
|
|
||||||
- what was not completed
|
|
||||||
- what the next Codex/human should do first
|
|
||||||
- what files matter most
|
|
||||||
- how to run/verify
|
|
||||||
- what is risky or fragile
|
|
||||||
- whether there are uncommitted changes
|
|
||||||
|
|
||||||
Another engineer should be able to continue without rereading the full repo history.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Repository-specific guidance for this hackathon
|
|
||||||
|
|
||||||
Assume this repository follows a hackathon task with:
|
|
||||||
- an indexing service
|
|
||||||
- a search service
|
|
||||||
- vector storage in Qdrant
|
|
||||||
- fixed API contracts for `/index`, `/sparse_embedding`, and `/search`
|
|
||||||
- local docker-based development
|
|
||||||
- quality measured by retrieval relevance rather than just code style
|
|
||||||
|
|
||||||
When working on search/index logic:
|
|
||||||
|
|
||||||
- preserve public request/response contracts
|
|
||||||
- do not introduce runtime internet dependency inside index/search containers
|
|
||||||
- prefer retrieval quality improvements over cosmetic refactors
|
|
||||||
- avoid breaking dockerized local startup
|
|
||||||
- keep local validation straightforward
|
|
||||||
|
|
||||||
For search logic, prefer this query priority:
|
|
||||||
1. `question.search_text`
|
|
||||||
2. fallback to `question.text`
|
|
||||||
3. enrich with `question.variants`
|
|
||||||
4. consider `question.hyde`
|
|
||||||
5. consider `question.keywords`
|
|
||||||
6. consider `question.entities`
|
|
||||||
7. consider `question.date_mentions` and `question.date_range`
|
|
||||||
8. keep rerank/retrieval consistent with top-50 relevance goals
|
|
||||||
|
|
||||||
For indexing logic:
|
|
||||||
- keep chunking explainable
|
|
||||||
- preserve `message_ids` coverage clarity
|
|
||||||
- think explicitly about `page_content`, `dense_content`, and `sparse_content`
|
|
||||||
- log chunking and retrieval decisions if they affect quality significantly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Collaboration rules
|
|
||||||
|
|
||||||
- Never assume previous work is obsolete without evidence.
|
|
||||||
- Read before rewriting.
|
|
||||||
- Prefer extending existing modules over creating parallel implementations.
|
|
||||||
- Preserve teammate intent when possible.
|
|
||||||
- If you must replace an approach, document why in `.ai_update/changelog.md` and `handoff.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Git rules
|
|
||||||
|
|
||||||
- Do not commit unrelated changes.
|
|
||||||
- Do not revert teammate changes without explicit reason.
|
|
||||||
- Do not force push unless explicitly instructed.
|
|
||||||
- Before commit, summarize exactly what changed in `.ai_update/`.
|
|
||||||
- Commit messages should be specific and scoped.
|
|
||||||
|
|
||||||
Preferred commit style:
|
|
||||||
- `search: use search_text with fallback to text`
|
|
||||||
- `index: enrich sparse content with metadata`
|
|
||||||
- `infra: add codex shared handoff workflow`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Minimal file templates
|
|
||||||
|
|
||||||
If files are missing, initialize them with these templates.
|
|
||||||
|
|
||||||
### `.ai_update/current_status.md`
|
|
||||||
|
|
||||||
```md
|
|
||||||
# Current Status
|
|
||||||
|
|
||||||
## Current goal
|
|
||||||
-
|
|
||||||
|
|
||||||
## Done
|
|
||||||
-
|
|
||||||
|
|
||||||
## In progress
|
|
||||||
-
|
|
||||||
|
|
||||||
## Blocked
|
|
||||||
-
|
|
||||||
|
|
||||||
## Next actions
|
|
||||||
-
|
|
||||||
|
|
||||||
## Current branch
|
|
||||||
-
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
- Not run / Passed / Failed
|
|
||||||
|
|
||||||
## Risks / notes
|
|
||||||
-
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
# AI Update Log
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
- File: `search/main.py`
|
|
||||||
- Purpose: fixed and extended retrieval/rerank pipeline according to TODO items.
|
|
||||||
|
|
||||||
## Done Changes
|
|
||||||
- Switched base query selection to `question.search_text` with fallback to `question.text`.
|
|
||||||
- Added support for `question.variants` as additional query variants in retrieval.
|
|
||||||
- Added support for `question.hyde` as additional dense-only queries.
|
|
||||||
- Added support for `question.keywords` as the primary source for sparse query text with fallback to current query variant.
|
|
||||||
- Stopped losing retrieval candidates after rerank: rerank is applied to head (`RERANK_LIMIT`), tail candidates are preserved.
|
|
||||||
- Added deduplication of retrieval points by Qdrant point id before rerank.
|
|
||||||
- Implemented score aggregation by `message_id` (sum of chunk scores mapped to same message).
|
|
||||||
- Limited final response to `top-50` message ids via `FINAL_TOP_K = 50`.
|
|
||||||
- Final output message ids are now selected from aggregated scores (sorted by score desc, tie-break by message_id).
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
- Earlier step introduced direct `message_ids` deduplication before response.
|
|
||||||
- Current logic supersedes this by ranking and selecting unique `message_id` values from aggregated scores.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
- Syntax check passed after each main change: `python -m py_compile search/main.py`.
|
|
||||||
|
|
||||||
## How To Use This Log
|
|
||||||
- Treat this file as the source of truth for already completed `search/main.py` tasks.
|
|
||||||
- On next tasks, read this file first to avoid duplicate edits.
|
|
||||||
290
doc/ai_update.md
290
doc/ai_update.md
|
|
@ -1,290 +0,0 @@
|
||||||
# AI Update по ТЗ
|
|
||||||
|
|
||||||
Дата: 2026-04-18
|
|
||||||
|
|
||||||
## Что просмотрено
|
|
||||||
|
|
||||||
- `doc/ТЗ_на_хакатон_Индексация_и_поиск_по_сообщениям.pdf`
|
|
||||||
- `README.md`
|
|
||||||
- `docker-compose.yml`
|
|
||||||
- `index/main.py`, `search/main.py`
|
|
||||||
- `index/Dockerfile`, `search/Dockerfile`
|
|
||||||
- `index/Makefile`, `search/Makefile`
|
|
||||||
- `data/Go Nova.json`
|
|
||||||
|
|
||||||
По примеру данных:
|
|
||||||
|
|
||||||
- всего сообщений: `25`
|
|
||||||
- с `parts`: `14`
|
|
||||||
- с цитатами: `5`
|
|
||||||
- с пересланными сообщениями: `2`
|
|
||||||
- с `mentions`: `4`
|
|
||||||
- с `file_snippets`: `1`
|
|
||||||
- системных сообщений: `1`
|
|
||||||
|
|
||||||
Это важно, потому что в текущем коде часть этих сигналов либо не используется вообще, либо теряет смысл при индексации.
|
|
||||||
|
|
||||||
## Что уже соответствует ТЗ
|
|
||||||
|
|
||||||
1. В репозитории есть оба требуемых сервиса: `index` и `search`.
|
|
||||||
2. Обязательные endpoints реализованы:
|
|
||||||
- `GET /health`, `POST /index`, `POST /sparse_embedding` в `index/main.py`
|
|
||||||
- `GET /health`, `POST /search` в `search/main.py`
|
|
||||||
3. Контракты request/response по основным endpoint'ам не менялись и в целом совпадают с шаблоном и ТЗ.
|
|
||||||
4. `search` использует `Qdrant`, dense endpoint и reranker через HTTP.
|
|
||||||
5. В обоих Dockerfile sparse-модель предзагружается внутрь образа, что соответствует оффлайн-ограничению контейнеров.
|
|
||||||
6. `HOST` и `PORT` читаются из env, как требует ТЗ.
|
|
||||||
|
|
||||||
## Что отсутствует или реализовано частично
|
|
||||||
|
|
||||||
### 1. Обогащенный вопрос из ТЗ почти не используется
|
|
||||||
|
|
||||||
В `search/main.py:90-100` описаны поля:
|
|
||||||
|
|
||||||
- `search_text`
|
|
||||||
- `variants`
|
|
||||||
- `hyde`
|
|
||||||
- `keywords`
|
|
||||||
- `entities`
|
|
||||||
- `date_mentions`
|
|
||||||
- `date_range`
|
|
||||||
- `asker`
|
|
||||||
|
|
||||||
Но в реальном поиске используется только `question.text`:
|
|
||||||
|
|
||||||
- `search/main.py:307-316`
|
|
||||||
|
|
||||||
Это главный недобор относительно ТЗ. Само ТЗ явно дает эти поля как сигналы для retrieval, а код их сейчас просто игнорирует.
|
|
||||||
|
|
||||||
### 2. Метаданные чанков объявлены, но не участвуют в поиске
|
|
||||||
|
|
||||||
В `README.md:52-58` отдельно сказано, что в metadata чанка сохраняются:
|
|
||||||
|
|
||||||
- `participants`
|
|
||||||
- `mentions`
|
|
||||||
- `contains_forward`
|
|
||||||
- `contains_quote`
|
|
||||||
|
|
||||||
В `search/main.py:132-145` есть модель `ChunkMetadata`, но дальше она никак не используется в `query_points`. Поиск не делает:
|
|
||||||
|
|
||||||
- фильтрацию по `mentions`
|
|
||||||
- фильтрацию по `participants`
|
|
||||||
- учет `thread_sn`
|
|
||||||
- учет временного диапазона через `start`/`end`
|
|
||||||
- отдельную обработку quote/forward чанков
|
|
||||||
|
|
||||||
То есть сильный канал улучшения качества уже предусмотрен схемой, но сейчас не задействован.
|
|
||||||
|
|
||||||
### 3. Реранк отбрасывает часть кандидатов
|
|
||||||
|
|
||||||
Сейчас:
|
|
||||||
|
|
||||||
- retrieval берет до `20` чанков: `search/main.py:174-177`
|
|
||||||
- rerank берет только первые `10`: `search/main.py:278-296`
|
|
||||||
- после rerank возвращаются только эти `10`, а хвост `11-20` теряется: `search/main.py:321-328`
|
|
||||||
|
|
||||||
Это не нарушение контракта, но это реальная потеря recall.
|
|
||||||
|
|
||||||
### 4. Выдача не дедуплицируется и не ограничивается по полезному top-K
|
|
||||||
|
|
||||||
Сейчас `message_ids` просто конкатенируются:
|
|
||||||
|
|
||||||
- `search/main.py:323-328`
|
|
||||||
|
|
||||||
Проблемы:
|
|
||||||
|
|
||||||
- дубликаты message id не удаляются
|
|
||||||
- результаты не агрегируются по лучшему score сообщения
|
|
||||||
- нет явного ограничения на топ полезных `50`, хотя именно `K=50` участвует в метрике из ТЗ
|
|
||||||
|
|
||||||
Если один и тот же `message_id` попал в несколько чанков, он тратит место в выдаче.
|
|
||||||
|
|
||||||
### 5. Индексация пока очень базовая: фиксированные символьные чанки
|
|
||||||
|
|
||||||
В `index/main.py:118-190` чанки строятся просто по длине строки:
|
|
||||||
|
|
||||||
- `CHUNK_SIZE = 512`
|
|
||||||
- `OVERLAP_SIZE = 256`
|
|
||||||
- разбиение идет по символам, а не по сообщениям, тайм-гепам, тредам или смысловым блокам
|
|
||||||
|
|
||||||
Из-за этого:
|
|
||||||
|
|
||||||
- длинные пересланные сообщения и цитаты могут резаться в неудобных местах
|
|
||||||
- один и тот же смысловой блок может быть разнесен по чанкам неестественно
|
|
||||||
- overlap строится по хвосту текста, а не по границе сообщений
|
|
||||||
|
|
||||||
### 6. `page_content`, `dense_content` и `sparse_content` сейчас одинаковые
|
|
||||||
|
|
||||||
См. `index/main.py:180-186`.
|
|
||||||
|
|
||||||
ТЗ прямо оставляет это место как точку оптимизации качества, но пока этот резерв не используется.
|
|
||||||
|
|
||||||
### 7. Индексация берет только `text` и `parts[*].text`, остальное почти теряется
|
|
||||||
|
|
||||||
См. `index/main.py:99-115`.
|
|
||||||
|
|
||||||
Сейчас не используются как поисковые сигналы:
|
|
||||||
|
|
||||||
- `sender_id`
|
|
||||||
- `mentions`
|
|
||||||
- `file_snippets`
|
|
||||||
- `member_event`
|
|
||||||
- `thread_sn`
|
|
||||||
- `is_hidden`
|
|
||||||
- `is_system`
|
|
||||||
- явное различение `quote` и `forward`
|
|
||||||
|
|
||||||
Особенно важные пробелы:
|
|
||||||
|
|
||||||
- `member_event` у системных сообщений сейчас фактически пропадает, если обычного текста нет
|
|
||||||
- `file_snippets` не разбирается, хотя там могут быть имена файлов, URL и служебные поля
|
|
||||||
- запросы вида "кто писал", "кого упоминали", "какой файл/документ кидали" сейчас поддержаны слабо
|
|
||||||
|
|
||||||
### 8. Смысл `quote` и `forward` не маркируется
|
|
||||||
|
|
||||||
В `index/main.py:105-113` текст из `parts` просто подшивается в общий текст без явных маркеров вида:
|
|
||||||
|
|
||||||
- "цитата:"
|
|
||||||
- "пересланное сообщение:"
|
|
||||||
- "автор цитаты:"
|
|
||||||
|
|
||||||
В итоге dense/sparse видят просто общий текстовый комок. Для поиска по обсуждениям это ощутимая потеря контекста.
|
|
||||||
|
|
||||||
### 9. Есть расхождение между локальной инфраструктурой и ТЗ
|
|
||||||
|
|
||||||
По ТЗ для `search` ожидается `API_KEY`.
|
|
||||||
|
|
||||||
В коде это поддержано:
|
|
||||||
|
|
||||||
- `search/main.py:21-30`
|
|
||||||
- `search/main.py:42-47`
|
|
||||||
- `search/Makefile:10-15`
|
|
||||||
|
|
||||||
Но локальный `docker-compose.yml:57-60` требует `OPEN_API_LOGIN` и `OPEN_API_PASSWORD`.
|
|
||||||
|
|
||||||
Итог:
|
|
||||||
|
|
||||||
- сам сервис гибче ТЗ
|
|
||||||
- локальный compose не повторяет боевую схему из ТЗ один в один
|
|
||||||
|
|
||||||
Это не ломает контракт, но может запутать при локальной отладке.
|
|
||||||
|
|
||||||
### 10. Есть еще одна инфраструктурная несостыковка со сдачей
|
|
||||||
|
|
||||||
В `doc/upload_to_docker.md:45-48` явно сказано собирать образы с `--platform linux/amd64`.
|
|
||||||
|
|
||||||
Но `index/Makefile:16-18` и `search/Makefile:26-28` собирают без `--platform linux/amd64`.
|
|
||||||
|
|
||||||
На x86 это может пройти незаметно, а на ARM-машине дать неправильный образ для отправки.
|
|
||||||
|
|
||||||
### 11. Есть неоднозначность между README и PDF по sparse в `search`
|
|
||||||
|
|
||||||
- `README.md:211` говорит, что sparse-модель для `search` должна быть локально внутри образа
|
|
||||||
- PDF в формулировке требований к `Search Service` делает акцент, что обращения к dense/sparse/rerank идут через проверяющую систему
|
|
||||||
|
|
||||||
Текущий код следует логике README/example: sparse считается локально в `search/main.py:148-151` и `search/main.py:198-207`.
|
|
||||||
|
|
||||||
Я бы это не считал блокером, но как минимум это место стоит держать в голове как неоднозначное требование.
|
|
||||||
|
|
||||||
## Что улучшать в первую очередь
|
|
||||||
|
|
||||||
### Приоритет 1. Начать использовать все поля `question`
|
|
||||||
|
|
||||||
Минимально стоит задействовать:
|
|
||||||
|
|
||||||
- `search_text` как основной нормализованный запрос
|
|
||||||
- `variants` как дополнительные формулировки
|
|
||||||
- `hyde` как дополнительные dense-запросы
|
|
||||||
- `keywords` как основу для sparse
|
|
||||||
- `entities` для фильтров и lexical boost
|
|
||||||
- `date_range` и `date_mentions` для ограничения по времени
|
|
||||||
- `asker` как сигнал по людям и email
|
|
||||||
|
|
||||||
Самый логичный путь без смены стэка: несколько dense/sparse запросов + fusion в `Qdrant`.
|
|
||||||
|
|
||||||
### Приоритет 2. Перестроить chunking под структуру чата, а не под символы
|
|
||||||
|
|
||||||
Нужны чанки по:
|
|
||||||
|
|
||||||
- окнам сообщений
|
|
||||||
- временным разрывам
|
|
||||||
- границам thread/forward/quote
|
|
||||||
- ограничению на размер по сообщениям, а не только по символам
|
|
||||||
|
|
||||||
Для чатов это обычно дает больше пользы, чем любые косметические тюнинги rerank.
|
|
||||||
|
|
||||||
### Приоритет 3. Развести `page_content`, `dense_content`, `sparse_content`
|
|
||||||
|
|
||||||
Хорошая схема:
|
|
||||||
|
|
||||||
- `page_content`: читабельный исходный текст чанка
|
|
||||||
- `dense_content`: нормализованный текст с ролями, автором, маркерами quote/forward
|
|
||||||
- `sparse_content`: keyword-heavy версия с email, mentions, именами файлов, ссылками, документами, леммами
|
|
||||||
|
|
||||||
Сейчас эта возможность не используется вообще.
|
|
||||||
|
|
||||||
### Приоритет 4. Нормально собирать финальную выдачу
|
|
||||||
|
|
||||||
Нужно:
|
|
||||||
|
|
||||||
- не терять кандидатов после rerank
|
|
||||||
- удалять дубликаты `message_id`
|
|
||||||
- агрегировать по лучшему score сообщения или чанка
|
|
||||||
- отдавать осмысленный top-50
|
|
||||||
|
|
||||||
Это прямой выигрыш по Recall@50 и nDCG@50.
|
|
||||||
|
|
||||||
### Приоритет 5. Начать использовать metadata в `Qdrant`
|
|
||||||
|
|
||||||
Особенно полезно для:
|
|
||||||
|
|
||||||
- `mentions`
|
|
||||||
- `participants`
|
|
||||||
- `contains_quote`
|
|
||||||
- `contains_forward`
|
|
||||||
- `thread_sn`
|
|
||||||
- `start` / `end`
|
|
||||||
|
|
||||||
Для многих вопросов это позволит не просто "лучше ранжировать", а сразу отрезать нерелевантный шум.
|
|
||||||
|
|
||||||
### Приоритет 6. Превратить скрытые сигналы в индексируемый текст
|
|
||||||
|
|
||||||
Стоит отдельно материализовать:
|
|
||||||
|
|
||||||
- `member_event` в текст вида "пользователь X добавил Y"
|
|
||||||
- `file_snippets` в текст вида "файл: NAME, url: ..."
|
|
||||||
- автора сообщения
|
|
||||||
- список упомянутых пользователей
|
|
||||||
|
|
||||||
Сейчас эти сигналы либо не попадают в индекс, либо попадают слишком слабо.
|
|
||||||
|
|
||||||
## Что можно добавить по Python-библиотекам, не меняя стек
|
|
||||||
|
|
||||||
Стек `Qdrant` менять не нужно. Самые полезные добавки я бы смотрел такие:
|
|
||||||
|
|
||||||
- `pymorphy3` для лемматизации русских слов при подготовке `sparse_content`
|
|
||||||
- `razdel` для аккуратной токенизации русского текста
|
|
||||||
- `rapidfuzz` для точного lexical match по именам, email, документам, ссылкам и названиям
|
|
||||||
- `python-dateutil` или `dateparser` для нормализации дат, если захотите усиливать работу с `date_mentions`
|
|
||||||
- `tenacity` для аккуратных retry/timeout-оберток вокруг dense/rerank HTTP вызовов
|
|
||||||
|
|
||||||
Что не нужно делать:
|
|
||||||
|
|
||||||
- менять `Qdrant`
|
|
||||||
- тащить внешние LLM/API
|
|
||||||
- усложнять архитектуру ради "модности", пока не использованы базовые сигналы из самого ТЗ
|
|
||||||
|
|
||||||
## Короткий вывод
|
|
||||||
|
|
||||||
Сейчас репозиторий соответствует ТЗ как рабочий базовый шаблон, но почти не использует те сигналы, ради которых это ТЗ вообще интересно:
|
|
||||||
|
|
||||||
- обогащение вопроса
|
|
||||||
- metadata чанков
|
|
||||||
- структуру chat messages
|
|
||||||
- сигналы автора, упоминаний, файлов, системных событий, цитат и пересылок
|
|
||||||
|
|
||||||
Самый большой потенциал улучшения здесь не в замене базы или модели, а в трех вещах:
|
|
||||||
|
|
||||||
1. умный chunking
|
|
||||||
2. multi-query hybrid retrieval
|
|
||||||
3. использование metadata и нормальной сборки финального top-50
|
|
||||||
329
doc/output.md
329
doc/output.md
|
|
@ -1,329 +0,0 @@
|
||||||
# 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`
|
|
||||||
269
doc/prompt.md
269
doc/prompt.md
|
|
@ -1,269 +0,0 @@
|
||||||
# Prompt For Next Refactor Pass
|
|
||||||
|
|
||||||
Считай этот файл каноническим планом работ по репозиторию. Старые заметки в `doc/todo.md`, `doc/todo_and_pipeline.md`, `doc/todo_people.md` и `doc/ai_update.md` можно использовать как справку, но не как основной источник правды.
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
|
|
||||||
В репозитории два сервиса:
|
|
||||||
|
|
||||||
- `index` строит чанки для индексации
|
|
||||||
- `search` получает вопрос и возвращает `message_ids`
|
|
||||||
|
|
||||||
Контракты `POST /index`, `POST /sparse_embedding` и `POST /search` менять нельзя.
|
|
||||||
|
|
||||||
Дополнительный контекст по текущему состоянию:
|
|
||||||
|
|
||||||
- worktree уже грязный, не откатывай чужие правки
|
|
||||||
- `main` отстает от `origin/main` на 3 коммита
|
|
||||||
- `.ai_update/` должен быть shared-state каталогом, но сейчас он игнорируется через `.gitignore`
|
|
||||||
- реальная логика почти целиком живет в `index/main.py` и `search/main.py`, поэтому следующий шаг должен быть не только про качество поиска, но и про разбиение кода на понятные модули
|
|
||||||
|
|
||||||
## Что уже очевидно сломано или недоделано
|
|
||||||
|
|
||||||
### P0. Исправить критические дефекты в `search`
|
|
||||||
|
|
||||||
- В `search/main.py` есть реальный баг: `must_conditions: []` не создает список. При запросах с `date_range` или `asker` код упадет на `.append()`. Исправить первым коммитом.
|
|
||||||
- Поиск использует только `question.text`, хотя схема уже содержит `search_text`, `variants`, `hyde`, `keywords`, `entities`, `date_mentions`, `date_range`.
|
|
||||||
- После rerank теряется хвост retrieval-кандидатов.
|
|
||||||
- Финальный список `message_ids` не дедуплицируется, не агрегируется по score и не ограничивается `top-50`, хотя метрика считается именно на `K=50`.
|
|
||||||
- Внешние HTTP-вызовы dense/rerank не имеют нормальных `timeout` и `retry`.
|
|
||||||
|
|
||||||
### P1. Перестроить индексацию под структуру чата
|
|
||||||
|
|
||||||
- Сейчас `index` режет текст по символам, а не по сообщениям.
|
|
||||||
- Overlap строится по хвосту строки, а не по границам сообщений.
|
|
||||||
- `page_content`, `dense_content` и `sparse_content` сейчас одинаковые, хотя должны выполнять разные задачи.
|
|
||||||
- В индекс почти не попадают важные сигналы: `sender_id`, `mentions`, `file_snippets`, `member_event`, `thread_sn`, маркеры `quote` и `forward`.
|
|
||||||
|
|
||||||
### P2. Начать использовать metadata осмысленно
|
|
||||||
|
|
||||||
- В README прямо указаны `participants`, `mentions`, `contains_forward`, `contains_quote`.
|
|
||||||
- В `search/main.py` есть модель `ChunkMetadata`, но retrieval почти не использует metadata для фильтрации и буста.
|
|
||||||
- Нужно поддержать фильтры/бусты по людям, mentions, thread, дате, quote/forward и не ломать контракт ответа.
|
|
||||||
|
|
||||||
### P3. Привести инфраструктуру и документацию в порядок
|
|
||||||
|
|
||||||
- `docker-compose.yml`, `README.md`, `Makefile` и `doc/upload_to_docker.md` частично расходятся по сценарию запуска и сборки.
|
|
||||||
- В `Makefile` нет `--platform linux/amd64`, хотя в документации на загрузку образов это требуется.
|
|
||||||
- В репозитории нет нормального `bin/codex-start`, хотя workflow на него ссылается.
|
|
||||||
- Планирование размазано по нескольким файлам вместо одного документа.
|
|
||||||
|
|
||||||
## Что нужно сделать
|
|
||||||
|
|
||||||
### 1. Рефакторинг `search`
|
|
||||||
|
|
||||||
Сначала разбей `search/main.py` на несколько логических частей. Минимально:
|
|
||||||
|
|
||||||
- `search/config.py`: env, валидация конфигурации, auth-настройки
|
|
||||||
- `search/schemas.py`: pydantic-модели запросов и ответов
|
|
||||||
- `search/query_builder.py`: сборка dense/sparse запросов из `question`
|
|
||||||
- `search/retrieval.py`: `Qdrant` prefetch, filters, fusion
|
|
||||||
- `search/rerank.py`: вызов reranker и работа с rerank-кандидатами
|
|
||||||
- `search/aggregation.py`: дедуп message ids, score aggregation, top-50
|
|
||||||
- `search/main.py`: только wiring FastAPI и вызовы сервисных функций
|
|
||||||
|
|
||||||
Что должно измениться по логике:
|
|
||||||
|
|
||||||
- основной dense query: `question.search_text.strip()` с fallback на `question.text.strip()`
|
|
||||||
- дополнительные dense query: `question.variants`, `question.hyde`
|
|
||||||
- основной sparse query: `keywords`, а если их нет, то нормализованный базовый запрос
|
|
||||||
- entity-сигналы: `people`, `emails`, `documents`, `names`, `links` использовать как lexical boost или metadata filter
|
|
||||||
- `date_range` и, по возможности, `date_mentions` использовать для фильтрации по `metadata.start` / `metadata.end`
|
|
||||||
- retrieval должен возвращать расширенный пул кандидатов
|
|
||||||
- rerank должен сортировать top-N, но не уничтожать полностью хвост retrieval
|
|
||||||
- финальный ответ должен:
|
|
||||||
- агрегировать score по `message_id`
|
|
||||||
- удалять дубликаты
|
|
||||||
- ограничиваться `top-50`
|
|
||||||
|
|
||||||
Отдельно:
|
|
||||||
|
|
||||||
- убери импорт-тайм побочный эффект `validate_required_env()` и переведи его в более тестируемую точку старта
|
|
||||||
- добавь явные `timeout` для `httpx.AsyncClient`
|
|
||||||
- добавь retry-политику на ошибки сети и 5xx
|
|
||||||
|
|
||||||
### 2. Рефакторинг `index`
|
|
||||||
|
|
||||||
Разбей `index/main.py` хотя бы так:
|
|
||||||
|
|
||||||
- `index/schemas.py`: request/response модели
|
|
||||||
- `index/rendering.py`: извлечение и разметка текста сообщения
|
|
||||||
- `index/cleaning.py`: локальная очистка и нормализация raw message payload
|
|
||||||
- `index/chunking.py`: сборка окон сообщений и overlap по сообщениям
|
|
||||||
- `index/sparse.py`: локальная sparse-эмбеддинг логика
|
|
||||||
- `index/main.py`: только FastAPI wiring
|
|
||||||
|
|
||||||
Что должно измениться по логике индексации:
|
|
||||||
|
|
||||||
- базовая единица чанка: сообщение, а не кусок строки
|
|
||||||
- окно чанка должно учитывать:
|
|
||||||
- число сообщений
|
|
||||||
- суммарную длину
|
|
||||||
- time gap между сообщениями
|
|
||||||
- границы thread/forward/quote, если они явно ломают контекст
|
|
||||||
- overlap должен повторять последние сообщения, а не последние символы
|
|
||||||
- `render_message()` должен материализовать:
|
|
||||||
- автора сообщения
|
|
||||||
- mentions
|
|
||||||
- quote / forward маркеры
|
|
||||||
- `file_snippets`
|
|
||||||
- `member_event`
|
|
||||||
- при необходимости `thread_sn`
|
|
||||||
|
|
||||||
### 2.1. Локальная очистка сообщений по реальному формату `data/Go Nova.json`
|
|
||||||
|
|
||||||
Очистка должна происходить локально внутри `index`, без внешних API и без попытки делегировать нормализацию в dense/rerank сервисы.
|
|
||||||
|
|
||||||
Что показал реальный датасет:
|
|
||||||
|
|
||||||
- значимая часть сообщений имеет пустой верхнеуровневый `text`
|
|
||||||
- смысл часто лежит в `parts[*].text`
|
|
||||||
- в `parts[*]` используется поле `mediaType`, а не `type`
|
|
||||||
- встречаются `mediaType = text`, `quote`, `forward`
|
|
||||||
- есть `member_event` без обычного текста
|
|
||||||
- `file_snippets` приходит JSON-строкой
|
|
||||||
- в данных встречаются URL, email и zero-width символы
|
|
||||||
|
|
||||||
Минимальный pipeline очистки:
|
|
||||||
|
|
||||||
1. Извлечение raw сигналов:
|
|
||||||
- `text`
|
|
||||||
- `parts`
|
|
||||||
- `mentions`
|
|
||||||
- `member_event`
|
|
||||||
- `file_snippets`
|
|
||||||
- `sender_id`
|
|
||||||
- флаги `is_system`, `is_forward`, `is_quote`
|
|
||||||
|
|
||||||
2. Unicode и whitespace normalization:
|
|
||||||
- удалить `\u200b`, `\u200c`, `\u200d`, `\ufeff`
|
|
||||||
- унифицировать переводы строк
|
|
||||||
- схлопнуть лишние пробелы и пустые строки
|
|
||||||
- не удалять email, URL, версии, имена файлов и технические токены
|
|
||||||
|
|
||||||
3. Нормализация `parts`:
|
|
||||||
- `mediaType = text`: включать как основной контент
|
|
||||||
- `mediaType = quote`: явно материализовать как `quote_from` + `quote_text`
|
|
||||||
- `mediaType = forward`: явно материализовать как `forwarded_from` + `forward_text`
|
|
||||||
- неизвестные типы не выбрасывать, а сохранять как маркированные текстовые блоки
|
|
||||||
|
|
||||||
4. Нормализация системных событий:
|
|
||||||
- `member_event` превращать в индексируемый текст
|
|
||||||
- минимум поддержать `addMembers`
|
|
||||||
- для неизвестных event type сохранять тип и payload в безопасной текстовой форме
|
|
||||||
|
|
||||||
5. Нормализация файлов:
|
|
||||||
- распарсить `file_snippets` локально из JSON-строки
|
|
||||||
- вытащить `name`, `mime`, `original_url`, `date_create`
|
|
||||||
- при невалидном JSON не падать, а сохранять `attachment_raw`
|
|
||||||
|
|
||||||
6. Правило пропуска:
|
|
||||||
- выбрасывать сообщение только если после очистки пусты и `text`, и `parts`, и `member_event`, и `file_snippets`
|
|
||||||
|
|
||||||
Развести три представления текста:
|
|
||||||
|
|
||||||
- `page_content`: читаемый текст чанка для payload
|
|
||||||
- `dense_content`: нормализованный текст с role-маркерами, авторами и служебным контекстом
|
|
||||||
- `sparse_content`: keyword-heavy текст, куда попадают имена людей, mentions, email, документы, файлы, ссылки, важные термины
|
|
||||||
|
|
||||||
При этом:
|
|
||||||
|
|
||||||
- не меняй внешний контракт `POST /index`
|
|
||||||
- сохрани понятную привязку `message_ids` к каждому чанку
|
|
||||||
- делай chunking объяснимым, а не магическим
|
|
||||||
|
|
||||||
### 3. Улучшить metadata-aware retrieval
|
|
||||||
|
|
||||||
После стабилизации `search` и `index`:
|
|
||||||
|
|
||||||
- добавь boost/filter по `participants`
|
|
||||||
- добавь boost/filter по `mentions`
|
|
||||||
- используй `contains_quote` и `contains_forward` как вторичные сигналы ранжирования
|
|
||||||
- если в payload есть `thread_sn`, учитывай его для вопросов про конкретную ветку обсуждения
|
|
||||||
- подбери новые значения для `DENSE_PREFETCH_K`, `SPRASE_PREFETCH_K`, `RETRIEVE_K`, `RERANK_LIMIT`
|
|
||||||
|
|
||||||
Если multi-query fusion в `Qdrant` начинает заметно улучшать recall, оставляй его. Если только усложняет код без эффекта, не тащи лишнюю сложность.
|
|
||||||
|
|
||||||
### 4. Навести порядок в repo hygiene
|
|
||||||
|
|
||||||
- перестань держать `.ai_update/` в `.gitignore`, если workflow действительно предполагает коммит этого каталога
|
|
||||||
- либо добавь реальный `bin/codex-start`, либо убери ссылки на него из документации
|
|
||||||
- приведи `docker-compose.yml` к тому же сценарию env, что и `README.md`
|
|
||||||
- добавь `--platform linux/amd64` в команды сборки из `Makefile`
|
|
||||||
- оставь `doc/prompt.md` основным планом, а дублирующие `todo`-файлы сократи или архивируй
|
|
||||||
|
|
||||||
### 5. Логирование каждого изменения и отчетность
|
|
||||||
|
|
||||||
Во время следующей реализации нельзя ограничиваться только кодом. После каждого meaningful change нужно фиксировать, что именно сделано и что сохранено.
|
|
||||||
|
|
||||||
Обязательные действия:
|
|
||||||
|
|
||||||
- после каждого существенного изменения обновлять `.ai_update/changelog.md`
|
|
||||||
- поддерживать `.ai_update/touched_files.md`
|
|
||||||
- обновлять `.ai_update/current_status.md` и `.ai_update/handoff.md` к концу сессии
|
|
||||||
- вести [doc/output.md](/home/q/doc/hackaton/doc/output.md) как человекочитаемый отчет по ходу работ
|
|
||||||
|
|
||||||
Что писать в `doc/output.md` после каждой существенной правки:
|
|
||||||
|
|
||||||
- дата/время
|
|
||||||
- что изменено
|
|
||||||
- какие файлы изменены
|
|
||||||
- зачем это сделано
|
|
||||||
- как это проверено
|
|
||||||
- что осталось недоделанным или рискованным
|
|
||||||
|
|
||||||
## Какие тесты и проверки нужны
|
|
||||||
|
|
||||||
Создай минимальный тестовый контур. Без этого рефакторинг превратится в угадывание.
|
|
||||||
|
|
||||||
### Unit tests
|
|
||||||
|
|
||||||
- `index/cleaning.py`: unicode/whitespace cleanup, `mediaType`, `member_event`, `file_snippets`
|
|
||||||
- `index/rendering.py`: сообщение с `parts`, `quote`, `forward`, `mentions`, `file_snippets`, `member_event`
|
|
||||||
- `index/chunking.py`: chunking по сообщениям, time gap, overlap по сообщениям
|
|
||||||
- `search/query_builder.py`: `search_text`, fallback на `text`, `variants`, `hyde`, `keywords`, entities, date range
|
|
||||||
- `search/aggregation.py`: dedup, score aggregation, `top-50`
|
|
||||||
|
|
||||||
### Smoke checks
|
|
||||||
|
|
||||||
- `python3 -m py_compile index/main.py search/main.py`
|
|
||||||
- `docker compose config`
|
|
||||||
- локальный запуск через `docker compose up --build`, если заполнен `.env`
|
|
||||||
- ручной smoke `curl` на `/health`, `/index`, `/search`
|
|
||||||
|
|
||||||
### Regression set
|
|
||||||
|
|
||||||
Зафиксируй отдельный markdown-файл с контрольными вопросами. Включи хотя бы такие классы запросов:
|
|
||||||
|
|
||||||
- кто что писал
|
|
||||||
- кого упоминали
|
|
||||||
- что писали про документ, файл или ссылку
|
|
||||||
- что обсуждали в конкретный период
|
|
||||||
- что было в пересланных сообщениях и цитатах
|
|
||||||
- что было в системных событиях и прикреплениях
|
|
||||||
|
|
||||||
Используй `data/Go Nova.json` как локальную fixture-основу.
|
|
||||||
|
|
||||||
## Порядок внедрения
|
|
||||||
|
|
||||||
1. Сначала внедрить и протестировать локальную очистку сообщений в `index/cleaning.py` на кейсах из `data/Go Nova.json`.
|
|
||||||
2. Переделать `index` на message-based chunking и разные `page_content` / `dense_content` / `sparse_content`.
|
|
||||||
3. После стабилизации входного текста починить P0 баги в `search` и добавить тесты на query builder и aggregation.
|
|
||||||
4. Вынести `search` из монолита `main.py` в модули без изменения API.
|
|
||||||
5. Подключить metadata-aware retrieval и тюнинг параметров.
|
|
||||||
6. Синхронизировать docker/docs/workflow и убрать repo hygiene противоречия.
|
|
||||||
|
|
||||||
## Критерий готовности
|
|
||||||
|
|
||||||
Можно считать работу завершенной только если одновременно выполнено все ниже:
|
|
||||||
|
|
||||||
- `search` использует не только `question.text`
|
|
||||||
- поиск не падает на `date_range` и `asker`
|
|
||||||
- retrieval + rerank не теряют кандидатов бессмысленно
|
|
||||||
- финальная выдача дедуплицирована и ограничена `top-50`
|
|
||||||
- `index` режет по сообщениям, а не по символам как основной механизм
|
|
||||||
- локальная очистка сообщений работает без внешних API и покрывает `parts`, `member_event`, `file_snippets`, URL и zero-width артефакты
|
|
||||||
- `page_content`, `dense_content`, `sparse_content` различаются по назначению
|
|
||||||
- в индекс и retrieval реально включены metadata и скрытые сигналы чата
|
|
||||||
- локальная документация, compose и сборка образов не противоречат друг другу
|
|
||||||
- история изменений и отчет в `doc/output.md` обновляются по ходу работы, а не только в конце
|
|
||||||
49
doc/todo.md
49
doc/todo.md
|
|
@ -1,49 +0,0 @@
|
||||||
# TODO
|
|
||||||
|
|
||||||
## `search/main.py`
|
|
||||||
|
|
||||||
- [ ] P0: Переключить основной query на `question.search_text` с fallback на `question.text`
|
|
||||||
- [ ] P0: Подключить `question.variants` как дополнительные query-варианты
|
|
||||||
- [ ] P0: Подключить `question.hyde` как дополнительные dense-запросы
|
|
||||||
- [ ] P0: Подключить `question.keywords` как основу для sparse-запросов
|
|
||||||
- [ ] P0: Перестать терять retrieval-кандидатов после rerank
|
|
||||||
- [ ] P0: Дедуплицировать `message_ids` перед ответом
|
|
||||||
- [ ] P0: Ограничить финальную выдачу до `top-50`
|
|
||||||
- [ ] P0: Агрегировать score по `message_id`
|
|
||||||
- [ ] P2: Использовать `entities.people` и `entities.emails` для boost или фильтрации
|
|
||||||
- [ ] P2: Использовать `entities.documents`, `entities.names`, `entities.links` для lexical boost
|
|
||||||
- [ ] P2: Использовать `date_range` для фильтрации по `metadata.start` и `metadata.end`
|
|
||||||
- [ ] P2: Использовать `contains_quote` и `contains_forward` как сигналы ранжирования
|
|
||||||
- [ ] P2: Добавить multi-query fusion в `Qdrant`
|
|
||||||
- [ ] P2: Подобрать `prefetch`, `retrieve_k`, `rerank_limit`
|
|
||||||
- [ ] P3: Добавить retry и timeout политику для dense/rerank HTTP вызовов
|
|
||||||
|
|
||||||
## `index/main.py`
|
|
||||||
|
|
||||||
- [ ] P1: Перейти с символьного chunking на chunking по сообщениям
|
|
||||||
- [ ] P1: Учитывать time gap при сборке чанков
|
|
||||||
- [ ] P1: Маркировать в тексте `quote`, `forward`, автора сообщения и автора цитаты
|
|
||||||
- [ ] P1: Развести `page_content`, `dense_content`, `sparse_content`
|
|
||||||
- [ ] P1: Материализовать `sender_id` и `mentions` в индексируемый текст
|
|
||||||
- [ ] P1: Разбирать `file_snippets` и вытаскивать имя файла, mime и url
|
|
||||||
- [ ] P1: Разбирать `member_event` и превращать его в индексируемый текст
|
|
||||||
|
|
||||||
## `docker-compose.yml`
|
|
||||||
|
|
||||||
- [ ] P3: Привести локальный `docker-compose.yml` к схеме с `API_KEY`
|
|
||||||
- [ ] P3: Добавить `--platform linux/amd64` в сборку образов
|
|
||||||
|
|
||||||
## `doc/` (новый файл с регрессионными вопросами)
|
|
||||||
|
|
||||||
- [ ] P3: Зафиксировать набор локальных тестовых вопросов для проверки регрессий
|
|
||||||
|
|
||||||
## `search/requirements.txt`
|
|
||||||
|
|
||||||
- [ ] P4: Добавить `python-dateutil` или `dateparser`
|
|
||||||
- [ ] P4: Добавить `tenacity`
|
|
||||||
|
|
||||||
## `index/requirements.txt` и/или `search/requirements.txt`
|
|
||||||
|
|
||||||
- [ ] P4: Добавить `razdel`
|
|
||||||
- [ ] P4: Добавить `pymorphy3`
|
|
||||||
- [ ] P4: Добавить `rapidfuzz`
|
|
||||||
|
|
@ -1,223 +0,0 @@
|
||||||
# To-Do и целевой pipeline
|
|
||||||
|
|
||||||
Дата: 2026-04-18
|
|
||||||
|
|
||||||
## Цель
|
|
||||||
|
|
||||||
Поднять `Recall@50` и `nDCG@50` без смены стэка:
|
|
||||||
|
|
||||||
- оставить `Qdrant`
|
|
||||||
- оставить внешний dense endpoint
|
|
||||||
- оставить внешний reranker
|
|
||||||
- усиливать только индекс, retrieval, rerank и post-processing
|
|
||||||
|
|
||||||
## Приоритетный to-do
|
|
||||||
|
|
||||||
### P0. Быстрые и самые окупаемые правки
|
|
||||||
|
|
||||||
- [ ] Переключить основной запрос в `search` на `question.search_text` с fallback на `question.text`
|
|
||||||
- [ ] Подключить `question.variants` как дополнительные query-формулировки
|
|
||||||
- [ ] Подключить `question.hyde` как дополнительные dense-запросы
|
|
||||||
- [ ] Подключить `question.keywords` как основу для sparse-запроса
|
|
||||||
- [ ] Перестать терять кандидатов после rerank: возвращать не только top-10 rerank, но и хвост retrieval
|
|
||||||
- [ ] Дедуплицировать `message_ids` перед ответом
|
|
||||||
- [ ] Ограничить финальную выдачу осмысленным `top-50`
|
|
||||||
- [ ] Агрегировать score по `message_id`, а не просто конкатенировать ids из чанков
|
|
||||||
|
|
||||||
### P1. Улучшение индексации
|
|
||||||
|
|
||||||
- [ ] Перейти с символьного chunking на chunking по сообщениям
|
|
||||||
- [ ] Учитывать временные разрывы между сообщениями при сборке чанка
|
|
||||||
- [ ] Не смешивать в одном чанке слишком далекие по смыслу блоки
|
|
||||||
- [ ] Отдельно маркировать `quote`, `forward`, автора сообщения и автора цитаты
|
|
||||||
- [ ] Развести `page_content`, `dense_content`, `sparse_content`
|
|
||||||
- [ ] Материализовать `mentions` в текст и metadata
|
|
||||||
- [ ] Материализовать `sender_id` в индексируемый текст
|
|
||||||
- [ ] Разбирать `file_snippets` и вытаскивать имя файла, mime, url
|
|
||||||
- [ ] Разбирать `member_event` и превращать его в индексируемый текст
|
|
||||||
|
|
||||||
### P2. Улучшение retrieval и фильтрации
|
|
||||||
|
|
||||||
- [ ] Использовать `entities.people` и `entities.emails` для boost или фильтрации по `participants` и `mentions`
|
|
||||||
- [ ] Использовать `entities.documents`, `entities.names`, `entities.links` для lexical boost
|
|
||||||
- [ ] Использовать `date_range` для фильтрации по `metadata.start` и `metadata.end`
|
|
||||||
- [ ] Использовать `contains_quote` и `contains_forward` как дополнительные сигналы ранжирования
|
|
||||||
- [ ] Добавить multi-query fusion в `Qdrant` для dense и sparse запросов
|
|
||||||
- [ ] Подобрать новые значения `prefetch`, `retrieve_k`, `rerank_limit`
|
|
||||||
|
|
||||||
### P3. Инфраструктура и надежность
|
|
||||||
|
|
||||||
- [ ] Привести локальный `docker-compose.yml` к схеме с `API_KEY`, чтобы локальный запуск был ближе к ТЗ
|
|
||||||
- [ ] Добавить `--platform linux/amd64` в сборку образов
|
|
||||||
- [ ] Добавить retry и timeout политику для dense/rerank HTTP вызовов
|
|
||||||
- [ ] Зафиксировать набор локальных тестовых запросов для регрессии качества
|
|
||||||
|
|
||||||
### P4. Библиотеки, которые можно добавить без смены стэка
|
|
||||||
|
|
||||||
- [ ] `razdel` для токенизации русского текста
|
|
||||||
- [ ] `pymorphy3` для лемматизации при подготовке `sparse_content`
|
|
||||||
- [ ] `rapidfuzz` для точного match по именам, email, документам и ссылкам
|
|
||||||
- [ ] `python-dateutil` или `dateparser` для нормализации дат
|
|
||||||
- [ ] `tenacity` для retry вокруг внешних HTTP запросов
|
|
||||||
|
|
||||||
## Целевой pipeline индексации
|
|
||||||
|
|
||||||
### 1. Подготовка сообщения
|
|
||||||
|
|
||||||
На входе каждое сообщение должно раскладываться на сигналы:
|
|
||||||
|
|
||||||
- основной текст сообщения
|
|
||||||
- `parts[*].text`
|
|
||||||
- тип части: `text`, `quote`, `forward`
|
|
||||||
- `sender_id`
|
|
||||||
- `mentions`
|
|
||||||
- `file_snippets`
|
|
||||||
- `member_event`
|
|
||||||
- `thread_sn`
|
|
||||||
- флаги `is_system`, `is_quote`, `is_forward`
|
|
||||||
|
|
||||||
### 2. Нормализация и разметка
|
|
||||||
|
|
||||||
Перед chunking сообщение стоит приводить к структурированному виду, например:
|
|
||||||
|
|
||||||
- `author: ...`
|
|
||||||
- `mentions: ...`
|
|
||||||
- `quote: ...`
|
|
||||||
- `forwarded: ...`
|
|
||||||
- `file: ...`
|
|
||||||
- `system_event: ...`
|
|
||||||
|
|
||||||
Смысл не в красивом выводе, а в том, чтобы dense и sparse видели роль каждого куска текста.
|
|
||||||
|
|
||||||
### 3. Chunking
|
|
||||||
|
|
||||||
Целевой принцип:
|
|
||||||
|
|
||||||
- базовая единица не символ, а сообщение
|
|
||||||
- чанк собирается как окно из нескольких соседних сообщений
|
|
||||||
- окно режется по лимиту размера
|
|
||||||
- окно закрывается на большом time gap
|
|
||||||
- `forward` и длинные `quote` не должны ломать соседний контекст
|
|
||||||
- overlap должен работать по границам сообщений, а не по хвосту строки
|
|
||||||
|
|
||||||
### 4. Формирование трех видов текста
|
|
||||||
|
|
||||||
`page_content`:
|
|
||||||
|
|
||||||
- человекочитаемый текст чанка для payload
|
|
||||||
|
|
||||||
`dense_content`:
|
|
||||||
|
|
||||||
- нормализованный текст с автором, role-маркерами, quote/forward маркерами
|
|
||||||
|
|
||||||
`sparse_content`:
|
|
||||||
|
|
||||||
- keyword-heavy текст
|
|
||||||
- леммы
|
|
||||||
- email
|
|
||||||
- mentions
|
|
||||||
- имена файлов
|
|
||||||
- ссылки
|
|
||||||
- названия документов и сервисов
|
|
||||||
|
|
||||||
### 5. Metadata для Qdrant
|
|
||||||
|
|
||||||
В metadata стоит стабильно сохранять:
|
|
||||||
|
|
||||||
- `message_ids`
|
|
||||||
- `participants`
|
|
||||||
- `mentions`
|
|
||||||
- `thread_sn`
|
|
||||||
- `start`
|
|
||||||
- `end`
|
|
||||||
- `contains_quote`
|
|
||||||
- `contains_forward`
|
|
||||||
- `chat_id`
|
|
||||||
- `chat_type`
|
|
||||||
|
|
||||||
## Целевой pipeline поиска
|
|
||||||
|
|
||||||
### 1. Подготовка query
|
|
||||||
|
|
||||||
Собирать query не из одного поля, а из набора:
|
|
||||||
|
|
||||||
- основной запрос: `search_text` или `text`
|
|
||||||
- дополнительные dense-query: `variants` и `hyde`
|
|
||||||
- дополнительные sparse-query: `keywords`
|
|
||||||
- entity-сигналы: `people`, `emails`, `documents`, `names`, `links`
|
|
||||||
- time constraints: `date_range`, `date_mentions`
|
|
||||||
|
|
||||||
### 2. Query builder
|
|
||||||
|
|
||||||
Нужно строить несколько представлений запроса:
|
|
||||||
|
|
||||||
- dense-query для смысла
|
|
||||||
- sparse-query для точных слов и терминов
|
|
||||||
- filter/boost по metadata
|
|
||||||
|
|
||||||
### 3. Retrieval в Qdrant
|
|
||||||
|
|
||||||
Практическая схема:
|
|
||||||
|
|
||||||
1. Выполнить несколько dense prefetch по разным вариантам запроса
|
|
||||||
2. Выполнить несколько sparse prefetch по keyword-heavy запросам
|
|
||||||
3. Добавить filters по датам, mentions, participants, если это явно следует из вопроса
|
|
||||||
4. Объединить результаты через fusion
|
|
||||||
5. Забрать расширенный пул кандидатов для rerank
|
|
||||||
|
|
||||||
### 4. Rerank
|
|
||||||
|
|
||||||
Rerank должен работать не на слишком маленьком пуле. Целевой принцип:
|
|
||||||
|
|
||||||
- retrieval дает расширенный пул
|
|
||||||
- rerank сортирует top-N кандидатов
|
|
||||||
- хвост retrieval не теряется полностью
|
|
||||||
|
|
||||||
### 5. Агрегация к `message_id`
|
|
||||||
|
|
||||||
После rerank:
|
|
||||||
|
|
||||||
- собрать `message_ids` из чанков
|
|
||||||
- удалить дубликаты
|
|
||||||
- агрегировать лучший score на сообщение
|
|
||||||
- собрать финальный `top-50`
|
|
||||||
|
|
||||||
Это особенно важно, потому что метрики в ТЗ считаются именно по `message_id`, а не по chunk id.
|
|
||||||
|
|
||||||
## Порядок внедрения
|
|
||||||
|
|
||||||
### Этап 1. Quick wins
|
|
||||||
|
|
||||||
- использовать `search_text`, `variants`, `hyde`, `keywords`
|
|
||||||
- перестать терять кандидатов после rerank
|
|
||||||
- добавить dedup и top-50
|
|
||||||
|
|
||||||
### Этап 2. Пересборка индекса
|
|
||||||
|
|
||||||
- новый renderer сообщения
|
|
||||||
- новый chunking по сообщениям
|
|
||||||
- разные `page_content`, `dense_content`, `sparse_content`
|
|
||||||
|
|
||||||
### Этап 3. Metadata-aware retrieval
|
|
||||||
|
|
||||||
- filters по дате
|
|
||||||
- boost по mentions/participants
|
|
||||||
- учет `contains_quote` и `contains_forward`
|
|
||||||
|
|
||||||
### Этап 4. Тюнинг
|
|
||||||
|
|
||||||
- подобрать размеры чанков
|
|
||||||
- подобрать `retrieve_k`
|
|
||||||
- подобрать `rerank_limit`
|
|
||||||
- прогнать локальный набор контрольных вопросов
|
|
||||||
|
|
||||||
## Минимальный критерий готовности
|
|
||||||
|
|
||||||
Можно считать, что pipeline собран в рабочем виде, если:
|
|
||||||
|
|
||||||
- `search` использует не только `question.text`
|
|
||||||
- индексация не режет чанки посреди сообщения как основной механизм
|
|
||||||
- `message_ids` дедуплицируются
|
|
||||||
- финальная выдача ограничивается top-50
|
|
||||||
- retrieval умеет использовать хотя бы часть metadata
|
|
||||||
- локальная сборка и запуск не расходятся с ТЗ по критичным env и platform
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
Для того чтобы раскидать задачи между тремя людьми, я распределю их по сложности и приоритету.
|
|
||||||
|
|
||||||
### Человек 1 (Основной фокус на поиске):
|
|
||||||
|
|
||||||
#### `search/main.py`
|
|
||||||
|
|
||||||
* **P0**: Переключить основной query на `question.search_text` с fallback на `question.text`
|
|
||||||
* **P0**: Подключить `question.variants` как дополнительные query-варианты
|
|
||||||
* **P0**: Подключить `question.hyde` как дополнительные dense-запросы
|
|
||||||
* **P0**: Подключить `question.keywords` как основу для sparse-запросов
|
|
||||||
* **P0**: Перестать терять retrieval-кандидатов после rerank
|
|
||||||
* **P0**: Дедуплицировать `message_ids` перед ответом
|
|
||||||
* **P0**: Ограничить финальную выдачу до `top-50`
|
|
||||||
* **P0**: Агрегировать score по `message_id`
|
|
||||||
* **P2**: Использовать `entities.people` и `entities.emails` для boost или фильтрации
|
|
||||||
* **P2**: Использовать `entities.documents`, `entities.names`, `entities.links` для lexical boost
|
|
||||||
* **P2**: Использовать `date_range` для фильтрации по `metadata.start` и `metadata.end`
|
|
||||||
* **P2**: Использовать `contains_quote` и `contains_forward` как сигналы ранжирования
|
|
||||||
* **P2**: Добавить multi-query fusion в `Qdrant`
|
|
||||||
* **P2**: Подобрать `prefetch`, `retrieve_k`, `rerank_limit`
|
|
||||||
* **P3**: Добавить retry и timeout политику для dense/rerank HTTP вызовов
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Человек 2 (Основной фокус на индексации и разметке):
|
|
||||||
|
|
||||||
#### `index/main.py`
|
|
||||||
|
|
||||||
* **P1**: Перейти с символьного chunking на chunking по сообщениям
|
|
||||||
* **P1**: Учитывать time gap при сборке чанков
|
|
||||||
* **P1**: Маркировать в тексте `quote`, `forward`, автора сообщения и автора цитаты
|
|
||||||
* **P1**: Развести `page_content`, `dense_content`, `sparse_content`
|
|
||||||
* **P1**: Материализовать `sender_id` и `mentions` в индексируемый текст
|
|
||||||
* **P1**: Разбирать `file_snippets` и вытаскивать имя файла, mime и url
|
|
||||||
* **P1**: Разбирать `member_event` и превращать его в индексируемый текст
|
|
||||||
|
|
||||||
#### `index/requirements.txt` и/или `search/requirements.txt`
|
|
||||||
|
|
||||||
* **P4**: Добавить `razdel`
|
|
||||||
* **P4**: Добавить `pymorphy3`
|
|
||||||
* **P4**: Добавить `rapidfuzz`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Человек 3 (Основной фокус на Docker и зависимостях):
|
|
||||||
|
|
||||||
#### `docker-compose.yml`
|
|
||||||
|
|
||||||
* **P3**: Привести локальный `docker-compose.yml` к схеме с `API_KEY`
|
|
||||||
* **P3**: Добавить `--platform linux/amd64` в сборку образов
|
|
||||||
|
|
||||||
#### `doc/` (новый файл с регрессионными вопросами)
|
|
||||||
|
|
||||||
* **P3**: Зафиксировать набор локальных тестовых вопросов для проверки регрессий
|
|
||||||
|
|
||||||
#### `search/requirements.txt`
|
|
||||||
|
|
||||||
* **P4**: Добавить `python-dateutil` или `dateparser`
|
|
||||||
* **P4**: Добавить `tenacity`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Таким образом, задачи равномерно распределены по 3 участникам с учётом сложности и области фокуса.
|
|
||||||
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
Шаг 2. Настройка Docker
|
|
||||||
|
|
||||||
Registry для хранения образов будет доступен по адресу 83.166.249.64:5000. Поскольку он работает без TLS, необходимо добавить его в список insecure registries в настройках Docker.
|
|
||||||
Docker Desktop (macOS / Windows)
|
|
||||||
|
|
||||||
Откройте Docker Desktop -> Settings -> Docker Engine.
|
|
||||||
Добавьте в JSON-конфиг поле insecure-registries:
|
|
||||||
|
|
||||||
{
|
|
||||||
"insecure-registries": ["83.166.249.64:5000"]
|
|
||||||
}
|
|
||||||
|
|
||||||
Нажмите Apply & Restart.
|
|
||||||
|
|
||||||
CLI — Linux
|
|
||||||
|
|
||||||
Откройте файл с конфигурацией докер демона в режиме редактирования
|
|
||||||
|
|
||||||
sudo nano /etc/docker/daemon.json
|
|
||||||
|
|
||||||
Добавьте в JSON-конфиг поле insecure-registries:
|
|
||||||
|
|
||||||
{
|
|
||||||
"insecure-registries": ["83.166.249.64:5000"]
|
|
||||||
}
|
|
||||||
|
|
||||||
Перезапустите docker
|
|
||||||
|
|
||||||
sudo systemctl restart docker
|
|
||||||
|
|
||||||
Шаг 3. Логин в registry
|
|
||||||
|
|
||||||
Необходимо пройти аутентификацию в docker registry, используя логин и пароль, полученные на шаге 1.
|
|
||||||
|
|
||||||
docker login 83.166.249.64:5000 -u <login> -p <password>
|
|
||||||
|
|
||||||
Ожидаемый вывод: Login Succeeded.
|
|
||||||
Шаг 4. Сборка образов
|
|
||||||
|
|
||||||
Соберите образы своих Index Service и Search Service. Образы должны иметь тег, который имеет строгий формат:
|
|
||||||
|
|
||||||
Для Index Service - 83.166.249.64:5000/35230/index-service:latest
|
|
||||||
Для Search Service - 83.166.249.64:5000/35230/search-service:latest
|
|
||||||
|
|
||||||
Образы должны быть собраны под платформу linux/amd64
|
|
||||||
|
|
||||||
docker build --platform linux/amd64 -t 83.166.249.64:5000/35230/index-service:latest {path_to_index_service_dir}
|
|
||||||
docker build --platform linux/amd64 -t 83.166.249.64:5000/35230/search-service:latest {path_to_search_service_dir}
|
|
||||||
|
|
||||||
Шаг 5. Публикация образов в registry
|
|
||||||
|
|
||||||
docker push 83.166.249.64:5000/35230/index-service:latest
|
|
||||||
docker push 83.166.249.64:5000/35230/search-service:latest
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2,7 +2,7 @@ services:
|
||||||
qdrant:
|
qdrant:
|
||||||
image: qdrant/qdrant:v1.14.1
|
image: qdrant/qdrant:v1.14.1
|
||||||
ports:
|
ports:
|
||||||
- "6333:6333"
|
- "6334:6333"
|
||||||
|
|
||||||
qdrant-init:
|
qdrant-init:
|
||||||
image: curlimages/curl:8.12.1
|
image: curlimages/curl:8.12.1
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ COPY main.py .
|
||||||
|
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=8000
|
ENV PORT=8000
|
||||||
ENV CHUNK_SIZE=10
|
|
||||||
ENV FASTEMBED_CACHE_PATH=/models/fastembed
|
ENV FASTEMBED_CACHE_PATH=/models/fastembed
|
||||||
ENV HF_HOME=/models/huggingface
|
ENV HF_HOME=/models/huggingface
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,26 @@
|
||||||
LOGIN ?=
|
LOGIN ?=
|
||||||
PASSWORD ?=
|
PASSWORD ?=
|
||||||
TEAM_ID ?=
|
TEAM_ID ?= 35230
|
||||||
DOCKER_REGISTRY_URL ?= 83.166.249.64:5000
|
DOCKER_REGISTRY_URL ?= 83.166.249.64:5000
|
||||||
PORT ?= 8000
|
PORT ?= 8000
|
||||||
|
|
||||||
IMAGE = $(DOCKER_REGISTRY_URL)/$(TEAM_ID)/index-service:latest
|
IMAGE = $(DOCKER_REGISTRY_URL)/$(TEAM_ID)/index-service:latest
|
||||||
|
|
||||||
.PHONY: login build run push
|
.PHONY: login build run push release
|
||||||
|
|
||||||
login:
|
login:
|
||||||
@: $(if $(LOGIN),,$(error LOGIN is required for make login))
|
@: $(if $(LOGIN),,$(error LOGIN is required))
|
||||||
@: $(if $(PASSWORD),,$(error PASSWORD is required for make login))
|
@: $(if $(PASSWORD),,$(error PASSWORD is required))
|
||||||
docker login $(DOCKER_REGISTRY_URL) -u $(LOGIN) -p $(PASSWORD)
|
docker login $(DOCKER_REGISTRY_URL) -u $(LOGIN) -p $(PASSWORD)
|
||||||
|
|
||||||
build:
|
build:
|
||||||
@: $(if $(TEAM_ID),,$(error TEAM_ID is required for make build))
|
docker build --platform linux/amd64 -t $(IMAGE) ./
|
||||||
docker build -t $(IMAGE) ./
|
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
docker run --rm -p $(PORT):8000 $(IMAGE)
|
docker run --rm -p $(PORT):8000 $(IMAGE)
|
||||||
|
|
||||||
push: login build
|
push:
|
||||||
docker push $(IMAGE)
|
docker push $(IMAGE)
|
||||||
|
|
||||||
|
release: build push
|
||||||
|
@echo "index-service pushed → $(IMAGE)"
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
"""Message-based chunking with window by count, length, and time gap."""
|
"""Message-based chunking with window by count, length, and time gap."""
|
||||||
|
|
||||||
from .cleaning import CleanedMessage, clean_message
|
from cleaning import CleanedMessage, clean_message
|
||||||
from .rendering import render_dense_content, render_page_content, render_sparse_content
|
from rendering import render_dense_content, render_page_content, render_sparse_content
|
||||||
from .schemas import IndexAPIItem, Message
|
from index_schemas import IndexAPIItem, Message
|
||||||
|
|
||||||
WINDOW_MAX_MESSAGES = 10
|
WINDOW_MAX_MESSAGES = 5
|
||||||
WINDOW_MAX_CHARS = 2048
|
WINDOW_MAX_CHARS = 512
|
||||||
TIME_GAP_SECONDS = 3600
|
TIME_GAP_SECONDS = 3600
|
||||||
OVERLAP_MESSAGES = 3
|
OVERLAP_MESSAGES = 2
|
||||||
|
|
||||||
|
|
||||||
def _clean_all(messages: list[Message]) -> list[CleanedMessage]:
|
def _clean_all(messages: list[Message]) -> list[CleanedMessage]:
|
||||||
cleaned = [clean_message(m) for m in messages]
|
cleaned = [clean_message(m) for m in messages if not m.is_system and not m.is_hidden]
|
||||||
return [c for c in cleaned if not c.is_empty]
|
return [c for c in cleaned if not c.is_empty]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
204
index/main.py
204
index/main.py
|
|
@ -1,24 +1,192 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from functools import lru_cache
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
from .chunking import build_chunks
|
|
||||||
from .schemas import IndexAPIRequest, IndexAPIResponse, SparseEmbeddingRequest
|
|
||||||
from .sparse import embed_sparse_texts
|
|
||||||
|
|
||||||
HOST = os.getenv("HOST", "0.0.0.0")
|
HOST = os.getenv("HOST", "0.0.0.0")
|
||||||
PORT = int(os.getenv("PORT", "8004"))
|
PORT = int(os.getenv("PORT", "8000"))
|
||||||
UVICORN_WORKERS = 8
|
UVICORN_WORKERS = 8
|
||||||
|
|
||||||
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
|
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
|
||||||
logger = logging.getLogger("index-service")
|
logger = logging.getLogger("index-service")
|
||||||
|
|
||||||
app = FastAPI(title="Index Service", version="0.2.0")
|
|
||||||
|
class Chat(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
sn: str
|
||||||
|
type: str
|
||||||
|
is_public: bool | None = None
|
||||||
|
members_count: int | None = None
|
||||||
|
members: list[dict[str, Any]] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Message(BaseModel):
|
||||||
|
id: str
|
||||||
|
thread_sn: str | None = None
|
||||||
|
time: int
|
||||||
|
text: str
|
||||||
|
sender_id: str
|
||||||
|
file_snippets: str
|
||||||
|
parts: list[dict[str, Any]] | None = None
|
||||||
|
mentions: list[str] | None = None
|
||||||
|
member_event: dict[str, Any] | None = None
|
||||||
|
is_system: bool
|
||||||
|
is_hidden: bool
|
||||||
|
is_forward: bool
|
||||||
|
is_quote: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ChatData(BaseModel):
|
||||||
|
chat: Chat
|
||||||
|
overlap_messages: list[Message]
|
||||||
|
new_messages: list[Message]
|
||||||
|
|
||||||
|
|
||||||
|
class IndexAPIRequest(BaseModel):
|
||||||
|
data: ChatData
|
||||||
|
|
||||||
|
|
||||||
|
class IndexAPIItem(BaseModel):
|
||||||
|
page_content: str
|
||||||
|
dense_content: str
|
||||||
|
sparse_content: str
|
||||||
|
message_ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class IndexAPIResponse(BaseModel):
|
||||||
|
results: list[IndexAPIItem]
|
||||||
|
|
||||||
|
|
||||||
|
class SparseEmbeddingRequest(BaseModel):
|
||||||
|
texts: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class SparseVector(BaseModel):
|
||||||
|
indices: list[int]
|
||||||
|
values: list[float]
|
||||||
|
|
||||||
|
|
||||||
|
CHUNK_SIZE = 256
|
||||||
|
OVERLAP_SIZE = 128
|
||||||
|
SPARSE_MODEL_NAME = "Qdrant/bm25"
|
||||||
|
FASTEMBED_CACHE_PATH = "/models/fastembed"
|
||||||
|
|
||||||
|
|
||||||
|
def render_message(message: Message) -> str:
|
||||||
|
parts_list: list[str] = []
|
||||||
|
|
||||||
|
if message.sender_id:
|
||||||
|
sender_name = message.sender_id.split("@")[0].replace(".", " ")
|
||||||
|
parts_list.append(f"[{sender_name}]:")
|
||||||
|
|
||||||
|
if message.text:
|
||||||
|
parts_list.append(message.text)
|
||||||
|
|
||||||
|
if message.parts:
|
||||||
|
for part in message.parts:
|
||||||
|
media_type = part.get("mediaType", "text")
|
||||||
|
part_text = part.get("text")
|
||||||
|
if isinstance(part_text, str) and part_text:
|
||||||
|
if media_type == "forward":
|
||||||
|
parts_list.append(f"[Пересланное]: {part_text}")
|
||||||
|
elif media_type == "quote":
|
||||||
|
parts_list.append(f"[Цитата]: {part_text}")
|
||||||
|
else:
|
||||||
|
parts_list.append(part_text)
|
||||||
|
|
||||||
|
if message.file_snippets:
|
||||||
|
parts_list.append(f"[Файл]: {message.file_snippets}")
|
||||||
|
|
||||||
|
return " ".join(parts_list).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def build_chunks(
|
||||||
|
chat: Chat,
|
||||||
|
overlap_messages: list[Message],
|
||||||
|
new_messages: list[Message],
|
||||||
|
) -> list[IndexAPIItem]:
|
||||||
|
new_messages = [m for m in new_messages if not m.is_system and not m.is_hidden]
|
||||||
|
overlap_messages = [m for m in overlap_messages if not m.is_system and not m.is_hidden]
|
||||||
|
|
||||||
|
result: list[IndexAPIItem] = []
|
||||||
|
|
||||||
|
def build_text_and_ranges(messages: list[Message]) -> tuple[str, list[tuple[int, int, str]]]:
|
||||||
|
text_parts: list[str] = []
|
||||||
|
message_ranges: list[tuple[int, int, str]] = []
|
||||||
|
position = 0
|
||||||
|
|
||||||
|
for index, message in enumerate(messages):
|
||||||
|
text = render_message(message)
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if index > 0 and text_parts:
|
||||||
|
text_parts.append("\n")
|
||||||
|
position += 1
|
||||||
|
|
||||||
|
start = position
|
||||||
|
text_parts.append(text)
|
||||||
|
position += len(text)
|
||||||
|
message_ranges.append((start, position, message.id))
|
||||||
|
|
||||||
|
return "".join(text_parts), message_ranges
|
||||||
|
|
||||||
|
def slice_tail(text: str, tail_size: int) -> str:
|
||||||
|
if tail_size <= 0:
|
||||||
|
return ""
|
||||||
|
tail_start = max(0, len(text) - tail_size)
|
||||||
|
return text[tail_start:]
|
||||||
|
|
||||||
|
overlap_text, _ = build_text_and_ranges(overlap_messages)
|
||||||
|
previous_chunk_text = slice_tail(overlap_text, OVERLAP_SIZE)
|
||||||
|
|
||||||
|
new_text, new_message_ranges = build_text_and_ranges(new_messages)
|
||||||
|
|
||||||
|
for start in range(0, len(new_text), CHUNK_SIZE):
|
||||||
|
chunk_body = new_text[start: start + CHUNK_SIZE]
|
||||||
|
if not chunk_body:
|
||||||
|
continue
|
||||||
|
|
||||||
|
chunk_body_ranges = [
|
||||||
|
(
|
||||||
|
max(message_start, start) - start,
|
||||||
|
min(message_end, start + len(chunk_body)) - start,
|
||||||
|
message_id,
|
||||||
|
)
|
||||||
|
for message_start, message_end, message_id in new_message_ranges
|
||||||
|
if message_end > start and message_start < start + len(chunk_body)
|
||||||
|
]
|
||||||
|
|
||||||
|
chunk_overlap = previous_chunk_text
|
||||||
|
chunk_text = chunk_overlap
|
||||||
|
if chunk_text and chunk_body:
|
||||||
|
chunk_text += "\n"
|
||||||
|
chunk_text += chunk_body
|
||||||
|
|
||||||
|
dense_text = f"[{chat.name}] {chunk_text}"
|
||||||
|
sparse_text = chunk_body
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
IndexAPIItem(
|
||||||
|
page_content=chunk_text,
|
||||||
|
dense_content=dense_text,
|
||||||
|
sparse_content=sparse_text,
|
||||||
|
message_ids=[message_id for _, _, message_id in chunk_body_ranges],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
previous_chunk_text = slice_tail(chunk_text, OVERLAP_SIZE)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Index Service", version="0.1.0")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
@ -30,16 +198,38 @@ async def health() -> dict[str, str]:
|
||||||
async def index(payload: IndexAPIRequest) -> IndexAPIResponse:
|
async def index(payload: IndexAPIRequest) -> IndexAPIResponse:
|
||||||
return IndexAPIResponse(
|
return IndexAPIResponse(
|
||||||
results=build_chunks(
|
results=build_chunks(
|
||||||
|
payload.data.chat,
|
||||||
payload.data.overlap_messages,
|
payload.data.overlap_messages,
|
||||||
payload.data.new_messages,
|
payload.data.new_messages,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_sparse_model():
|
||||||
|
from fastembed import SparseTextEmbedding
|
||||||
|
|
||||||
|
logger.info("Loading sparse model %s from cache %s", SPARSE_MODEL_NAME, FASTEMBED_CACHE_PATH)
|
||||||
|
return SparseTextEmbedding(model_name=SPARSE_MODEL_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def embed_sparse_texts(texts: list[str]) -> list[dict]:
|
||||||
|
model = get_sparse_model()
|
||||||
|
vectors = []
|
||||||
|
for item in model.embed(texts):
|
||||||
|
vectors.append(
|
||||||
|
{
|
||||||
|
"indices": item.indices.tolist(),
|
||||||
|
"values": item.values.tolist(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return vectors
|
||||||
|
|
||||||
|
|
||||||
@app.post("/sparse_embedding")
|
@app.post("/sparse_embedding")
|
||||||
async def sparse_embedding(payload: SparseEmbeddingRequest) -> dict[str, Any]:
|
async def sparse_embedding(payload: SparseEmbeddingRequest) -> dict[str, Any]:
|
||||||
vectors = await asyncio.to_thread(embed_sparse_texts, payload.texts)
|
vectors = await asyncio.to_thread(embed_sparse_texts, payload.texts)
|
||||||
return {"vectors": [{"indices": v.indices, "values": v.values} for v in vectors]}
|
return {"vectors": vectors}
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from .cleaning import CleanedMessage
|
from cleaning import CleanedMessage
|
||||||
|
|
||||||
|
|
||||||
def _format_time(ts: int) -> str:
|
def _format_time(ts: int) -> str:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import logging
|
||||||
import os
|
import os
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from .schemas import SparseVector
|
from index_schemas import SparseVector
|
||||||
|
|
||||||
SPARSE_MODEL_NAME = "Qdrant/bm25"
|
SPARSE_MODEL_NAME = "Qdrant/bm25"
|
||||||
FASTEMBED_CACHE_PATH = "/models/fastembed"
|
FASTEMBED_CACHE_PATH = "/models/fastembed"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
LOGIN ?=
|
LOGIN ?=
|
||||||
PASSWORD ?=
|
PASSWORD ?=
|
||||||
TEAM_ID ?=
|
TEAM_ID ?= 35230
|
||||||
DOCKER_REGISTRY_URL ?= 83.166.249.64:5000
|
DOCKER_REGISTRY_URL ?= 83.166.249.64:5000
|
||||||
PORT ?= 8000
|
PORT ?= 8000
|
||||||
QDRANT_URL ?=
|
QDRANT_URL ?=
|
||||||
|
|
@ -16,16 +16,15 @@ REQUIRED_RUN_VARS := QDRANT_URL EMBEDDINGS_DENSE_URL API_KEY RERANKER_URL
|
||||||
|
|
||||||
IMAGE = $(DOCKER_REGISTRY_URL)/$(TEAM_ID)/search-service:latest
|
IMAGE = $(DOCKER_REGISTRY_URL)/$(TEAM_ID)/search-service:latest
|
||||||
|
|
||||||
.PHONY: login build run push check-run-env
|
.PHONY: login build run push release
|
||||||
|
|
||||||
login:
|
login:
|
||||||
@: $(if $(LOGIN),,$(error LOGIN is required for make login))
|
@: $(if $(LOGIN),,$(error LOGIN is required))
|
||||||
@: $(if $(PASSWORD),,$(error PASSWORD is required for make login))
|
@: $(if $(PASSWORD),,$(error PASSWORD is required))
|
||||||
docker login $(DOCKER_REGISTRY_URL) -u $(LOGIN) -p $(PASSWORD)
|
docker login $(DOCKER_REGISTRY_URL) -u $(LOGIN) -p $(PASSWORD)
|
||||||
|
|
||||||
build:
|
build:
|
||||||
@: $(if $(TEAM_ID),,$(error TEAM_ID is required for make build))
|
docker build --platform linux/amd64 -t $(IMAGE) ./
|
||||||
docker build -t $(IMAGE) ./
|
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
@: $(foreach var,$(REQUIRED_RUN_VARS),$(if $($(var)),,$(error $(var) is required for make run)))
|
@: $(foreach var,$(REQUIRED_RUN_VARS),$(if $($(var)),,$(error $(var) is required for make run)))
|
||||||
|
|
@ -41,5 +40,8 @@ run: build
|
||||||
-e QDRANT_SPARSE_VECTOR_NAME=$(QDRANT_SPARSE_VECTOR_NAME) \
|
-e QDRANT_SPARSE_VECTOR_NAME=$(QDRANT_SPARSE_VECTOR_NAME) \
|
||||||
$(IMAGE)
|
$(IMAGE)
|
||||||
|
|
||||||
push: login build
|
push:
|
||||||
docker push $(IMAGE)
|
docker push $(IMAGE)
|
||||||
|
|
||||||
|
release: build push
|
||||||
|
@echo "search-service pushed → $(IMAGE)"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .config import TOP_K
|
from config import TOP_K
|
||||||
from .retrieval import extract_message_ids
|
from retrieval import extract_message_ids
|
||||||
|
|
||||||
|
|
||||||
def aggregate_message_ids(
|
def aggregate_message_ids(
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,10 @@ QDRANT_SPARSE_VECTOR_NAME = os.getenv("QDRANT_SPARSE_VECTOR_NAME", "sparse")
|
||||||
OPEN_API_LOGIN = os.getenv("OPEN_API_LOGIN")
|
OPEN_API_LOGIN = os.getenv("OPEN_API_LOGIN")
|
||||||
OPEN_API_PASSWORD = os.getenv("OPEN_API_PASSWORD")
|
OPEN_API_PASSWORD = os.getenv("OPEN_API_PASSWORD")
|
||||||
|
|
||||||
DENSE_PREFETCH_K = 50
|
DENSE_PREFETCH_K = 80
|
||||||
SPARSE_PREFETCH_K = 100
|
SPARSE_PREFETCH_K = 200
|
||||||
RETRIEVE_K = 80
|
RETRIEVE_K = 150
|
||||||
RERANK_LIMIT = 60
|
RERANK_LIMIT = 15
|
||||||
TOP_K = 50
|
TOP_K = 50
|
||||||
|
|
||||||
HTTP_TIMEOUT = 30.0
|
HTTP_TIMEOUT = 30.0
|
||||||
|
|
|
||||||
464
search/main.py
464
search/main.py
|
|
@ -2,56 +2,157 @@ import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from fastembed import SparseTextEmbedding
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from qdrant_client import AsyncQdrantClient
|
from pydantic import BaseModel, Field
|
||||||
|
from qdrant_client import AsyncQdrantClient, models
|
||||||
|
|
||||||
from .aggregation import aggregate_message_ids
|
EMBEDDINGS_DENSE_MODEL = "Qwen/Qwen3-Embedding-0.6B"
|
||||||
from .config import (
|
|
||||||
API_KEY,
|
HOST = os.getenv("HOST", "0.0.0.0")
|
||||||
HOST,
|
PORT = int(os.getenv("PORT", "8000"))
|
||||||
HTTP_MAX_RETRIES,
|
|
||||||
HTTP_TIMEOUT,
|
API_KEY = os.getenv("API_KEY")
|
||||||
PORT,
|
EMBEDDINGS_DENSE_URL = os.getenv("EMBEDDINGS_DENSE_URL")
|
||||||
QDRANT_URL,
|
QDRANT_DENSE_VECTOR_NAME = os.getenv("QDRANT_DENSE_VECTOR_NAME", "dense")
|
||||||
logger,
|
QDRANT_SPARSE_VECTOR_NAME = os.getenv("QDRANT_SPARSE_VECTOR_NAME", "sparse")
|
||||||
validate_required_env,
|
SPARSE_MODEL_NAME = "Qdrant/bm25"
|
||||||
)
|
RERANKER_MODEL = "nvidia/llama-nemotron-rerank-1b-v2"
|
||||||
from .query_builder import (
|
RERANKER_URL = os.getenv("RERANKER_URL")
|
||||||
build_extra_dense_queries,
|
OPEN_API_LOGIN = os.getenv("OPEN_API_LOGIN")
|
||||||
build_primary_query,
|
OPEN_API_PASSWORD = os.getenv("OPEN_API_PASSWORD")
|
||||||
build_sparse_query,
|
QDRANT_URL = os.getenv("QDRANT_URL")
|
||||||
embed_dense,
|
QDRANT_COLLECTION_NAME = os.getenv("QDRANT_COLLECTION_NAME", "evaluation")
|
||||||
embed_dense_multi,
|
REQUIRED_ENV_VARS = [
|
||||||
embed_sparse,
|
"EMBEDDINGS_DENSE_URL",
|
||||||
)
|
"RERANKER_URL",
|
||||||
from .rerank import rerank_points
|
"QDRANT_URL",
|
||||||
from .retrieval import qdrant_search
|
]
|
||||||
from .schemas import SearchAPIItem, SearchAPIRequest, SearchAPIResponse, SparseVector
|
|
||||||
|
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
|
||||||
|
logger = logging.getLogger("search-service")
|
||||||
|
|
||||||
|
|
||||||
async def _embed_dense_with_retry(client: httpx.AsyncClient, text: str) -> list[float]:
|
def validate_required_env() -> None:
|
||||||
last_exc: Exception | None = None
|
if bool(OPEN_API_LOGIN) != bool(OPEN_API_PASSWORD):
|
||||||
for attempt in range(HTTP_MAX_RETRIES + 1):
|
raise RuntimeError("OPEN_API_LOGIN and OPEN_API_PASSWORD must be set together")
|
||||||
try:
|
|
||||||
return await embed_dense(client, text)
|
if not API_KEY and not (OPEN_API_LOGIN and OPEN_API_PASSWORD):
|
||||||
except (httpx.TransportError, httpx.HTTPStatusError) as exc:
|
raise RuntimeError("Either API_KEY or OPEN_API_LOGIN and OPEN_API_PASSWORD must be set")
|
||||||
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 500:
|
|
||||||
raise
|
missing_env_vars = [
|
||||||
last_exc = exc
|
name for name in REQUIRED_ENV_VARS if os.getenv(name) is None or os.getenv(name) == ""
|
||||||
if attempt < HTTP_MAX_RETRIES:
|
]
|
||||||
await asyncio.sleep(0.5 * (attempt + 1))
|
if not missing_env_vars:
|
||||||
raise RuntimeError(f"Dense embedding failed after retries: {last_exc}")
|
return
|
||||||
|
|
||||||
|
logger.error("Empty required env vars: %s", ", ".join(missing_env_vars))
|
||||||
|
raise RuntimeError(f"Empty required env vars: {', '.join(missing_env_vars)}")
|
||||||
|
|
||||||
|
|
||||||
|
validate_required_env()
|
||||||
|
|
||||||
|
|
||||||
|
def get_upstream_request_kwargs() -> dict[str, Any]:
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
kwargs: dict[str, Any] = {"headers": headers}
|
||||||
|
|
||||||
|
if OPEN_API_LOGIN and OPEN_API_PASSWORD:
|
||||||
|
kwargs["auth"] = (OPEN_API_LOGIN, OPEN_API_PASSWORD)
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
if API_KEY:
|
||||||
|
headers["Authorization"] = f"Bearer {API_KEY}"
|
||||||
|
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
class DateRange(BaseModel):
|
||||||
|
from_: str = Field(alias="from")
|
||||||
|
to: str
|
||||||
|
|
||||||
|
|
||||||
|
class Entities(BaseModel):
|
||||||
|
people: list[str] | None = None
|
||||||
|
emails: list[str] | None = None
|
||||||
|
documents: list[str] | None = None
|
||||||
|
names: list[str] | None = None
|
||||||
|
links: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Question(BaseModel):
|
||||||
|
text: str
|
||||||
|
asker: str = ""
|
||||||
|
asked_on: str = ""
|
||||||
|
variants: list[str] | None = None
|
||||||
|
hyde: list[str] | None = None
|
||||||
|
keywords: list[str] | None = None
|
||||||
|
entities: Entities | None = None
|
||||||
|
date_mentions: list[str] | None = None
|
||||||
|
date_range: DateRange | None = None
|
||||||
|
search_text: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class SearchAPIRequest(BaseModel):
|
||||||
|
question: Question
|
||||||
|
|
||||||
|
|
||||||
|
class SearchAPIItem(BaseModel):
|
||||||
|
message_ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class SearchAPIResponse(BaseModel):
|
||||||
|
results: list[SearchAPIItem]
|
||||||
|
|
||||||
|
|
||||||
|
class DenseEmbeddingItem(BaseModel):
|
||||||
|
index: int
|
||||||
|
embedding: list[float]
|
||||||
|
|
||||||
|
|
||||||
|
class DenseEmbeddingResponse(BaseModel):
|
||||||
|
data: list[DenseEmbeddingItem]
|
||||||
|
|
||||||
|
|
||||||
|
class SparseVector(BaseModel):
|
||||||
|
indices: list[int] = Field(default_factory=list)
|
||||||
|
values: list[float] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ChunkMetadata(BaseModel):
|
||||||
|
chat_name: str
|
||||||
|
chat_type: str
|
||||||
|
chat_id: str
|
||||||
|
chat_sn: str
|
||||||
|
thread_sn: str | None = None
|
||||||
|
message_ids: list[str]
|
||||||
|
start: str
|
||||||
|
end: str
|
||||||
|
participants: list[str] = Field(default_factory=list)
|
||||||
|
mentions: list[str] = Field(default_factory=list)
|
||||||
|
contains_forward: bool = False
|
||||||
|
contains_quote: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_sparse_model() -> SparseTextEmbedding:
|
||||||
|
logger.info("Loading local sparse model %s", SPARSE_MODEL_NAME)
|
||||||
|
return SparseTextEmbedding(model_name=SPARSE_MODEL_NAME)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
validate_required_env()
|
app.state.http = httpx.AsyncClient()
|
||||||
app.state.http = httpx.AsyncClient(timeout=HTTP_TIMEOUT)
|
app.state.qdrant = AsyncQdrantClient(
|
||||||
app.state.qdrant = AsyncQdrantClient(url=QDRANT_URL, api_key=API_KEY)
|
url=QDRANT_URL,
|
||||||
|
api_key=API_KEY,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -59,7 +160,225 @@ async def lifespan(app: FastAPI):
|
||||||
await app.state.qdrant.close()
|
await app.state.qdrant.close()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Search Service", version="0.2.0", lifespan=lifespan)
|
app = FastAPI(title="Search Service", version="0.1.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
DENSE_PREFETCH_K = 120
|
||||||
|
SPARSE_PREFETCH_K = 200
|
||||||
|
RETRIEVE_K = 150
|
||||||
|
RERANK_LIMIT = 35
|
||||||
|
KEYWORD_BOOST_EXTRA = 10
|
||||||
|
|
||||||
|
|
||||||
|
async def embed_dense(client: httpx.AsyncClient, text: str) -> list[float]:
|
||||||
|
response = await client.post(
|
||||||
|
EMBEDDINGS_DENSE_URL,
|
||||||
|
**get_upstream_request_kwargs(),
|
||||||
|
json={
|
||||||
|
"model": os.getenv("EMBEDDINGS_DENSE_MODEL", EMBEDDINGS_DENSE_MODEL),
|
||||||
|
"input": [text],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = DenseEmbeddingResponse.model_validate(response.json())
|
||||||
|
if not payload.data:
|
||||||
|
raise ValueError("Dense embedding response is empty")
|
||||||
|
return payload.data[0].embedding
|
||||||
|
|
||||||
|
|
||||||
|
async def embed_dense_batch(client: httpx.AsyncClient, texts: list[str]) -> list[list[float]]:
|
||||||
|
response = await client.post(
|
||||||
|
EMBEDDINGS_DENSE_URL,
|
||||||
|
**get_upstream_request_kwargs(),
|
||||||
|
json={
|
||||||
|
"model": os.getenv("EMBEDDINGS_DENSE_MODEL", EMBEDDINGS_DENSE_MODEL),
|
||||||
|
"input": texts,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = DenseEmbeddingResponse.model_validate(response.json())
|
||||||
|
payload.data.sort(key=lambda x: x.index)
|
||||||
|
return [item.embedding for item in payload.data]
|
||||||
|
|
||||||
|
|
||||||
|
def embed_sparse_sync(text: str) -> SparseVector:
|
||||||
|
vectors = list(get_sparse_model().embed([text]))
|
||||||
|
if not vectors:
|
||||||
|
raise ValueError("Sparse embedding response is empty")
|
||||||
|
item = vectors[0]
|
||||||
|
return SparseVector(
|
||||||
|
indices=[int(index) for index in item.indices.tolist()],
|
||||||
|
values=[float(value) for value in item.values.tolist()],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_dense_query(question: Question) -> str:
|
||||||
|
q = question.search_text.strip() if question.search_text else question.text.strip()
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def build_sparse_query(question: Question) -> str:
|
||||||
|
base = question.search_text.strip() if question.search_text else question.text.strip()
|
||||||
|
parts = [base]
|
||||||
|
if question.keywords:
|
||||||
|
parts.extend(question.keywords)
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_keyword_set(question: Question) -> list[str]:
|
||||||
|
tokens: list[str] = []
|
||||||
|
if question.keywords:
|
||||||
|
tokens.extend(kw.lower() for kw in question.keywords if kw)
|
||||||
|
if question.entities:
|
||||||
|
for field in (
|
||||||
|
question.entities.people,
|
||||||
|
question.entities.emails,
|
||||||
|
question.entities.documents,
|
||||||
|
question.entities.names,
|
||||||
|
question.entities.links,
|
||||||
|
):
|
||||||
|
tokens.extend(e.lower() for e in (field or []) if e)
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
def prefilter_for_rerank(
|
||||||
|
points: list[Any],
|
||||||
|
question: Question,
|
||||||
|
) -> tuple[list[Any], list[Any]]:
|
||||||
|
"""Select candidates for reranking: top by RRF + keyword-boosted stragglers."""
|
||||||
|
if not points:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
head = points[:RERANK_LIMIT]
|
||||||
|
tail = points[RERANK_LIMIT:]
|
||||||
|
|
||||||
|
keywords = _build_keyword_set(question)
|
||||||
|
if not keywords or not tail:
|
||||||
|
return head, tail
|
||||||
|
|
||||||
|
extra: list[Any] = []
|
||||||
|
remaining_tail: list[Any] = []
|
||||||
|
for p in tail:
|
||||||
|
if len(extra) >= KEYWORD_BOOST_EXTRA:
|
||||||
|
remaining_tail.append(p)
|
||||||
|
continue
|
||||||
|
content = ((p.payload or {}).get("page_content") or "").lower()
|
||||||
|
if any(kw in content for kw in keywords):
|
||||||
|
extra.append(p)
|
||||||
|
else:
|
||||||
|
remaining_tail.append(p)
|
||||||
|
|
||||||
|
return head + extra, remaining_tail
|
||||||
|
|
||||||
|
|
||||||
|
async def qdrant_search(
|
||||||
|
client: AsyncQdrantClient,
|
||||||
|
dense_vectors: list[list[float]],
|
||||||
|
sparse_vector: SparseVector,
|
||||||
|
) -> list[Any] | None:
|
||||||
|
prefetch_list = []
|
||||||
|
for dv in dense_vectors:
|
||||||
|
prefetch_list.append(
|
||||||
|
models.Prefetch(
|
||||||
|
query=dv,
|
||||||
|
using=QDRANT_DENSE_VECTOR_NAME,
|
||||||
|
limit=DENSE_PREFETCH_K,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
prefetch_list.append(
|
||||||
|
models.Prefetch(
|
||||||
|
query=models.SparseVector(
|
||||||
|
indices=sparse_vector.indices,
|
||||||
|
values=sparse_vector.values,
|
||||||
|
),
|
||||||
|
using=QDRANT_SPARSE_VECTOR_NAME,
|
||||||
|
limit=SPARSE_PREFETCH_K,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.query_points(
|
||||||
|
collection_name=QDRANT_COLLECTION_NAME,
|
||||||
|
prefetch=prefetch_list,
|
||||||
|
query=models.FusionQuery(fusion=models.Fusion.RRF),
|
||||||
|
limit=RETRIEVE_K,
|
||||||
|
with_payload=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not response.points:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return response.points
|
||||||
|
|
||||||
|
|
||||||
|
def extract_message_ids(point: Any) -> list[str]:
|
||||||
|
payload = point.payload or {}
|
||||||
|
metadata = payload.get("metadata") or {}
|
||||||
|
message_ids = metadata.get("message_ids") or []
|
||||||
|
return [str(message_id) for message_id in message_ids]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_rerank_scores(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
label: str,
|
||||||
|
targets: list[str],
|
||||||
|
) -> list[float]:
|
||||||
|
if not targets:
|
||||||
|
return []
|
||||||
|
|
||||||
|
for attempt in range(5):
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
RERANKER_URL,
|
||||||
|
**get_upstream_request_kwargs(),
|
||||||
|
json={
|
||||||
|
"model": RERANKER_MODEL,
|
||||||
|
"encoding_format": "float",
|
||||||
|
"text_1": label,
|
||||||
|
"text_2": targets,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if response.status_code == 429:
|
||||||
|
wait = 2 ** attempt
|
||||||
|
logger.warning(f"Rerank 429, retry {attempt+1}/5 in {wait}s")
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
continue
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
data = payload.get("data") or []
|
||||||
|
return [float(sample["score"]) for sample in data]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Rerank error attempt {attempt+1}/5: {e}")
|
||||||
|
if attempt < 4:
|
||||||
|
await asyncio.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
logger.error("Rerank failed after 5 attempts, using fallback")
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.error("Rerank 429 after 5 retries, using fallback")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def rerank_points(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
query: str,
|
||||||
|
points: list[Any],
|
||||||
|
) -> list[Any]:
|
||||||
|
if not points:
|
||||||
|
return []
|
||||||
|
targets = [point.payload.get("page_content") for point in points]
|
||||||
|
scores = await get_rerank_scores(client, query, targets)
|
||||||
|
|
||||||
|
if not scores or len(scores) != len(points):
|
||||||
|
logger.warning("Reranker unavailable or score mismatch, returning RRF order")
|
||||||
|
return points
|
||||||
|
|
||||||
|
return [
|
||||||
|
point
|
||||||
|
for _, point in sorted(
|
||||||
|
zip(scores, points),
|
||||||
|
key=lambda item: item[0],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
@ -70,39 +389,62 @@ async def health() -> dict[str, str]:
|
||||||
@app.post("/search", response_model=SearchAPIResponse)
|
@app.post("/search", response_model=SearchAPIResponse)
|
||||||
async def search(payload: SearchAPIRequest) -> SearchAPIResponse:
|
async def search(payload: SearchAPIRequest) -> SearchAPIResponse:
|
||||||
question = payload.question
|
question = payload.question
|
||||||
primary_query = build_primary_query(question)
|
query = question.text.strip()
|
||||||
if not primary_query:
|
if not query:
|
||||||
raise HTTPException(status_code=400, detail="question.text is required")
|
raise HTTPException(status_code=400, detail="question.text is required")
|
||||||
|
|
||||||
client: httpx.AsyncClient = app.state.http
|
client: httpx.AsyncClient = app.state.http
|
||||||
qdrant: AsyncQdrantClient = app.state.qdrant
|
qdrant: AsyncQdrantClient = app.state.qdrant
|
||||||
|
|
||||||
extra_texts = build_extra_dense_queries(question)
|
dense_query = build_dense_query(question)
|
||||||
sparse_text = build_sparse_query(question)
|
sparse_query = build_sparse_query(question)
|
||||||
|
|
||||||
async def _no_extra() -> list:
|
dense_task = embed_dense(client, dense_query)
|
||||||
return []
|
sparse_task = asyncio.to_thread(lambda: embed_sparse_sync(sparse_query))
|
||||||
|
dense_vector, sparse_vector = await asyncio.gather(dense_task, sparse_task)
|
||||||
|
|
||||||
extra_task = embed_dense_multi(client, extra_texts) if extra_texts else _no_extra()
|
dense_vectors = [dense_vector]
|
||||||
primary_dense, extra_dense_vecs, sparse_vec = await asyncio.gather(
|
extra_texts: list[str] = []
|
||||||
_embed_dense_with_retry(client, primary_query),
|
raw_text = question.text.strip()
|
||||||
extra_task,
|
if raw_text and raw_text != dense_query:
|
||||||
asyncio.to_thread(embed_sparse, sparse_text),
|
extra_texts.append(raw_text)
|
||||||
)
|
for v in (question.variants or []):
|
||||||
|
q_v = v.strip()
|
||||||
|
if q_v and q_v != dense_query and q_v not in extra_texts:
|
||||||
|
extra_texts.append(q_v)
|
||||||
|
for h in (question.hyde or []):
|
||||||
|
q_h = h.strip()
|
||||||
|
if q_h and q_h != dense_query and q_h not in extra_texts:
|
||||||
|
extra_texts.append(q_h)
|
||||||
|
extra_texts = extra_texts[:3]
|
||||||
|
if extra_texts:
|
||||||
|
try:
|
||||||
|
extra_vecs = await embed_dense_batch(client, extra_texts)
|
||||||
|
dense_vectors.extend(extra_vecs)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Extra dense embedding failed: {e}")
|
||||||
|
|
||||||
points = await qdrant_search(
|
all_points = await qdrant_search(qdrant, dense_vectors, sparse_vector)
|
||||||
qdrant,
|
|
||||||
primary_dense,
|
|
||||||
extra_dense_vecs,
|
|
||||||
sparse_vec,
|
|
||||||
question,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not points:
|
if all_points is None:
|
||||||
return SearchAPIResponse(results=[])
|
return SearchAPIResponse(results=[])
|
||||||
|
|
||||||
reranked_head, retrieval_tail = await rerank_points(client, primary_query, points)
|
all_points = list(all_points)
|
||||||
message_ids = aggregate_message_ids(reranked_head, retrieval_tail)
|
|
||||||
|
rerank_pool, rerank_tail = prefilter_for_rerank(all_points, question)
|
||||||
|
reranked = await rerank_points(client, query, rerank_pool)
|
||||||
|
final_points = reranked + rerank_tail
|
||||||
|
|
||||||
|
msg_score: dict[str, float] = {}
|
||||||
|
for rank, point in enumerate(reranked):
|
||||||
|
score = 1.0 / (rank + 1)
|
||||||
|
for mid in extract_message_ids(point):
|
||||||
|
msg_score[mid] = msg_score.get(mid, 0.0) + score
|
||||||
|
for rank, point in enumerate(rerank_tail):
|
||||||
|
score = 1.0 / (60 + rank + 1)
|
||||||
|
for mid in extract_message_ids(point):
|
||||||
|
msg_score[mid] = msg_score.get(mid, 0.0) + score
|
||||||
|
message_ids = sorted(msg_score, key=lambda m: msg_score[m], reverse=True)[:50]
|
||||||
|
|
||||||
return SearchAPIResponse(results=[SearchAPIItem(message_ids=message_ids)])
|
return SearchAPIResponse(results=[SearchAPIItem(message_ids=message_ids)])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import asyncio
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
@ -6,14 +5,14 @@ from functools import lru_cache
|
||||||
import httpx
|
import httpx
|
||||||
from fastembed import SparseTextEmbedding
|
from fastembed import SparseTextEmbedding
|
||||||
|
|
||||||
from .config import (
|
from config import (
|
||||||
EMBEDDINGS_DENSE_MODEL,
|
EMBEDDINGS_DENSE_MODEL,
|
||||||
EMBEDDINGS_DENSE_URL,
|
EMBEDDINGS_DENSE_URL,
|
||||||
SPARSE_MODEL_NAME,
|
SPARSE_MODEL_NAME,
|
||||||
get_upstream_kwargs,
|
get_upstream_kwargs,
|
||||||
logger,
|
logger,
|
||||||
)
|
)
|
||||||
from .schemas import DenseEmbeddingResponse, Question, SparseVector
|
from schemas import DenseEmbeddingResponse, Question, SparseVector
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
|
|
@ -38,9 +37,20 @@ async def embed_dense(client: httpx.AsyncClient, text: str) -> list[float]:
|
||||||
return payload.data[0].embedding
|
return payload.data[0].embedding
|
||||||
|
|
||||||
|
|
||||||
async def embed_dense_multi(client: httpx.AsyncClient, texts: list[str]) -> list[list[float]]:
|
async def embed_dense_batch(client: httpx.AsyncClient, texts: list[str]) -> list[list[float]]:
|
||||||
tasks = [embed_dense(client, t) for t in texts]
|
"""Single request for multiple texts — avoids N parallel calls and rate limiting."""
|
||||||
return list(await asyncio.gather(*tasks))
|
response = await client.post(
|
||||||
|
str(EMBEDDINGS_DENSE_URL),
|
||||||
|
**get_upstream_kwargs(),
|
||||||
|
json={
|
||||||
|
"model": os.getenv("EMBEDDINGS_DENSE_MODEL", EMBEDDINGS_DENSE_MODEL),
|
||||||
|
"input": texts,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = DenseEmbeddingResponse.model_validate(response.json())
|
||||||
|
payload.data.sort(key=lambda x: x.index)
|
||||||
|
return [item.embedding for item in payload.data]
|
||||||
|
|
||||||
|
|
||||||
def embed_sparse(text: str) -> SparseVector:
|
def embed_sparse(text: str) -> SparseVector:
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
|
import asyncio
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from .config import RERANK_LIMIT, RERANKER_MODEL, RERANKER_URL, get_upstream_kwargs, logger
|
from config import RERANK_LIMIT, RERANKER_MODEL, RERANKER_URL, get_upstream_kwargs, logger
|
||||||
from .retrieval import extract_page_content
|
from retrieval import extract_page_content
|
||||||
|
|
||||||
|
|
||||||
async def get_rerank_scores(
|
async def _get_rerank_scores(
|
||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient,
|
||||||
query: str,
|
query: str,
|
||||||
targets: list[str],
|
targets: list[str],
|
||||||
|
|
@ -14,6 +15,8 @@ async def get_rerank_scores(
|
||||||
if not targets:
|
if not targets:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
for attempt in range(5):
|
||||||
|
try:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
str(RERANKER_URL),
|
str(RERANKER_URL),
|
||||||
**get_upstream_kwargs(),
|
**get_upstream_kwargs(),
|
||||||
|
|
@ -24,35 +27,47 @@ async def get_rerank_scores(
|
||||||
"text_2": targets,
|
"text_2": targets,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
except Exception as exc:
|
||||||
|
if attempt < 4:
|
||||||
|
await asyncio.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
if response.status_code == 429:
|
||||||
|
wait = 2 ** attempt
|
||||||
|
logger.warning("Rerank 429, retry %d/5 in %ds", attempt + 1, wait)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
continue
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
data = response.json().get("data") or []
|
data = response.json().get("data") or []
|
||||||
return [float(sample["score"]) for sample in data]
|
return [float(sample["score"]) for sample in data]
|
||||||
|
|
||||||
|
logger.error("Rerank 429 after all retries, falling back")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def rerank_points(
|
async def rerank_points(
|
||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient,
|
||||||
query: str,
|
query: str,
|
||||||
points: list[Any],
|
points: list[Any],
|
||||||
) -> tuple[list[Any], list[Any]]:
|
) -> tuple[list[Any], list[Any]]:
|
||||||
"""Return (reranked_head, retrieval_tail) so we don't lose candidates."""
|
|
||||||
if not points:
|
if not points:
|
||||||
return [], []
|
return [], []
|
||||||
|
|
||||||
rerank_candidates = points[:RERANK_LIMIT]
|
head = points[:RERANK_LIMIT]
|
||||||
tail = points[RERANK_LIMIT:]
|
tail = points[RERANK_LIMIT:]
|
||||||
|
targets = [extract_page_content(p) for p in head]
|
||||||
|
|
||||||
targets = [extract_page_content(p) for p in rerank_candidates]
|
|
||||||
try:
|
try:
|
||||||
scores = await get_rerank_scores(client, query, targets)
|
scores = await _get_rerank_scores(client, query, targets)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Rerank failed, using retrieval order: %s", exc)
|
logger.warning("Rerank failed, using retrieval order: %s", exc)
|
||||||
return rerank_candidates, tail
|
return head, tail
|
||||||
|
|
||||||
if len(scores) != len(rerank_candidates):
|
if len(scores) != len(head):
|
||||||
logger.warning("Rerank score count mismatch, using retrieval order")
|
logger.warning("Rerank score count mismatch, using retrieval order")
|
||||||
return rerank_candidates, tail
|
return head, tail
|
||||||
|
|
||||||
paired = sorted(zip(scores, rerank_candidates), key=lambda x: x[0], reverse=True)
|
reranked = [p for _, p in sorted(zip(scores, head), key=lambda x: x[0], reverse=True)]
|
||||||
reranked = [p for _, p in paired]
|
|
||||||
return reranked, tail
|
return reranked, tail
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from typing import Any
|
||||||
|
|
||||||
from qdrant_client import AsyncQdrantClient, models
|
from qdrant_client import AsyncQdrantClient, models
|
||||||
|
|
||||||
from .config import (
|
from config import (
|
||||||
DENSE_PREFETCH_K,
|
DENSE_PREFETCH_K,
|
||||||
QDRANT_COLLECTION_NAME,
|
QDRANT_COLLECTION_NAME,
|
||||||
QDRANT_DENSE_VECTOR_NAME,
|
QDRANT_DENSE_VECTOR_NAME,
|
||||||
|
|
@ -11,22 +11,28 @@ from .config import (
|
||||||
SPARSE_PREFETCH_K,
|
SPARSE_PREFETCH_K,
|
||||||
logger,
|
logger,
|
||||||
)
|
)
|
||||||
from .schemas import Question, SparseVector
|
from schemas import Question, SparseVector
|
||||||
|
|
||||||
|
|
||||||
def _build_filter(question: Question) -> models.Filter | None:
|
def _build_filter(question: Question) -> models.Filter | None:
|
||||||
must_conditions: list[models.Condition] = []
|
must_conditions: list[models.Condition] = []
|
||||||
|
|
||||||
if question.date_range:
|
if question.date_range:
|
||||||
|
try:
|
||||||
|
must_conditions.append(
|
||||||
|
models.FieldCondition(
|
||||||
|
key="metadata.end",
|
||||||
|
range=models.Range(gte=question.date_range.from_),
|
||||||
|
)
|
||||||
|
)
|
||||||
must_conditions.append(
|
must_conditions.append(
|
||||||
models.FieldCondition(
|
models.FieldCondition(
|
||||||
key="metadata.start",
|
key="metadata.start",
|
||||||
range=models.Range(
|
range=models.Range(lte=question.date_range.to),
|
||||||
gte=question.date_range.from_,
|
|
||||||
lte=question.date_range.to,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Date filter failed: %s", e)
|
||||||
|
|
||||||
if question.asker:
|
if question.asker:
|
||||||
must_conditions.append(
|
must_conditions.append(
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
"""Unit tests for search/aggregation.py"""
|
"""Unit tests for search/aggregation.py"""
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
|
_SEARCH_DIR = os.path.join(os.path.dirname(__file__), "..", "search")
|
||||||
|
sys.path.insert(0, _SEARCH_DIR)
|
||||||
|
|
||||||
os.environ.setdefault("EMBEDDINGS_DENSE_URL", "http://localhost/embed")
|
os.environ.setdefault("EMBEDDINGS_DENSE_URL", "http://localhost/embed")
|
||||||
os.environ.setdefault("RERANKER_URL", "http://localhost/rerank")
|
os.environ.setdefault("RERANKER_URL", "http://localhost/rerank")
|
||||||
os.environ.setdefault("QDRANT_URL", "http://localhost:6333")
|
os.environ.setdefault("QDRANT_URL", "http://localhost:6333")
|
||||||
os.environ.setdefault("API_KEY", "test-key")
|
os.environ.setdefault("API_KEY", "test-key")
|
||||||
|
|
||||||
from search.aggregation import aggregate_message_ids
|
from aggregation import aggregate_message_ids
|
||||||
from search.config import TOP_K
|
from config import TOP_K
|
||||||
|
|
||||||
|
|
||||||
def _point(message_ids: list[str]):
|
def _point(message_ids: list[str]):
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
"""Unit tests for index/chunking.py"""
|
"""Unit tests for index/chunking.py"""
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
|
|
||||||
from index.chunking import build_chunks, _split_windows, WINDOW_MAX_MESSAGES, TIME_GAP_SECONDS
|
_INDEX_DIR = os.path.join(os.path.dirname(__file__), "..", "index")
|
||||||
from index.cleaning import CleanedMessage
|
sys.path.insert(0, _INDEX_DIR)
|
||||||
from index.schemas import Message
|
|
||||||
|
from chunking import build_chunks, _split_windows, WINDOW_MAX_MESSAGES, TIME_GAP_SECONDS
|
||||||
|
from cleaning import CleanedMessage
|
||||||
|
from index_schemas import Message
|
||||||
|
|
||||||
|
|
||||||
def _make_message(id: str, time: int, text: str = "hello", **kwargs) -> Message:
|
def _make_message(id: str, time: int, text: str = "hello", **kwargs) -> Message:
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
"""Unit tests for index/cleaning.py"""
|
"""Unit tests for index/cleaning.py"""
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
|
_INDEX_DIR = os.path.join(os.path.dirname(__file__), "..", "index")
|
||||||
|
sys.path.insert(0, _INDEX_DIR)
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from index.cleaning import (
|
from cleaning import (
|
||||||
normalize_unicode,
|
normalize_unicode,
|
||||||
parse_file_snippets,
|
parse_file_snippets,
|
||||||
normalize_member_event,
|
normalize_member_event,
|
||||||
normalize_part,
|
normalize_part,
|
||||||
clean_message,
|
clean_message,
|
||||||
)
|
)
|
||||||
from index.schemas import Message
|
from index_schemas import Message
|
||||||
|
|
||||||
|
|
||||||
def _make_message(**kwargs) -> Message:
|
def _make_message(**kwargs) -> Message:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
"""Unit tests for search/query_builder.py (pure logic only, no HTTP)"""
|
"""Unit tests for search/query_builder.py (pure logic only, no HTTP)"""
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
|
_SEARCH_DIR = os.path.join(os.path.dirname(__file__), "..", "search")
|
||||||
|
sys.path.insert(0, _SEARCH_DIR)
|
||||||
|
|
||||||
# Stub env vars before importing search modules
|
# Stub env vars before importing search modules
|
||||||
os.environ.setdefault("EMBEDDINGS_DENSE_URL", "http://localhost/embed")
|
os.environ.setdefault("EMBEDDINGS_DENSE_URL", "http://localhost/embed")
|
||||||
|
|
@ -9,8 +11,8 @@ os.environ.setdefault("RERANKER_URL", "http://localhost/rerank")
|
||||||
os.environ.setdefault("QDRANT_URL", "http://localhost:6333")
|
os.environ.setdefault("QDRANT_URL", "http://localhost:6333")
|
||||||
os.environ.setdefault("API_KEY", "test-key")
|
os.environ.setdefault("API_KEY", "test-key")
|
||||||
|
|
||||||
from search.schemas import Entities, Question
|
from schemas import Entities, Question
|
||||||
from search.query_builder import (
|
from query_builder import (
|
||||||
build_primary_query,
|
build_primary_query,
|
||||||
build_extra_dense_queries,
|
build_extra_dense_queries,
|
||||||
build_sparse_query,
|
build_sparse_query,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
"""Unit tests for index/rendering.py"""
|
"""Unit tests for index/rendering.py"""
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
|
|
||||||
from index.cleaning import CleanedMessage
|
_INDEX_DIR = os.path.join(os.path.dirname(__file__), "..", "index")
|
||||||
from index.rendering import render_page_content, render_dense_content, render_sparse_content
|
sys.path.insert(0, _INDEX_DIR)
|
||||||
|
|
||||||
|
from cleaning import CleanedMessage
|
||||||
|
from rendering import render_page_content, render_dense_content, render_sparse_content
|
||||||
|
|
||||||
|
|
||||||
def _make_cleaned(**kwargs) -> CleanedMessage:
|
def _make_cleaned(**kwargs) -> CleanedMessage:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue