core: гибридный поиск (BM25 + вектор + RRF)
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
Проект: /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<Hit>
|
||||
}
|
||||
|
||||
Правила реализации:
|
||||
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).
|
||||
- Не добавлять зависимости.
|
||||
- Не создавать другие файлы.
|
||||
@@ -0,0 +1,79 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ по CLI — руками проверяемые команды.
|
||||
|
||||
Правки:
|
||||
- memo-cli/build.gradle.kts: добавить application-плагин и зависимость на :memo-core
|
||||
- новый файл memo-cli/src/main/kotlin/memo/cli/Main.kt
|
||||
- новый файл memo-cli/src/test/kotlin/memo/cli/CliArgsTest.kt
|
||||
|
||||
## Настройка модуля memo-cli
|
||||
|
||||
build.gradle.kts модуля:
|
||||
plugins { kotlin("jvm"); application }
|
||||
application { mainClass.set("memo.cli.MainKt") }
|
||||
dependencies { implementation(project(":memo-core")) }
|
||||
|
||||
## Main.kt — контракт команд
|
||||
|
||||
fun main(args: Array<String>)
|
||||
|
||||
Разбор аргументов вынести в чистую функцию (тестируемую без процесса):
|
||||
sealed interface Cmd
|
||||
data class IndexCmd(val path: String) : Cmd
|
||||
data class SearchCmd(val path: String, val query: String, val k: Int, val mode: SearchMode, val json: Boolean) : Cmd
|
||||
data class StatusCmd(val path: String) : Cmd
|
||||
data class McpProbeCmd(val tool: String, val argsJson: String) : Cmd
|
||||
object HelpCmd : Cmd
|
||||
fun parseArgs(args: Array<String>): Cmd // при ошибке — HelpCmd
|
||||
|
||||
Правила разбора:
|
||||
- `index <path>` → IndexCmd
|
||||
- `search <path> <query> [--k N] [--mode hybrid|lex|vec] [--json]` → SearchCmd
|
||||
(по умолчанию k = 8, mode = HYBRID); неизвестный флаг или нечисловой --k → HelpCmd
|
||||
- `status <path>` → StatusCmd
|
||||
- `mcp-probe --tool <name> [--args <json>]` → McpProbeCmd (args по умолчанию "{}")
|
||||
- нет аргументов, `--help`, `-h`, неизвестная команда → HelpCmd
|
||||
|
||||
Поведение команд:
|
||||
1. `index <path>`: определить корень так — если path оканчивается на "/.memo" или содержит его, взять родителя;
|
||||
каталог `X` считается коллекцией, если в нём есть файлы *.md (искать на глубине до 2 от path).
|
||||
Для каждой найденной коллекции: создать каталог `<коллекция>/.memo`, базу `<коллекция>/.memo/index.db`,
|
||||
Db(...).init(), затем Indexer(db, embedder).indexTree(коллекция).
|
||||
Путь к модели: переменная окружения MEMO_MODEL_DIR, иначе /root/WORK/memo/models/siglip2.
|
||||
Печатать по строке на коллекцию: `индексировано: <N> обновлено, <файлов всего>` и в конце
|
||||
`итого: <сумма> обновлено в <число коллекций> коллекциях`.
|
||||
2. `search <path> <query>`: path — ЛИБО коллекция, ЛИБО файл *.md (тогда коллекция = его каталог),
|
||||
ЛИБО корень, содержащий коллекции (тогда искать по всем коллекциям внутри, объединив результаты
|
||||
по убыванию score и обрезав до k).
|
||||
Модель/эмбеддер обязателен: без него базы не откроются — это нормально.
|
||||
Refresh-хук передавать обязательно: он делает Indexer(db, embedder).indexTree(root) перед поиском.
|
||||
Без `--json` печатать человекочитаемо:
|
||||
`<score с 3 знаками> <path>:<line> <heading>`
|
||||
далее текст чанка с отступом 4 пробела, обрезанный до 300 символов.
|
||||
С `--json` печатать ОДИН JSON-массив, без пояснений и без лишних строк:
|
||||
[{"path":"...","line":12,"heading":"...","score":0.51,"text":"..."}]
|
||||
Если ничего не найдено — пустой JSON-массив `[]` (в режиме --json) и код возврата 0.
|
||||
3. `status <path>`: по каждой коллекции печатать `<<коллекция>>: файлов <N>, чанков <M>, индекс <дата ISO>`
|
||||
(дата — максимальный indexed_at из files).
|
||||
4. `mcp-probe --tool <name> --args <json>`: выполнить то же, что делает MCP-инструмент, и напечатать
|
||||
результат в формате MCP-ответа:
|
||||
{"content":[{"type":"text","text":"<результат>"}],"isError":false}
|
||||
Поддерживаемые инструменты на этом шаге: memo_search (аргументы path, query, k, mode) и memo_status (path).
|
||||
memo_reindex — вернуть isError: true с текстом "not implemented yet".
|
||||
|
||||
## CliArgsTest.kt — ровно 5 тестов, имена ровно такие
|
||||
|
||||
1. `indexCommandParsed` — ["index", "/tmp/x"] → IndexCmd с path "/tmp/x".
|
||||
2. `searchDefaultsAreK8HybridNoJson` — ["search", "/tmp/x", "вопрос"] → SearchCmd(k = 8, mode = HYBRID, json = false).
|
||||
3. `searchFlagsParsed` — ["search", "/tmp/x", "вопрос", "--k", "3", "--mode", "lex", "--json"] → k = 3, mode = LEX, json = true.
|
||||
4. `badKValueFallsBackToHelp` — ["search", "/tmp/x", "вопрос", "--k", "abc"] → HelpCmd.
|
||||
5. `unknownCommandFallsBackToHelp` — ["сломать"] → HelpCmd.
|
||||
|
||||
После: ./gradlew :memo-cli:test --rerun-tasks и ./gradlew :memo-cli:installDist — обе команды зелёные.
|
||||
Проверь руками, что бинарь появился: ls memo-cli/build/install/memo-cli/bin/memo
|
||||
Коммит: git add -A && git commit -m "cli: index/search/status/mcp-probe"
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу правь файлы.
|
||||
- Не трогать memo-core (ни src/main, ни src/test), кроме использования его публичного API.
|
||||
- Не добавлять зависимости, кроме :memo-core.
|
||||
- Не создавать другие файлы.
|
||||
@@ -0,0 +1,74 @@
|
||||
Проект: /root/WORK/memo (Kotlin/JVM). Заказ по слежению за файлами.
|
||||
|
||||
Создать РОВНО ДВА новых файла:
|
||||
memo-watch/build.gradle.kts (плагин kotlin("jvm") + зависимость на :memo-core)
|
||||
memo-watch/src/main/kotlin/memo/watch/Watcher.kt
|
||||
memo-watch/src/main/kotlin/memo/watch/WatchMain.kt
|
||||
и ОДИН тестовый:
|
||||
memo-watch/src/test/kotlin/memo/watch/WatcherTest.kt
|
||||
|
||||
## Watcher.kt
|
||||
|
||||
package memo.watch
|
||||
|
||||
class Watcher(
|
||||
private val collections: List<java.io.File>, // каталоги-коллекции
|
||||
private val index: (java.io.File) -> Unit, // функция индексации одной коллекции
|
||||
private val debounceMillis: Long = 500,
|
||||
private val reconcileMillis: Long = 10 * 60 * 1000,
|
||||
) : AutoCloseable {
|
||||
|
||||
/** Запускает фоновый поток: WatchService на каждый каталог рекурсивно,
|
||||
* debounce, плюс периодическая полная реконсиляция по mtime. */
|
||||
fun start()
|
||||
|
||||
/** Останавливает поток, закрывает WatchService. Идемпотентно. */
|
||||
override fun close()
|
||||
|
||||
/** Количество выполненных вызовов index(...) — для тестов. */
|
||||
val indexCalls: Int
|
||||
}
|
||||
|
||||
Требования:
|
||||
1. Регистрировать каждый каталог через FileSystems.getDefault().newWatchService() с ENTRY_CREATE,
|
||||
ENTRY_MODIFY, ENTRY_DELETE и при регистрации рекурсивно обойти подкаталоги (WatchService не рекурсивен).
|
||||
Каталоги, начинающиеся с '.', не обходить и не регистрировать.
|
||||
2. События собирать в Set<File> (какие коллекции затронуты). Поток: take() с таймаутом debounceMillis;
|
||||
при накоплении — по истечении debounce вызвать index(коллекция) для каждой затронутой коллекции.
|
||||
Не вызывать index десятки раз на одно сохранение файла.
|
||||
3. Раз в reconcileMillis — полная реконсиляция: обойти все файлы *.md во всех коллекциях,
|
||||
сравнить (path, mtime, size) со таблицей files, при расхождении вызвать index(коллекция).
|
||||
Реализацию сравнения НЕ дублировать: у :memo-core уже есть Indexer.indexFile, который сам
|
||||
пропускает неизменённые файлы и возвращает Boolean. Использовать его: index(коллекция) должен
|
||||
внутри вызывать Indexer.indexTree(коллекция).
|
||||
4. Все исключения в рабочем потоке не должны убивать поток: ловить, печатать в stderr, продолжать.
|
||||
5. close(): interrupt + закрыть WatchService, поток завершается не дольше 2 секунд.
|
||||
|
||||
## WatchMain.kt
|
||||
|
||||
fun main(args: Array<String>)
|
||||
- Аргумент: корень, содержащий коллекции (или одну коллекцию).
|
||||
- Определение коллекций — как в CLI (каталог с *.md на глубине до 2).
|
||||
- Создаёт Db(коллекция/.memo/index.db), .init(), Indexer(db, embedder) — по одному на коллекцию,
|
||||
Watcher с index = { коллекция -> indexer.indexTree(коллекция) }.
|
||||
- Печатает `наблюдаю: <коллекция>` по строке на каждую, затем блокируется навсегда до SIGINT.
|
||||
- Модель: MEMO_MODEL_DIR, иначе /root/WORK/memo/models/siglip2.
|
||||
|
||||
## WatcherTest.kt — ровно 3 теста, имена ровно такие
|
||||
|
||||
1. `modifyTriggersSingleIndexCall` — временный каталог с note.md; Watcher с index-счётчиком и
|
||||
debounce 200 мс; start(); правка файла; подождать 1.5 с; indexCalls == 1 (не больше!);
|
||||
close(). (Перед стартом дать watcher 300 мс прогреться.)
|
||||
2. `unchangedFileDoesNotTriggerIndex` — после прогрева ничего не менять 1.5 с → indexCalls == 0.
|
||||
3. `closeIsIdempotentAndStopsThread` — start(); close(); close(); повторный вызов не бросает исключение.
|
||||
|
||||
Тесты должны быть устойчивыми: ждать не фиксированным sleep, а опросом условия с таймаутом
|
||||
(например, до 5 с с шагом 100 мс), чтобы не флапать на медленной машине.
|
||||
|
||||
После: ./gradlew :memo-watch:test --rerun-tasks — зелёные; ./gradlew build -x test компилируется.
|
||||
Коммит: git add -A && git commit -m "watch: слежение за файлами (WatchService + debounce + реконсиляция)"
|
||||
|
||||
СТРОГИЕ ЗАПРЕТЫ:
|
||||
- Не выводить план текстом; сразу создавай файлы.
|
||||
- Не трогать memo-core и memo-cli.
|
||||
- Не добавлять зависимости, кроме :memo-core.
|
||||
@@ -0,0 +1,157 @@
|
||||
package memo.core
|
||||
|
||||
import java.io.File
|
||||
|
||||
enum class SearchMode { HYBRID, LEX, VEC }
|
||||
|
||||
data class Hit(
|
||||
val path: String,
|
||||
val line: Int,
|
||||
val heading: String,
|
||||
val score: Double,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
fun interface RefreshHook { fun refresh(root: File) }
|
||||
|
||||
class Searcher(
|
||||
private val db: Db,
|
||||
private val embedder: Embedder,
|
||||
private val refresh: RefreshHook? = null,
|
||||
) {
|
||||
fun search(
|
||||
root: File,
|
||||
query: String,
|
||||
k: Int = 8,
|
||||
mode: SearchMode = SearchMode.HYBRID,
|
||||
): List<Hit> {
|
||||
if (refresh != null) {
|
||||
refresh.refresh(root)
|
||||
}
|
||||
|
||||
val lexRows: List<Pair<Long, Double>> =
|
||||
if (mode == SearchMode.LEX || mode == SearchMode.HYBRID) lexSearch(query) else emptyList()
|
||||
val vecRows: List<Pair<Long, Double>> =
|
||||
if (mode == SearchMode.VEC || mode == SearchMode.HYBRID) vecSearch(query) else emptyList()
|
||||
|
||||
val scores = HashMap<Long, Double>()
|
||||
for ((idx, row) in lexRows.withIndex()) {
|
||||
val rank = idx + 1
|
||||
scores.merge(row.first, 1.0 / (60.0 + rank), Double::plus)
|
||||
}
|
||||
for ((idx, row) in vecRows.withIndex()) {
|
||||
val rank = idx + 1
|
||||
scores.merge(row.first, 1.0 / (60.0 + rank), Double::plus)
|
||||
}
|
||||
|
||||
if (scores.isEmpty()) return emptyList()
|
||||
|
||||
val topEntries = scores.entries.sortedByDescending { it.value }.take(k)
|
||||
val orderedRowids = topEntries.map { it.key }
|
||||
val scoreByRowid = orderedRowids.associateWith { scores[it]!! }
|
||||
|
||||
val placeholders = orderedRowids.joinToString(",") { "?" }
|
||||
val select = db.conn.prepare(
|
||||
"SELECT id, path, line, heading, text FROM chunks WHERE id IN ($placeholders)"
|
||||
)
|
||||
val rowData = HashMap<Long, RowData>()
|
||||
try {
|
||||
for ((idx, id) in orderedRowids.withIndex()) {
|
||||
select.bindLong(idx + 1, id)
|
||||
}
|
||||
val rs = select.executeQuery()
|
||||
try {
|
||||
while (rs.next()) {
|
||||
val id = rs.getLong(0)!!
|
||||
val path = rs.getText(1) ?: ""
|
||||
val line = rs.getInt(2)!!
|
||||
val heading = rs.getText(3) ?: ""
|
||||
val textRaw = rs.getText(4) ?: ""
|
||||
val text = if (textRaw.length > 1200) textRaw.substring(0, 1200) else textRaw
|
||||
rowData[id] = RowData(path, line, heading, text)
|
||||
}
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
select.close()
|
||||
}
|
||||
|
||||
return orderedRowids.mapNotNull { id ->
|
||||
val rd = rowData[id] ?: return@mapNotNull null
|
||||
Hit(
|
||||
path = rd.path,
|
||||
line = rd.line,
|
||||
heading = rd.heading,
|
||||
score = scoreByRowid[id] ?: 0.0,
|
||||
text = rd.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun lexSearch(query: String): List<Pair<Long, Double>> {
|
||||
val tokens = tokensOf(query)
|
||||
if (tokens.isEmpty()) return emptyList()
|
||||
val matchExpr = tokens.joinToString(" OR ") { "\"$it\"" }
|
||||
val stmt = db.conn.prepare(
|
||||
"SELECT rowid, bm25(chunks_fts) AS s FROM chunks_fts " +
|
||||
"WHERE chunks_fts MATCH ? ORDER BY s LIMIT 32"
|
||||
)
|
||||
return try {
|
||||
stmt.bindText(1, matchExpr)
|
||||
val rs = stmt.executeQuery()
|
||||
try {
|
||||
val out = ArrayList<Pair<Long, Double>>()
|
||||
while (rs.next()) {
|
||||
out.add(rs.getLong(0)!! to rs.getDouble(1)!!)
|
||||
}
|
||||
out
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
emptyList()
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun vecSearch(query: String): List<Pair<Long, Double>> {
|
||||
val stmt = db.conn.prepare(
|
||||
"SELECT rowid, distance FROM chunks_vec " +
|
||||
"WHERE embedding MATCH ? ORDER BY distance LIMIT 32"
|
||||
)
|
||||
return try {
|
||||
stmt.bindVector(1, embedder.embed(query))
|
||||
val rs = stmt.executeQuery()
|
||||
try {
|
||||
val out = ArrayList<Pair<Long, Double>>()
|
||||
while (rs.next()) {
|
||||
out.add(rs.getLong(0)!! to rs.getDouble(1)!!)
|
||||
}
|
||||
out
|
||||
} finally {
|
||||
rs.close()
|
||||
}
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private data class RowData(val path: String, val line: Int, val heading: String, val text: String)
|
||||
}
|
||||
|
||||
private fun tokensOf(query: String): List<String> {
|
||||
val tokens = ArrayList<String>()
|
||||
val sb = StringBuilder()
|
||||
for (ch in query) {
|
||||
if (Character.isLetterOrDigit(ch)) {
|
||||
sb.append(ch)
|
||||
} else {
|
||||
if (sb.length >= 2) tokens.add(sb.toString())
|
||||
sb.setLength(0)
|
||||
}
|
||||
}
|
||||
if (sb.length >= 2) tokens.add(sb.toString())
|
||||
return if (tokens.size <= 8) tokens else tokens.subList(0, 8)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package memo.core
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SearcherTest {
|
||||
|
||||
private class Fixture : AutoCloseable {
|
||||
val tempDir: File = Files.createTempDirectory("memo-searcher-").toFile()
|
||||
val db = Db(File(tempDir, "index.db").absolutePath)
|
||||
val embedder: Embedder
|
||||
|
||||
init {
|
||||
db.init()
|
||||
val modelDir = System.getenv("MEMO_MODEL_DIR") ?: "/root/WORK/memo/models/siglip2"
|
||||
val modelPath = "$modelDir/text_model_int8.onnx"
|
||||
val tokenizerPath = "$modelDir/tokenizer.model"
|
||||
embedder = Embedder(modelPath, tokenizerPath)
|
||||
}
|
||||
|
||||
fun addChunk(path: String, heading: String, line: Int, text: String) {
|
||||
val conn = db.conn
|
||||
val insChunk = conn.prepare(
|
||||
"INSERT INTO chunks(path, heading, line, ord, text, hash) VALUES (?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
try {
|
||||
insChunk.bindText(1, path)
|
||||
insChunk.bindText(2, heading)
|
||||
insChunk.bindLong(3, line.toLong())
|
||||
insChunk.bindLong(4, 0L)
|
||||
insChunk.bindText(5, text)
|
||||
insChunk.bindText(6, "$path#$line")
|
||||
insChunk.executeUpdate()
|
||||
} finally {
|
||||
insChunk.close()
|
||||
}
|
||||
val id = conn.lastInsertRowId
|
||||
val insFts = conn.prepare(
|
||||
"INSERT INTO chunks_fts(rowid, text, heading) VALUES (?, ?, ?)"
|
||||
)
|
||||
try {
|
||||
insFts.bindLong(1, id)
|
||||
insFts.bindText(2, text)
|
||||
insFts.bindText(3, heading)
|
||||
insFts.executeUpdate()
|
||||
} finally {
|
||||
insFts.close()
|
||||
}
|
||||
val insVec = conn.prepare(
|
||||
"INSERT INTO chunks_vec(rowid, embedding) VALUES (?, ?)"
|
||||
)
|
||||
try {
|
||||
insVec.bindLong(1, id)
|
||||
insVec.bindVector(2, embedder.embed(text))
|
||||
insVec.executeUpdate()
|
||||
} finally {
|
||||
insVec.close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
embedder.close()
|
||||
db.close()
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lexModeFindsExactValue() {
|
||||
Fixture().use { f ->
|
||||
f.addChunk("/a.md", "A", 1, "Прокси корпоративных доменов на 76.132")
|
||||
f.addChunk("/b.md", "B", 1, "Список контактов службы поддержки")
|
||||
f.addChunk("/c.md", "C", 1, "Описание архитектуры сетевого шлюза")
|
||||
val s = Searcher(f.db, f.embedder)
|
||||
val hits = s.search(f.tempDir, "76.132", k = 8, mode = SearchMode.LEX)
|
||||
assertTrue(hits.isNotEmpty(), "ожидались хиты, получено 0")
|
||||
assertTrue(hits[0].text.contains("76.132"), "первый хит должен содержать 76.132")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vecModeFindsSemanticMatch() {
|
||||
Fixture().use { f ->
|
||||
f.addChunk("/server.md", "Server", 1, "Настройка сервера приложений и конфигурация Tomcat")
|
||||
f.addChunk("/book.md", "Book", 1, "Аннотация книги по истории Древнего Рима")
|
||||
f.addChunk("/ci.md", "CI", 1, "Пайплайн выпуска приложения: сборка, тесты, деплой в Kubernetes")
|
||||
val s = Searcher(f.db, f.embedder)
|
||||
val hits = s.search(f.tempDir, "настройку выпуска приложения", k = 1, mode = SearchMode.VEC)
|
||||
assertTrue(hits.isNotEmpty(), "ожидались хиты, получено 0")
|
||||
assertEquals("/ci.md", hits[0].path, "первый хит должен быть чанк про CI")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lexModeIgnoresVectorOnlyMatch() {
|
||||
Fixture().use { f ->
|
||||
f.addChunk("/p.md", "P", 1, "опрос")
|
||||
f.addChunk("/s.md", "S", 1, "случай")
|
||||
f.addChunk("/z.md", "Z", 1, "знание")
|
||||
val s = Searcher(f.db, f.embedder)
|
||||
val lex = s.search(f.tempDir, "автомобиль", k = 8, mode = SearchMode.LEX)
|
||||
val vec = s.search(f.tempDir, "автомобиль", k = 8, mode = SearchMode.VEC)
|
||||
assertTrue(lex.isEmpty(), "LEX должен быть пустым, получили ${lex.size} хитов")
|
||||
assertTrue(vec.isNotEmpty(), "VEC должен быть непустым, получили 0")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hybridCombinesBoth() {
|
||||
Fixture().use { f ->
|
||||
f.addChunk("/exact.md", "Exact", 1, "Прокси корпоративных доменов на 76.132")
|
||||
f.addChunk("/sem.md", "Sem", 1, "Пайплайн выпуска приложения: сборка, тесты, деплой в Kubernetes")
|
||||
f.addChunk("/other.md", "Other", 1, "Заметки о книге по истории")
|
||||
val s = Searcher(f.db, f.embedder)
|
||||
val hits = s.search(f.tempDir, "выпуск приложения 76.132", k = 5, mode = SearchMode.HYBRID)
|
||||
val paths = hits.map { it.path }.toSet()
|
||||
assertTrue(paths.contains("/exact.md"), "чанк exact отсутствует в: $paths")
|
||||
assertTrue(paths.contains("/sem.md"), "чанк sem отсутствует в: $paths")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resultsRespectKAndTextLength() {
|
||||
Fixture().use { f ->
|
||||
f.addChunk("/a.md", "A", 1, "Первый фрагмент про сервер")
|
||||
f.addChunk("/b.md", "B", 1, "Второй фрагмент про книгу")
|
||||
f.addChunk("/c.md", "C", 1, "Третий фрагмент про CI")
|
||||
f.addChunk("/d.md", "D", 1, "Четвёртый фрагмент про настройку")
|
||||
val s = Searcher(f.db, f.embedder)
|
||||
val hits = s.search(f.tempDir, "фрагмент", k = 2, mode = SearchMode.HYBRID)
|
||||
assertEquals(2, hits.size, "должно быть ровно 2 хита при k=2")
|
||||
for (h in hits) {
|
||||
assertTrue(h.text.isNotEmpty(), "text не должен быть пустым: $h")
|
||||
assertTrue(h.text.length <= 1200, "длина text превышает 1200: ${h.text.length}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user