Memory向量记忆系统4-文本向量化

135 阅读9分钟

需要先将文本切成片,然后才能做向量化。

一、切分方案

不同的类型使用的切分方案不一样,像文本,我们以前尽量按照标题、结构切分,代码按照AST解析切分。但说实话,随着模型能力、上下文增加,工程手段带来的收益可能也没那么大了。

文本切分方案对比

方案原理优点缺点适用场景工具
固定长度切分按字符/token数硬切简单、快、好实现容易切断语义快速demo、简单文本CharacterTextSplitter
递归字符切分按优先级尝试不同分隔符(段落→句子→词)比固定长度智能,尽量保持语义还是比较粗暴大多数通用场景(默认首选)RecursiveCharacterTextSplitter
按句子切分按句号/问号/感叹号切每个chunk是完整句子句子长短不一对语义完整性要求高spaCy、NLTK、jieba
按标题/结构切分按Markdown/HTML标题层级切利用文档结构,语义边界清晰要求文档有规范结构技术文档、知识库、书籍MarkdownHeaderTextSplitter
语义切分用embedding判断语义相似度突变点最智能,真正按语义边界切慢、费钱、调参麻烦追求质量、预算充足SemanticChunker
滑动窗口chunk之间有重叠避免边界信息丢失有冗余,存储计算量增加几乎所有场景都建议加各种splitter都支持
父子切分小chunk检索+大chunk回答兼顾召回精度和上下文完整实现复杂,存储翻倍生产级RAG,追求效果ParentDocumentRetriever

代码切分方案对比

方案原理优点缺点适用场景工具
按函数/方法切每个函数/方法一个chunk完整功能单元,语义完整函数大小差异大大多数代码检索(推荐)tree-sitter、AST
按类切分每个类一个chunk完整逻辑单元大类可能太长中小型类tree-sitter
按文件切分整个文件一个chunk最简单,上下文最完整大文件超长,粒度粗小文件、配置文件、工具类直接读文件
AST解析切分用语法树递归切(类→方法→逻辑块)最智能,严格按语法边界实现复杂,每种语言单独处理生产级代码检索(推荐)tree-sitter、LSP
行级+上下文按行切+带前后几行每个chunk都有上下文冗余多代码搜索、定位问题自己实现

切分大小参考

场景chunk大小(token)重叠比例
问答/FAQ100 ~ 30010%
普通文章300 ~ 50010~20%
技术文档500 ~ 100015~20%
长文档/书籍1000 ~ 200020%
代码(函数级)按函数,一般 100~500带前后几行
代码(文件级)小文件直接用,大文件按函数切-

最佳实践总结

维度建议
文本首选递归字符切分 + 10~20% 重叠
代码首选tree-sitter 按函数/方法切
追求效果父子切分(小chunk检索,大chunk回答)
有结构文档按标题切,带上标题路径元数据
一定要做加重叠、带元数据(来源/标题/路径)
别做固定长度硬切、切太小导致语义不完整

二、AST介绍

AST(Abstract Syntax Tree,抽象语法树)就是把代码解析成一棵树状结构,每个节点代表代码里的一个语法元素(函数、变量、表达式等)。

AST 是什么?

大白话:把代码从"一串文字"变成"一棵有结构的树",让程序能理解代码的语法结构。

比如这段代码:

def add(a, b):
    return a + b

它的 AST 结构大概是这样:

FunctionDef (函数定义)
├── name: "add"
├── args: [a, b]
└── body:
    └── Return (返回语句)
        └── BinOp (二元运算)
            ├── left: Name("a")
            ├── op: Add (+)
            └── right: Name("b")

具体实例

我们用 Python 自带的 ast 模块来实际看一下:

输入代码

import ast

code = """
def greet(name):
    message = "Hello, " + name
    print(message)
    return message
"""

解析成 AST

tree = ast.parse(code)
print(ast.dump(tree, indent=2))

输出的 AST 结构

Module(
  body=[
    FunctionDef(
      name='greet',
      args=arguments(
        args=[
          arg(arg='name')
        ]
      ),
      body=[
        Assign(
          targets=[Name(id='message')],
          value=BinOp(
            left=Constant(value='Hello, '),
            op=Add(),
            right=Name(id='name')
          )
        ),
        Expr(
          value=Call(
            func=Name(id='print'),
            args=[Name(id='message')]
          )
        ),
        Return(
          value=Name(id='message')
        )
      ]
    )
  ]
)

对应关系

代码AST 节点
def greet(name):FunctionDef 节点
name 参数arg 节点
message = "Hello, " + nameAssign 节点(赋值)
"Hello, " + nameBinOp 节点(二元运算)
print(message)Call 节点(函数调用)
return messageReturn 节点(返回)

AST 能用来干嘛?

1. 代码切分(之前聊的)

按函数、类、方法来切代码,不会切到半截:

# 遍历 AST,找到所有函数
for node in ast.walk(tree):
    if isinstance(node, ast.FunctionDef):
        print(f"找到函数: {node.name}")
        print(f"第 {node.lineno} 行 到 第 {node.end_lineno} 行")

输出:

找到函数: greet
第 2 行 到 第 5 行

2. 代码分析

  • 统计有多少个函数/类
  • 找未使用的变量
  • 检查代码规范

3. 代码转换/生成

  • 自动重构代码
  • 代码格式化(Black 就是基于 AST 的)
  • 代码翻译(Python → JavaScript)

4. 代码搜索

按结构搜,比文本搜精准:

  • 找所有调用了 print 的地方
  • 找所有继承了 BaseModel 的类
  • 找所有名字以 test_ 开头的函数

实战例子:提取所有函数名

import ast

code = """
def add(a, b):
    return a + b

def subtract(a, b):
    return a - b

class Calculator:
    def multiply(self, a, b):
        return a * b
"""

# 解析成 AST
tree = ast.parse(code)

# 遍历所有节点,找函数
functions = []
for node in ast.walk(tree):
    if isinstance(node, ast.FunctionDef):
        functions.append({
            'name': node.name,
            'line': node.lineno,
            'args': [arg.arg for arg in node.args.args]
        })

# 输出结果
for func in functions:
    print(f"函数: {func['name']}")
    print(f"  行号: {func['line']}")
    print(f"  参数: {func['args']}")
    print()

输出:

函数: add
  行号: 2
  参数: ['a', 'b']

函数: subtract
  行号: 5
  参数: ['a', 'b']

函数: multiply
  行号: 9
  参数: ['self', 'a', 'b']

不同语言的 AST 工具

语言AST 工具
Python自带 ast 模块
JavaScriptacorn、babel parser
Javajavaparser
Gogo/ast
C/C++clang AST
多语言通用tree-sitter(推荐,40+ 语言)

和之前聊的关系

之前说的代码切分、找相似代码,AST 就是基础:

  1. 代码切分:用 AST 找到函数/类的边界,精准切分
  2. 代码搜索:用 AST 按结构搜,比文本搜准
  3. 向量化:先按 AST 切成函数,再每个函数做 embedding

一句话总结

AST 就是代码的结构化表示,把"一串文字"变成"一棵树",让程序能理解代码的语法结构。

想玩的话,Python 直接 import ast 就能用,几行代码就能解析和遍历。

三、开源向量模型对比

BGE-M3就挺好,做个向量而已,不用单独花钱。

模型出品方参数维度最大长度语言核心特点优点缺点适用场景
BGE-M3智源研究院568M10248192100+稠密 + 稀疏 + 多向量三合一中文效果好、功能最全、混合检索强模型稍大、CPU 跑慢中文 RAG、混合检索、长文档
Qwen3-Embedding阿里通义0.6B/2B/4B/8B多种32768119多尺寸可选、超长上下文MTEB 榜单第一、长文本强、多尺寸灵活新模型生态稍弱长文档、多语言、追求性能
GTE阿里达摩院base/large768/1024512中英多语轻量高效、平衡好速度快、效果稳、资源要求低输入短、功能单一短文本、嵌入式、资源受限
E5微软small/base/large384/768/1024512中英多语经典老牌、生态成熟稳定、文档多、社区活跃输入短、中文一般英文场景、传统检索
Jina Embeddings v2/v3Jina AI小 / 中 / 大多种8192多语长上下文、多尺寸长文本好、开源可商用中文一般、国内资料少长文档、多语言、海外项目
Nomic EmbedNomic AI137M/274M7688192多语轻量、开放训练数据小而快、完全开放效果不是顶尖轻量部署、边缘设备
Snowflake Arctic-EmbedSnowflake335M/1.3B1024819250+企业级、BGE 兼容Apache 2.0、企业支持、稳定中文一般企业生产、合规要求高
mxbai-embed-largemixedbread.ai335M1024512多语小模型高性能体积小效果好、速度快输入短轻量部署、快速检索
BGE 系列智源研究院small/base/large384/768/1024512中英经典稳定、中文好成熟、文档全、社区大输入短、功能单一中文短文本、传统 RAG
text2vec中文社区base/large768512中文纯中文优化中文效果好、轻量只有中文、功能少纯中文短文本场景

四、向量存储与更新

向量存储的结构、更新方式,各家情况可能不一样。

OpenClaw

像OpenClaw主要是下面的表。

image-20260724133711123.png

-- src/memory/storage.ts - Schema 定义

-- 元数据表:追踪索引状态
CREATE TABLE IF NOT EXISTS meta (
  key   TEXT PRIMARY KEY,
  value TEXT
);

-- 文件表:增量同步的基础
CREATE TABLE IF NOT EXISTS files (
  path      TEXT PRIMARY KEY,           -- ★ 相对于项目根目录的路径
  hash      TEXT NOT NULL,              -- ★ 内容 SHA-256,用于检测变更
  mtime     INTEGER NOT NULL,           -- 修改时间戳
  size      INTEGER NOT NULL            -- 文件大小(字节)
);

-- 文本块表:核心数据存储
CREATE TABLE IF NOT EXISTS chunks (
  id         TEXT PRIMARY KEY,          -- ★ 复合 Hash ID
  file_path  TEXT NOT NULL,
  content    TEXT NOT NULL,             -- 原始文本
  embedding  TEXT,                      -- JSON 编码的向量
  start_line INTEGER,
  end_line   INTEGER,
  hash       TEXT NOT NULL,             -- 内容 Hash
  FOREIGN KEY (file_path) REFERENCES files(path) ON DELETE CASCADE
);

-- 嵌入缓存表:节省 API 调用
CREATE TABLE IF NOT EXISTS embedding_cache (
  content_hash TEXT NOT NULL,
  model        TEXT NOT NULL,
  embedding    TEXT NOT NULL,           -- JSON 编码的向量
  created_at   INTEGER DEFAULT (unixepoch()),
  PRIMARY KEY (content_hash, model)     -- ★ 内容+模型组合唯一
);

-- 向量虚拟表(sqlite-vec)
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(
  id       TEXT PRIMARY KEY,
  embedding FLOAT[1536]                 -- ★ 维度需与模型匹配
);

-- 全文索引虚拟表(FTS5)
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
  id,
  content,
  content='chunks',                     -- ★ 内容来源于 chunks 表
  content_rowid='rowid'
);

其中Chunk的生成方式如下:

// Chunk ID = SHA-256(source:path:startLine:endLine:contentHash:model)
function generateChunkId(params: {
  source: string;      // 数据来源(如 "file")
  path: string;        // 文件路径
  startLine: number;
  endLine: number;
  contentHash: string; // 内容 Hash
  model: string;       // 嵌入模型名
}): string {
  const input = [
    params.source,
    params.path,
    params.startLine,
    params.endLine,
    params.contentHash,
    params.model,
  ].join(":");
  
  return crypto.createHash("sha256").update(input).digest("hex");
}

这种设计有三个好处:

  1. 天然去重:相同内容、相同模型生成的 Chunk ID 必然相同
  2. 模型隔离:切换模型后,ID 不同,不会误用旧向量
  3. 可追溯:从 ID 可以反推出数据来源

自己

像我们可能存文件id、向量、md5、行号、时间、绑定关系这些。

为什么不需要内容?因为拿着行号直接去用户侧找就行。

更新也不需要那么实时,改动的文件重新向量化,老的文件解除绑定关系。

随着模型强大,工程介入的比例会变低。