从结论开始
是的。我把意图弄错了。
准确地重新把握的话就是这个。
1. zip 中包含 markdown 原始文件、模式、调试器、设计文档。
2. 你的本地已经有 markdown → JSONL → DB 的转换版本。
3. 我能看到的是“转换前的 markdown 与 DB 模式/搜索流程”。
4. 因此 PoC 需要把 zip 中的 markdown 像 JSONL chunk 那样拆分,并模拟可能进入你本地 DB 的搜索目标。
5. 你想要的不是代码框架,而是追踪当 chunk 进入时,各种搜索方式如何生成分数以及为何成功/失败的流程。还有一个重要的修改。
当前项目的 /api/retrieve 不是 BM25。
当前代码是:
chunkText
-> plainto_tsquery('simple', chunkText)
-> search_tsv @@ query
-> ts_rank_cd(...)这是。PostgreSQL 的 ts_rank_cd 是全文检索排名函数,而不是 BM25 本身。PostgreSQL 文档也说明 ts_rank/ts_rank_cd 是带有文档长度归一化选项的排名函数。plainto_tsquery 会将输入句子分词,忽略标点符号,并将非停用词构造成查询。(PostgreSQL)
从结论开始
满足你的条件的最佳方案就是这个。
输入:
raw chunkText 原样
Retriever 内部:
1. raw chunk 原样 BM25 候选搜索
2. raw chunk 原样 code embedding 候选搜索
3. 候选 union
4. 使用 reranker 重新排序
5. Qwen 或 validator 直接验证 JSONL 依据Godot API 信号提取不是必需的版本。
也就是说,我之前提到的 “Godot API signal extractor” 不能作为必需项。正如你所说,这会带有硬编码的味道。虽然可以使用,但如果把它作为基本设计的核心,维护性会变差。
最终推荐是:
第一候选:BM25
第二候选:code embedding
最终排序:reranker
验证:Qwen direct-evidence validator项目理解验证
zip中确认的实际结构如下。
JSONL 设计
文档中产出物的定义如下。
work/godot_rag/jsonl/docs_chunks.jsonl
work/godot_rag/jsonl/api_mapping.jsonl
work/godot_rag/jsonl/label_prototypes.jsonl
work/godot_rag/jsonl/ingest_report.jsonlDB 模式不是将整行 JSONL 完整放入 payload jsonb 的结构。
JSONL 字段会展开为表格列。
docs_chunks:
chunk_id
doc_version
source_url
source_file
source_sha256
doc_type
symbol
section_path
heading
content
code_blocks
language_tags
godot_version_tags
api_symbols
token_count
metadata
embedding
search_tsv
api_mapping:
mapping_id
source_api
target_api
change_type
godot_from
godot_to
confidence
evidence_chunk_ids
match_terms
notes
negative_patterns
label_prototypes:
prototype_id
label
task_type
input_pattern
expected_finding
recommended_action
evidence_mapping_ids
evidence_chunk_ids
severity
validator_rules
embedding
search_tsv也就是说,你的本地数据库里会是这样的。
{"chunk_id":"...","source_file":"outputs/godot_docs_full/pages/getting_started__first_2d_game__03.coding_the_player__161d377b.md","doc_type":"tutorial","heading":"Coding the player","content":"...","code_blocks":["func _process(delta): ..."],"api_symbols":["Input.is_action_pressed","Vector2.ZERO","AnimatedSprite2D","position.clamp"]}四块输入
假设输入始终保持原样。
func _process(delta):
var velocity = Vector2.ZERO
if Input.is_action_pressed(&"move_right"):
velocity.x += 1
if Input.is_action_pressed(&"move_left"):
velocity.x -= 1
if velocity.length() > 0:
velocity = velocity.normalized() * speed
$AnimatedSprite2D.play()
else:
$AnimatedSprite2D.stop()
position += velocity * delta
position = position.clamp(Vector2.ZERO, screen_size)如果把这个块视为代码分词器,唯一标记如下所示。
_process
animatedsprite2d
clamp
delta
else
func
if
input
is_action_pressed
length
move_left
move_right
normalized
play
position
screen_size
speed
stop
var
vector2
velocity
x
zero
0
1总计 43 个 token,unique 25 个。
这里重要的 token 是这个。
强令牌:
animatedsprite2d
is_action_pressed
move_left
move_right
normalized
clamp
screen_size
vector2
zero
弱令牌:
func
var
if
else
velocity
position
delta
x
0
1BM25对重要的词元给予更高的分数。
因为在整个文档中出现频率越低的词,其 IDF 越大。
BM25是如何工作并产生结果的
BM25大致计算“查询词在文档中嵌入得有多好”。
正式来说,核心有三点。
TF: 在此文档中单词出现了多少次
IDF: 在整个语料库中该单词有多稀少
Length normalization: 对长文档进行校正,使其不会自动占优势Elasticsearch 也使用 BM25 作为默认的相关性算法,并将词频、逆文档频率、字段长度归一化作为核心要素进行说明。 (Elastic)
公式大致是这样的。
score(query, doc)
= Σ over query terms [
IDF(term)
*
TF_boost(term frequency in doc, doc length)
]即如果像 animatedsprite2d 这样罕见的词在文档中出现多次,分数会大幅提升。
如同真实的模拟结果
zip 中的实际 Godot markdown 原始文件:
outputs/godot_docs_full/pages/将其像 JSONL docs_chunks 那样拆分为约 1800 字符的块,并将你的原始块直接作为查询输入 BM25。
候选第一名
Input.is_action_pressed("move_right")
Input.is_action_pressed("move_left")
velocity.length()
velocity.normalized() * speed
$AnimatedSprite2D.play()
$AnimatedSprite2D.stop()为什么是第一名,因为这些令牌直接匹配了。
animatedsprite2d
is_action_pressed
move_left
move_right
normalized
play
stop
input
speed
length
velocity
vector2
zero得分显著提升的主要贡献代币如下。
animatedsprite2d df=18 tf=7 contribution=11.975
is_action_pressed df=27 tf=2 contribution=8.609
move_left df=13 tf=1 contribution=7.479
velocity df=180 tf=18 contribution=7.467
move_right df=15 tf=1 contribution=7.299
normalized df=67 tf=2 contribution=7.069
stop df=133 tf=2 contribution=5.900
play df=144 tf=2 contribution=5.764
input df=323 tf=7 contribution=5.650
speed df=219 tf=3 contribution=5.637这里的 df 是在全部 4165 个 chunk 中包含该词的 chunk 数量。
即:
animatedsprite2d仅在全部 4165 个块中的 18 个出现
move_left仅在 13 个出现
move_right仅在 15 个出现
is_action_pressed仅在 27 个出现因此,包含这些词的文档会显著上升。
这就是 BM25 “如何” 获得第一名的过程。
候选第二名
AnimatedSprite2D 说明
$AnimatedSprite2D.play()
get_node("AnimatedSprite2D").play()
position += velocity * delta
position = position.clamp(Vector2.ZERO, screen_size)匹配的令牌:
animatedsprite2d
clamp
screen_size
play
delta
_process
velocity
position
stop
vector2
zero这个块排名第二的原因是虽然没有输入处理部分,但 clamp、screen_size、AnimatedSprite2D 匹配得很强。
尤其是:
screen_size df=3
clamp df=32
animatedsprite2d df=18screen_size是几乎不出现的词,即使只出现一次也得分很高。
候选 3 名: 失败候选
3D player movement
Input.is_action_pressed("move_right")
Input.is_action_pressed("move_left")
Vector3.ZERO
direction.normalized()匹配的令牌:
is_action_pressed
move_left
move_right
normalized
speed
velocity
delta
input
x
var
func
zero这看起来相关,但不是准确的答案。
原因是:
Input.is_action_pressed
move_right
move_left
normalized
speed这种移动代码的公共标记高度重叠。
但是本文档是 3D 的。chunk 是 2D 的,AnimatedSprite2D、Vector2、position.clamp、screen_size 是关键。
也就是说,仅使用 BM25 会产生这种 false positive。
当前方式失败的原因
当前接近 /api/retrieve 的方式是 plainto_tsquery(raw_chunk)。
这种方式大致会形成如下查询。
func & process & delta & var & velocity & vector2 & zero
& input & is_action_pressed & move_right & move_left
& length & normalized & speed & animatedsprite2d
& play & stop & position & clamp & screen_size这样的话,一个 JSONL 块会要求太多 token。
在我的模拟中:
strict raw AND hits: 0即如果一个块没有同时满足所有唯一 token,则为 0 条。
因为实际的官方文档也是这样划分的。
chunk A:
Input.is_action_pressed
velocity.normalized
AnimatedSprite2D.play/stop
chunk B:
AnimatedSprite2D 说明
position.clamp
screen_size两者都有关系,但并非所有内容都在同一行中。
所以目前的方式需要彻底改写。
各方法的备选方案
备选方案 A. 当前方式:raw chunk + PostgreSQL plainto_tsquery
流程:
raw chunkText
-> plainto_tsquery
-> search_tsv @@ query
-> ts_rank_cd是块结果:
失败可能性大
即使 chunk 分割稍有不同,也为 0 件优点:
实现已经完成
仅使用 PostgreSQL 即可
基础设施简单缺点:
对长代码块弱
AND 条件过多
代码标记噪声多
不是 BM25
无法进行语义搜索判定:
废弃替代方案 B. raw chunk + BM25 only
流程:
raw chunkText
-> tokenizer
-> BM25
-> top JSONL 返回是块结果:
1位: first_2d_game / coding_the_player
2位: first_2d_game / coding_the_player / clamp 部分
3位: first_3d_game / player_movement_code为什么成功:
AnimatedSprite2D
move_right
move_left
is_action_pressed
screen_size
clamp这些词在整个 Godot 文档中很少见,因此得分很高。
优点:
原理透明
调试容易
无模型费用
raw chunk 可直接使用
代码/API 对精确字符串强大缺点:
同义词/说明文较弱
如果 API 名称没有直接出现就找不到
像 3D movement 那样混入相似的代码
文档表达不同就会错过判定:
必须写
但单独不足替代方案 C. raw chunk + embedding only
流程:
-> 嵌入向量
-> docs_chunks/api_mapping/label_prototypes 嵌入与余弦搜索
-> 返回 top JSONL使用模型的原因:
为了在字符串不完全匹配的情况下找到意义相近的文档例如,查询包含:
position.clamp(Vector2.ZERO, screen_size)有,文档中有:
prevent the player from leaving the screen
clamping a value means restricting it to a given range如果这样描述的话,BM25 可能会变得较弱。embedding 捕捉这种语义关联。
优点:
即使表达不同也能找到
对文档说明型内容强大
可以直接放入原始块
也能处理长块缺点:
准确的 API 判断较弱
可能混合 3D/2D、Godot3/Godot4 等细微差异
很难解释为什么会出现这个结果
在 api_mapping 中 false positive 风险很大是 chunk 预期:
成功:
first_2d_game / coding_the_player 找到的可能性高
失败:
first_3d_game movement也可能一起高出现
因为“player movement”意义相似判定:
禁止单独使用
用于补充 BM25 候选替代方案 D. raw chunk + BM25 + embedding 并行
流程:
raw chunkText
-> BM25 top 50
-> embedding top 50
-> 合并
-> 混合分数
-> 返回 top JSONL这方式相当不错。
你的 chunk 流程:
BM25捕获的内容:
Input.is_action_pressed
AnimatedSprite2D
position.clamp
screen_size
embedding捕获的内容:
player movement
2D movement tutorial
moving inside screen
animation based on movement优点:
相互补足字符串搜索和语义搜索的弱点
即使没有 Qwen,首次质量也提升
保持 raw chunk 条件缺点:
# 分数组合调优是必需的
BM25 分数和向量分数的尺度不同
无法完全阻止 false positive判定:
实战最低推荐线替代方案 E. raw chunk + Qwen 查询 JSON 生成 + 搜索
为了避免硬编码,这种方式也是可行的。
流程:
-> 请求 Qwen 生成用于搜索的 JSON
-> 使用生成的 JSON 进行 BM25/vector 搜索
-> 返回 JSONL 候选项示例 Qwen 输出:
{
"search_intent": "Godot 4 2D player movement tutorial",
"important_literals": [
"Input.is_action_pressed",
"Vector2.ZERO",
"AnimatedSprite2D",
"position.clamp",
"screen_size"
],
"likely_doc_topics": [
"first 2D game",
"coding the player",
"player movement",
"clamp position to screen",
"play and stop AnimatedSprite2D"
],
"migration_signals": []
}这是一种不直接硬编码 Godot API 信号提取器的方式。
相反,Qwen 会创建查询配置文件。
优点:
硬编码少
在复杂的块中可以进行意图摘要
可以将搜索词制作得更易于人阅读
可以将针对 Godot 的专化判断交给 Qwen缺点:
慢速
产生费用
可以制造出没有 Qwen 的线索
从搜索前阶段就可能出现幻觉
必须对原始块进行直接依据验证已成功的块:
Qwen提取 AnimatedSprite2D / Input.is_action_pressed / position.clamp 即成功网络块失败:
如果 Qwen 产生了类似 “migration from Godot 3 to 4” 这样的不存在的意图,就会错误地拉取 api_mapping。判定:
适用于搜索质量实验
作为生产第一搜索引擎需谨慎替代方案 F. raw chunk + BM25 + embedding + reranker
这就是最好的。
流程:
-> BM25 top 80
-> embedding top 80
-> 候选 union
-> reranker直接比较 raw chunk 与各 JSONL 候选
-> 返回 top JSONL
-> Qwen validator 进行直接依据验证reranker 会同时读取 query 和候选文档,并重新衡量相关度。Voyage 文档也将 reranker 解释为一种模型,它接受 embedding/BM25 等一次检索结果的候选,并以 relevance score 重新排序。rerank-2.5 是一个拥有 32K 上下文的质量优化 reranker。(Voyage AI)
在每个 chunk 中 reranker 的工作:
候选 1:
first_2d_game / coding_the_player
raw chunk 与直接代码流程几乎相同
=> 非常高
候选 2:
same page / clamp section
包含 position.clamp 和 AnimatedSprite2D 的说明
=> 高
候选 3:
first_3d_game / player_movement
Input.is_action_pressed 相同,但为 Vector3/3D 上下文
没有 AnimatedSprite2D
没有 screen_size clamp
=> 降低优点:
质量最佳
大幅减少 BM25 假阳性
也减少 embedding 假阳性
保持 raw chunk 条件
硬编码依赖低缺点:
有费用
有延迟
候选项放得太多会变慢
reranker也不是依据验证器,所以需要最后的验证器判定:
最终推荐为什么使用模型以及为什么不使用模型
BM25 没有模型
BM25 是统计检索。
这个词在 query 中。
这个词也在 document 中。
这个词在 corpus 中很稀有。
那么提升分数。因此,它在类似 chunk 的代码搜索方面非常强大。
AnimatedSprite2D
Input.is_action_pressed
Vector2.ZERO
position.clamp这种情况下,字符串比意义更重要。
但是 BM25 对于“clamp 不能超出屏幕”之类的语义关联较弱。
使用 embedding 模型的原因
embedding 将句子转换为向量。
raw chunk
-> [0.12, -0.03, ...]
docs_chunk
-> [0.11, -0.02, ...]
cosine similarity所以即使字符串没有完全重叠,也会寻找最接近的内容。
在本项目中需要使用 embedding 的情况:
1. 文档是说明性的,当代码和词语不一致时
2. API 名称不重复,但属于相同概念时
3. tutorial 块以多种表达方式分散时
4. 当 BM25 为 0 条时需要回退机制需要排除 embedding 的情况:
1. api_mapping source_api 精确匹配
2. Godot3/Godot4 版本判定
3. 仅匹配 target_api 的 false positive 移除
4. 迁移规则确定即:
embedding = recall 扩展
BM25/exact = 直接依据
reranker/Qwen validator = 候选整理与验证各模型的特性
voyage-code-3
最适合此项目。
原因:
query 不是自然语言问题,而是 GDScript 代码块。
搜索目标 JSONL 中也混杂了 code_blocks、API 名称、文档说明。Voyage 官方文档中,voyage-code-3 是用于代码检索优化的模型,支持 32K 上下文,默认 1024 维,支持 256/512/1024/2048 维度。Voyage 在发布时也说明,在 32 个代码检索数据集上,相较于 OpenAI text-embedding-3-large 和 CodeSage-large,取得了更高的平均性能。(Voyage AI)
特性:
## 优点
- 对 code query 强大
- 能处理长 chunk
- 可选择 1024/2048
- 适合 code → docs 搜索
## 缺点
- 依赖外部 API
- 会产生费用
- 并非专用的 Godot 模型推荐使用:
voyage-code-3 1024 float品质只看的话 2048 也是可以的,但 1024 + reranker 更现实。
OpenAI text-embedding-3-large
通用高质量 embedding。
根据 OpenAI 官方文档,text-embedding-3-large 默认 3072 维,text-embedding-3-small 默认 1536 维。(OpenAI 开发者)
特性:
优点:
通用语义检索强大
对文档说明搜索稳定
OpenAI 生态系统良好
缺点:
代码检索并非专用
3072维导致存储/索引成本大
在此项目的 “GDScript chunk -> JSONL” 中比 voyage-code-3 更不直接推荐度:
第二优先级Gemini 嵌入
Google Gemini 嵌入的默认维度是 3072,可以通过 output_dimensionality 选择 768/1536/3072 等大小。Google 文档也说明,使用较小的维度可以在降低存储空间和计算成本的同时,将质量损失降到最小。(Google AI for Developers)
特性:
优点:
通用/多语言语义检索强大
适用于文档说明型检索
可进行维度降维
缺点:
不是专用于代码检索的选项
更侧重语义相似性而非 API 代码准确性
在判断 Godot 迁移是否精确时单独使用存在风险推荐度:
如果以文档 QA 为中心会更好
如果以代码块搜索为中心,请在 voyage-code-3 下面Jina embeddings v4
Jina embeddings v4 是在复杂文档检索、多语言、multimodal、表格/图表/图像等视觉丰富文档检索方面具有优势的模型。根据 Jina 的说明,它强调长输入和 multimodal 文档检索。(jina.ai)
特性:
优势:
文档搜索范围广
在多模态/复合文档方面强
也有代码适配器系列
缺点:
你的项目目前以 markdown/code 为中心
图像/表格检索不是核心
可能是过度的选择推荐度:
目前优先级较低在搜索中使用 Qwen 的情况
Qwen 正如你所说,目前是用于 JSONL 生成/验证的。
也可以用于搜索。
使用方式有两种。
1. Qwen 查询配置生成器
2. Qwen 验证器 / 重排序器Qwen 查询配置生成器
-> Qwen生成用于搜索的JSON
-> BM25/向量搜索优点:
减少硬编码
可进行 Godot 上下文推理
可以对复杂的代码块进行摘要缺点:
可能出现幻觉
在搜索前阶段可能注入错误的意图
慢
有成本Qwen 验证器
raw chunk
+ retrieved JSONL
-> 判断此 JSONL 是否为直接依据这很强大。
在项目观察文档中,已经确定了“不是宽泛主题相似度,而是 JSONL 字段和 chunk 字符串必须直接匹配”的标准。
推荐:
Qwen 在搜索前生成查询相比,搜索后用作直接证据验证器更安全。基于块的成功/失败流程
成功流程:BM25 + 嵌入 + 重排序器
输入:
raw chunk 原样第一次 BM25 是噪声:
first_2d_game / coding_the_player
依据:
Input.is_action_pressed
move_right
move_left
velocity.normalized
AnimatedSprite2D.play
AnimatedSprite2D.stop二次 BM25 是噪声:
same page / clamp section
依据:
AnimatedSprite2D
position.clamp
Vector2.ZERO
screen_sizeembedding已补充:
player movement
2D movement
animation based on movement
screen boundsreranker整理:
1位:
first_2d_game / coding_the_player
2位:
same page / clamp and AnimatedSprite2D explanation
降级:
first_3d_game / player_movement_code最终 JSONL:
{"table":"docs_chunks","payload":{"source_file":"outputs/godot_docs_full/pages/getting_started__first_2d_game__03.coding_the_player__161d377b.md","doc_type":"tutorial","heading":"Coding the player","content":"...Input.is_action_pressed...velocity.normalized...$AnimatedSprite2D.play()...$AnimatedSprite2D.stop()...","api_symbols":["Input.is_action_pressed","Vector2.ZERO","AnimatedSprite2D"]}}{"table":"docs_chunks","payload":{"source_file":"outputs/godot_docs_full/pages/getting_started__first_2d_game__03.coding_the_player__161d377b.md","doc_type":"tutorial","heading":"Coding the player","content":"...position += velocity * delta...position = position.clamp(Vector2.ZERO, screen_size)...","api_symbols":["Vector2.ZERO","position.clamp","AnimatedSprite2D"]}}这是成功。
失败流程 1:当前 raw tsquery
输入:
raw chunk 原样内部查询:
func & process & delta & var & velocity & vector2 & zero
& input & is_action_pressed & move_right & move_left
& length & normalized & speed & animatedsprite2d
& play & stop & position & clamp & screen_size结果:
0件 可用失败原因:
如果一个 JSONL 块中没有所有词则失败这必须重新处理。
失败流程 2:BM25 only
结果:
第1名 first_2d_game / coding_the_player
第2名 same page / clamp section
第3名 first_3d_game / player_movement_code失败原因:
first_3d_game也共享 Input.is_action_pressed、move_left、move_right、normalized。BM25并不完全理解“是2D还是3D”。
它只会计算词语得分。
因此仅使用 BM25 并不足够。
失败流程 3:仅使用嵌入
结果预期:
2D player movement
3D player movement
scripting player input
vector math
animation player docs失败原因:
在意义上,全部都接近玩家移动embedding是能够很好地找到语义相近的内容,但API的准确度较低。
失败流程 4:仅 Qwen 查询配置文件
如果 Qwen 能够这样很好地提取,就会成功。
{"important_terms":["Input.is_action_pressed","AnimatedSprite2D","position.clamp","Vector2.ZERO"]}但是这样抽取会失败。
{"migration_intent":"Godot 3 to Godot 4 migration","important_terms":["AnimatedSprite","AnimatedSprite2D"]}失败原因:
raw chunk中没有 AnimatedSprite,只有 AnimatedSprite2D。
这是 Godot 4 的正常代码,但可以拉取迁移映射。所以,仅在搜索前将 Qwen 用作查询生成器是有风险的。
搜索后使用验证器更安全。
最终推荐方案 3个
1案:最小复杂度
raw chunk
-> BM25
-> top JSONL使用:
PoC 第一次
快速用眼睛检查搜索质量优点:
简单
快速
透明
无模型费用缺点:
存在误报
无法进行意义搜索
文档表达不同会遗漏在 net chunk 中:
成功是
但是 3D movement 混入方案二:平衡型
-> BM25 top 50
-> embedding top 50
-> union
-> weighted score
-> JSONL 返回
-> Qwen direct-evidence 验证使用:
当前项目中实际最好的中间阶段优点:
BM25与 embedding 补充
使用 Qwen 删除无关 JSONL
硬编码较少缺点:
需要分数调优
如果没有reranker,ranking会稍有波动在 net chunk 中:
first_2d_game 稳定地 位于上位
3D movement 在 Qwen 验证中 下降方案3:质量至上
- BM25 top 80
- voyage-code-3 embedding top 80
- union
- rerank-2.5
- Qwen direct-evidence validator
- 最终 JSONL使用:
最终推荐优点:
质量最佳
最小化硬编码
保持原始块条件
reranker 减少 BM25/embedding 的误报
Qwen 验证器直接删除无依据的 JSONL缺点:
成本
延迟
实现阶段很多在 net chunk 中:
最终接受:
docs_chunks / first_2d_game / coding_the_player
最终拒绝或低等级:
first_3d_game / player_movement_code
unrelated api_mapping
migration label_prototypes最终判断
如果我现在把这个项目翻天覆地的话,就会这样。
输入仅保留一个 `chunkText`。
第一次搜索使用 BM25。
原因:在代码/API 字符串搜索中最透明且最强大。
第二次搜索使用 voyage-code-3 嵌入。
原因:查询是代码块,使用代码检索模型更合适。
第三次排序使用 rerank-2.5。
原因:在 BM25 和嵌入得到的相似候选中,将真正匹配的块及其对应的 JSONL 提升到顶部。
最后的验证使用 Qwen direct-evidence validator。
原因:如果 JSONL 中没有直接匹配块的字符串/模式依据,则必须丢弃。一行概括:
raw chunk 그대로 입력 → BM25 + code embedding 병렬 후보 생성 → reranker 재정렬 → Qwen 직접근거 검증这就是答案。
Godot API signal extractor 必须不要放入。即使放入,也只能作为“分数奖励”使用。基本搜索应基于 raw chunk 的 BM25/vector,这符合你的意图。