元数据生成

本文只讲思路,代码是从 MoAI 里面抽出来的,不是单独完整的 Demo,你是跑不通的,只需要理解本章的案例即可,不需要亲自跑代码。

上一章把整篇文本按 Token 拆成了有序的 Chunk。到这一步,每个 Chunk 都具备直接向量化的条件,但如果就这样把原文送进 Embedding 模型,召回效果不一定理想,原因在于搜索的表述方式和原文并不总是一致。

文档里的句子通常完整、正式,用户搜索时却习惯用简称、口语甚至一个疑问句。比如原文写的是:

系统使用访问令牌完成身份验证,令牌的默认有效期为十分钟。


用户可能会直接搜 “登录状态多久过期”,也可能只输入 “Token 过期时间” 。说的是同一件事,用词和句式却不一样。Embedding 模型能理解一部分语义相似性,却不能保证这段原文每次都排在最前面。


于是 MoAI 在 “切片” 和 “向量化” 之间插进了一步元数据生成。这里说的元数据不是文件大小、创建时间这类属性,而是根据原文生成的提纲、问题、关键词、摘要和语义子段。原始 Chunk 继续负责提供完整内容,生成的元数据负责让这段内容更容易被检索到。

这不是用模型改写原文再把原文扔掉,而是为同一段内容准备多个检索入口。检索命中问题或摘要后,仍然能沿着 ChunkId 找回原始 Chunk,把完整内容交给模型。

原始 Chunk
    ├── 提纲
    ├── 用户可能提出的问题
    ├── 关键词和摘要
    └── 按语义重新聚合的子段
             ↓
        分别生成向量
             ↓
        通过 ChunkId 关联原文

image-20260825094329608

image-20260825094410461


原文是一份,检索入口却可以有很多个

最省事的做法,是把模型生成的提纲当作 “改写后的原文”,向量化和召回都只依赖这一份改写结果。这样做的代价是原内容被压缩掉了,提纲或摘要里没写到的细节,检索命中后照样拿不回来。

也就是说,除了可以把原文切片向量化,还可以把原文生成大纲、问题、关键词、摘要等,那么检索时入口有多个,最后把原文召回即可。

MoAI 对切片生成的元数据示例:

image-20260825094734589


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 提高代码质量 这个问题去检索。这样我们召回的元数据会更加精准和吻合,最后使用元数据召回原文切片即可。