ThingJS RAG 知识库(2025.07 – 2025.10)
独立负责 ThingJS API 文档知识库。编辑器 MCP 与网页共用一套检索;文档更新后重建索引即可用,不必等模型训练。
栈:TypeScript + LangChain + LanceDB + MCP。离线用本地 Qwen3-Embedding-4B;在线只 embed 查询,生成走阿里云 Chat。
在线链路
意图分流 → 问句规划 → LanceDB + BM25 → RRF → 按类采用 → /search 原文或 /ask 生成。
- 意图分流:纯规则。有类名、别名、
THING.、领域词才当文档题;你好 / 闲聊 / 天气直接固定话术,0 次 embed、0 次 chat。 - 问句规划:别名扩写(摄像机 → Camera / lookAt),近邻类名按编辑距离替换进问句。不用 LLM 改写,避免改出文档里没有的类。
- 双路检索:向量整句 embed 一次,LanceDB Top28;BM25 最多 6 路各 Top28,按 chunk_id 去重。
- RRF:只按名次加分,
1 / (60 + 名次 + 1)。同一 chunk 两路分相加。 - 采用:单类仍 5 条;问句里 2 个及以上类,每类最多 3 条、按类轮流发。
/search 只返回片段;/ask 再拼 prompt 生成,超时 60 秒。空检索、熔断都不调 chat。
切片
知识主要是 Markdown API 页:类下面挂成员。
- 先按
###切到成员级,一块通常就是一个方法。 - 同一成员超过 800 字再分段,相邻段重叠 200;不同成员不重叠。
- 代码块整块留下,不从中间切。图片挂在对应切片末尾,不单独成片。
- 块上补类名、成员名、标题;检索用富化
search_text,生成仍用原文。 - 成员 ≥ 8 时额外打一条 section-summary(成员名列表)。
800 和 200 写进切块指纹,改了要全量重嵌。要不要改,看门禁,不当旋钮拧。
发布与门禁
索引按 embedding 模型分版本,不是在同一份库上原地改。
- 文档或别名变了:建新版本 → 校验 → 切 active 指针;当前检索打完再热加载。
- 默认留最近 5 个版本方便回滚;再老的 prune。
- 换 embedding 是新 profile,旧维度不混用。
- 检索门禁:可答题 Top5 要有期望类名(Class Hit@5);有期望方法的,前 3 条要出现(Member Hit@3)。还看 MRR 和误空率。
- 生成门禁:schema、引用必须落在本次检索、类名方法有没有提到、幻觉为 0。过不了回滚上一版。
召回差看采用列表里有没有金标;生成差看引用和是否编了检索里没有的内容。两段日志分开看。
鉴权与额度
- MCP:公司 Token 换 LDAP 身份和部门额度,按团队扣减。
- 网页:独立日池,不占部门配额。
- LDAP 管「谁能调」,不管「谁能看哪份文档」。现在是一套索引、语料打标,不是按租户拆库。
两道必答题
摄像机看向某点。 字面没有 Camera / lookAt。纯向量会漂,纯 BM25 对中文也打不中。检索前做规划:别名把「摄像机」扩成 Camera 和关键字;类目录纠拼写。没有用大模型改写。
Box、Sphere、Cylinder 三个怎么 new。 融合池里都有,采用写死 5 条时先到的类占满格子,Cylinder 进不了采用列表,模型只能说「文档未提供」。改成按类留席:单类 5 条,多类每类最多 3 条轮流发。
可能会问的问题
你的RAG知识库是如何切片的?怎么分的?
text
因为我们这个RAG知识库主要以 markdown知识为主,所以先按照`###` 切到成员级,一块通常就是一个方法。
上限800个字,同一个 成员级块如果超过了800个字符,再切成n段,相邻段目标重叠200。
不同成员之间不重叠,代码块整块留着,不从中间切。
如果这个片段中有引入图片等资源。图片不单独切成片,从正文抽出来挂在对应切片的最后面。
同时每个块的下面还会补上 类名、成员名和标题。追问1:为什么不切成512/1024 ?为什么偏偏选了800呢?
text
ThingJS 的 API 页是类下面挂成员,我们先按 ### 切到成员,一块通常就是一个方法签名、说明、加一小段示例,多数一张卡片就放下。
再小,比如 512,签名和说明容易被切开;
再大,比如 1024,短成员根本吃不到好处——它们本来就不到 800,一块还是一块。
只有卡在 800 到 1024 之间的会少切一刀,更长的示例、参数表照样还是要分段。
如果改成1024就会变成一块更肥的向量:说明 加 大段代码糊在一起,成员特征会被稀释。Top5 每条也更长,更占 token。
早期按固定长度切过,方法和描述对不齐,相邻方法也会糊在一起。
后来改成先按标题切,800 只是同一成员的上限,超长再用 200 字重叠往下切。
800 和 200 写进切块指纹,一改就要全量重嵌,所以没有当旋钮拧。要改,得看门禁的 Class Hit@5、Member Hit@3,不是把上限抬到 1024 就更好。追问2: 代码块怎么处理?
先拆成 text/code 原子,围栏内不切断;超长代码按行且,每片补齐```和标题。
追问3: 元数据挂了哪些?有什么用?
class_name / member_name / namespace / parent_id / 行号。检索、引用、parent 回填都靠它们。
追问4:成员特别多的章节怎么办?
成员 ≥ 8 时额外打一条 section-summary(成员名列表),方便查看「这个类有哪些方法」。
文档更新了怎么增量?chunk id稳不稳?
chunk_id 由 source + member + part + 行号 + 内容哈希生成;还有索引版本、热加载、校验。
召回差 VS 生成差,怎么判断?
先看检索,再看生成。
金标类名、方法名不在采用列表里,是召回差:改切片、别名、融合、问句规划。单类采用仍是 5 条;问句里点了两个及以上类,按类数×3 留席,不再写死 Top5。检索对了,但答案没提这个方法、引用对不上、或者编了检索里没有的内容,是生成差:改 prompt、引用校验、温度。
线上我也这么拆:检索阶段有 confidence 和命中列表,生成阶段有引用是否落在 retrieved 里。两段日志一对,就能分开。
怎么评判RAG好不好?
我们分两套题。
检索用 golden search:可回答的题,Top5 里要有期望类名;有期望方法的,前 3 条里要出现。还看 MRR,以及「明明能答却空结果」的误空率。
生成用 golden generation:schema 对不对、引用是不是检索里的、类名方法有没有提到、路径有没有脱敏。上面还有质量门禁,比如类名覆盖大概 95%、方法 85%、幻觉要是 0。
发版前这两关过了才认为这版索引能上。
延迟拆在哪?哪一段最慢?
链路打了分段计时:意图分流、问句规划、embedding、向量检索、BM25、回填、生成。
「你好 / 谢谢 / 今天天气怎么样」这类非文档话术在进检索槽之前就被规则拦下,不占并发、不 embed、不调 chat,计时里只有 queryIntent,几毫秒结束。不要拿这类请求去说「embedding 要几百毫秒」——它们已经不走那一段了。
真正的文档题,通常最慢的是 embedding 和最后一次 chat。规划、融合、去重是纯内存,很快。向量和 BM25 是并行的,墙钟时间取较慢的那一路。
查询向量做了缓存,同样的问句不会反复 embed。要优化,先看这两段,别先去抠 RRF。
多类题(比如一次问五个类怎么 new)采用能到十几条,prompt 还要求每个类写完整参数表,chat 会明显变慢。生成超时现在是 60 秒;以前 25 秒时检索已经成功,模型没写完就被掐掉,日志上是 generate=0ms total≈25s,看起来像检索失败。
并发打满会怎样?
检索、embedding、问答各有并发槽和排队。检索默认大概 100,问答 50,还有等待上限。
槽满了就排队;排太久直接超时,不会把进程拖死。上游连续失败会熔断,问答走降级,把检索结果给出去。
所以打满时是「部分请求失败或降级」,不是整站一起挂。
知识过期/文档改了怎么办?如何清除线上那些过期的文档?
索引是版本化的,不是在同一份库上原地改。
文档或别名变了,watch 会防抖后走发布:建新版本、校验、切 active 指针。服务会等当前检索打完,再热加载新目录。
旧版本不是立刻删。默认留最近 5 个,方便回滚;再老的 prune 掉。用户请求里会带当前 indexVersion,对得上才能说「现在用的是哪一版」。
换 embedding 模型也是新 profile,旧维度的库不会混用。
为什么不用 GraphRAG / Agent 多条检索?
我们的知识主要是 API 树:类、章节、方法。结构切片加类目录,已经能表达「这个方法属于哪个类」。GraphRAG 要抽实体、建边、还要维护,收益和成本不匹配。
Agent 多跳是多次检索加多次模型,延迟和费用都乘上去,还更难评。用户问的大多是单点:某个方法怎么用。
以后如果大量出现「对比两个类,再结合教程写一段」,再上多跳。现在先把单跳召回和引用做准。
重点 有没有遇到过怎么也改不对的问题?
text
有的。例如用户问: 摄像机看向某点?
字面中没有Camera,也没有 lookAt。纯向量可能漂到别的相机组件中。纯BM25对着中文也查询不到关键字。
我们检索前先做规划,不调用 LLM。别名表会把 【摄像机】扩成 Camera 和关键字;类目录用前缀和编辑距离纠正错误拼写;切片的 search_text 里预置了类名,成员,层级。
BM25吃这份富化文本,生成仍用原文,避免把元数据交给模型当正文。
没有用大模型改写,因为它可能改出文档里没有的类,后面就全错,还多一跳延迟。
后来还有一类:Box、Sphere、Cylinder 三个怎么 new。融合 28 条里都有,但采用写死 5 条,按名次往下收,Box 先占 3 格、Sphere 占 2 格,Cylinder 进不了采用列表,模型只能写「文档未提供」。
不是提示词不肯写,是采用列表里根本没有 Cylinder。改成:单类仍 5 条;问句里 2 个及以上类,每类最多 3 条、按类轮流发,优先构造函数/概述。五个类就是 15 条,不写死上限 12。
目录里没有 THING.Line,会落到 FatLine / PixelLine;Line 也不会再被纠成已经出现的 Plane。重点 权限 / 多租户:不同产品线会不会串库?
text
现在是一套索引、语料打标,不是按租户拆库。路径会标成官方 API、编辑器、示例、项目约定。冲突时官方优先,纯项目题则标明项目名,禁止写成官方口径。
这不是权限隔离。LDAP 鉴权在 MCP 入口,管「谁能调」,不管「谁能看哪份文档」。文档层没有 ACL。如果两个客户的项目文档进同一索引,检索上能串,只是回答时会标来源。
真要多租户,我会按产品线拆 profile,检索带 sourceTypes / namespace 过滤,而不是提示词里写「请不要看别人的文档」——那不安全。我们现在的过滤能力是有的,还没做到租户级强制。请求中一共几次embedding?几次chat,cache?
text
先看是不是文档题。非文档话术(你好、谢谢、天气、笑话、你是谁)规则拦下:embedding 0 次,chat 0 次,固定话术直接回。search / MCP 一样跳过,emptyReason=offtopic,文案和「没搜到文档」分开。
文档题才往下:
一次 ask:查询 embedding 最多 1 次(按向量 query 文本缓存,默认 500 条、TTL 5 分钟),chat 1 次。
一次 search:只有 embedding,不调 chat。BM25 是本地 CPU,几乎不计模型成本。
空检索不调 chat。熔断也不调 chat,直接吐片段。所以贵的是「有命中的 ask」,不是每次都双模型,更不是「你好」也去 embed。重点 如果用户用提示词注入,你怎么防?
用户那句话只用来检索,不拿来改规则。不像文档问题连检索都不走;搜不到也不调用模型。搜到了也只能引用检索结果,编造的出处不要。文档有人审,安全重点在用户输入这一侧。
text
1. 用户输入只当问题,不当命令。 系统规则写死在 system 里,用户原文放在「用户问题」里,不拼进系统提示。
2. 先看像不像文档题,再决定搜不搜。 你好、闲聊、天气这类规则拦下,不 embed、不调模型。注入句通常也没有类名/领域词,同样到不了检索。
3. 真去搜了,搜不到仍不调模型,直接拒绝。
4. 回答和工具走白名单。 只能按固定格式答,引用必须是本次搜到的编号;Agent 不能随便调转账、删数据这类接口。
5. 入口再收一道。 限字数、登录、限流。提示词里写「别被骗」只是辅助,防不住认真越狱。重点 如果后面的文档从1万篇到100万篇,哪一层会先爆?
按爆炸顺序说
text
1. 先爆 BM25(内存)。
现在关键词索引是一整份文件,启动时全部装进内存。每开一个计算进程,就再拷一份。篇数变 100 倍,切块还更碎,这份索引会非常大。机器内存先满,热更新时还要重新加载,服务会抖。
所以不是「搜不动」,是「进程先被内存打死」。
2. 再爆召回质量(不是磁盘)。
我们按方法切,一篇文档会变成十几到几十个片段。100 万篇可能是一两千万个片段。向量库还能存。但每次还是两边各捞几十条再混合,池子越大,无关结果越多,真正有用的越容易被挤掉。
用户感知是「答非所问变多」,不是「磁盘满了」
3. 最后爆并发和 embedding。
文档题才把问句变成向量,依赖外部模型。人一多,排队和额度先到上限。闲聊已经被规则拦下,不再浪费这一跳。大模型生成更贵,但不是每次检索都走,所以它不是最先炸的那层。
我不会因为规模到了就上 GraphRAG。那是换检索形态,解决不了内存和噪声。追问1:如果后续文档从1万篇到100万篇,你的RAG要怎么去改?
text
1. BM25 不要整库驻内存
关键词索引外置,或按产品线、命名空间拆开,一次只加载要搜的那一摊。进程里不再人手一份全库。
2. 先缩小范围,再精细搜
现在是全库撒网。100 万必须先定域:用类目录判断用户在问哪个类、哪条产品线,只在这个小集合里做向量和关键词检索。
切法还是按文档结构切,不会改成切更大。切更大只是更糊,噪声不会变少。
3. embedding 结果跨机器共享
同样的问句不要每个实例都算一遍。缓存放到 Redis 这类外部存储。请求多了,先扩检索并发,生成单独限流。
先爆内存里的关键词索引,再爆全库乱搜带来的噪声,最后才是 embedding 并发。到 100 万我先拆开关键词索引,检索前先定到类或产品线,缓存外置。切块方式不动,也不先上 GraphRAG。重点 你的RAG知识库,从用户输入问题到RAG输出,经历了哪些步骤?
text
0. 意图分流(纯规则,不调模型)。问句有没有文档信号:类目录、别名、THING. / app. / 驼峰标识符、领域词(物体、相机、盒子、挤出……)。
- 没有信号,或落在问候/闲聊/天气/笑话黑名单:直接固定话术返回,emptyReason=offtopic。不占检索槽,不 embed,不调 chat。
- 「你好,Box 怎么创建」有 Box,算文档题,继续往下。口语文档题(拉个多边形挤出来)靠领域词,不能误拦。
1. 从问句中抽出像类名的词。例如:THING.xxx这个方法怎么用?会抽出THING.xxx。去掉前缀变成xxx。THING.bxo 这种后缀小写也能抽。
2. 和类名目录比较编辑距离。会维护一个 parents 类名表(主要是解决拼写错单词)。规则是 token 至少 4 个字母(THING. 后面可以 3 个,如 bxo),首字母要相同,和某个类名的 Levenshtein(莱文斯坦距离)<=2,就当成近邻拼写。
1. 匹配上之后是替换进问句,不是把错词和正词并排去搜。例如 THING.bxo怎么创建 → 检索串里是 THING.Box,不再带着 bxo。问句里已经有的类,不用错词去顶替(Line 不会被改成 Plane)。
2. 别名仍是扩:【摄像机】补上 Camera、lookAt。目录没有 THING.Line 时,落到 FatLine / PixelLine。
3. 不用大模型改写。
3. 开始检索。例如问题:THING.bxo怎么创建
1. 搜索计划:vectorQuery 是纠正后的整句;BM25 最多 6 路,多类时先放类名,别名往后排。
1. 向量检索:整段 embedding 一次,去 LanceDB 通过余弦相似度取前 28 个片段。
2. BM25检索:数组里每条各搜一次,每条 Top28,按 chunk_id 去重留高分,合成最多 28 条。
2. RRF融合:两路 28 条,只按名次加分【公式:一路贡献 = 1 / (60 + 名次 + 1)】。同一个 chunkId 两路都出现了,就把两路的分加上。不再按 exactClasses 加 0.04。
3. 采用:单类题仍取 5 条。问句里 2 个及以上类,每类最多 3 条、按类轮流发(三个类 9 条,五个类 15 条),优先构造函数/概述,避免先到的类占满格子。
4. 采用列表拼成上下文,再拼系统提示词和用户提示词,发给大模型。
5. 如果是 /search 直接返回内容。如果是 /ask,拼进提示词发给大模型。生成超时 60 秒。对应总图(意图分流 → 问句规划 → 双路检索 → RRF → 按类采用 → /search 原文 或 /ask 生成;非文档话术在分流处结束)。
一次提问可以记成:
text
意图分流(规则:文档信号 / 闲聊黑名单;非文档直接固定话术,0 次 embed)
→ 问句规划(别名扩写 / 近邻类名替换进问句,不用 LLM 改写)
→ 向量 LanceDB(整句 embed 一次,Top28)
→ BM25(计划里每条各搜 Top28,最多 6 路,按 chunk_id 去重)
→ RRF 融合(只按名次,不加类名加分)
→ 采用:单类 5 条;多类每类最多 3 条
→ /search 返回片段 | /ask 拼 prompt 再生成「你好」也会去知识库里搜吗?
text
不会。一句问候也要 embed + 向量 + BM25,白花几百毫秒。
现在检索槽之前先做规则分流:有类名、别名、THING.、领域词才当文档题。
你好、谢谢、天气、笑话直接固定话术,embedding 0、chat 0。
「你好,Box 怎么创建」有 Box,照常走检索。不用 LLM 分类,避免又多一跳。口述时补一句:图上没有、代码里有的
图是主链路。面试若追问「就这些吗」,补这几刀,不要再把 server.ts 走一遍:
- 意图分流:检索槽之前用规则判断像不像文档题。你好/谢谢/天气直接固定话术,不 embed。混合句「你好,Box 怎么创建」有类名,照常检索。不用 LLM 做分类。
- 查询计划:向量用纠正后的整句;BM25 用多条(纠正后的句子、类名、别名,最多 6 条;多类时先放类名)
- 为什么双路:类名/方法名靠 BM25 对词;口语换问法靠向量。没有给每段文档用 LLM 预生成问题
- 召回之后:hydrate 补全文/图 → 按类采用(单类 5,多类每类最多 3,优先概述/构造函数)→ Parent 扩展(只附类名/章节,不贴整章)
- 线上:检索有并发槽和超时;查询向量加 Instruct,文档侧不加;chat 超时 60 秒
查询计划长这样即可,不必背字段名:
text
原问:THING.bxo怎么创建
normalized / vectorQuery:THING.Box 创建 (bxo 已被替换,不再带着错词去搜)
bm25Queries:纠正后的句子、THING.Box …(最多 6 条)
exactClasses:Box,THING.Box → 用来算采用配额,不再给融合加 0.04
原问:Box、Sphere、Cylinder 三个怎么 new
exactClasses:三个类 → 采用 9 条,每类 3 条,Cylinder 也能进建索引时同一批切块写两份:Qwen3-Embedding-4B、2560 维进 LanceDB(文档侧无 Instruct);BM25 是中文单字+二字,再拆 THING.Box 这类标识符。口语别名不进倒排,在 aliases.yaml。
资料
后面过几个月再看,这两段要能独立看懂。面试被追问也从这里掏。
Levenshtein(莱文斯坦距离)
「最少要改几个字,才能把 A 变成 B」
text
只允许三种改法,每改一次算 1:
改:把一个字母换成另一个
删:去掉一个字母
增:插入一个字母
例子:Etrudeshap → ExtrudeShape
中间差的就是插/改了几个字母。距离越小,越像拼写错误,而不是两个无关词。
本项目怎么用:
- 从问句里抽出像类名的 token(至少 4 个字母;THING. 后面可以 3 个,如 bxo)
- 和 parents 类名表比距离,首字母相同且 <=2 就当成近邻拼写
- 例如 THING.bxo 替换成 THING.Box 再检索,不是错词和正词并排去搜
- 问句里已经有的类,不用错词去顶替(Line 不会被改成 Plane)
- 别名、口语映射仍是扩写;不用大模型改写用户原句融合 RRF 公式
RRF 论文里的常用默认值,代码里写成 RRF_K = 60,当平滑项。
完整公式:一路贡献 = 1 / (k + 名次 + 1)。
k 越大,第 1 名和第 28 名差得越小;k 越小,越听 Top1。
text
一路贡献 = 1 / (60 + 名次 + 1)
同一个 chunkId 两路都出现了,就把两路的分加起来,再按合成分从高到低排。
融合只看名次,不再给 exactClasses 加 0.04——类名已经在问句规划里用过了,用来算采用配额,不要在排序上再翻盘。
采用不是永远 Top5:单类 5 条;2 个及以上类,每类最多 3 条、轮流发。
用现在的 k=60:
第 1 名:1/61 ≈ 0.0164
第 28 名:1/89 ≈ 0.0112
差大约 1.5 倍,两边都排前才会明显抬上去
如果改成 10:
第 1 名:1/11 ≈ 0.091
第 28 名:1/39 ≈ 0.026
Top1 权重大很多,一路碰巧排第一会压过另一路
如果改成 200:
第 1 名:1/201 ≈ 0.0050
第 28 名:1/229 ≈ 0.0044
名次几乎拉平,融合变钝
所以 60 的作用是:别让某一路的第 1 名说了算,也别把 28 个名次抹成一样。
不是「文档有 60 个类」,也不是「切块 800 字」。