Проект: /root/WORK/memo (Kotlin/JVM). Заказ по поиску. Создать РОВНО ДВА новых файла: memo-core/src/main/kotlin/memo/core/Searcher.kt memo-core/src/test/kotlin/memo/core/SearcherTest.kt ## Searcher.kt package memo.core enum class SearchMode { HYBRID, LEX, VEC } data class Hit( val path: String, val line: Int, val heading: String, val score: Double, val text: String, // текст чанка, обрезанный до 1200 символов ) fun interface RefreshHook { fun refresh(root: java.io.File) } class Searcher( private val db: Db, private val embedder: Embedder, private val refresh: RefreshHook? = null, ) { /** Ищет по индексу. Если задан [refresh] — сначала вызывает его для root * (догон свежих правок файлов до поиска). */ fun search(root: java.io.File, query: String, k: Int = 8, mode: SearchMode = SearchMode.HYBRID): List } Правила реализации: 1. Если refresh != null — вызвать refresh.refresh(root) ПЕРЕД любым запросом к базе. 2. Лексический список (режимы LEX и HYBRID): разбить query на токены по всем символам, кроме букв и цифр (unicode: Character.isLetterOrDigit); оставить токены длиной >= 2; взять первые 8; собрать строку вида "ток1" OR "ток2" (каждый токен в двойных кавычках); если токенов нет — лексический список пуст, без ошибки. Запрос: SELECT rowid, bm25(chunks_fts) AS s FROM chunks_fts WHERE chunks_fts MATCH ? ORDER BY s LIMIT 32. Ошибку SQLite на MATCH не ронять наружу, а считать списком пустым. 3. Векторный список (режимы VEC и HYBRID): embedder.embed(query) → FloatArray[768]; запрос: SELECT rowid, distance FROM chunks_vec WHERE embedding MATCH ? ORDER BY distance LIMIT 32. Байндить вектор тем же способом, что в тесте vec0KnnRoundTrip (bindVector). 4. Слияние RRF: score(rowid) = сумма по спискам 1.0/(60.0 + rank), где rank — позиция в списке, начиная с 1. В режиме LEX — только лексический список, в VEC — только векторный, в HYBRID — оба. 5. По итоговому score взять первые k rowid; вычитать из chunks поля path, line, heading, text (SELECT path, line, heading, text FROM chunks WHERE id IN (...)); сохранить порядок по score убыванию. 6. text обрезать до 1200 символов (если длиннее — substring(0,1200) без многоточий). 7. Пустой индекс → пустой список, без исключений. ## SearcherTest.kt — ровно 5 тестов, имена ровно такие Подготовка (общий помощник): временный каталог + Db(index.db) + db.init() + Embedder(модель из MEMO_MODEL_DIR как в CoreSmokeTest.embedderProduces768). Помощник `addChunk(path, heading, line, text)`, который вставляет строку в chunks, берёт last_insert_rowid() и вставляет тот же rowid в chunks_fts(text, heading) и в chunks_vec(embedding) с эмбеддингом embedder.embed(text). 1. `lexModeFindsExactValue` — 3 чанка, в одном текст "Прокси корпоративных доменов на 76.132"; поиск с mode = LEX по запросу "76.132" → первый хит содержит "76.132". 2. `vecModeFindsSemanticMatch` — 3 чанка на разные темы (сервер/книга/CI); запрос про «настройку выпуска приложения», mode = VEC → первый хит — чанк про CI. 3. `lexModeIgnoresVectorOnlyMatch` — запрос, не имеющий ни одного общего токена с чанками (например, при тексте чанков "опрос", "случай", "знание" запрос "автомобиль"); mode = LEX → пустой список, mode = VEC → непустой. 4. `hybridCombinesBoth` — в индексе чанк с точным значением и чанк со смысловым совпадением; запрос, попадающий в оба, mode = HYBRID, k = 5 → в результате присутствуют оба чанка. 5. `resultsRespectKAndTextLength` — 4 чанка; k = 2 → ровно 2 хита; у каждого text непустой и его длина <= 1200. После: ./gradlew :memo-core:test --rerun-tasks — все тесты зелёные. Коммит: git add -A && git commit -m "core: гибридный поиск (BM25 + вектор + RRF)" СТРОГИЕ ЗАПРЕТЫ: - Не выводить план текстом; сразу создавай файлы. - Не трогать существующие файлы (Chunker.kt, Indexer.kt, Embedder.kt, Db.kt, CoreSmokeTest.kt, ChunkerTest.kt, build.gradle.kts). - Не добавлять зависимости. - Не создавать другие файлы.