CocoIndex Rust 示例深度解析:用 Whisper 把音频文件夹增量转写成 Postgres 索引表
【免费下载链接】cocoindexIncremental engine for long horizon agents 🌟 Star if you like it!项目地址: https://gitcode.com/GitHub_Trending/co/cocoindex
本篇技术指南围绕 CocoIndex 仓库中的 Rust 示例 audio_to_text 展开,讲解如何用一段约 150 行的 Rust 程序,把本地目录中的音频文件逐个交给 OpenAI Whisper(whisper-1)转写,并以文件名为主键、每个文件一行地写入 Postgres。读完本文,你将掌握 CocoIndex Rust SDK 的完整调用链:fs::walk_items目录遍历、#[cocoindex::function]组件声明、ApiTranscriber转写、mount_table_target托管目标,以及增量处理(未变更文件免转写、删除文件自动清理孤儿行)的底层原理。
示例定位:把"死音频"变成可查询的转写表
一个存放语音备忘录、会议录音、播客片段的文件夹,在没有转写文本之前几乎是"死数据"。本示例的核心思路极其简洁——不做分块(chunking),每个文件对应一行:
- Walk:递归遍历本地目录,按常见音频扩展名(
.mp3、.wav、.m4a、.flac、.ogg、.webm、.aac、.aiff)过滤文件; - Transcribe:用 OpenAI Whisper(默认模型
whisper-1)转写每个文件; - Store:在 Postgres 中为每个文件写入一行
AudioTranscription,以filename为主键。
最终得到的表coco_examples.audio_transcriptions只有两列filename(主键)和text。由于主键就是文件名,这张表同时充当"哪些文件已被转写"的索引——无需任何额外的记账表。你可以直接对它做 SQL 查询、JOIN 其他表,或者把它喂给后续的 embedding 管线。
示例的默认输入目录 examples/rust/audio_to_text/audio_files 内置了两个测试音频(greeting.wav、pipeline_note.wav),clone 仓库后即可直接运行验证。
与 Python 示例的平行对照
本示例是仓库内 Python 示例 examples/audio_to_text 的 Rust 移植版。官方 README 给出了四行核心对照,四个关注点(来源、逐文件计算、转写、目标)在两个语言实现中一一对应:
| 关注点 | Python | Rust(本示例) |
|---|---|---|
| 数据源 | localfs.walk_dir | cocoindex::fs::walk |
| 逐文件计算 | @coco.fn(memo=True) process_file | #[cocoindex::function(memo)] transcribe |
| 转写服务 | LiteLLMTranscriber("whisper-1") | OpenAI/v1/audio/transcriptions(whisper-1) |
| 目标端 | postgres.mount_table_target | postgres::mount_table_target |
从实现层面看,Python 版在 examples/audio_to_text/main.py 中用@coco.fn(memo=True)修饰process_file,Rust 版在 examples/rust/audio_to_text/src/main.rs 中用#[cocoindex::function]修饰transcribe,两者语义相同:函数级 memo 使未变更的音频文件绝不会被重复转写。
一个值得注意的差异:Python 版通过LiteLLMTranscriber走 LiteLLM 的转写接口(模型字符串可随意换成elevenlabs/scribe_v1或自托管端点),而 Rust 版直接调用 OpenAI 兼容的/v1/audio/transcriptionsHTTP 接口(由 SDK 的ApiTranscriber封装)。两种实现的增量语义完全一致。
源码剖析:端到端管线是怎么拼起来的
依赖与特性开关
先看 Cargo.toml。示例依赖本地路径的 SDK crate,并通过feature 开关按需启用能力:
cocoindex = { path = "../../../rust/sdk/cocoindex", features = [ "postgres", "embed_api", ] } tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } dotenvy = "0.15"postgres:启用 Postgres 目标端(postgres::mount_table_target、TableTarget、TableSchema);embed_api:拉入 SDK 的ops::api::ApiTranscriber(Whisper 转写能力),示例不再手写 HTTP 请求;dotenvy:启动时自动加载.env文件,便于管理OPENAI_API_KEY、POSTGRES_URL。
这种特性开关设计是 CocoIndex SDK 的一贯做法:不用的连接器不会进入编译产物。
目录遍历:cocoindex::fs::walk与walk_items
示例用 src/main.rs 定义匹配模式:
const AUDIO_PATTERNS: &[&str] = &[ "**/*.aac", "**/*.aiff", "**/*.flac", "**/*.m4a", "**/*.mp3", "**/*.ogg", "**/*.wav", "**/*.webm", ];然后调用walk_items(&sourcedir, AUDIO_PATTERNS)?得到Vec<(String, FileEntry)>——每个元素是"稳定 key + 文件条目"。其底层实现位于 rust/sdk/cocoindex/src/fs.rs:walk用walkdir递归遍历并依据 glob 模式过滤文件,walk_items再为每个文件计算稳定 key。从 fs.rs 可见 key 就是相对路径(正斜杠分隔),例如sub/recording.mp3;文件按相对路径排序返回,保证遍历结果确定性。
FileEntry提供两类关键 API(fs.rs):
content():读取文件原始字节(转写需要原始音频);relative_path()/key():用于拼转写上传文件名与目标行主键;stem():去除扩展名的文件名。
转写核心:ApiTranscriber
src/main.rs 中的transcribe函数把转写封装成一个 memo 化的组件函数:
#[cocoindex::function] async fn transcribe(_ctx: &Ctx, file: &FileEntry) -> Result<String> { let bytes = file.content()?; // Whisper 通过上传文件名的扩展名嗅探音频容器, // 因此必须保留扩展名再上传。 let name = file .relative_path() .file_name() .and_then(|n| n.to_str()) .unwrap_or("audio") .to_string(); let mut transcriber = ApiTranscriber::new(TRANSCRIBE_MODEL); // "whisper-1" if let Ok(base_url) = std::env::var("OPENAI_BASE_URL") { transcriber = transcriber.with_base_url(base_url); } transcriber.transcribe_bytes(bytes, name).await }ApiTranscriber定义在 rust/sdk/cocoindex/src/ops/api.rs,其行为要点如下(均有源码可证):
ApiTranscriber::new(model)默认使用 OpenAI 基础 URL,并从OPENAI_API_KEY环境变量读取密钥(api.rs);with_base_url()可覆盖端点,因此示例支持OPENAI_BASE_URL环境变量以对接OpenAI 兼容的自托管端点(api.rs);此外还有with_api_key()显式指定密钥、with_language()传入 ISO-639-1 语言提示(如"en")等可选方法;transcribe_bytes(bytes, filename)以 multipart 表单 POST 到{base_url}/audio/transcriptions,字段为model与file,带 Bearer 认证,返回 JSON 中的text(api.rs)。
关键细节:Whisper 服务端靠上传文件名的扩展名嗅探音频容器格式,所以示例特意从relative_path()取出带扩展名的文件名再上传——若统一传audio这样的名字,.wav之外的格式可能识别失败。
Postgres 目标:mount_table_target与TableSchema
src/main.rs 先声明表结构:
fn transcription_schema() -> Result<postgres::TableSchema> { postgres::TableSchema::new( [ ("filename", postgres::ColumnDef::new("text")), ("text", postgres::ColumnDef::new("text")), ], ["filename"], // 主键列 ) }随后在app_main中挂载目标(src/main.rs):
let table = postgres::mount_table_target(&ctx, &DB, TABLE, transcription_schema()?, Some(PG_SCHEMA)) .await?; let files = walk_items(&sourcedir, AUDIO_PATTERNS)?; mount_each!(files, |file| process_audio(ctx, file, table)).await?;TABLE = "audio_transcriptions"、PG_SCHEMA = "coco_examples",最终落库到coco_examples.audio_transcriptions;mount_table_target是托管目标:自动建表、幂等 upsert、以及删除源文件后自动清理孤儿行;mount_each!宏把files列表的每个元素挂载为一个子组件,逐一执行process_audio。
process_audio是真正的"每文件处理"组件(src/main.rs):先调用 memo 化的transcribe拿到文本,再向目标声明一行:
#[cocoindex::function] async fn process_audio(ctx: &Ctx, file: FileEntry, table: postgres::TableTarget) -> Result<()> { let text = transcribe(ctx, &file).await?; table.declare_row( ctx, &AudioTranscription { filename: file.key(), text }, )?; Ok(()) }运行入口:Environment 与 ContextKey
main.rs 中的main展示了 Rust 版的应用装配方式:
let db = postgres::Database::connect(&database_url()).await?; let app = Environment::builder() .db_path(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".cocoindex_db")) .provide_key(&DB, db) .build() .await? .app("AudioToText") .await?; let stats = app.run(move |ctx| app_main(ctx, dir)).await?; println!("{stats}");Environment::builder()构建运行环境,.db_path()指向本地 LMDB 状态库(示例放在.cocoindex_db,用于持久化 memo 与目标状态);ContextKey(示例中的static DB)是跨组件共享的依赖注入句柄,provide_key把 Postgres 连接注册进去。值得注意的是 main.rs 中ContextKey::new_with_state用db.state_id()生成指纹——数据库状态变化会参与 memo 判定;- 命令行第一个参数若为
index则取第二个参数为目录,否则取第一个参数,缺省回退到audio_files。
增量语义:为什么同一段音频不会付两次钱
官方 README 明确了本示例的增量行为,结合源码可以拆成三层机制:
1. 组件 memo 跳过未变更文件。#[cocoindex::function]声明的transcribe是 memo 化的。FileEntry携带文件大小与修改时间元数据(fs.rs),引擎据此计算指纹:文件内容与函数代码都未变 → 直接命中 memo,Whisper 调用不会发生。这意味着反复cargo run时,未改动过的音频零成本跳过。
2. 目标状态对账删除孤儿行。托管TableTarget在每次运行时把"本次声明的目标状态"与"上次运行记录的状态"做对账(reconcile)。当某音频文件从源目录中被删除,本次不再声明对应行,引擎会在 Postgres 中删除这行孤儿数据——表始终与目录内容保持一致。
3. 逻辑变更触发重转写。如果改动了transcribe的代码(例如换了模型),memo 因函数代码指纹变化而失效,CocoIndex 会对受影响文件重新转写,并与 Postgres 中已有内容比对、只应用差异。这就是 README 中所说的"逻辑变更也会对账"。
这套机制的工程价值很直接:语音转写是昂贵的付费 API 调用,增量语义确保你只为自己真正新增/改动的音频买单。
运行指南
前置条件
- 一个可连接的Postgres实例。仓库提供了现成的 compose 文件 dev/postgres.yaml,可用
docker compose -f dev/postgres.yaml up -d启动本地容器; - OpenAI API 密钥(
whisper-1需要),或指向 OpenAI 兼容端点的OPENAI_BASE_URL。
配置环境变量
export POSTGRES_URL=postgres://cocoindex:cocoindex@localhost/cocoindex export OPENAI_API_KEY=...POSTGRES_URL缺省值正是postgres://cocoindex:cocoindex@localhost/cocoindex(见 main.rs 的database_url()),因此若使用默认本地容器可省略第一行。OPENAI_API_KEY由ApiTranscriber::new自动读取,也支持在仓库根目录放置.env(dotenvy会自动加载)。
运行
cargo run # 遍历 ./audio_files -> 转写 -> 写入 Postgres cargo run -- /path/to/audio # 指定自定义源目录首次运行会转写目录下所有匹配的音频;再次运行则只处理新增、修改、删除的文件。结束后控制台会打印本次运行的统计信息(println!("{stats}"))。
验证结果
用普通 SQL 即可查看转写结果:
psql "$POSTGRES_URL" -c \ 'SELECT filename, left(text, 200) AS preview FROM coco_examples.audio_transcriptions ORDER BY filename;'输出形如:每个音频一行,filename是主键、text是转写文本。
扩展指引:按需改造这个示例
基于上面的调用链,你可以低成本地把它改造成自己的管线:
- 换模型/换端点:把
TRANSCRIBE_MODEL常量从"whisper-1"改成其他 OpenAI 兼容模型,或用with_base_url指向自托管 Whisper 服务(示例已支持OPENAI_BASE_URL环境变量); - 换音频范围:调整
AUDIO_PATTERNS中的 glob 列表,或给walk_items传入不同的目录与模式; - 加列/换主键:修改
AudioTranscription结构体与transcription_schema(),例如增加duration、language列,或改用其他稳定键; - 接其他目标:SDK 的
mount_table_target同样存在于 lancedb.rs、doris.rs 等模块中,可把转写结果导入向量库等下游存储; - 对照 Python 实现:两个语言版本的语义一一对应,阅读 examples/audio_to_text/main.py 与 examples/audio_to_text/README.md 可同时掌握两种写法。
小结
audio_to_text是理解 CocoIndex Rust SDK 增量语义的最佳入门示例:数据源(fs::walk)、逐文件计算(#[cocoindex::function]+ memo)、昂贵外部调用(ApiTranscriber)与托管目标(postgres::mount_table_target)各司其职,15 行核心管线代码背后是完整的指纹、memo 与目标对账机制。读懂这个示例,你就能举一反三,把"音频 → 文本 → Postgres"的管线模式迁移到任意"文件 → LLM/模型调用 → 目标存储"的场景。
【免费下载链接】cocoindexIncremental engine for long horizon agents 🌟 Star if you like it!项目地址: https://gitcode.com/GitHub_Trending/co/cocoindex
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考