Files
memo/docs/orders/04-searcher.md
T

5.4 KiB
Raw Blame History

Проект: /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).
  • Не добавлять зависимости.
  • Не создавать другие файлы.