bgeEmbedder.ts 的源代码:
/**
* BgeEmbeddingFunction —— BGE 中文 embedding 适配器
* ============================================================
*
* 继承官方 @chroma-core/default-embed 的 DefaultEmbeddingFunction,
* 补一个 generateForQueries 方法处理 BGE 查询前缀。
*
* 【BGE 的前缀规则】(非对称检索模型特性)
* - 文档端(入库):不加前缀,原文向量化
* - 查询端(检索):加官方前缀 "为这个句子生成表示以用于检索相关文章:"
* 不加前缀,模型不知道当前是"查询",检索质量打折。
*
* 【chromadb 的自动分流机制】(源码:chromadb.mjs:3976-3979 的 embed() 方法)
* - collection.add() 调 generate()(文档端,不加前缀)
* - collection.query() 优先调 generateForQueries()(查询端,加前缀)
* 所以只要实现 generateForQueries,chromadb 内部自动分流,调用方无感。
*
* 【一致性约束】
* 写入端和查询端必须用同一个模型名 + dtype,否则向量空间不匹配。
* 换模型 = 必须重建整个库。
*/
import { DefaultEmbeddingFunction } from '@chroma-core/default-embed'
/** BGE 官方查询前缀(中文版,必须一字不差) */
export const BGE_QUERY_PREFIX = '为这个句子生成表示以用于检索相关文章:'
export class BgeEmbeddingFunction extends DefaultEmbeddingFunction {
/**
* 查询路径:chromadb query 时自动调用,加 BGE 前缀。
* 给每条 query 拼上前缀,再交给父类的 generate(它已封装好向量化全流程)。
*/
async generateForQueries(texts: string[]): Promise<number[][]> {
return super.generate(texts.map(t => BGE_QUERY_PREFIX + t))
}
// generate()(文档端)直接继承父类,不加前缀
}
loadNovelsFromDir.ts 的源代码:
import { readdirSync, readFileSync } from 'node:fs'
import { join, basename, extname } from 'node:path'
export interface NovelDocument {
id: string
source: string
text: string
}
/**
* 读取目录下所有 .txt 文件,组装成 NovelDocument 数组
* - id:文件名去掉 .txt 扩展名(同时作为 chunk id 前缀和 metadata.book)
* - source:原始文件名(含 .txt)
* - text:文件全文,utf-8 解码
*
* 仅处理 .txt,其他文件忽略;目录为空时返回空数组。
*/
export function loadNovelsFromDir(dir: string): NovelDocument[] {
const entries = readdirSync(dir, { withFileTypes: true })
const docs: NovelDocument[] = []
for (const entry of entries) {
// 只处理文件,跳过子目录
if (!entry.isFile()) continue
if (extname(entry.name).toLowerCase() !== '.txt') continue
const fullPath = join(dir, entry.name)
const text = readFileSync(fullPath, 'utf-8')
const id = basename(entry.name, extname(entry.name))
docs.push({ id, source: entry.name, text })
}
return docs
}
simpleChroma.ts 的源代码:
/**
* SimpleChroma —— 自封装的 Chroma LangChain 向量库集成(生产级)
* ============================================================
*
* 【对齐的官方 VectorStore 契约】(不继承抽象类,但接口对齐——鸭子类型)
* - addDocuments:写入(支持增量去重)
* - similaritySearch / similaritySearchWithScore:检索
* - delete:按 ids 或 filter 删除
* - asRetriever:转 Runnable,能接 LCEL 链
* 额外补了 count(查总数,增量逻辑要用)
*
* 【核心设计:embedding 模型一致性】
* 写入和查询必须用同一个 embedding 模型 + 同一个 dtype。
* 本类接受任意 EmbeddingFunction(chromadb 的接口),只要写入端和查询端传同一个实例。
* 配套的 BgeEmbeddingFunction 见本文件底部(或 8.3/8.4 各自有一份)。
*
* 【增量去重】
* addDocuments 支持传 ids 参数。如果传了,会先查集合里已存在的 id,跳过已存在的,
* 只写入新的。这样重复运行不会产生重复 chunk(解决"每次跑都新增"的问题)。
*/
import { ChromaClient, type Collection, type Where } from 'chromadb'
import { Document } from '@langchain/core/documents'
import { Runnable, RunnableLambda } from '@langchain/core/runnables'
// ============================================================================
// 配置类型
// ============================================================================
/** SimpleChroma 构造配置 */
export interface SimpleChromaConfig {
/** Chroma 服务 host */
host: string
/** Chroma 服务 port */
port: number
/** Basic Auth 凭据(明文,内部会转 base64) */
username: string
password: string
/** 集合名 */
collectionName: string
/** embedding 函数(写入和查询必须用同一个实例) */
embedder: {
generate(texts: string[]): Promise<number[][]>
generateForQueries?(texts: string[]): Promise<number[][]>
}
/** 是否用 ssl(默认 false) */
ssl?: boolean
}
/** addDocuments 的选项 */
export interface AddDocumentsOptions {
/**
* 每个 doc 的 id。如果传了,会做增量去重(已存在的 id 跳过)。
* 如果不传,自动用 `chunk_${timestamp}_${i}` 生成(不去重,每次都新增)。
*/
ids?: string[]
}
/** delete 的参数:ids 和 filter 至少传一个 */
export interface DeleteParams {
/** 按 id 删除(精确删除指定 chunk) */
ids?: string[]
/** 按 metadata 条件删除(Chroma Where 语法,如 { source: 'xxx.txt' } 或 { $or: [...] }) */
filter?: Where
}
// ============================================================================
// SimpleChroma 主类
// ============================================================================
/**
* 自封装的 Chroma LangChain 向量库集成。
*
* 用法:
* ```ts
* const store = new SimpleChroma({
* host: '192.168.31.249', port: 7777,
* username: 'admin', password: '123456',
* collectionName: 'my_kb',
* embedder: new BgeEmbeddingFunction({ modelName: 'Xenova/bge-small-zh-v1.5', dtype: 'fp16' }),
* })
*
* // 建库(增量去重)
* await store.addDocuments(docs, { ids: docs.map((_, i) => `chunk_${i}`) })
*
* // 检索
* const hits = await store.similaritySearchWithScore('问题', 3)
*
* // 删除
* await store.delete({ filter: { source: 'xxx.txt' } })
*
* // 接 LCEL 链
* const retriever = store.asRetriever(3)
* const chain = retriever.pipe(...)
* ```
*/
export class SimpleChroma {
private client: ChromaClient
private collectionName: string
private embedder: SimpleChromaConfig['embedder']
private collectionCache?: Collection
constructor(config: SimpleChromaConfig) {
this.collectionName = config.collectionName
this.embedder = config.embedder
const auth = Buffer.from(`${config.username}:${config.password}`).toString('base64')
this.client = new ChromaClient({
ssl: config.ssl ?? false,
host: config.host,
port: config.port,
headers: { Authorization: `Basic ${auth}` },
})
}
/** 拿到(或创建)集合,结果缓存避免重复请求 */
private async getCollection(): Promise<Collection> {
if (this.collectionCache) return this.collectionCache
try {
this.collectionCache = await this.client.getCollection({ name: this.collectionName })
} catch {
this.collectionCache = await this.client.getOrCreateCollection({ name: this.collectionName })
}
return this.collectionCache
}
/** 重置集合缓存(delete 等操作后集合引用可能失效时调用) */
private resetCollectionCache() {
this.collectionCache = undefined
}
/**
* 查询集合中已存在的 id 集合(用于增量去重)。
* 注意:这里拉所有 id 到内存。如果集合非常大(百万级),需改用更分批的策略。
*/
private async getExistingIds(): Promise<Set<string>> {
const collection = await this.getCollection()
const result = await collection.get()
return new Set(result.ids ?? [])
}
/**
* ✅ 官方契约:写入文档(建库用)。
*
* @param docs LangChain Document 数组
* @param options.ids 可选,传了会增量去重(已存在的 id 跳过)
* @returns 实际写入的 id 数组(已跳过已存在的)
*/
async addDocuments(docs: Document[], options?: AddDocumentsOptions): Promise<string[]> {
const collection = await this.getCollection()
// 确定 id:传了用传的,没传自动生成(不去重)
const useDedup = !!options?.ids
const ids = options?.ids ?? docs.map((_, i) => `chunk_${Date.now()}_${i}`)
// 增量去重:过滤掉已存在的 id
let pendingIndices: number[]
let pendingIds: string[]
if (useDedup) {
const existingIds = await this.getExistingIds()
pendingIndices = []
pendingIds = []
for (let i = 0; i < ids.length; i++) {
if (!existingIds.has(ids[i])) {
pendingIndices.push(i)
pendingIds.push(ids[i])
}
}
if (pendingIndices.length === 0) {
return [] // 全部已存在,无需写入
}
} else {
pendingIndices = docs.map((_, i) => i)
pendingIds = ids
}
const pendingDocs = pendingIndices.map(i => docs[i])
const texts = pendingDocs.map(d => d.pageContent)
const vectors = await this.embedder.generate(texts)
// 过滤 metadata:Chroma 只支持 string/number/boolean 基本类型
// RecursiveCharacterTextSplitter 会加 loc(对象,记录行号),要剔除
const cleanMetadatas = pendingDocs.map(d => filterBasicMetadata(d.metadata))
await collection.add({
ids: pendingIds,
documents: texts,
embeddings: vectors,
metadatas: cleanMetadatas,
})
return pendingIds
}
/**
* ✅ 官方契约:检索(带距离)。距离越小越相关。
* 查询端如果 embedder 实现了 generateForQueries,会调用它(BGE 用来加查询前缀)。
*/
async similaritySearchWithScore(query: string, k: number): Promise<[Document, number][]> {
const collection = await this.getCollection()
// 查询端:优先用 generateForQueries(BGE 加前缀),没有就 fallback 到 generate
const queryVectors = this.embedder.generateForQueries
? await this.embedder.generateForQueries([query])
: await this.embedder.generate([query])
const queryVector = queryVectors[0]
const result = await collection.query({
queryEmbeddings: [queryVector],
nResults: k,
})
// chromadb v3 返回 QueryResult 类:documents/metadatas/distances 都是二维数组(取 [0])
const docs = result.documents[0] ?? []
const metas = result.metadatas[0] ?? []
const dists = result.distances[0] ?? []
const hits: [Document, number][] = []
for (let i = 0; i < docs.length; i++) {
const text = docs[i]
if (text == null) continue
hits.push([
new Document({
pageContent: text,
metadata: (metas[i] as Record<string, unknown>) ?? {},
}),
dists[i] ?? Infinity,
])
}
return hits
}
/** ✅ 官方契约:检索(不带距离),返回纯 Document 数组。 */
async similaritySearch(query: string, k: number): Promise<Document[]> {
const hits = await this.similaritySearchWithScore(query, k)
return hits.map(([doc]) => doc)
}
/**
* ✅ 官方契约:删除文档。支持按 ids 或按 filter 删除,至少传一个。
*/
async delete(params: DeleteParams): Promise<void> {
const collection = await this.getCollection()
if (params.ids && params.ids.length > 0) {
await collection.delete({ ids: params.ids })
} else if (params.filter) {
await collection.delete({ where: params.filter })
} else {
throw new Error('delete 至少要传 ids 或 filter 之一')
}
}
/**
* ✅ 官方契约:转 Runnable(对齐 asRetriever)。
* 用 RunnableLambda 包一层 similaritySearch,返回的 Runnable 能 pipe 进 LCEL 链。
* 输入:查询字符串;输出:Document[]。
*/
asRetriever(k = 3): Runnable<string, Document[]> {
const self = this
return RunnableLambda.from(async (query: string) => {
return self.similaritySearch(query, k)
})
}
/**
* 查询集合中的文档总数。
* 增量逻辑判断、监控统计常用。
*/
async count(): Promise<number> {
const collection = await this.getCollection()
return await collection.count()
}
/**
* 删除整个集合并重置缓存(慎用,不可恢复)。
* 用于"重建知识库"场景:先 dropCollection,再重新 addDocuments。
*/
async dropCollection(): Promise<void> {
try {
await this.client.deleteCollection({ name: this.collectionName })
} catch {
// 集合本来就不存在,忽略
}
this.resetCollectionCache()
}
}
// ============================================================================
// 辅助函数
// ============================================================================
/** 过滤 metadata:只保留 string/number/boolean 基本类型(Chroma 的要求) */
function filterBasicMetadata(meta?: Record<string, unknown>): Record<string, string | number | boolean> {
const clean: Record<string, string | number | boolean> = {}
if (!meta) return clean
for (const [k, v] of Object.entries(meta)) {
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
clean[k] = v
}
}
return clean
}
// ============================================================================
// 配套:BgeEmbeddingFunction(BGE 中文 embedding 适配器)
// ============================================================================
/**
* BGE embedding 适配器:继承官方 DefaultEmbeddingFunction,补一个 generateForQueries。
* - generate() 继承父类:文档端用,不加前缀
* - generateForQueries():查询端用,加 BGE 官方前缀
*
* 必须和写入端用同一个模型名 + dtype,否则向量空间不匹配。
* 建库和查询共用同一个实例。
*/
export { BgeEmbeddingFunction } from './bgeEmbedder'
splitText.ts 的源代码:
interface SplitTextOptions {
/** 每个块的最大字符数(非严格,边界模式可能略少) */
chunkSize?: number
/** 相邻两块之间的重叠字符数,用于保证跨块上下文不丢失 */
overlap?: number
/** 是否在切割时尽量回退到句子/词边界,保证语义单元的完整性 */
respectBoundary?: boolean
/** 边界模式下,允许 end 最多往回退的比例(相对于 chunkSize),防止过度回退产生极小碎块,默认 0.5 */
maxBacktrackRatio?: number
}
/**
* 将长文本切分成多个带有重叠的文本块,专为 RAG(检索增强生成)和向量化场景设计
*
* 核心机制:
* 1. 滑动窗口 + 重叠(overlap),保证上下文连续性
* 2. 支持语义边界回退(句号、换行),避免切断句子
* 3. 严格限制回退范围(maxBacktrackRatio),防止碎块过多导致效率低下
* 4. 完整的边界安全(Unicode 代理对保护、除零/死循环防御)
*/
export function splitText(text: string, options: SplitTextOptions = {}): string[] {
const {
chunkSize = 500,
overlap = 50,
respectBoundary = false,
maxBacktrackRatio = 0.5,
} = options
// --- 1. 防御性校验:彻底杜绝死循环与非法入参 ---
if (!Number.isFinite(chunkSize) || chunkSize <= 0) {
throw new Error(`chunkSize 必须为正数,当前为 ${chunkSize}`)
}
if (!Number.isFinite(overlap) || overlap < 0 || overlap >= chunkSize) {
throw new Error(`overlap 必须满足 0 <= overlap < chunkSize,当前 overlap=${overlap}`)
}
if (text.length === 0) return []
const chunks: string[] = []
// 最大允许回退的字符数(用于边界模式下限制搜索范围)
const maxBacktrack = Math.floor(chunkSize * maxBacktrackRatio)
// 非边界模式下的固定步长
const fixedStep = chunkSize - overlap
let start = 0
while (start < text.length) {
// --- 2. 确定初始结束位置(硬切) ---
let end = Math.min(start + chunkSize, text.length)
// --- 3. Unicode 安全切割:避免在 UTF-16 代理对(如 Emoji)中间截断 ---
// 如果 end 指向低位代理(Low Surrogate),则回退一格,保证不拆散字符
if (end < text.length && isLowSurrogate(text.charCodeAt(end))) {
end -= 1
}
// --- 4. 语义边界回退(可选) ---
if (respectBoundary && end < text.length) {
// 设定回退的底线:至少要比 start 大(避免原地踏步),且不能退过 maxBacktrack 范围
const floor = Math.max(start + 1, end - maxBacktrack)
// 在 (start, end] 范围内寻找最优的边界(句号、换行、空格等)
const boundary = findBoundary(text, start, end)
// 如果找到的边界在底线之上,则采用,否则宁可硬切(保底策略)
if (boundary >= floor) {
end = boundary
}
// 如果 boundary < floor,说明最优边界离当前 end 太远了,强行回退会导致块太小,
// 此时保留硬切的 end,避免产生大量无意义的碎块。
}
// --- 5. 截取并存储当前块 ---
chunks.push(text.slice(start, end))
// --- 6. 判断是否已经到达末尾,跳出循环 ---
if (end >= text.length) break
// --- 7. 计算下一次循环的起始位置(核心策略) ---
if (respectBoundary) {
// 【关键修复】当开启边界模式时,end 可能向左回退。
// 为了确保 overlap 精确,下一块的起点必须基于实际的 end 反推。
// 这样即使 end 回退了,下一块的开头也会相应提前,保留足够的重叠文本。
const nextStart = end - overlap
// 兜底保护:万一 overlap 为 0 或极端情况导致 nextStart 不大于 start,
// 强制向前推进至少 1 个字符,避免死循环。
start = nextStart > start ? nextStart : start + 1
} else {
// 非边界模式下,按固定步长滑动(普通硬切模式)
start += fixedStep
}
}
return chunks
}
/**
* 判断某个 Unicode 码点是否为 UTF-16 低位代理(Low Surrogate)
* 用于检测是否切中了 Emoji 或特殊字符的内部,防止乱码
*/
function isLowSurrogate(code: number): boolean {
return code >= 0xdc00 && code <= 0xdfff
}
/**
* 在 (start, end] 区间内,从 end 开始向前寻找最近的语义边界
* 优先级:换行/句子终结符 > 空格/逗号等分隔符
*
* 注意:搜索范围包含 end 位置本身的字符,修正了 V2 版本遗漏 end 处标点的 bug
*/
function findBoundary(text: string, start: number, end: number): number {
// 限定搜索上界,防止 end 等于 text.length 时越界
const upperBound = Math.min(end, text.length - 1)
// 第一优先级:段落/句子终结符(中英文标点、换行)
for (let i = upperBound; i > start; i--) {
if (/[\n。!?!?;;]/.test(text[i])) {
// 返回边界的下一个索引(将标点符号保留在当前块中,或者归入下一块开头?此处返回 i+1,
// 意味着标点符号被归入当前块末尾,符合大多数场景的阅读习惯)
return i + 1
}
}
// 第二优先级:词/词组分隔符(空格、逗号、顿号)
for (let i = upperBound; i > start; i--) {
if (/[\s,,、]/.test(text[i])) {
return i + 1
}
}
// 找不到合适边界:返回原 end,保持硬切
return end
}
💬 评论 0
还没有评论,快来抢沙发吧~