feat(ai): hybrid RRF retrieval, heading-breadcrumb chunks, merged search tool

Improve agent RAG quality with three changes, plus a roadmap doc for the rest.

- Indexer: prefix each chunk with its heading path ("Page > H1 > H2"), built by
  walking the ProseMirror JSON (heading nodes) so a `#` inside a fenced code block
  is never mistaken for a heading. Falls back to plain-text chunking on any error.
  buildChunkRows: drop indexOf-against-source offsets (breadcrumb prefixes break
  verbatim matching) for a cumulative cursor — offsets are provenance-only.
- Hybrid search: new migration adds a generated `fts` tsvector column + GIN index
  to page_embeddings (same english+f_unaccent config as pages.tsv). New
  PageEmbeddingRepo.hybridSearch fuses cosine + full-text rankings via Reciprocal
  Rank Fusion (k=60, equal weights) in one SQL query at chunk granularity.
- Tools: collapse semanticSearch + searchPages into one hybrid `searchPages` tool
  with a query-rewrite-oriented description; gracefully falls back to the REST
  full-text path when embeddings are unconfigured. Access control (space scope +
  page-permission post-filter) preserved. Add a query-rewrite hint to the default
  system prompt.
- docs/rag-improvements-plan.md: record what shipped and the deferred backlog
  (reranker, attachment indexing, eval harness, tuning).

Note: requires a corpus reindex to populate breadcrumbs on existing pages.
This commit is contained in:
vvzvlad
2026-06-18 03:43:01 +03:00
parent 91a63f0b2c
commit c8e41e8916
6 changed files with 555 additions and 145 deletions

View File

@@ -9,6 +9,8 @@ const DEFAULT_PROMPT = [
'You help the current user find, read, and reason about pages in their workspace.', 'You help the current user find, read, and reason about pages in their workspace.',
'Use the available tools to search and read pages before answering when the answer', 'Use the available tools to search and read pages before answering when the answer',
'depends on the workspace content. Cite the pages you used. Be concise and accurate.', 'depends on the workspace content. Cite the pages you used. Be concise and accurate.',
"When searching, rephrase the user's question into focused keyword queries, and search",
'again with different terms if the first results are weak.',
].join(' '); ].join(' ');
/** /**

View File

@@ -77,9 +77,28 @@ export class EmbeddingIndexerService {
return; return;
} }
const text = this.extractText(page); // Prefer heading-breadcrumb chunks: each chunk is prefixed with its heading
if (!text || text.trim().length === 0) { // path ("Page Title > H1 > H2") so the breadcrumb is embedded AND stored in
// Empty page -> remove any prior embeddings so search returns nothing. // `content` (feeding the fts column and the agent's snippet). Walk the
// ProseMirror JSON — NOT the markdown text — so a `#` inside a fenced code
// block is never mistaken for a heading. Degrades to the plain-text path on
// any error / unknown structure (returns null).
const breadcrumbChunks = page.content
? await this.safeBuildBreadcrumbChunks(page.content, page.title)
: null;
// Fall back to plain text when breadcrumb chunking is unavailable.
const fallbackText =
breadcrumbChunks && breadcrumbChunks.length > 0
? null
: this.extractText(page);
// Empty page (neither path produced content) -> remove any prior embeddings
// so search returns nothing.
if (
(!breadcrumbChunks || breadcrumbChunks.length === 0) &&
(!fallbackText || fallbackText.trim().length === 0)
) {
await this.pageEmbeddingRepo.deleteByPage(pageId, workspaceId); await this.pageEmbeddingRepo.deleteByPage(pageId, workspaceId);
return; return;
} }
@@ -105,12 +124,17 @@ export class EmbeddingIndexerService {
throw err; throw err;
} }
// Chunk the plain text. // Use breadcrumb chunks when available; otherwise chunk the plain text.
const splitter = new RecursiveCharacterTextSplitter({ let chunks: string[];
chunkSize: CHUNK_SIZE, if (breadcrumbChunks && breadcrumbChunks.length > 0) {
chunkOverlap: CHUNK_OVERLAP, chunks = breadcrumbChunks;
}); } else {
const chunks = await splitter.splitText(text); const splitter = new RecursiveCharacterTextSplitter({
chunkSize: CHUNK_SIZE,
chunkOverlap: CHUNK_OVERLAP,
});
chunks = await splitter.splitText(fallbackText as string);
}
if (chunks.length === 0) { if (chunks.length === 0) {
await this.pageEmbeddingRepo.deleteByPage(pageId, workspaceId); await this.pageEmbeddingRepo.deleteByPage(pageId, workspaceId);
return; return;
@@ -139,7 +163,6 @@ export class EmbeddingIndexerService {
const rows = this.buildChunkRows( const rows = this.buildChunkRows(
chunks, chunks,
vectors, vectors,
text,
{ pageId, workspaceId, spaceId }, { pageId, workspaceId, spaceId },
modelName, modelName,
); );
@@ -255,14 +278,16 @@ export class EmbeddingIndexerService {
} }
/** /**
* Map chunk strings + vectors to insertable rows, computing chunkStart / * Map chunk strings + vectors to insertable rows. Breadcrumb-prefixed chunks
* chunkLength against the source text. A moving cursor handles repeated * are NOT verbatim substrings of any source text, so chunkStart is a running
* substrings and overlap so offsets stay monotonic. * cumulative offset (sum of previous chunk lengths) rather than an indexOf
* position. These offsets are informational provenance only — search returns
* `content` and never slices by offset. chunkIndex stays a global monotonic
* index.
*/ */
private buildChunkRows( private buildChunkRows(
chunks: string[], chunks: string[],
vectors: number[][], vectors: number[][],
sourceText: string,
ids: { pageId: string; workspaceId: string; spaceId: string }, ids: { pageId: string; workspaceId: string; spaceId: string },
modelName: string, modelName: string,
): PageEmbeddingChunkRow[] { ): PageEmbeddingChunkRow[] {
@@ -272,11 +297,8 @@ export class EmbeddingIndexerService {
const chunk = chunks[i]; const chunk = chunks[i];
const embedding = vectors[i]; const embedding = vectors[i];
if (!embedding) continue; if (!embedding) continue;
const found = sourceText.indexOf(chunk, cursor); const chunkStart = cursor;
const chunkStart = found >= 0 ? found : cursor; cursor += chunk.length;
// Advance the cursor past the start so later identical chunks resolve to
// later occurrences (overlap keeps the next search valid).
cursor = chunkStart + 1;
rows.push({ rows.push({
pageId: ids.pageId, pageId: ids.pageId,
workspaceId: ids.workspaceId, workspaceId: ids.workspaceId,
@@ -295,4 +317,106 @@ export class EmbeddingIndexerService {
} }
return rows; return rows;
} }
/**
* Thin try/catch wrapper around buildBreadcrumbChunks. Any failure (malformed
* structure, unknown node type, etc.) returns null so the caller degrades
* gracefully to the plain-text chunking path.
*/
private async safeBuildBreadcrumbChunks(
contentJson: unknown,
pageTitle: string | null,
): Promise<string[] | null> {
try {
return await this.buildBreadcrumbChunks(contentJson, pageTitle);
} catch {
return null;
}
}
/**
* Build heading-breadcrumb chunks by walking the ProseMirror JSON document.
*
* Each section (the body following a heading) is split with the same 1000/200
* RecursiveCharacterTextSplitter, and every resulting piece is prefixed with
* its heading path ("Page Title > H1 > H2"). Walking the JSON — not markdown
* text — means a `#` inside a fenced code block is never treated as a heading
* (ProseMirror heading nodes are explicit).
*
* Returns null when `contentJson` is not an object with an array `content`, so
* the caller falls back to plain-text chunking.
*/
private async buildBreadcrumbChunks(
contentJson: unknown,
pageTitle: string | null,
): Promise<string[] | null> {
const doc = contentJson as { content?: unknown };
if (
typeof contentJson !== 'object' ||
contentJson === null ||
!Array.isArray(doc.content)
) {
return null;
}
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: CHUNK_SIZE,
chunkOverlap: CHUNK_OVERLAP,
});
const out: string[] = [];
const stack: { level: number; text: string }[] = [];
let buffer = '';
// Flush the accumulated body as one or more chunks under the CURRENT crumb.
const flush = async (): Promise<void> => {
if (buffer.trim().length === 0) {
buffer = '';
return;
}
const crumb = [pageTitle, ...stack.map((s) => s.text)]
.filter((s) => typeof s === 'string' && s.trim().length > 0)
.join(' > ');
const pieces = await splitter.splitText(buffer);
for (const piece of pieces) {
out.push(crumb ? `${crumb}\n\n${piece}` : piece);
}
buffer = '';
};
for (const block of doc.content as Array<{
type?: string;
attrs?: { level?: number };
}>) {
if (block?.type === 'heading') {
// Flush the preceding body under the crumb in effect BEFORE this
// heading, then update the heading stack.
await flush();
const level =
typeof block.attrs?.level === 'number' ? block.attrs.level : 1;
// Pop deeper-or-equal headings: a new H2 replaces a prior H2/H3/...
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
stack.pop();
}
const headingText = jsonToText({
type: 'doc',
content: [block],
} as never).trim();
if (headingText.length > 0) {
stack.push({ level, text: headingText });
}
} else {
const blockText = jsonToText({
type: 'doc',
content: [block],
} as never);
buffer = buffer.length > 0 ? `${buffer}\n${blockText}` : blockText;
}
}
// Flush any trailing body after the last heading.
await flush();
return out;
}
} }

View File

@@ -87,37 +87,126 @@ export class AiChatToolsService {
return { return {
searchPages: tool({ searchPages: tool({
description: description:
'Full-text search across the pages the current user can access. ' + 'Search the wiki for pages relevant to a query. Combines exact ' +
'Returns a compact list of matching pages with a short snippet.', 'keyword/identifier matching with semantic meaning and returns the ' +
'most relevant pages with a short snippet, best match first. ' +
"Rephrase the user's question into a focused search query (key terms " +
'and entities), not a full sentence. If the first results look weak ' +
'or incomplete, search again with different wording or synonyms ' +
'before answering.',
inputSchema: z.object({ inputSchema: z.object({
query: z.string().describe('The search query.'), query: z.string().describe('The search query.'),
limit: z limit: z
.number() .number()
.int() .int()
.min(1) .min(1)
.max(50) .max(20)
.optional() .optional()
.describe('Maximum number of results (1-50).'), .describe('Maximum number of results (1-20).'),
}), }),
execute: async ({ query, limit }) => { execute: async ({ query, limit }) => {
// search(query, spaceId?, limit?) -> { items, success }. const trimmed = (query ?? '').trim();
// Items are filterSearchResult(): { id, title, highlight, ... }. if (!trimmed) return [];
const result = await client.search(query, undefined, limit);
const items = Array.isArray(result?.items) ? result.items : []; const cap = limit ?? 10;
// Keep the payload token-efficient: id + title + a short snippet only.
return items.map((raw) => { // Loopback REST full-text fallback. Used when AI search is not
const item = raw as { // configured, embedding fails, there are no accessible spaces, or the
id?: string; // hybrid query returns nothing — so keyword search always works.
slugId?: string; const fallback = async () => {
title?: string; // search(query, spaceId?, limit?) -> { items, success }.
highlight?: string; // Items are filterSearchResult(): { id, title, highlight, ... }.
}; const result = await client.search(trimmed, undefined, cap);
return { const items = Array.isArray(result?.items) ? result.items : [];
id: item.id ?? item.slugId, // Keep the payload token-efficient: id + title + a short snippet.
title: item.title ?? '', return items.map((raw) => {
snippet: snippet(item.highlight), const item = raw as {
}; id?: string;
}); slugId?: string;
title?: string;
highlight?: string;
};
return {
id: item.id ?? item.slugId,
title: item.title ?? '',
snippet: snippet(item.highlight),
};
});
};
// HYBRID path: fuse semantic (vector) + lexical (full-text) rankings
// via RRF. Over-fetch candidates so the page-permission post-filter
// still leaves enough results.
const candidates = Math.min(Math.max(cap * 5, 50), 200);
// 1) Embed the query. Unconfigured embeddings (or any embedding error)
// routes to the REST full-text fallback instead of erroring.
let queryVector: number[];
try {
const [vec] = await this.aiService.embedTexts(workspaceId, [
trimmed,
]);
if (!vec) return await fallback();
queryVector = vec;
} catch (err) {
if (!(err instanceof AiEmbeddingNotConfiguredException)) {
// Never leak provider/key details; log generically and fall back.
this.logger.warn(
`searchPages embed failed: ${
err instanceof Error ? err.message : 'unknown error'
}`,
);
}
return await fallback();
}
// 2) ACCESS CONTROL: the hybrid query runs IN-PROCESS (a direct
// pgvector + full-text query), so unlike the loopback REST tools it
// does NOT get CASL for free. Scope to the spaces the user can read
// (member spaces + groups), mirroring SearchService.searchPage. No
// accessible spaces => fall back to REST (which is CASL-scoped).
const accessibleSpaceIds =
await this.spaceMemberRepo.getUserSpaceIds(user.id);
if (accessibleSpaceIds.length === 0) return await fallback();
// 3) Hybrid RRF retrieval, scoped to the workspace AND accessible
// spaces.
const hits = await this.pageEmbeddingRepo.hybridSearch(
workspaceId,
queryVector,
trimmed,
accessibleSpaceIds,
candidates,
);
if (hits.length === 0) return await fallback();
// 4) Page-level permission post-filter: an accessible space does not
// imply every page in it is accessible (restricted pages). Mirror
// SearchService.searchPage's filterAccessiblePageIds pass.
const pageIds = Array.from(new Set(hits.map((h) => h.pageId)));
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId: user.id,
});
const accessibleSet = new Set(accessibleIds);
// Keep the best (first — hits are ordered by fused score desc) chunk
// per page, capped to `cap`.
const seen = new Set<string>();
const results: { id: string; title: string; snippet: string }[] = [];
for (const hit of hits) {
if (!accessibleSet.has(hit.pageId)) continue;
if (seen.has(hit.pageId)) continue;
seen.add(hit.pageId);
results.push({
id: hit.pageId,
title: hit.title ?? '',
snippet: snippet(hit.content),
});
if (results.length >= cap) break;
}
return results;
}, },
}), }),
@@ -142,110 +231,6 @@ export class AiChatToolsService {
}, },
}), }),
semanticSearch: tool({
description:
'Semantic (vector) search across the pages the current user can ' +
'access. Finds pages by meaning, not just keywords — use it to ' +
'answer conceptual questions. Returns a compact list of relevant ' +
'pages with a short snippet. Falls back to searchPages if semantic ' +
'search is unavailable.',
inputSchema: z.object({
query: z.string().describe('The natural-language search query.'),
limit: z
.number()
.int()
.min(1)
.max(20)
.optional()
.describe('Maximum number of results (1-20).'),
}),
execute: async ({ query, limit }) => {
// ACCESS CONTROL: this tool runs IN-PROCESS (a direct pgvector query),
// so unlike the loopback REST tools it does NOT get CASL for free. We
// scope every query to the spaces the user can read, mirroring
// SearchService.searchPage (§6.7 / §8). We additionally post-filter by
// page-level permissions so restricted pages inside an accessible
// space are never returned.
const trimmed = (query ?? '').trim();
if (trimmed.length === 0) return [];
// 1) Embed the query (no-op fallback when embeddings are unconfigured
// so the agent can fall back to searchPages instead of erroring).
let queryVector: number[];
try {
const [vec] = await this.aiService.embedTexts(workspaceId, [
trimmed,
]);
if (!vec) return [];
queryVector = vec;
} catch (err) {
if (err instanceof AiEmbeddingNotConfiguredException) {
return {
unavailable: true,
reason:
'semantic search unavailable (embeddings not configured)',
};
}
// Never leak provider/key details; surface a generic unavailable.
this.logger.warn(
`semanticSearch embed failed: ${
err instanceof Error ? err.message : 'unknown error'
}`,
);
return {
unavailable: true,
reason: 'semantic search unavailable',
};
}
// 2) Resolve the spaces this user can read (member spaces + groups),
// mirroring SearchService's space scoping. No spaces => no results.
const accessibleSpaceIds =
await this.spaceMemberRepo.getUserSpaceIds(user.id);
if (accessibleSpaceIds.length === 0) return [];
// 3) Cosine ANN over the embeddings, scoped to the workspace AND the
// accessible spaces. Over-fetch a little so the page-permission
// post-filter still leaves enough results.
const cap = limit ?? 10;
const hits = await this.pageEmbeddingRepo.searchByEmbedding(
workspaceId,
queryVector,
accessibleSpaceIds,
cap * 3,
);
if (hits.length === 0) return [];
// 4) Page-level permission post-filter: a space being accessible does
// not imply every page in it is (restricted pages). Mirror
// SearchService.searchPage's filterAccessiblePageIds pass.
const pageIds = Array.from(new Set(hits.map((h) => h.pageId)));
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId: user.id,
});
const accessibleSet = new Set(accessibleIds);
// Keep the best (lowest-distance) hit per page, capped to `limit`.
const seen = new Set<string>();
const results: { pageId: string; title: string; snippet: string }[] =
[];
for (const hit of hits) {
if (!accessibleSet.has(hit.pageId)) continue;
if (seen.has(hit.pageId)) continue;
seen.add(hit.pageId);
results.push({
pageId: hit.pageId,
title: hit.title ?? '',
snippet: snippet(hit.content),
});
if (results.length >= cap) break;
}
return results;
},
}),
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) --- // --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
createPage: tool({ createPage: tool({

View File

@@ -0,0 +1,48 @@
import { type Kysely, sql } from 'kysely';
/**
* Chunk-level lexical index for HYBRID retrieval (RRF) over `page_embeddings`.
*
* The agent's retrieval used to be either pure full-text (loopback REST over
* `pages.tsv`) OR pure vector (cosine over `page_embeddings.embedding`). Hybrid
* retrieval fuses BOTH rankings with Reciprocal Rank Fusion so exact keyword /
* identifier matches AND semantic matches both surface. The vector side already
* exists; this migration adds the missing LEXICAL side AT CHUNK GRANULARITY so
* both CTEs of the fused query rank the SAME chunk rows.
*
* `fts` is a GENERATED ALWAYS ... STORED `tsvector` built from `content` with
* the SAME text-search config as `pages.tsv`: `to_tsvector('english',
* f_unaccent(content))`. Using the identical config keeps lexical behaviour
* consistent with the existing page search (incl. unaccented Cyrillic content).
* `f_unaccent(text)` is declared IMMUTABLE (migration 20250729T213756), which is
* exactly what a GENERATED STORED column requires — so this needs NO trigger.
* The column is independent of the embedding vector dimension: it indexes text,
* not the vector, so it works for any model dimension.
*
* NOTE: `fts` is deliberately NOT added to the `PageEmbeddings` Kysely type. It
* is a generated column accessed ONLY via raw SQL (the hybrid query); adding it
* to the Kysely type would force it into the explicit-column insert in
* `insertChunks` and break inserts (a GENERATED column cannot be written to).
*/
export async function up(db: Kysely<any>): Promise<void> {
// Generated STORED tsvector mirroring pages.tsv's config. f_unaccent is
// IMMUTABLE so it is valid inside a GENERATED column expression (no trigger).
await sql`
ALTER TABLE page_embeddings
ADD COLUMN IF NOT EXISTS fts tsvector
GENERATED ALWAYS AS (to_tsvector('english', f_unaccent(content))) STORED
`.execute(db);
// GIN index for fast `fts @@ query` lexical matching on the chunk text.
await sql`
CREATE INDEX IF NOT EXISTS idx_page_embeddings_fts
ON page_embeddings USING gin(fts)
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX IF EXISTS idx_page_embeddings_fts`.execute(db);
await sql`
ALTER TABLE page_embeddings DROP COLUMN IF EXISTS fts
`.execute(db);
}

View File

@@ -48,6 +48,16 @@ export interface PageEmbeddingSearchHit {
distance: number; distance: number;
} }
/** A single hybrid (RRF-fused) search hit. Higher `score` is more relevant. */
export interface PageEmbeddingHybridHit {
pageId: string;
spaceId: string;
title: string | null;
content: string;
// Fused Reciprocal Rank Fusion score (sum of 1/(k+rank) across CTEs).
score: number;
}
@Injectable() @Injectable()
export class PageEmbeddingRepo { export class PageEmbeddingRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {} constructor(@InjectKysely() private readonly db: KyselyDB) {}
@@ -173,6 +183,102 @@ export class PageEmbeddingRepo {
})); }));
} }
/**
* HYBRID retrieval: fuse semantic (cosine) and lexical (full-text) chunk
* rankings with Reciprocal Rank Fusion (RRF). Scoped to a workspace AND the
* set of spaces the caller may read. Returns [] when `spaceIds` is empty.
*
* Two CTEs each rank chunks independently, then a FULL OUTER JOIN on the chunk
* `id` fuses them. RRF combines RANKS (not raw scores), so the cosine-distance
* and ts_rank scales never need normalizing — that is the whole point of RRF.
*
* score = 1/(k + rank_semantic) + 1/(k + rank_lexical)
*
* with k = 60 (Cormack et al. 2009; the default in Elasticsearch, OpenSearch
* and Weaviate) and equal 1.0/1.0 weights as a starting point. `candidates`
* is both the per-CTE over-fetch limit and the final fused LIMIT.
*
* The `model_dimensions = $dim` filter applies ONLY on the semantic side
* (cosine compares same-dimension vectors; pgvector errors otherwise). The
* lexical side (`fts`) is dimension-independent. If `websearch_to_tsquery`
* yields an EMPTY query (e.g. the text is all stopwords) the `@@` matches
* nothing and the lexical CTE is empty, so results degrade to pure-semantic —
* which is correct behaviour, not an error.
*
* `fts` is a generated column accessed only here via raw SQL (deliberately not
* in the Kysely `PageEmbeddings` type — see migration 20260618T150000).
*/
async hybridSearch(
workspaceId: string,
queryEmbedding: number[],
queryText: string,
spaceIds: string[],
// Per-CTE over-fetch AND the final fused LIMIT.
candidates: number,
): Promise<PageEmbeddingHybridHit[]> {
if (spaceIds.length === 0) return [];
const queryVector = sql`${pgvector.toSql(queryEmbedding)}::vector`;
const queryDim = queryEmbedding.length;
const spaceList = sql.join(
spaceIds.map((s) => sql`${s}`),
sql`, `,
);
const result = await sql<{
pageId: string;
spaceId: string;
title: string | null;
content: string;
score: number;
}>`
WITH semantic AS (
SELECT pe.id, pe.page_id, pe.space_id, pe.content, p.title,
row_number() OVER (ORDER BY pe.embedding <=> ${queryVector}) AS rank_ix
FROM page_embeddings pe
JOIN pages p ON p.id = pe.page_id
WHERE pe.workspace_id = ${workspaceId}
AND pe.space_id IN (${spaceList})
AND pe.model_dimensions = ${queryDim}
AND p.deleted_at IS NULL
ORDER BY pe.embedding <=> ${queryVector}
LIMIT ${candidates}
),
full_text AS (
SELECT pe.id, pe.page_id, pe.space_id, pe.content, p.title,
row_number() OVER (ORDER BY ts_rank(pe.fts, q.query) DESC) AS rank_ix
FROM page_embeddings pe
JOIN pages p ON p.id = pe.page_id,
websearch_to_tsquery('english', f_unaccent(${queryText})) AS q(query)
WHERE pe.workspace_id = ${workspaceId}
AND pe.space_id IN (${spaceList})
AND p.deleted_at IS NULL
AND pe.fts @@ q.query
ORDER BY ts_rank(pe.fts, q.query) DESC
LIMIT ${candidates}
)
SELECT
coalesce(semantic.page_id, full_text.page_id) AS "pageId",
coalesce(semantic.space_id, full_text.space_id) AS "spaceId",
coalesce(semantic.title, full_text.title) AS title,
coalesce(semantic.content, full_text.content) AS content,
coalesce(1.0/(60 + semantic.rank_ix), 0.0) * 1.0
+ coalesce(1.0/(60 + full_text.rank_ix), 0.0) * 1.0 AS score
FROM semantic
FULL OUTER JOIN full_text ON semantic.id = full_text.id
ORDER BY score DESC
LIMIT ${candidates}
`.execute(this.db);
return result.rows.map((row) => ({
pageId: row.pageId,
spaceId: row.spaceId,
title: row.title,
content: row.content,
score: Number(row.score),
}));
}
/** /**
* Count DISTINCT non-deleted pages that have at least one embedding row in this * Count DISTINCT non-deleted pages that have at least one embedding row in this
* workspace — i.e. how many pages currently have stored embeddings. * workspace — i.e. how many pages currently have stored embeddings.

View File

@@ -0,0 +1,145 @@
# Улучшение качества RAG-поиска агента — план по итерациям
> Статус: живой документ. Итерация 1 **реализована** (см. ниже). Остальное —
> бэклог на следующие итерации, отсортированный по «качество / усилие».
> Контекст: gitmost — форк Docmost. Семантический поиск агента: per-workspace
> эмбеддинги в `page_embeddings` (pgvector, dimension-agnostic колонка, seq-scan
> с `<=>`), индексация через BullMQ (`reindexPage` / `reindexWorkspace`).
> Активная embedding-модель деплоя: OpenAI `text-embedding-3-large` (3072d).
## Как сверялось с реальным кодом
Внешнее предложение по улучшению RAG было сверено с кодовой базой. Точные факты
на момент итерации 1:
- Хранилище: [page_embeddings](../apps/server/src/database/migrations/20260617T120000-page-embeddings.ts),
колонка `embedding` сделана dimension-agnostic в
[20260617T140000](../apps/server/src/database/migrations/20260617T140000-page-embeddings-dimension-agnostic.ts);
`model_name` / `model_dimensions` хранятся по строке.
- Полнотекстовые индексы **уже существуют** (предложение ошибочно утверждало
обратное): `pages_tsv_idx` на `pages.tsv` и `attachments_tsv_idx`. Конфигурация —
`to_tsvector('english', f_unaccent(...))` + `setweight`
([тут](../apps/server/src/database/migrations/20250729T213756-add-unaccent-pg_trm-update-tsvector..ts)).
- Чанкинг: `RecursiveCharacterTextSplitter` 1000/200, без префиксов.
- Префиксы `query:` / `passage:` **не нужны**: они требуются для e5/bge/gte/Qwen3,
а деплой на OpenAI `text-embedding-3-large` (этот пункт предложения неприменим).
- Вложения (`attachment_id` в схеме есть) **не индексируются** — индексатор всегда
пишет `attachmentId: null`.
---
## Итерация 1 — РЕАЛИЗОВАНО
Три «низковисящих фрукта»:
### 1. Хлебные крошки заголовков в чанках
Файл: [embedding-indexer.service.ts](../apps/server/src/core/ai-chat/embedding/embedding-indexer.service.ts).
Каждый чанк префиксуется путём заголовков `«Заголовок страницы > H1 > H2»` перед
эмбеддингом. Крошки строятся обходом **ProseMirror JSON** (`heading`-ноды с
`attrs.level`), а не markdown-текста — поэтому `#` внутри fenced-код-блока (типичный
bash-сниппет в WirenBoard-вики) **никогда** не принимается за заголовок. Деградация
к старому plain-text чанкингу при отсутствии/сбое `content`. Префикс попадает и в
эмбеддинг, и в `content` (а значит — в лексический индекс `fts` и в сниппет агента).
### 2. Гибридный поиск (RRF), слияние двух инструментов в один
- Миграция [20260618T150000-page-embeddings-fts.ts](../apps/server/src/database/migrations/20260618T150000-page-embeddings-fts.ts):
генерируемая колонка `fts tsvector GENERATED ALWAYS AS (to_tsvector('english',
f_unaccent(content))) STORED` + GIN-индекс. Конфиг совпадает с `pages.tsv` (та же
обработка unaccent/Cyrillic); `f_unaccent` IMMUTABLE → триггер не нужен.
- Репозиторий: метод `hybridSearch` в
[page-embedding.repo.ts](../apps/server/src/database/repos/ai-chat/page-embedding.repo.ts) —
один SQL-запрос, два CTE (cosine + `websearch_to_tsquery`), слияние Reciprocal Rank
Fusion через FULL OUTER JOIN на уровне чанков. `k=60` (дефолт Cormack 2009 /
ES / OpenSearch / Weaviate), равные веса 1.0/1.0. RRF сливает **ранги**, поэтому
несовместимость шкал BM25 и косинуса не требует нормализации. Dimension-фильтр —
только на семантической стороне.
- Инструменты: `semanticSearch` удалён, `searchPages` стал единым гибридным
инструментом ([ai-chat-tools.service.ts](../apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts)).
Контроль доступа сохранён 1-в-1 (scope по доступным спейсам + пост-фильтр прав
страниц). Если эмбеддинги не настроены / эмбеддинг упал / нет доступных спейсов /
гибрид пуст → graceful fallback на прежний REST-полнотекст (CASL-enforced).
### 3. Переписывание запроса + описания инструментов
- Описание `searchPages` теперь явно просит агента переформулировать вопрос в
сфокусированный поисковый запрос и переискивать при слабой выдаче (это переживает
кастомный admin-промпт, т.к. лежит в описании инструмента).
- Одна строка-подсказка добавлена в `DEFAULT_PROMPT`
([ai-chat.prompt.ts](../apps/server/src/core/ai-chat/ai-chat.prompt.ts)).
> ВАЖНО после деплоя: чтобы крошки и `fts` появились у существующих страниц, нужна
> **переиндексация корпуса** (кнопка «Reindex now» / `WORKSPACE_CREATE_EMBEDDINGS`).
> Миграция заполнит `fts` у текущих строк автоматически, но крошки добавляются только
> при переиндексации (она же перезапишет `content`).
### Известные нюансы текущей реализации (осознанные компромиссы)
- Гибрид покрывает только проиндексированные чанки. Свежесозданная страница
становится искомой после отработки её BullMQ-`reindexPage`. Пока эмбеддинги не
настроены — работает только REST-fallback (полнотекст уровня страницы по `pages.tsv`).
- Если **весь** пул кандидатов гибрида (до 200 чанков) оказался из закрытых для
пользователя страниц, инструмент вернёт пусто, а не уйдёт в keyword-fallback.
Узкий кейс; возможное улучшение — fallback и при пустом результате пост-фильтра.
- `fts` использует конфиг `english` (как и `pages.tsv`) — без русской стеммизации.
Для русской вики это консистентно с текущим поиском; переход на `simple`/`russian`
конфиг — отдельная задача с переиндексацией.
- `candidates` (=clamp(limit×5, 50, 200)) служит и per-CTE лимитом, и финальным
лимитом слияния; веса RRF равные. Тюнится после появления оценочного харнесса.
---
## Бэклог следующих итераций (по приоритету «качество / усилие»)
### A. Реранкер (cross-encoder) — наибольший ROI после гибрида
Вставить между over-fetch гибрида и дедупом: брать топ-50–100 кандидатов от
`hybridSearch`, реранкать, оставлять топ-5–10. Ожидаемый прирост precision/MRR
+10–25 %. Точка вставки уже готова — это шаг между `hybridSearch(... candidates)` и
циклом дедупа в `searchPages`.
- Хостовый старт (раз уже на OpenAI-инфраструктуре): **Cohere Rerank** или
**Voyage `rerank-2.5`** — провайдер по аналогии с текущим pluggable embedding-конфигом.
- Self-hosted (под Ollama-этос): **BGE-reranker-v2-m3** через HF Text Embeddings
Inference (`/rerank`), либо FlashRank (ONNX/CPU, ~15–30 мс).
- Диагностика: если реранк не двигает метрики — узкое место в recall (чанкинг/гибрид),
а не в ранжировании.
### B. Индексация вложений — закрыть пробел покрытия
Схема уже готова (`attachment_id`). Добавить в BullMQ-flow шаг извлечения текста из
PDF/документов (PyMuPDF для цифровых PDF; OCR для сканов; для таблиц — markdown через
LLM-парсер) и вливать его в тот же путь чанк→эмбеддинг→`fts`, помечая `attachment_id`.
Структура извлечённых данных важнее голой точности OCR.
### C. Тюнинг гибрида и оценочный харнесс
- Золотой датасет 30–100 примеров (вопрос → нужная страница/чанк) + Ragas/DeepEval
(Recall@k, MRR/nDCG, context precision/recall, faithfulness). Прогон до/после
каждого изменения. **Прерогатива пропущена в итерации 1 осознанно** — без неё все
нижеследующие тюнинги делаются «на глаз».
- После харнесса: тюнить веса RRF (старт 1.0/1.0), `k` (старт 60), число `candidates`.
- Эксперимент: чанки ~512 симв. против 1000 (предложение указывает на рост precision).
### D. Contextual Retrieval (Anthropic), если крошек мало
Один LLM-вызов на чанк добавляет предложение-контекст. Снижение провалов выдачи
на 35–49 %. Ложится в BullMQ-`reindexPage`; на сотнях страниц с prompt caching — копейки.
Применять, только если хлебных крошек окажется недостаточно против потери контекста.
### E. ParadeDB `pg_search` (настоящий BM25), если лексика станет узким местом
Нативный `ts_rank` использует только TF и длину документа, без IDF. `pg_search`
(Rust/Tantivy) даёт честный BM25-индекс. Не drop-in (свои операторы вместо `@@`) —
это изменение кода, а не флаг. На сотнях страниц нативного `tsvector` хватает; брать
только если качество лексического ранжирования упрётся в потолок.
### F. Прочее
- **Префиксы query/passage** — НЕ нужны на OpenAI. Внедрять только при переходе на
e5/bge/gte/Qwen3 (тогда индексатор ставит `passage:`, запрос — `query:`; BGE-v1.5,
наоборот, префиксов НЕ должна получать). Зафиксировано как ловушка на будущее.
- **Апгрейд embedding-модели** — уже на `text-embedding-3-large` (топ среди закрытых).
Matryoshka (обрезка размерности) — запас на будущее; dimension-agnostic колонка
делает миграцию тривиальной (цена — переэмбеддинг корпуса).
- **HyDE и широкий multi-query/RAG-Fusion** — НЕ рекомендуются как дефолт: в свежих
бенчмарках уступали и добавляют задержку/галлюцинации.
## Оговорки
- Все внешние числа (62→84 % precision, +17 % Recall@5, −35…49 % провалов, +10–25 %
от реранка) получены на ДРУГИХ корпусах (SEC-отчёты, финтекст, право, медицина).
На этой вики величины будут иными — поэтому пункт C (свой датасет) обязателен перед
тонким тюнингом. Внешние числа — направление, не гарантия величины.
- Часть источников предложения — вендорский маркетинг (Cohere, Voyage, ParadeDB);
направление подтверждается независимыми (T2-RAGBench, оценка Anthropic), но величины
у вендоров могут быть завышены.