01 · 全景数据流
四种源、三块索引、一个出口
点步骤或框图节点逐层聚焦。左边四种数据源各自走不同的归一化路线,中间只有一个共用的嵌入模型,右边是三类索引,最终都汇进同一处:系统提示词。
02 · 切块实验室
不按固定字数切,按目录切
业界常见做法是「每 500 字一刀」,代价是切断语义。这里换了个思路(PageIndex 式):
按 Markdown 标题层级切成一棵目录树,每个节点天然带标题、面包屑路径与行号区间——
既是嵌入单位,也是「让 LLM 在目录上导航」的单位。下面的编辑器完整复刻
parseMarkdownTree(同一套正则、同一套祖先栈),改一改标题层级就能看出切法。
粘一段 Markdown 试试
切出来的目录树
切块规则 parseMarkdownTree
| 标题正则 | /^(#{1,6})\s+(.+?)\s*#*\s*$/,支持收尾的闭合井号 |
| 围栏保护 | 遇到 ``` 或 ~~~ 翻转状态,围栏内的 # 不当标题——否则代码注释会把文档切碎 |
| 根节点 | position 0、depth 0,标题取文章标题,正文是首个标题之前的引言 |
| 父子关系 | 维护 (depth → position) 祖先栈,弹掉所有 depth ≥ 当前层级的祖先,栈顶即父节点 |
| 正文区间 | 标题行的下一行 → 下一个标题行之前;同时记录 startLine / endLine |
| 业务键 | a{articleId}-{position},全库唯一;从 nodeKey 能反解出 articleId |
| token 估算 | 中日韩字符算 1 token,其余按 0.25,向上取整——只用于展示与预算判断 |
摘要与指纹缓存 buildArticleTree
切完之后再花一次模型调用给所有章节写摘要,而不是每节点一次:
| ≤40 个节点 | 有正文的节点超过 MAX_NODES_FOR_LLM_SUMMARY 就整篇退回本地摘要,不发请求 |
| 每节点 ≤400 字 | 送进摘要提示词的正文上限,超出截断 |
| 一句话 ≤60 字 | 要求模型逐节点输出「这一节回答了什么」,JSON 形态 { summaries: { position: 文本 } } |
| 本地兜底 | 失败 / JSON 解析不出来 / 单节点缺失 → localSummary:剥掉代码块、图片、链接与 Markdown 记号后截 120 字 |
| 根节点存树指纹 | sha256(position|depth|title|content 全表),作为整篇结构的缓存判据 |
| 跳过重建 | 指纹一致且节点数相同且非强制 → 整篇跳过,不重复消耗模型额度 |
| 重建即替换 | 需要重建时在一个事务里先删该文章全部节点再整批插入,不做增量 diff |
03 · 向量化与入库
一个模型配置,两条写入路径
全站只有一处嵌入模型入口 ai/embedding.ts:读用户在后台配的那条默认启用的
EMBEDDING 配置,解密 API Key,按 OpenAI 兼容协议建模型。模型是用户自备的,
所以「没配」是常态而不是异常——两条写入路径对此的处理方式不同。
路径 A:知识库章节主动 + 顺带
嵌入文本由三段拼成,中间用换行连接后截断:
[title, summary ?? "", contentMd] .map(trim).filter(Boolean) .join("\n") .slice(0, 4000)
| 64 条/批 | KB_EMBED_BATCH_SIZE,一次 embedMany 后逐条 update |
| 2000 条/次请求 | KB_MAX_EMBED_PER_REQUEST:避免超大知识库把接口拖超时,剩余可再点一次或跑 backfill |
| 4000 字 | 单节点嵌入文本上限 KB_EMBED_MAX_CHARS |
| 只补空缺 | 只查 embedding is null 的节点;已有向量不重算 |
| 手动入口 | 后台「生成向量」按钮 → embedKnowledgeBaseTreeNodes,缺配置直接报错并给出建议(推荐 bge-m3) |
| 自动入口 | 编译 Wiki 之后 embedKnowledgeBaseTreeNodesBestEffort:无配置 / SQLite / 出错一律静默跳过,只往 warnings 里加一条,绝不影响编译结果 |
| 可观测 | getKbWikiEmbeddingStatus 返回 total / embedded / pending,后台直接显示「已向量化 N / M」 |
路径 B:对话消息后台异步
这条路径在回答的热路径上,所以设计目标是「一秒都不能拖」:
| 先查后补 | 召回时只读已有向量;缺向量的补齐用 void ... .catch() 丢进后台,不 await |
| 扫描上限 200 | 按时间正序取该线程最近 200 条消息作候选 |
| 排除窗口内 | 还在原文窗口里的消息不入向量(它们本来就在上下文里) |
| ≥8 字才入库 | 脱敏后不足 8 字的摘录直接丢弃,避免「好的」「收到」污染向量空间 |
| 16 条/批 | CONTEXT_RECALL_EMBED_BATCH,每次最多补 16 条,余量下轮再补 |
| 幂等写入 | on conflict (message_id) do update,摘录与向量一起覆盖 |
insert into petrichor_assistant_message_embedding (message_id, thread_id, user_id, excerpt_md, embedding, created_at) values ($1, $2, $3, $4, $5::vector, now()) on conflict (message_id) do update set excerpt_md = excluded.excerpt_md, embedding = excluded.embedding
模型配置 ai/embedding.ts · config-logic.ts
| configType | 四类之一:CHAT / VISION / DOC_QA / EMBEDDING,各自独立选默认 |
| 解析规则 | resolveChatConfig(userId, null, "EMBEDDING"):取该用户 isDefault && enabled 的那条 |
| 协议 | 统一用 createOpenAI({ apiKey, baseURL, name }) 建 provider,再取 textEmbeddingModel(config.model) |
| 必填校验 | 缺 API Key 或 BaseUrl 直接 400;API Key 加密落库,用时才解密 |
| 探测 | hasEmbeddingConfig(userId) 只查一行 id,用于所有 best-effort 分支的前置判断 |
| 维度 | EMBEDDING_DIMENSIONS = 1024,与两处 vector(1024) 列绑定——换维度要改迁移 |
索引 DDL full-migration.ts
两个扩展、两个 HNSW、一个 GIN,全部 if not exists:
create extension if not exists pg_trgm; create extension if not exists vector; alter table petrichor_kb_wiki_tree_node add column if not exists embedding vector(1024); create index if not exists idx_petrichor_kb_wiki_tree_node_embedding on petrichor_kb_wiki_tree_node using hnsw (embedding vector_cosine_ops); create index if not exists idx_petrichor_assistant_message_embedding on petrichor_assistant_message_embedding using hnsw (embedding vector_cosine_ops); create index if not exists petrichor_assistant_message_embedding_fts_idx on petrichor_assistant_message_embedding using gin (to_tsvector('simple', coalesce(excerpt_md, '')));
注意 kb_wiki_tree_node.embedding 故意不在 Drizzle schema 里声明:
声明了会让 select() 全列查询在没有该列的 SQLite 本地库上直接报错。这一列只走原生 SQL 读写。
04 · 三块索引空间
哪些东西真的被向量化了
一句话答案:只有两处。文档库分片、公开文章、星图节点都不入向量——它们各有更合适的索引方式。
| 知识库章节 | 对话消息 | 文档库分片 | 公开文章 / 星图 | |
|---|---|---|---|---|
| 表 | kb_wiki_tree_node |
assistant_message_embedding |
doc_chunk |
kb_article · site_graph_node/edge |
| 切块单位 | Markdown 标题章节 | 一条消息 → 一条摘录 | 解析产出的文本片(≤4000 字,≤4000 条) | 整篇文章 / 概念节点 |
| 向量列 | ✓ vector(1024) | ✓ vector(1024) | ✗ 无 | ✗ 无 |
| 关键词索引 | 内存打分(scoreSearchFields) | GIN to_tsvector('simple') | pg_trgm word_similarity | pg_trgm similarity 加权 |
| 额外索引 | 目录树本身(LLM 可导航) | — | — | 邻接表 + 关系表 + 递归 CTE |
| 写入时机 | 编译 Wiki 后自动补 + 后台按钮手动补 | 召回时后台异步补 | 上传解析时一次写入 | 后台点「生成图谱」时全量重跑 |
| 隔离维度 | user_id + knowledge_base_id | user_id + thread_id | user_id + library_id | 站点级单例(图谱按 user_id 存但只有站长发布) |
| 没有嵌入模型时 | 退回目录导航 + 关键词 | 召回整段跳过 | 不受影响 | 不受影响 |
还有一列向量,但已经没人读了
迁移脚本里还有第三处 vector(1024):petrichor_agent_memory.embedding,
连同它的 HNSW 索引一起保留在 full-migration.ts 里,原本用于旧问答 Agent 长期记忆的语义去重。
但当前代码路径已经不再读写它——站内助手的操作员记忆走的是
assistant_operator_profile(两块 Markdown 短文)与
assistant_message_embedding(情景检索),与这张旧表互不相干。
列与索引留着,代码没有引用:这是事实,不是遗漏说明。
为什么文档分片不做向量
不是做不了,是这条线上向量的性价比不高:
- 文档分片是定位而非理解:用户往往已经知道要查哪份文件,需要的是「第几页、哪一段」,
locator与page比语义相似度更有用。 - 分片量级远大于章节(单文档上限 4000 片),全量嵌入的成本与延迟都由用户的 API 额度承担。
pg_trgm的word_similarity(query, text)对中文子串友好,且 SQLite 分支也能用 LIKE + 词频兜底,零依赖。- 真需要语义时,用户可以把文档导入知识库——那条线是有向量的。
05 · 召回路径
八条召回路径,只有三条真的用向量
每条路径都标了它的触发条件与关键常量。注意第 1、2 条会被 search_knowledge 合并成一次工具调用,第 5、6 条是同一张表的两种读法。
-
推理式目录导航
wiki-tree.ts · retrieveTreeNodesForAgentLLM把整棵目录(
[nodeKey] 标题 — 摘要)渲染给模型,让它推理该看哪几节,并要求给出 reason。目录超过
MAX_OUTLINE_NODES = 200个节点直接不发请求;返回 JSON 解析失败、nodeKey 不在目录里、或重复的一律丢弃;全空则回退关键词打分。limit 夹在 1–12(默认 6),单节点正文截 1600 字。 -
章节向量语义检索
wiki-tree.ts · semanticSearchTreeNodes向量把查询嵌入后按余弦距离排序取前 N,再回填面包屑路径。
select node_key from petrichor_kb_wiki_tree_node where user_id = $1 and knowledge_base_id = $2 and embedding is not null order by embedding <=> $3::vector limit $4
SQLite 环境直接抛错(由调用方吞掉);不设分数阈值,靠 limit 控量。
-
跨知识库模糊检索
wiki-agent-logic.ts · searchWikiPagesAcrossKbs关键词
search_knowledge不传 knowledgeBaseId 且无 focus 默认库时走这条:Wiki 页与源文章各拉最近 500 条,在内存里用scoreSearchFields打分合并去重。为中文整句查询专门做了 n-gram 近邻(见下一节的打分器)——「全部歌曲下载进度」也能命中「歌曲下载进度」。
-
文档分片检索
doc-library/library-logic.ts · searchChunks关键词查询按空白切最多 5 个词,ILIKE OR 先粗召回,再用
word_similarity(keyword, text)排序,同分按 chunkIndex 升序。返回带
locator(形如p.12)与 page,片段截 600 字。SQLite 分支退化为 LIKE + JS 词频打分。 -
线程内向量召回
context-recall.ts · recallRelevantHistory向量被折叠出窗口的旧对话,靠这条重新被想起来。
select message_id, excerpt_md, (1 - (embedding <=> $q::vector))::float8 as score from petrichor_assistant_message_embedding where thread_id = $1 and user_id = $2 and embedding is not null and message_id not in (窗口内消息) order by embedding <=> $q::vector limit 4
阈值
score ≥ 0.25,查询文本截 4000 字。整条链路 try/catch 包住,任何异常都只打日志返回空数组。 -
跨线程情景检索
operator-episodic.ts · searchOperatorHistory向量关键词操作员专属,跨自己全部线程。三种模式:
keyword 走
to_tsvector('simple') @@ plainto_tsquery('simple')+ts_rank;semantic 走 pgvector(阈值同样 0.25);both(默认)两路Promise.all并行,按threadId:messageId去重合并,语义结果排在前面。默认排除当前线程,limit 夹在 1–20,SQLite 退化为 ilike。 -
全站星图检索
site-graph/qa-retrieval.ts · retrieveFromGraph图谱把问句按标点切词,逐词对概念/实体节点打分(名称精确 100 / 别名精确 95 / 互含
60×长度比+20/ 别名互含 55 / 属性 40 / 摘要 30),取每节点最高分排前 5。再从入口做限跳 BFS(默认 2 跳,上限 3;节点上限 60、链路上限 12),结构边不参与扩散,最后回溯出「概念 → … → 文章」的可读链路。读的是缓存后的公开载荷,不重查库。
-
公开文章全文检索
kb/public-qa-logic.ts · searchPublicArticles关键词前台公开问答用,四字段加权相似度:
similarity(title, $q) * 4 + similarity(coalesce(public_excerpt, ''), $q) * 2 + similarity(coalesce(ai_summary, ''), $q) * 2 + similarity(coalesce(content_md, ''), $q)
先用 ILIKE 四字段 OR 过滤,取
limit × 3行再逐条过一遍应用层可见性(未列出 / 已过期 / 带密码全部剔除),最后截到 limit(默认 8,上限 20)。
06 · 关键词打分器
中文整句查询为什么还能命中
中文用户习惯把整句话当查询(无空格),精确 includes 一定漏。
search-terms.ts 的做法是:整句命中给高分,不命中就退到二元组覆盖率,
按覆盖比例给部分分。下面完整复刻了 scoreSearchFields,改改候选文本就能看出分数从哪来。
查询与候选
打分结果
tokenizeSearchQuery 展开出的词项(命中标题的高亮):
| 整句命中 | 100 + 查询长度 × 4,命中标题再 +40 |
| 整词命中 | 标题 ×6 / 摘要 ×3 / 正文 ×1,按词长加权 |
| 中文近邻 | 整词不中且为 ≥3 字中文时展开 bigram:标题覆盖率 ≥0.5 → 词长×5×覆盖率;全字段覆盖率 ≥0.6 → 词长×2×覆盖率 |
| tokenize | 按空白与标点切词;≥3 字的中文词额外展开 2 / 3 字 n-gram |
| 用在哪 | 目录导航的关键词回退、跨库模糊检索的排序 |
07 · 合并、去重与注入
两路结果怎么合,合完怎么进提示词
混合检索的难点从来不是「多查一路」,而是「合起来之后还能解释」。这里的选择是:不做重排、不做分数归一,只做顺序拼接 + 去重。
search_knowledge 的合并 tools/knowledge.ts
指定了知识库(或有 focus 默认库)时,两路都跑,然后按 nodeKey 去重:
- 先跑推理式导航
retrieveTreeNodesForAgent(LLM 选节点) - 再跑向量语义
semanticSearchTreeNodes,用try/catch包住并记下semanticAvailable - 把
[...treeHits, ...semanticHits]灌进 Map,先到者胜——导航结果天然排在前面 - 截到 limit,返回
mode: "tree+semantic"或降级后的"tree"
mode 字段是给模型看的:它能据此知道这次到底有没有语义检索兜底。
不传 knowledgeBaseId 时则走完全不同的 mode: "cross_kb" 分支。
注入提示词 buildInstructionsWithContextExtras
召回片段不是伪装成对话,而是明确标注来源与可信度:
{基础系统提示词} 以下是本对话较早内容的摘要(已折叠细节,仅供连贯理解; 最近几轮原文仍在消息中): {contextSummaryMd} 以下是与当前问题相关的较早对话片段 (向量召回,供参考,非完整历史): 1. (相关度 0.62) … 2. (相关度 0.41) …
两段都是可选的:没有摘要就不写摘要段,没有召回就不写召回段。
而工具检索的结果走的是另一条路——它们以工具返回值的形态进入对话,由模型自行决定引用哪些,
最终必须调 show_citations 出引用卡,字段不得改写。
08 · 脱敏与降级
把「不可用」当成常态来设计
向量这条线上有三个随时可能缺席的前提:Postgres、嵌入配置、嵌入接口本身。每一条召回路径都得回答「它们不在时怎么办」。
| 场景 | 章节语义检索 | 线程内召回 | 跨线程情景检索 | 关键词 / 图谱 |
|---|---|---|---|---|
| 跑在 SQLite | 抛 400「需要 PostgreSQL」,被 search_knowledge 吞成 mode: "tree" |
直接 return [] |
keyword 退 ilike,semantic 返回空 | 图查询有等价内存遍历兜底 |
| 没配 EMBEDDING | 检索侧 embedQuery 抛错 → 被吞成 mode: "tree";写入侧手动按钮报错并提示去配(推荐 bge-m3),自动补写静默跳过 |
hasEmbeddingConfig 为假直接返回空 |
semantic 分支返回空,只剩 FTS | 不受影响 |
| 嵌入接口报错 | 异常上抛 → 调用方 catch → 降级为 tree | catch 打日志返回空 | catch 打日志返回空 | 不受影响 |
| 目录树没建 | 无节点可查,返回空 | 无关 | 无关 | 跨库检索仍可用(走 Wiki 页与源文章) |
| 星图未发布 | 无关 | 无关 | 无关 | 载荷为空 → emptyMessage 指引改用全文检索 |
入库前脱敏 sanitizeRecallExcerpt
对话摘录会长期留在向量表里,所以清洗必须发生在入库前,而不是展示时:
| sk-… | /sk-[a-zA-Z0-9]{10,}/g → [redacted] |
| 键值对 | api_key / token / secret / password / cookie 后跟 : 或 = 的值 → 键=[redacted] |
| Bearer | /bearer\s+[a-zA-Z0-9._\-]+/gi → Bearer [redacted] |
| 确认卡 | executionOutcome 之后 200 字 → [confirmation omitted],避免删除操作的完整参数被记住 |
| 压缩空白 | 连续空白折成单空格,再截 500 字并补省略号 |
| 二次防御 | 跨线程检索取出结果后再过一遍同一函数,历史脏数据也不会漏 |
另一处脱敏:步骤落档 redactAssistantStepInput
工具调用的入参会原样写进 assistant_step.input 用于回放,
所以落库前会递归遍历对象,把敏感键名的值换成 [redacted],数组与嵌套对象一并处理。
两处脱敏的目标不同:这一处防的是运维回放时看到密钥, 上一处防的是密钥被向量化后在别的对话里被召回出来——后者更隐蔽,也更重要。
检索链路的设计边界(known limits)
- 向量维度硬编码 1024,与两处
vector(1024)列绑定。换嵌入模型必须选同维度的,否则要出迁移。 - 章节语义检索不设分数阈值,只靠 limit 控量——查询与库内容完全无关时也会返回「最不差」的几节;判断相关性的责任交给模型。
- 混合检索不做重排(rerank)也不做分数归一:两路结果按顺序拼接去重,导航结果天然优先。可解释性换掉了一点召回质量。
- 跨库模糊检索是内存打分,各拉 500 条封顶。知识库规模远超这个量级时,尾部内容可能进不了候选集。
- 目录树重建是「先删后插」的整篇替换,没有节点级 diff;结构指纹一变就重跑一次摘要。
- 对话向量的补写每轮最多 16 条,长线程需要多轮对话才能把历史补齐;补齐前召回结果会偏少。
- 嵌入模型是用户自备的,成本与速率限制都由用户的额度承担——这也是所有批量上限存在的原因。