Поиск и сборка контекста¶
Статья описывает, как memory-service находит релевантные знания и собирает из них
контекст: гибридный поиск (вектор + полнотекстовый + граф), слияние RRF, реранкинг
через LLM, синтез ответа, источники, Context Compiler (/api/memory/context) и
типизированный обход. Она для интеграторов, которые строят на памяти ответы ботов и
контекст агентов, и для тех, кто настраивает качество поиска.
Режимы чтения¶
| Маршрут | Что возвращает | LLM | Когда использовать |
|---|---|---|---|
POST /api/brain/query |
Фрагменты, соседи по графу, источники, готовый текст контекста; опционально ответ LLM | Только при synthesize: true |
Вопрос → ответ с цитатами |
POST /api/brain/recall |
То же, объём задан бюджетом low/mid/high |
Нет | Realtime-путь, суфлёр, подсказки оператору |
POST /api/brain/search |
Семантический top-k или структурный список узлов | Нет | Точечный поиск, фильтры по тегам |
GET /api/brain/sources/{key} |
Оригинал документа целиком с provenance | Нет | Карточка «откуда взято» |
POST /api/memory/context |
ContextPack: секции, источники, бюджет, трейс |
Нет | Контекст для агента или harness |
POST /api/memory/context/typed |
Обход по типизированным связям на момент as_of |
Нет | Контекст задачи по доменной модели |
Гибридный поиск /api/brain/*¶
query, recall и семантический search используют один конвейер:
flowchart TD
Q[Вопрос] --> E[Эмбеддинг вопроса]
E --> V[Векторный поиск<br/>косинус, HNSW]
Q --> F[Полнотекстовый поиск<br/>русская конфигурация]
V --> R[RRF-слияние<br/>score = Σ 1/(60 + rank + 1)]
F --> R
R --> N[Соседи по графу<br/>hops 1–2, до 40 узлов]
N --> RR{Реранк включён?}
RR -- нет --> TOP[top-k по RRF]
RR -- да --> POOL[Пул: вектор-хиты ∪ соседи с текстом]
POOL --> LLM[LLM-оценка 0–100 → rerank_score 0..1]
LLM --> TOP2[top-k по rerank_score]
TOP --> S[Источники + текст контекста]
TOP2 --> S
S --> SYN{synthesize?}
SYN -- да --> ANS[Ответ LLM с цитатами]
Каналы и слияние¶
- Векторный канал. Вопрос векторизуется той же моделью, что и фрагменты;
поиск идёт по косинусному расстоянию (HNSW-индекс pgvector).
scoreфрагмента в этом канале —1 - cosine_distance. - Полнотекстовый канал. Документ фрагмента —
title + heading + textв русской конфигурации PostgreSQL; запрос — OR по стеммированным лексемам вопроса (стоп-слова отбрасываются); ранжирование —ts_rank. - Пул кандидатов. Каждый канал возвращает
max(20, k × max(2, число namespaces))кандидатов: фильтры (namespace,meta, видимость) применяются поверх HNSW, поэтому пул расширен. - Reciprocal Rank Fusion. Итоговый
scoreфрагмента — сумма1 / (60 + rank + 1)по каналам, где он найден. Берутся top-k. - Граф. От узлов найденных фрагментов движок берёт соседей на
hopsшагов (1–2, не более 40 узлов) — строго внутри запрошенных namespaces. Соседи идут вneighbors, в источники и в текст контекста.
Все фильтры — реальные WHERE-предикаты: фрагменты другого namespace или вне
видимости principal в выдачу не попадают даже как кандидаты.
score — это не уверенность
RRF-score мал по природе: максимум около 2/61 ≈ 0.033 (фрагмент первый в обоих
каналах). Он годится только для порядка. Не стройте порог уверенности по
score — используйте rerank_score (0..1), который появляется, когда включён
реранкинг.
Реранкинг через LLM¶
При CB_RERANK_ENABLED=true кандидаты переоцениваются моделью:
| Шаг | Как |
|---|---|
| Вектор-кандидатов | min(CB_RERANK_POOL, 12) — меньше, чтобы освободить место соседям |
| Пул | Вектор-хиты ∪ соседи по графу с текстом (content или заголовок), дедуп по node_key, не больше max(CB_RERANK_POOL, k) |
| Вызов | Один запрос chat completions на весь пул; от каждого кандидата — первые 600 символов (title — heading + текст) |
| Оценка | Модель возвращает JSON {"0": 91, "1": 37, …} по шкале 0–100; rerank_score = оценка / 100 |
| Модель | CB_RERANK_MODEL, по умолчанию CB_LLM_MODEL; temperature=0, таймаут 12 с |
| Итог | Сортировка по rerank_score, top-k |
Реранк никогда не роняет поиск. Если LLM недоступен (CB_LLM_PROVIDER=echo или
пустой ключ), вызов упал, превысил таймаут или вернул неполный JSON — возвращается
исходный порядок RRF, а rerank_score остаётся null.
Как выбрать порог уверенности
Показывать пользователю только уверенные ответы можно по rerank_score первого
хита: например, скрывать подсказку при значении ниже 0.3. Подбирайте порог на
своих контрольных вопросах: у разных моделей шкала разная.
Реранк добавляет к запросу один вызов LLM. Для realtime-сценариев выбирайте быструю модель или оставляйте реранк выключенным и опирайтесь на порядок RRF.
Синтез ответа¶
POST /api/brain/query с "synthesize": true (или CB_QUERY_SYNTHESIZE_DEFAULT=true,
если поле не передано) отправляет найденный контекст в LLM с системной инструкцией:
отвечать только по контексту, не выдумывать, при отсутствии данных честно сказать об
этом и перечислить использованные источники. Параметры вызова: temperature=0.1,
таймаут 25 с. Если ничего не найдено, LLM не вызывается и возвращается фиксированный
ответ «В графе знаний нет данных по этому вопросу».
С CB_LLM_PROVIDER=echo вместо ответа возвращается сам контекст с пометкой режима
echo. Если ответ генерирует ваша модель, вызывайте synthesize: false и используйте
поле context.
Текст контекста¶
Поле context — готовый к вставке в промпт текст:
# Найденные фрагменты
## [<node_key>] <title> — <heading>
Источник: <source_path>
<text фрагмента>
# Связанные сущности (граф, соседи)
- [<natural_key>] (<type>) <title> status=… — <source_path>
Тело каждого фрагмента перед склейкой проходит нейтрализацию prompt-injection: контент писем, транскриптов и записей агентов считается недоверенным.
Источники¶
sources — дедуплицированный по node_key список {source_path, node_key, title}:
сначала узлы найденных фрагментов, затем соседи. По node_key источник открывается
целиком:
curl "$MEMORY_URL/api/brain/sources/https://kb.example.com/articles/guest-pass?namespace=support" \
-H "Authorization: Bearer $TOKEN"
Ответ содержит content (оригинал или склейка фрагментов), content_source
(original | chunks), число фрагментов, provenance и признаки ПДн. Узел ищется
строго в указанном namespace: статья другой базы знаний даёт 404.
Пример: вопрос → ответ с цитатами¶
curl -X POST "$MEMORY_URL/api/brain/query" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"question": "Как оформить гостевой пропуск?", "k": 8, "hops": 1,
"synthesize": false, "scope": {"namespace": "support"}}'
{
"question": "Как оформить гостевой пропуск?",
"scope": {"namespace": "support"},
"synthesized": false,
"hits": [
{"chunk_id": 128, "node_key": "https://kb.example.com/articles/guest-pass",
"source_path": "kb.example.com", "title": "Гостевой пропуск", "heading": "",
"text": "Как оформить пропуск для гостя…", "score": 0.0325,
"namespace": "support", "meta": {}, "rerank_score": 0.91}
],
"neighbors": [],
"sources": [{"source_path": "kb.example.com",
"node_key": "https://kb.example.com/articles/guest-pass",
"title": "Гостевой пропуск"}],
"context": "# Найденные фрагменты\n\n## [https://kb.example.com/…"
}
Recall и search¶
POST /api/brain/recall — то же чтение, но размер задаётся бюджетом:
budget |
k фрагментов |
|---|---|
low |
4 |
mid (по умолчанию) |
8 |
high |
16 |
Ответ: memories[] (node_key, title, heading, source_path, text,
namespace), sources, neighbors, context, count.
POST /api/brain/search работает в двух режимах:
- без
filters.type— семантический top-k (filters.limit, по умолчанию 20) с необязательнымfilters.meta— фильтром по тегам фрагментов (meta @> {...}: фрагмент подходит, если содержит все указанные пары); - с
filters.type(и опциональноfilters.status) — структурный список узлов графа без векторного поиска.
{"query": "лицензия", "filters": {"meta": {"collection": "licenses"}, "limit": 10},
"scope": {"namespace": "support"}}
Context Compiler: POST /api/memory/context¶
Главный маршрут чтения для агентов: возвращает структурированный ContextPack под
бюджет токенов без LLM-синтеза. Именно его вызывает Control Plane, когда собирает
контекст задачи (POST /api/v1/context ядра).
{
"query": "Продолжить разбор падения деплоя",
"scopes": ["project:alpha"],
"anchors": ["person:alice"],
"ephemeral_context": {"current_state": "deploy failed on step 3"},
"budget": {"tokens": 12000},
"strategy": "hybrid",
"as_of": "",
"k": 8,
"scope": {"namespaces": ["support", "shared"]}
}
| Поле | По умолчанию | Смысл |
|---|---|---|
query |
"" |
Текст запроса |
scopes |
[] |
Scopes релевантности type:id (до 10): элементы с пересечением плюс элементы без scopes |
anchors |
[] |
Сущности-якоря для обхода графа и фактов |
subject |
— | {type, id} — за кого собирается контекст |
ephemeral_context |
{} |
Текущее состояние вызывающего: попадает в секцию current, не сохраняется |
budget.tokens / max_tokens |
CB_CONTEXT_DEFAULT_MAX_TOKENS (8000) |
Бюджет пака |
strategy |
hybrid |
Набор каналов (см. ниже) |
as_of |
сейчас | Исторический срез фактов (ISO-8601) |
k |
8 | Целевой размер каждого канала |
Каналы и стратегии¶
| Канал | Что находит | Вес в RRF |
|---|---|---|
lexical |
Точные идентификаторы (UUID, SHA, коды ошибок, имена файлов) через триграммы + русский FTS | 1.0 |
vector |
Фрагменты по эмбеддингу | 1.0 |
facts |
Temporal-факты вокруг якорей на as_of; конфликты помечаются |
1.0 |
graph |
Соседи якорей и топ-хитов (глубина ≤ CB_CONTEXT_MAX_DEPTH, узлов ≤ CB_CONTEXT_MAX_NODES, рёбер ≤ CB_CONTEXT_MAX_EDGES) |
0.7 |
recent |
Свежие наблюдения по scopes | 0.6 |
strategy |
Каналы |
|---|---|
hybrid (по умолчанию), context |
все пять |
semantic |
vector |
exact |
lexical |
graph |
graph, facts |
briefing |
graph, facts, recent — стоячий контекст без query |
Скоринг¶
Базис — взвешенный RRF: Σ weight_канала / (60 + rank + 1). Поверх — аддитивные
бонусы, которые решают ничьи:
| Сигнал | Бонус |
|---|---|
| Точное совпадение идентификатора из запроса | +0.010 |
| Элемент в явно запрошенном scope | +0.004 |
| Действующий факт (интервал не закрыт) | +0.003 |
| Сила свидетельства | +0.002 за ранг (asserted = 3 → +0.006) |
| Свежесть (экспоненциальный спад, полупериод 14 дней) | до +0.005 |
Все слагаемые возвращаются в signals каждого элемента — выдача объяснима.
Бюджет и секции¶
Размер элемента оценивается как len(text) / CB_CONTEXT_CHARS_PER_TOKEN (по
умолчанию 3.0 — консервативно для кириллицы). Секция current включается первой,
остальные элементы отбираются жадно по score; что не влезло, попадает в
budget.dropped с причиной.
Порядок секций: current → relevant_facts → episodes → documents →
related_entities → recent_observations.
{
"query": "…",
"sections": [
{"kind": "current", "items": [...]},
{"kind": "relevant_facts", "items": [...]},
{"kind": "documents", "items": [...]}
],
"sources": [{"source_path": "…", "node_key": "…", "title": "…"}],
"token_estimate": 9341,
"budget": {"max_tokens": 12000, "dropped": [...], "truncated": false},
"conflicts": ["fact-…"],
"trace_id": "ctx-…"
}
Элемент секции несёт kind, id, text, title, source_path, score,
signals, token_estimate и provenance (цепочку до наблюдения или документа). Конфликтующие версии
фактов помечены "conflict": true, обе остаются в выдаче.
Трейс компиляции¶
GET /api/memory/context/trace/{trace_id}?namespace=… возвращает, что происходило
при сборке: каналы и их счётчики, веса, ranking- и budget-решения. По нему
восстанавливается, почему в контекст попал (или не попал) конкретный элемент.
ephemeral_context в трейс не сохраняется — только его размер.
Типизированный обход: POST /api/memory/context/typed¶
Второй режим компилятора для контекста задачи в доменной модели: вместо ранжирования кандидатов — детерминированный обход по связям доменных пакетов.
{
"anchors": [{"kind": "endpoint", "value": "GET /api/v1/runs/{}/checkpoints"}],
"traverse": [
{"relation": "calls", "direction": "in", "depth": 1, "limit": 50}
],
"as_of": "2026-09-05T00:00:00Z",
"allow_semantic": false,
"scope": {"namespaces": ["tenant:<tenant-id>:ws:<workspace-id>"]}
}
- Якорь разрешается по порядку: точный ключ сущности → псевдоним ключа (
aliasesпакета) → идентификатор, извлечённый изvalueшаблонамиidPatterns. Векторный поиск — только при"allow_semantic": true, такие якоря помечаются"evidence": "inferred". - Шаг обхода:
relation,direction(in|out|both),depth1..5,limit1..200 новых сущностей на шаг,from—anchors(по умолчанию) илиprevious. - Проходятся только факты, валидные на
as_of(по умолчанию — сейчас): закрытые связи не проходятся; атрибуты сущности берутся из версии, действовавшей наas_of.
Ответ: anchors (как разрешён каждый якорь), unresolved, sections по видам
сущностей, facts, used (полный список сущностей, фактов и снимков — для evidence
в вашей системе), sources, trace_id. Права, видимость и маскирование ПДн — как у
POST /api/memory/context.
Как контекст собирает Control Plane¶
sequenceDiagram
participant H as Harness / агент
participant CP as control-plane-api
participant M as memory-service
H->>CP: POST /api/v1/context
CP->>CP: операционный срез (сессии, claims, runs, approvals)
CP->>M: POST /api/memory/context<br/>namespaces: tenant + корневой воркспейс,<br/>allowedScopes, ephemeral_context
M-->>CP: ContextPack + trace_id
CP-->>H: operational + memory + memoryStatus + freshness
- Harness не выбирает namespace сам: его вычисляет ядро после проверки principal, воркспейса, задачи и прогона.
- Операционное состояние ядра передаётся в
ephemeral_contextи не сохраняется в памяти. - Недоступность памяти не блокирует работу: ядро возвращает операционный контекст и
memoryStatusunavailableилиtimeoutвместо ошибки.
Подробнее — в Контексте Control Plane.
Производительность поиска¶
| Фактор | Влияние | Что делать |
|---|---|---|
| Эмбеддинг вопроса | Первый вызов конвейера, до обращения к БД | Держите CB_EMBEDDING_TIMEOUT разумным: зависший провайдер иначе держит воркер |
| Реранк | +1 вызов LLM (таймаут 12 с) | Быстрая модель или реранк выключен для realtime |
| Синтез | +1 вызов LLM (таймаут 25 с) | synthesize: false и своя модель |
| Обход графа | Запросы Cypher через AGE | JIT PostgreSQL выключен по умолчанию (CB_DB_JIT=false); не включайте без замера |
| Число namespaces в запросе | Расширяет пул кандидатов | Читайте только нужные базы |
Подробнее об эксплуатационных настройках — в Конфигурации.