先回忆一下上一篇讲分词架构,留了一个小尾巴,ES 字段咋设计,因为这个设计原则确实影响了我们如何设计分词,所以本文就记录一下我们RAG 项目里 ES 字段的设计思路,抛砖引玉一下😯

在做 RAG 的混合检索时,首先要明白一点:每个 chunk 入库时,不只把原文存进去,还需要额外存一批专门给检索用的字段

通常ES 里一条 document 基本对应一个 chunk。

主链路是:

1
2
3
4
5
6
7
8
文件上传
-> execute_insert_process()
-> parse()
-> naive.chunk()
-> nlp.tokenize()
-> process_items()
-> ESConnection.insert()
-> Elasticsearch bulk 写入

文件上传后的解析在file_parse.py 统一根据文件类型进行分发parser

1. 写入哪些核心字段

当前 process_items() 会构造这些字段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{
  "id": chunck_id,
  "content_ltks": item["content_ltks"],
  "content_with_weight": item["content_with_weight"],
  "content_sm_ltks": item["content_sm_ltks"],
  "important_kwd": [],
  "important_tks": [],
  "question_kwd": [],
  "question_tks": [],
  "create_time": "...",
  "create_timestamp_flt": ...,
  "kb_id": index_name,
  "docnm_kwd": item["docnm_kwd"],
  "title_tks": item["title_tks"],
  "doc_id": xxhash(file_name),
  "docnm": file_name,
  "q_1024_vec": embedding
}

注意:q_1024_vec 不是固定名字,它取决于 embedding 维度:

1
d[f"q_{len(embedding)}_vec"] = embedding

如果 embedding 是 1024 维,就是:

1
q_1024_vec

如果是 1536 维,就是:

1
q_1536_vec

2. 为啥是这些字段

content_with_weight

这是 chunk 原文。

它主要给大模型生成答案用,也给前端展示引用用。这个字段在 mapping 里属于 *_with_weight,设置了:

1
"index": "false"

所以它不参与 ES 检索,只是存储和返回!!!

这是为啥🤏? 因为原文通常很长,直接让 ES 对原文做中文 analyzer 不稳定;我们自己设计了分词器进行分词,把可检索 token 放到 粗粒度content_ltks/细粒度content_sm_ltks,这俩的详细解释看之前分词器架构那篇就👌了。

  • 粗粒度做正文全文检索,查询字段是:content_ltks^2:正文粗粒度命中有一定权重,x2,但低于标题、关键词字段。

  • 细粒度查询字段里是:content_sm_ltks: 没有显式 boost,默认权重 1。 为什么权重低?
    因为细粒度字段是兜底召回。它能解决用户问得不完整的问题,比如用户只问“据点奖励”,但如果给太高权重,会把很多只包含“据点”“奖励”的泛内容召回来。

  • 文件名/标题分词字段title_tks^10

1
"title_tks": rag_tokenizer.tokenize(filename_without_ext)

为什么标题权重大?
因为标题命中通常比正文偶然命中更可靠。比如 chunk 来自《血盟据点战玩法说明》,用户问“据点战奖励”,标题命中说明这个文档主题就是它。


important_kwd / important_tks这两个是重要关键词字段。

查询侧权重很高:

1
2
"important_kwd^30",
"important_tks^20"

如果离线阶段抽取了 chunk 的核心关键词,比如:

1
2
3
血盟据点战
贡献奖励
攻城玩法

那么用户 query 命中这些字段时,应该比正文普通命中更强。


question_kwd / question_tks

这是 FAQ/问答型 chunk 预留字段。

查询侧权重:

1
"question_tks^20"

如果某些资料本身是问答形式,比如:

1
2
Q:据点战什么时候开启?
A:每周六晚开启。

可以把问题部分单独写入 question_tks,用户 query 命中问题字段时权重更高。


q_1024_vec

这是向量字段,用于 dense vector 相似度检索。

1
2
texts = [item["content_with_weight"] for item in items]
embeddings = generate_embedding(texts)

也就是说,embedding 是基于 chunk 原文 content_with_weight 生成的。

为什么需要向量字段?
因为用户 query 和文档表达不一定同词。

比如用户问:

1
怎么快速提升战力?

文档可能写:

1
装备强化、坐骑培养、技能升级可以提升角色评分。

全文检索不一定强命中,但向量语义能召回。


doc_id / docnm_kwd / kb_id

这些是过滤、聚合、引用字段。

1
2
3
doc_id:同一个文件的稳定 ID
docnm_kwd:文件名 keyword,用于展示、聚合、引用来源
kb_id:知识库 ID / 索引归属过滤

搜索结果返回后,retrieve_content() 会拿:

1
2
3
doc_id
docnm_kwd
content_with_weight

组装引用信息。


create_time / create_timestamp_flt

这是时间字段。

用于排序、调试、后续按时间过滤。*_flt 会被 ES mapping 识别成 float。

3. ES mapping 怎么识别这些字段

mapping 在 mapping.json,大量依赖字段后缀自动映射:

1
2
3
4
5
6
7
8
9
*_tks        -> text + whitespace analyzer + scripted_sim
*_ltks       -> text + whitespace analyzer
*_kwd        -> keyword
*_with_weight -> text,但 index=false
*_flt        -> float
*_int        -> integer
*_vec        -> dense_vector
*_fea        -> rank_feature
*_feas       -> rank_features

但是为什么 *_tks*_ltkswhitespace analyzer

因为系统已经用 RagTokenizer 提前分好词了:

1
血盟 据点战 奖励

ES 不需要再做中文分词!!!只需要按空格切 token。

这样做的好处是:

1
2
3
4
1. 文档侧和 query 侧使用同一套分词逻辑
2. 可以加入游戏领域词典
3. 可以控制粗粒度/细粒度字段
4. 避免 ES 默认中文分词不可控

4. 怎么写入 ES

最终写入在 es_conn.py

1
2
3
operations.append({"index": {"_index": indexName, "_id": meta_id}})
operations.append(d_copy)
self.es.bulk(index=indexName, operations=operations)

通过批量 bulk 写入,这里有个细节:

1
meta_id = d_copy.pop("id", "")

id 不作为普通字段写入 _source,而是作为 ES 的 _id

所以 ES 里的结构是:

1
2
3
_index = 用户/知识库对应的 indexName
_id = chunk_id
_source = chunk 的文本字段、token 字段、向量字段、元数据字段

chunk_id 的生成方式是:

1
xxhash(content_with_weight + index_name)

这样同一个 chunk 在同一个 index 里有稳定 ID。

5. 查询时用哪些字段,怎么用

查询字段在 query.py

1
2
3
4
5
6
7
8
9
[
  "title_tks^10",
  "title_sm_tks^5",
  "important_kwd^30",
  "important_tks^20",
  "question_tks^20",
  "content_ltks^2",
  "content_sm_ltks",
]

这些字段被包装成:

1
MatchTextExpr(...)

然后在 [es_conn.py]里变成 ES query_string

1
2
3
4
5
6
7
Q(
  "multi_match",
  fields=m.fields,
  type="best_fields",
  query=m.matching_text,
  minimum_should_match=minimum_should_match
)

所以用户问:

1
血盟据点战奖励怎么领

会先被分词、同义词扩展、细粒度扩展,然后去这些字段里查:

1
2
3
4
5
6
title_tks
important_kwd
important_tks
question_tks
content_ltks
content_sm_ltks

命中标题分高,命中重要词更高,命中正文粗粒度次之,命中细粒度最低。

同时,向量检索会用:

1
q_1024_vec

在 [search_v2.py]service/core/rag/nlp/search_v2.py:46) 里,query 也会生成 embedding,然后自动拼出同维度字段名:

1
vector_column_name = f"q_{len(embedding_data)}_vec"

然后 ES 做 KNN:

什么时候选暴力 KNN,什么时候 HNSW? 向量数量 <5 万:直接暴力 KNN(Flat),简单、无精度损失 10 万~千万级向量:优先 HNSW(RAG 主流标配) 亿级超大向量:IVF-PQ / DiskANN(磁盘型图索引)

1
2
3
4
5
6
7
8
s.knn(
  m.vector_column_name,
  m.topn,
  m.topn * 2,
  query_vector=list(m.embedding_data),
  filter=bqry.to_dict(),
  similarity=similarity
)

也就是说:

1
2
3
全文检索查 token 字段
向量检索查 q_xxx_vec 字段
两者通过 FusionExpr 做混合

当前融合权重是:

1
{"weights": "0.05, 0.95"}

可以理解为:

1
2
文本检索提供精确词约束和字段权重
向量检索提供语义召回

ES相当于是一个大的仓库,分门别类的存储了很多类型的东西:

  1. 原文类字段:content_with_weight,用于回答和引用
  2. 检索类字段:content_ltks/content_sm_ltks/title_tks/important_tks/question_tks,用于 BM25/query_string
  3. 向量类字段:q_1024_vec,用于语义相似度 KNN
  4. 元数据字段:doc_id/docnm_kwd/kb_id/create_timestamp_flt,用于过滤、聚合、排序、引用

所以把一个 chunk 写入 ES 时,会同时写原文、粗粒度 token、细粒度 token、标题 token、元数据和 embedding 向量。 原文不直接参与检索,只用于回答和引用; token 字段用 whitespace analyzer,保证离线文档分词和在线 query 分词一致; 向量字段用于语义召回; 查询时先构造 query_string 查多字段并带不同 boost,再用 dense vector 做 KNN,最后融合和 rerank。 这样既能保证游戏领域术语的精确命中,也能覆盖用户表达不一致时的语义召回。