LanceDB 基础使用:用 TypeScript 完成第一次向量检索
本文是「从零搭建私人 RAG 知识库」专栏的基础篇。上一篇介绍了 Vector Store 的概念和常见产品,这一篇将使用 CorpRAG 采用的 LanceDB,完成文档写入、持久化和语义检索。
前言
我们准备三句话:
text
登录状态保存在 Redis 中。
订单支付成功后会发送通知。
系统使用 Docker 容器部署。
然后搜索:
text
用户会话存在哪里?
最终希望得到:
text
内容:登录状态保存在 Redis 中。
来源:auth.md
为了完成这个最小示例,我们会使用:
- TypeScript:编写程序;
- Ollama:在本地运行模型;
nomic-embed-text:把文本转换成向量;- LanceDB:保存向量并执行相似度检索;
- LangChain.js:连接文档、Embedding 和 LanceDB。
完整流程如下:
text
准备文档
↓
Embedding 生成文档向量
↓
写入 LanceDB
↓
Embedding 生成查询向量
↓
LanceDB 返回最相似的文档
一、为什么使用 LanceDB
LanceDB 是一个面向向量检索的数据库。它可以把数据直接保存在本地目录中,不要求我们先启动一个独立的数据库服务。
对于个人知识库和学习项目,它有几个比较直观的优点:
- 可以嵌入应用运行;
- 本地开发环境搭建简单;
- 同时保存向量、文本和结构化字段;
- 提供 TypeScript SDK;
- 可以通过 LangChain 的 Vector Store 封装使用。
需要注意,LanceDB 和 Ollama 的职责不同:
text
Ollama + Embedding 模型:把文本转换成向量
LanceDB:保存向量并查找相近向量
LanceDB 不会自己理解自然语言,也不会生成最终答案。
二、准备运行环境
1. 安装依赖
创建一个 Node.js 项目后,安装以下依赖:
bash
npm install @lancedb/lancedb \
@langchain/community \
@langchain/core \
@langchain/ollama \
@langchain/textsplitters
npm install -D typescript tsx @types/node
本文使用 ESM。可以在 package.json 中加入:
json
{
"type": "module"
}
2. 准备 Ollama
确保本地已经安装并启动 Ollama,然后下载 Embedding 模型:
bash
ollama pull nomic-embed-text
可以通过下面的命令查看本地模型:
bash
ollama list
这里使用的 nomic-embed-text 只负责生成向量,不负责生成聊天回答。
三、运行第一个 LanceDB 示例
创建 lancedb-demo.ts:
ts
import { Document } from "@langchain/core/documents";
import { LanceDB } from "@langchain/community/vectorstores/lancedb";
import { OllamaEmbeddings } from "@langchain/ollama";
const documents = [
new Document({
pageContent: "登录状态保存在 Redis 中。",
metadata: { source: "auth.md" },
}),
new Document({
pageContent: "订单支付成功后会发送通知。",
metadata: { source: "order.md" },
}),
new Document({
pageContent: "系统使用 Docker 容器部署。",
metadata: { source: "deploy.md" },
}),
];
const embeddings = new OllamaEmbeddings({
model: "nomic-embed-text",
baseUrl: "http://localhost:11434",
});
const store = await LanceDB.fromDocuments(documents, embeddings, {
uri: "./vector-data",
tableName: "documents",
mode: "overwrite",
});
const results = await store.similaritySearch(
"用户会话存在哪里?",
1,
);
for (const doc of results) {
console.log("内容:", doc.pageContent);
console.log("来源:", doc.metadata.source);
}
运行程序:
bash
npx tsx lancedb-demo.ts
预期得到类似结果:
text
内容:登录状态保存在 Redis 中。
来源:auth.md
实际结果由模型版本和输入内容决定。如果最相关结果不同,可以增加示例文本,或调整问题后再次观察。
程序运行后,当前目录还会出现:
text
vector-data/
这就是 LanceDB 保存本地数据的目录。
四、代码逐步解释
1. 用 Document 表示文档
LangChain 使用 Document 同时保存正文和元数据:
ts
new Document({
pageContent: "登录状态保存在 Redis 中。",
metadata: {
source: "auth.md",
},
});
其中:
pageContent是参与 Embedding 和检索的正文;metadata是来源、分类等附加信息。
元数据不会替代正文,但可以随检索结果一起返回,帮助我们展示来源:
text
来源:auth.md
真实项目还可以保存:
ts
metadata: {
source: "auth.md",
system: "ext",
docType: "FADR",
}
同一批数据中的字段类型应尽量保持一致。例如,不要让某些记录中的 createdAt 是字符串,另一些记录中却是对象。
2. 创建 Embedding 模型
ts
const embeddings = new OllamaEmbeddings({
model: "nomic-embed-text",
baseUrl: "http://localhost:11434",
});
OllamaEmbeddings 是 LangChain 对 Ollama Embedding API 的封装。
写入文档时,它会调用:
ts
embeddings.embedDocuments(texts);
查询时,它会调用:
ts
embeddings.embedQuery(question);
二者都使用同一个模型,生成的向量才能在同一个语义空间中进行比较。
3. 创建表并写入文档
ts
const store = await LanceDB.fromDocuments(documents, embeddings, {
uri: "./vector-data",
tableName: "documents",
mode: "overwrite",
});
这段代码完成了四件事:
text
读取 pageContent
↓
调用 Embedding 模型生成向量
↓
创建 documents 表
↓
写入文本、向量和元数据
三个配置项的含义是:
uri:LanceDB 数据目录;tableName:表名;mode:创建表时如何处理同名表。
本文使用 overwrite,每次运行都会重建同名表。这适合可以反复执行的教学示例,但不适合直接用于需要保留历史数据的写入流程。
4. 执行相似度检索
ts
const results = await store.similaritySearch(
"用户会话存在哪里?",
1,
);
第二个参数 1 表示返回最相关的一条结果,也就是 Top-1。
内部过程可以概括为:
text
问题文本
↓
生成查询向量
↓
在 documents 表中搜索最近向量
↓
返回对应的 Document
查询返回的是 Document[],因此可以继续读取正文和元数据:
ts
for (const doc of results) {
console.log(doc.pageContent);
console.log(doc.metadata);
}
五、调整 Top-K
如果希望一次返回三条结果,可以把第二个参数改成 3:
ts
const results = await store.similaritySearch(
"系统运行相关信息",
3,
);
输出每条结果:
ts
results.forEach((doc, index) => {
console.log(`第 ${index + 1} 条:${doc.pageContent}`);
console.log(`来源:${doc.metadata.source}`);
});
K 值需要根据实际场景调整:
- K 太小,可能漏掉有用片段;
- K 太大,可能返回更多无关内容;
- 在 RAG 中,结果过多还会占用大模型上下文。
学习阶段可以从 Top-3 开始,再根据实际查询效果进行调整。
六、重新打开已经存在的表
前面的示例在同一个进程中完成写入和查询。真实项目通常会把索引和查询分开:
text
索引程序:读取文档并建立向量表
查询程序:打开已有向量表并执行检索
可以使用 LanceDB SDK 打开已有表,再交给 LangChain 的 LanceDB 封装:
ts
import { connect } from "@lancedb/lancedb";
import { LanceDB } from "@langchain/community/vectorstores/lancedb";
import { OllamaEmbeddings } from "@langchain/ollama";
const embeddings = new OllamaEmbeddings({
model: "nomic-embed-text",
baseUrl: "http://localhost:11434",
});
const connection = await connect("./vector-data");
const table = await connection.openTable("documents");
const store = new LanceDB(embeddings, { table });
const results = await store.similaritySearch(
"应用是如何部署的?",
1,
);
console.log(results[0].pageContent);
console.log(results[0].metadata.source);
预期更容易命中:
text
系统使用 Docker 容器部署。
这里仍然要使用建立索引时的 Embedding 模型。更换模型后,即使成功打开旧表,旧向量与新查询向量也可能无法正确比较。
先检查表是否存在
如果表名来自用户配置或不同业务模块,可以先读取表名:
ts
const connection = await connect("./vector-data");
const tableNames = await connection.tableNames();
if (!tableNames.includes("documents")) {
throw new Error("向量表不存在,请先建立索引");
}
const table = await connection.openTable("documents");
这样可以把数据库底层错误转换成更容易理解的业务提示。
七、增加文档并观察结果
在示例中增加一条安全相关文档:
ts
new Document({
pageContent: "用户密码经过哈希处理后保存。",
metadata: { source: "security.md" },
});
然后分别搜索:
text
密码是怎样存储的?
登录状态保存在哪里?
两个问题都和用户系统有关,但关注点不同。可以观察 Embedding 模型是否能分别找到密码和登录状态对应的内容。
还可以尝试:
- 用不同表达方式提出同一个问题;
- 把 Top-K 从 1 改为 3;
- 增加语义相近的干扰文档;
- 修改元数据并观察返回结果;
- 比较短句与长段落的检索效果。
这些实验可以帮助我们理解:向量数据库只负责搜索,最终效果还取决于文档内容和 Embedding 模型。
八、从短句走向真实文档
真实知识库中的 Markdown 往往很长,不适合直接把整篇文档作为一条记录写入。
如果一篇文档同时包含认证、数据库和部署方案,整篇文档生成的单个向量可能无法准确表达每个局部主题。因此,写入向量库之前通常需要先分块:
text
Markdown 文档
↓
读取正文
↓
按一定大小切分为 Chunk
↓
每个 Chunk 分别生成向量
↓
写入 LanceDB
使用 LangChain 文本分割器时,代码类似:
ts
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
});
const chunks = await splitter.splitDocuments(documents);
其中:
chunkSize控制每个片段的目标大小;chunkOverlap让相邻片段保留一部分重复内容,减少语义在边界处被完全切断的情况。
然后把 chunks 写入 LanceDB:
ts
const store = await LanceDB.fromDocuments(chunks, embeddings, {
uri: "./vector-data",
tableName: "documents",
mode: "overwrite",
});
500 和 50 只是一个起点,并不是适合所有文档的标准答案。后续需要使用真实问题评估检索结果。
九、常见问题
1. 无法连接 Ollama
如果出现连接失败,先确认 Ollama 服务已经启动,并检查地址:
ts
baseUrl: "http://localhost:11434"
还要确认模型已经下载:
bash
ollama list
2. 每次运行后旧数据都消失
示例使用了:
ts
mode: "overwrite"
它会重建同名表。正式项目应明确区分"全量重建索引"和"向已有表增加数据",不要在普通查询流程中执行覆盖写入。
3. 打开表后无法检索
创建 LangChain LanceDB 实例时,需要把打开的 table 传进去:
ts
const store = new LanceDB(embeddings, { table });
只调用 openTable() 并不会自动得到 LangChain Vector Store。
4. 更换模型后结果异常
文档向量和查询向量必须来自同一个 Embedding 模型及兼容配置。更换模型后通常需要重建索引。
5. 最相似结果不符合预期
可以从以下方面排查:
- 文档是否包含足够明确的信息;
- 文本分块是否过大或过小;
- Embedding 模型是否适合当前语言和领域;
- 问题表达是否过于模糊;
- Top-K 是否太小;
- 是否存在大量语义相近的干扰内容。
向量检索返回的是"模型认为最接近的内容",并不保证结果一定正确。
十、在 RAG 中如何使用
目前我们完成的是检索部分:
text
问题
↓
LanceDB
↓
相关文档
完整 RAG 还会把检索结果交给大模型:
text
用户问题
↓
LanceDB 检索相关片段
↓
构造"问题 + 相关片段"
↓
大模型根据片段组织答案
LanceDB 的职责到"返回相关片段"为止。它不会自行判断最终答案,也不会代替大模型生成自然语言回复。
在 CorpRAG 中,这些能力会进一步拆分为:
text
文档加载 → 文本分块 → Embedding → LanceDB → Retriever → MCP 工具
掌握本篇的最小示例后,再阅读项目代码时,就能更容易理解每一层的职责。
总结
本文完成了 LanceDB 最基础的使用流程:
text
准备 Document
↓
通过 Ollama 生成向量
↓
使用 LanceDB.fromDocuments() 建立向量表
↓
使用 similaritySearch() 执行语义检索
↓
返回正文和来源
需要记住的核心结论有:
- LanceDB 负责保存和检索向量,不负责生成向量;
- LangChain 的
Document同时保存正文和元数据; fromDocuments()会生成向量并创建向量表;similaritySearch()会把问题向量化并返回 Top-K 个结果;- 索引和查询必须使用同一个 Embedding 模型;
overwrite适合重建索引,不适合无条件用于日常增量写入;- 真实长文档通常需要先分块,再写入 LanceDB。
最后用一句话概括:
Ollama 和 Embedding 模型负责生成语义坐标,LanceDB 负责保存坐标,并找出与问题最接近的文档。