元数据生成
本文只讲思路,代码是从 MoAI 里面抽出来的,不是单独完整的 Demo,你是跑不通的,只需要理解本章的案例即可,不需要亲自跑代码。
上一章把整篇文本按 Token 拆成了有序的 Chunk。到这一步,每个 Chunk 都具备直接向量化的条件,但如果就这样把原文送进 Embedding 模型,召回效果不一定理想,原因在于搜索的表述方式和原文并不总是一致。
文档里的句子通常完整、正式,用户搜索时却习惯用简称、口语甚至一个疑问句。比如原文写的是:
系统使用访问令牌完成身份验证,令牌的默认有效期为十分钟。
用户可能会直接搜 “登录状态多久过期”,也可能只输入 “Token 过期时间” 。说的是同一件事,用词和句式却不一样。Embedding 模型能理解一部分语义相似性,却不能保证这段原文每次都排在最前面。
于是 MoAI 在 “切片” 和 “向量化” 之间插进了一步元数据生成。这里说的元数据不是文件大小、创建时间这类属性,而是根据原文生成的提纲、问题、关键词、摘要和语义子段。原始 Chunk 继续负责提供完整内容,生成的元数据负责让这段内容更容易被检索到。
这不是用模型改写原文再把原文扔掉,而是为同一段内容准备多个检索入口。检索命中问题或摘要后,仍然能沿着 ChunkId 找回原始 Chunk,把完整内容交给模型。
原始 Chunk
├── 提纲
├── 用户可能提出的问题
├── 关键词和摘要
└── 按语义重新聚合的子段
↓
分别生成向量
↓
通过 ChunkId 关联原文


原文是一份,检索入口却可以有很多个
最省事的做法,是把模型生成的提纲当作 “改写后的原文”,向量化和召回都只依赖这一份改写结果。这样做的代价是原内容被压缩掉了,提纲或摘要里没写到的细节,检索命中后照样拿不回来。
也就是说,除了可以把原文切片向量化,还可以把原文生成大纲、问题、关键词、摘要等,那么检索时入口有多个,最后把原文召回即可。
MoAI 对切片生成的元数据示例:

MoAI 的思路是让原文和检索表示分开保存。所有策略都实现同一个 IParagraphPreprocessStrategy,接口只声明策略类型和一个处理方法,具体模型调用通过 IParagraphPreprocessAiClient 传入。
public interface IParagraphPreprocessStrategy
{
PreprocessStrategyType StrategyType { get; }
Task<ParagraphPreprocessResult> ProcessAsync(
string paragraph,
IParagraphPreprocessAiClient aiClient);
}
DocumentPreprocessor 默认注册了四种策略。它们复用同一个结果模型,解决的问题却不一样。
| 策略 | 生成内容 | 使用的 AI 能力 | 适合解决的问题 |
|---|---|---|---|
OutlineGeneration | 一条简短提纲 | Chat | 原文铺垫太多,主题不突出 |
QuestionGeneration | 用户可能提出的问题 | Chat | 查询是疑问句,原文是陈述句 |
KeywordSummaryFusion | 关键词、摘要和部分原文 | Chat | 需要同时保留术语和语义概括 |
SemanticAggregation | 重新组合后的相似子段 | Embedding | 一个 Chunk 内存在重复或分散表达 |
提纲生成:把铺垫压缩成一条核心信息
提纲策略做的事很直接,让模型把整段压成不超过五十字的结构化提纲,再清洗掉多余的换行和空格。提示词特意强调 “突出核心信息”、“符合中文表达习惯”,因为它要解决的正是那种绕来绕去、中心被埋住的段落。
public class OutlineGenerationStrategy : IParagraphPreprocessStrategy
{
public PreprocessStrategyType StrategyType =>
PreprocessStrategyType.OutlineGeneration;
public async Task<ParagraphPreprocessResult> ProcessAsync(
string paragraph,
IParagraphPreprocessAiClient aiClient)
{
if (string.IsNullOrWhiteSpace(paragraph))
{
return new ParagraphPreprocessResult
{
OriginalText = paragraph,
ProcessedText = string.Empty,
StrategyType = StrategyType
};
}
var prompt = $@"请为以下段落生成简洁的结构化提纲(控制在50字以内,突出核心信息):
段落内容:{paragraph}
提纲要求:1. 仅保留核心观点和关键信息 2. 语言简洁 3. 符合中文表达习惯";
var outline = await aiClient.GenerateTextAsync(prompt);
outline = Regex.Replace(outline, @"\s+", " ").Trim();
return new ParagraphPreprocessResult
{
OriginalText = paragraph,
ProcessedText = outline,
StrategyType = StrategyType,
Metadata = new KeyValueString[]
{
new KeyValueString
{
Key = ParagrahProcessorMetadataType.Outline.ToJsonString(),
Value = outline
}
}
};
}
}
原文的切片:
# 代码质量思考
要写出一个好的项目,从技术上除了基础编程代码外,还有要了解设计模式、软件工程、DDD 相关的知识点,因为编码很多时候是一个工程问题,就像领域驱动设计(DDD),我们如何对代码进行抽象?如何设计同上下文对象的内聚和降低不同上下文的耦合,如何兼顾程序性能和代码阅读性。
另外,每个人的编码习惯也是不一样的,当我们编写项目时能不能也让别人很容易地看懂代码?让新人加入开发时,迅速掌握?项目也是由一点点代码积累起来的,代码质量很多时候会影响到程序的健壮性,是否能够便于维护项目。
当谈到代码质量,我们去百度一下相关的文章很多,但是可能看来看去,似乎都是命名规范之类的,这些资料看多了,其实千篇一律,不一能够真正提高代码质量。前面说到,编码很多时候是个工程问题,我们要提高代码质量,那么要从软件工程的角度去看待,去分析。
首先,我们可以从四个维度去考虑代码质量:
生成大纲:
1. **技术知识**:掌握设计模式、软件工程、DDD 等知识对提高代码质量的重要性。 2. **代码可读性**:确保代码易于理解,方便新人加入并快速掌握。 3. **代码质量影响**:代码质量影响程序健壮性和项目维护性。 4. **提升方法**:从软件工程角度分析和提升代码质量。
问题生成:提前准备用户可能的问法
正式文档几乎不用问句,用户却习惯用问题去搜索。问题生成策略就在写入阶段提前准备 “用户可能会怎样问”,它的提示词默认让模型针对每个 Chunk 生成两个问题,要求每行一个、控制在三十字以内。模型返回的是带换行的一段文本,代码先按 \r?\n 拆行,去掉空行,再取前 QuestionCount 条。
public class QuestionGenerationStrategy : IParagraphPreprocessStrategy
{
public int QuestionCount { get; set; } = 2;
public PreprocessStrategyType StrategyType =>
PreprocessStrategyType.QuestionGeneration;
public async Task<ParagraphPreprocessResult> ProcessAsync(
string paragraph,
IParagraphPreprocessAiClient aiClient)
{
if (string.IsNullOrWhiteSpace(paragraph))
{
return new ParagraphPreprocessResult
{
OriginalText = paragraph,
ProcessedText = string.Empty,
StrategyType = StrategyType
};
}
var prompt = $@"请基于以下段落生成{QuestionCount}个核心问题(仅保留问题,每行一个,控制在30字以内):
段落内容:{paragraph}
问题要求:1. 覆盖段落核心信息 2. 符合用户实际提问习惯 3. 中文表述";
var questionsStr = await aiClient.GenerateTextAsync(prompt);
var questions = Regex.Split(questionsStr, @"\r?\n")
.Select(q => q.Trim())
.Where(q => !string.IsNullOrWhiteSpace(q))
.Take(QuestionCount)
.ToList();
var processedText = string.Join(" | ", questions);
return new ParagraphPreprocessResult
{
OriginalText = paragraph,
ProcessedText = processedText,
StrategyType = StrategyType,
Metadata = questions.Select(x => new KeyValueString
{
Key = ParagrahProcessorMetadataType.Question.ToJsonString(),
Value = x
}).ToArray()
};
}
}
原文的切片:
# 代码质量思考
要写出一个好的项目,从技术上除了基础编程代码外,还有要了解设计模式、软件工程、DDD 相关的知识点,因为编码很多时候是一个工程问题,就像领域驱动设计(DDD),我们如何对代码进行抽象?如何设计同上下文对象的内聚和降低不同上下文的耦合,如何兼顾程序性能和代码阅读性。
另外,每个人的编码习惯也是不一样的,当我们编写项目时能不能也让别人很容易地看懂代码?让新人加入开发时,迅速掌握?项目也是由一点点代码积累起来的,代码质量很多时候会影响到程序的健壮性,是否能够便于维护项目。
当谈到代码质量,我们去百度一下相关的文章很多,但是可能看来看去,似乎都是命名规范之类的,这些资料看多了,其实千篇一律,不一能够真正提高代码质量。前面说到,编码很多时候是个工程问题,我们要提高代码质量,那么要从软件工程的角度去看待,去分析。
首先,我们可以从四个维度去考虑代码质量:
要求模型对切片生成两个问题,模型返回结果如下:
1. 如何有效运用DDD提高代码质量?
2. 怎样设计代码以便于他人理解和维护?
MoAI 的批量知识库流程读取的是 Metadata,所以每个问题都会单独生成一条向量,而不是把拼接后的整行拿去向量化。
问题数量不是越多越好。生成太多相似问题,既增加向量数量,也可能让同一个 Chunk 在检索时反复被命中。
用户往往会这样提问:
使用 DDD 提高代码质量
怎么使用 DDD 提高代码质量
代码怎么写方面理解和维护
这样对用户问题检索时,跟原文切片差异很大,得分并不高。但是会生成的两个元数据问题在语义上关联度比较高,所以先检索到元数据,然后通过元数据召回原文切片,这样可以大大提升检索能力。
关键词与摘要融合
有些内容既依赖精确术语,又需要表达段落主题。比如一段讲 “访问令牌” 的配置,用户可能直接搜 “token 过期”,也可能搜 “登录怎么失效”。
关键词负责命中术语,摘要负责概括语义,原文片段负责补充细节。
这个策略会调用模型两次,第一次提取关键词,第二次生成摘要,再把原文前一百个字符附加到结果里。
public class KeywordSummaryFusionStrategy : IParagraphPreprocessStrategy
{
public int KeywordCount { get; set; } = 5;
public PreprocessStrategyType StrategyType =>
PreprocessStrategyType.KeywordSummaryFusion;
public async Task<ParagraphPreprocessResult> ProcessAsync(
string paragraph,
IParagraphPreprocessAiClient aiClient)
{
if (string.IsNullOrWhiteSpace(paragraph))
{
return new ParagraphPreprocessResult
{
OriginalText = paragraph,
ProcessedText = string.Empty,
StrategyType = StrategyType
};
}
var keywordPrompt = $@"请提取以下段落的{KeywordCount}个核心关键词(用逗号分隔,仅保留关键词):
段落内容:{paragraph}";
var keywordsStr = await aiClient.GenerateTextAsync(keywordPrompt);
var keywords = keywordsStr.Split(',')
.Select(k => k.Trim())
.Where(k => !string.IsNullOrWhiteSpace(k))
.Take(KeywordCount)
.ToList();
var summaryPrompt = $@"请为以下段落生成精简摘要(控制在80字以内):
段落内容:{paragraph}";
var summary = await aiClient.GenerateTextAsync(summaryPrompt);
summary = Regex.Replace(summary, @"\s+", " ").Trim();
var processedText = $"关键词:{string.Join(",", keywords)} | 摘要:{summary} | 核心内容:{GetMainContent(paragraph, 100)}";
return new ParagraphPreprocessResult
{
OriginalText = paragraph,
ProcessedText = processedText,
StrategyType = StrategyType,
Metadata = new KeyValueString[]
{
new KeyValueString
{
Key = ParagrahProcessorMetadataType.Keyword.ToJsonString(),
Value = string.Join(",", keywords)
},
new KeyValueString
{
Key = ParagrahProcessorMetadataType.Summary.ToJsonString(),
Value = summary
}
}
};
}
private string GetMainContent(string text, int maxLength)
{
if (text.Length <= maxLength)
{
return text;
}
return text[..maxLength] + "...";
}
}
原文切片:
# 代码质量思考
要写出一个好的项目,从技术上除了基础编程代码外,还有要了解设计模式、软件工程、DDD 相关的知识点,因为编码很多时候是一个工程问题,就像领域驱动设计(DDD),我们如何对代码进行抽象?如何设计同上下文对象的内聚和降低不同上下文的耦合,如何兼顾程序性能和代码阅读性。
另外,每个人的编码习惯也是不一样的,当我们编写项目时能不能也让别人很容易地看懂代码?让新人加入开发时,迅速掌握?项目也是由一点点代码积累起来的,代码质量很多时候会影响到程序的健壮性,是否能够便于维护项目。
当谈到代码质量,我们去百度一下相关的文章很多,但是可能看来看去,似乎都是命名规范之类的,这些资料看多了,其实千篇一律,不一能够真正提高代码质量。前面说到,编码很多时候是个工程问题,我们要提高代码质量,那么要从软件工程的角度去看待,去分析。
首先,我们可以从四个维度去考虑代码质量:
生成关键词:
代码质量,设计模式,领域驱动设计,软件工程,代码阅读性
生成摘要:
要提升代码质量,不仅需掌握编程基础,还需了解设计模式、软件工程和领域驱动设计等知识。编码是工程问题,应注重代码抽象、上下文对象设计与低耦合、性能与可读性平衡。此外,编写规范易读的代码有助于维护和新成员快速上手。
这个例子能看出一个细节<Metadata 里只有关键词和摘要,没有核心内容。因为核心内容是从原文直接截取的,写进 ProcessedText 即可;而关键词和摘要是模型生成的派生数据,需要分别保存和向量化。
对用户问题检索时,例如用户提问 怎么使用 DDD 提高代码质量,我们可以使用 AI 模型先对用户问题提取关键词,获得 DDD、领域驱动设计、代码质量,然后我们使用这些关键词去检索元数据,而不是直接对 怎么使用 DDD 提高代码质量 这个问题去检索。这样我们召回的元数据会更加精准和吻合,最后使用元数据召回原文切片即可。