0%

LangChainGo 学习 03:RAG 实现

在前面的快速入门和架构原理中,我们已经把模型、提示词、链和文档检索放进同一个应用结构。现在继续沿着“报销制度问答”这个场景,深入看一条完整的 RAG 链路:制度文档怎样变成向量,问题怎样找到相关条款,条款又怎样进入模型的回答。

RAG 是 Retrieval-Augmented Generation,即检索增强生成。应用先从自己的数据中检索资料,再把资料和问题一起交给大模型。LangChainGo 提供其中的文档加载、文本切分、向量模型接入、向量存储适配和问答编排组件,应用负责选择并连接它们。

RAG 的两条执行路径

假设公司发布了新的差旅制度,规定“境内住宿费上限为每晚 500 元”。模型未必见过这份制度,但应用可以把它存入知识库。员工提问“出差住宿能报多少”,应用先检索出对应条款,再要求模型依据条款回答。

这分成两个执行阶段:资料变更时建立或更新索引,用户提问时检索并生成回答。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
文档准备:在资料变更时执行

[文件 / 网页 / 业务资料]
|
v
[Loader:解析为 Document]
|
v
[TextSplitter:生成文档片段]
|
v
[Embedder:生成文档向量]
|
v
[向量存储:向量、正文、metadata]


在线问答:在用户提问时执行

[用户问题] ------------------------------------+
| |
v |
[EmbedQuery:生成问题向量] |
| |
v |
[相似度查询与过滤] <---- [向量存储] |
| |
v |
[Retriever:返回相关 Document] |
| |
v |
[组合资料与问题,渲染提示词] <-----------------+
|
v
[LLM:生成回答]
|
v
[回答与来源展示] <---- [本次检索的来源文档]

在 pgvector 等适配器中,图里的文档向量化由 Store.AddDocuments 内部触发,问题向量化由 Store.SimilaritySearch 内部触发。调用者可以直接传入文档和问题文本,不必先手动计算一次向量。

RAG 主要改变模型本次调用能看到的资料,通常不涉及更新模型权重。知识库更新后,后续问题便可以检索到新内容;能否正确回答,仍取决于资料是否完整、检索是否命中,以及模型是否遵循资料。

串起整条链路的数据与接口

理解实现时,可以先记住六个约定:

组件 主要约定 传递的数据
文档 schema.Document 正文、metadata、检索得分
文档加载器 documentloaders.Loader 数据源 → []schema.Document
文本切分器 textsplitter.TextSplitter 字符串 → 多个字符串
向量生成器 embeddings.Embedder 文档或问题 → 浮点向量
向量存储 vectorstores.VectorStore 文档写入、相似度查询
检索器 schema.Retriever 问题 → 相关文档

Document:保留正文与出处

schema/documents.go 中的文档结构如下:

1
2
3
4
5
type Document struct {
PageContent string
Metadata map[string]any
Score float32
}

PageContent 是用于切分、向量化和回答的正文。Metadata 可以记录文件名、URL、页码、制度版本、部门、片段编号等。Score 可以由检索实现填入,用于表示该结果与问题的相关程度。

为了让用户能从回答找到原始资料,可以将文件路径、URL、页码等来源信息放入 Metadata,随文档一起存储,并在检索后展示。不同 loader 会提供不同的字段:文本 loader 默认返回空 map,需要应用补充文件来源;PDF loader 会写入页码和总页数,还可以补充文件名,方便定位到具体文件的对应页面。

Embedder:区分文档与问题

embeddings/embedding.go 定义了两个入口:

1
2
3
4
type Embedder interface {
EmbedDocuments(ctx context.Context, texts []string) ([][]float32, error)
EmbedQuery(ctx context.Context, text string) ([]float32, error)
}

这两个方法分别用于准备知识库和处理用户提问。EmbedDocuments 一次接收多个文档片段,为每个片段生成一个向量;EmbedQuery 接收一个问题,生成用于搜索的问题向量。例如,制度入库时,用前者处理各条报销规定;用户问“住宿费能报多少”时,用后者处理这个问题。

有些向量模型还要求调用方说明这段文字的用途:是准备存入知识库的资料,还是用来查找资料的问题。因此,两个方法也给具体实现留出了分别设置请求参数的位置。仓库中的 Bedrock/Cohere 实现就会将文档标记为 search_document,将问题标记为 search_query,再发送给模型。

VectorStore 与 Retriever:保存文档与查找资料

制度文档切分完成后,需要存入知识库;员工提问时,又需要从中查找相关条款。VectorStore 为这两项操作提供统一的方法,具体的数据库读写由 pgvector、Chroma 等适配器完成。接口定义在 vectorstores/vectorstores.go:

1
2
3
4
type VectorStore interface {
AddDocuments(ctx context.Context, docs []schema.Document, options ...Option) ([]string, error)
SimilaritySearch(ctx context.Context, query string, numDocuments int, options ...Option) ([]schema.Document, error)
}

AddDocuments 接收文档片段,将它们写入存储,并返回记录 ID。以 pgvector 适配器为例,它会先调用配置好的 embedder 生成向量,再把向量、正文和 metadata 一起写入数据库。SimilaritySearch 则接收问题文本和期望返回的数量,查找语义相近的文档。例如,传入“住宿费能报多少”和数量 4,就会尝试找出最多 4 个相关片段。

问答链拿到这些片段后,才能组织资料并调用模型。对它来说,只需把问题交出去,再取回相关文档,文档怎样入库、搜索哪个数据库都可以由其他组件处理。LangChainGo 用 schema.Retriever 表达这一步:

1
2
3
type Retriever interface {
GetRelevantDocuments(ctx context.Context, query string) ([]Document, error)
}

已有向量存储时,可以用 vectorstores.ToRetriever(store, 4) 创建检索器。这里的 store 指定搜索哪个存储,4 指定最多返回多少个片段。之后,问答链调用 GetRelevantDocuments(ctx, question),检索器便会将请求转交给 store.SimilaritySearch(ctx, question, 4),再把查到的文档返回给问答链。创建时还可以传入过滤条件、分数阈值等查询选项。

这种分工也方便调整检索方式。例如,希望同时搜索关键词和向量,再合并结果,可以编写自己的 Retriever,在 GetRelevantDocuments 中完成这些步骤。只要最后返回 []schema.Document,就能继续交给原来的问答链生成回答。

文档加载:先得到可用的文本

documentloaders.Loader 包含 Load 和 LoadAndSplit。后者通常先调用 Load,再调用 textsplitter.SplitDocuments,并没有在后台建立向量索引。

常见实现的处理方式如下:

Loader 处理方式 使用时需要补充的信息
Text 从 io.Reader 读取全文,返回一个 Document 文件路径、资料版本等来源字段
HTML 解析 HTML,提取文本并清理,返回一个 Document URL,以及按站点特点提取正文的逻辑
PDF 按页提取文字,每页生成一个 Document 文件标识;默认已有 page、total_pages
CSV 首行作为表头,每行生成一个文档,正文使用“列名: 值” 业务主键与字段含义;默认已有 row 行号

例如,NewHTML(reader) 接收的是 HTML 内容的 reader。下载网页、处理登录态、限制抓取范围等工作,需要由上游代码安排。通用 HTML 文本提取也可能包含菜单、页脚等内容,知识库通常需要按具体页面结构提取正文。

PDF loader 使用 ledongthuc/pdf 提取已有的文字层。扫描版 PDF 的信息可能只有图片,需要先经过 OCR。对于制度表格,还应检查列名、金额、单位是否在提取后保持了正确对应关系。解析阶段丢失的关系,很难依靠后续向量搜索恢复。

文本切分:决定知识库的检索粒度

将整本制度保存为一个向量,容易让不同条款的语义混在一起;切得过碎,又可能把“适用条件”和“报销上限”分开。因此,chunk 的边界直接影响检索和回答质量。

递归字符切分的执行过程

RecursiveCharacter 默认按以下分隔符依次尝试:

1
空行 "\n\n" → 换行 "\n" → 空格 " " → 字符级 ""

处理一个文本段时,它找到当前可用的分隔符并拆开文本。较短的部分进入待合并列表,过长的部分再用后续分隔符递归拆分。最后,mergeSplits 将小片段合并到接近目标长度,并在进入下一块时尝试保留一部分尾部内容。

这样做是为了尽量先保留段落边界,再处理段落中仍然过长的内容。中文制度中还可以显式加入“。”、“;”等分隔符,或按业务条款预先组织文本。

使用时,可以通过 ChunkSize 设置每块的目标长度,通过 ChunkOverlap 设置相邻块希望保留的重叠长度。片段应尽量包含完整的条款,同时为问题、提示词和其他检索结果留出输入空间。例如,住宿费每晚最多报销 500 元 和 超过标准需提前审批 可能刚好被分到相邻两块。设置适当的 overlap,可以让后一块保留前一块末尾的部分内容,使检索到的片段更容易同时包含报销标准和例外条件。重叠过多也会增加重复内容,因此应结合实际切分结果调整。

按 token 与按 Markdown 结构切分

TokenSplitter 使用 tiktoken-go 编码文本,以 token 窗口切分,再解码为文本。窗口步长为 ChunkSize - ChunkOverlap,因此应用应保证 ChunkSize > 0 且 0 <= ChunkOverlap < ChunkSize。

token 数取决于模型使用的分词规则,同一段文字在不同规则下可能得到不同的 token 数。TokenSplitter 默认采用 cl100k_base 这套规则,因此它切出的“500 个 token”,在另一个模型中可能超过 500 个。选择分词规则时,应与所用模型匹配,才能更准确地控制片段长度。

MarkdownTextSplitter 则通过 CommonMark 解析器识别文档结构,再结合二级 splitter 处理长内容,并提供标题层级、代码块、表格行等相关选项。对于技术文档,保留标题路径往往能帮助片段在脱离原文后仍表达完整含义。

切分以后保留下来的内容

SplitDocuments 取出每篇文档的正文和 metadata,切分正文后,为每个 chunk 新建 metadata map 并复制顶层键值。

它会保留已有的文件来源或页码,但不会自动创建字符偏移、chunk ID 或标题路径;这些字段需要由应用或相应 splitter 的处理结果补充。这里的 map 复制也是浅复制,嵌套 map、切片等值仍可能共享底层对象。

Embedding:把片段与问题转换成可比较的向量

通用的 EmbedderImpl 包装一个 EmbedderClient,后者只需实现 CreateEmbedding(ctx, texts)。例如,openai.LLM 同时提供生成能力和这个 embedding client 方法,可以交给 embeddings.NewEmbedder 使用。

它的文档处理过程可以概括为:

1
2
3
4
5
6
[]string
→ 可选地将换行替换为空格
→ 按 BatchSize 拆批
→ 顺序调用 client.CreateEmbedding
→ 按批次顺序拼接结果
→ [][]float32

BatchSize 控制一次请求发送多少个文档片段。例如,设为 32 时,100 个片段会分成四次请求,分别处理 32、32、32 和 4 个片段。每个片段有多长,则由前面的文本切分决定。如果模型提示输入过长,可以缩短片段,或减少每批发送的片段数量。

EmbedQuery 则对单条问题做相同的可选换行处理,再请求一个向量。向量维度、语义能力和语言覆盖来自具体 embedding 模型。通用包装器主要安排输入预处理与批处理。

生成回答的模型和 embedding 模型可以分别选择。例如,文档与问题使用同一个中文 embedding 模型,回答阶段使用另一个聊天模型。二者分别承担“查找资料”和“依据资料表达答案”的任务。

以 pgvector 为例理解向量存储

前面已经把制度切成片段,并介绍了如何为片段生成向量。接下来的问题是:这些内容存在哪里,用户提问时又怎样找到它们?

这里使用 PostgreSQL 和它的 pgvector 扩展。PostgreSQL 保存制度正文、来源和版本等信息,pgvector 为它增加保存向量和比较向量距离的能力。LangChainGo 的 vectorstores/pgvector 适配器把这些数据库操作封装起来,应用通过 AddDocuments 写入资料,通过 SimilaritySearch 查找资料。

一条制度需要保存哪些内容

假设切分后得到这样一个片段:“境内出差住宿费上限为每晚 500 元。”保存它时,需要将三类内容关联起来:

内容 在这个例子中保存什么 用途
正文 境内出差住宿费上限为每晚 500 元。 检索命中后,交给模型作为回答依据
向量 embedding 模型根据这段正文生成的一组数值 与问题向量比较,判断内容是否相关
metadata 来源为“差旅制度.md”,版本为“2026-10” 筛选有效制度,并展示资料出处

向量用于查找,正文用于回答,metadata 则补充来源和筛选条件。三者一起保存,检索命中一个向量后,才能取回它对应的原文和出处。

一份制度通常会产生多个片段。适配器为每条记录生成独立 ID,并通过 collection 将相关记录归在一起。比如,可以把差旅制度的片段放入一个名为 travel_policy 的 collection,查询时就在这个范围内查找。

写入时,文档怎样进入数据库

创建 pgvector store 时,需要提供数据库连接、embedding 组件和 collection 名称。pgvector.New 会准备保存文档所需的表和 collection;数据库环境的准备方式会在后面的完整示例中给出。

准备好 store 后,将文档片段传给 store.AddDocuments 即可。适配器会读取每个片段的正文,调用 EmbedDocuments 生成向量,再将正文、向量和 metadata 一起写入数据库。应用不必先手动生成向量,再调用一次写入方法。

1
2
3
4
5
6
7
8
9
[住宿费条款 + 来源、版本]
|
v
[AddDocuments]
|
+--> 取出正文 --> [EmbedDocuments] --> 文档向量
| |
v v
[PostgreSQL:保存正文、metadata 和对应的向量]

例如,输入十个制度片段,就会为这十个片段分别生成向量,并保存为十条记录。以后检索时,返回的便是其中相关的片段。

提问时,怎样找到住宿费条款

员工问“出差住宿能报多少”,这句话和制度中的“住宿费上限”并不完全相同。向量检索会把问题也转换成向量,再比较它与各个文档向量的距离,因此有机会找到表达不同但含义相关的条款。

调用 store.SimilaritySearch(ctx, "出差住宿能报多少", 4) 后,适配器先通过 EmbedQuery 生成问题向量,再让数据库查找当前 collection 中距离最近的文档向量,最后取回最多四个片段的正文和 metadata。

1
2
3
4
5
6
7
8
9
10
11
12
13
[问题:出差住宿能报多少]
|
v
[EmbedQuery:生成问题向量]
|
v
[pgvector:比较问题向量与已存文档向量的距离]
|
v
[按相关程度取回片段]
|
v
[住宿费上限条款、超标审批条款等]

当前 pgvector 适配器使用 余弦距离 进行比较,它衡量两个向量的方向是否接近:距离越小,表示它们越接近。查询按距离从小到大排列结果,并将 1 - 距离 放入返回文档的 Score 字段,所以这里的分数越大,表示越相关。

这个分数反映的是检索相关程度,不能直接当作答案正确的概率。如果取回的都是无关资料,后续模型仍然缺少回答依据。实际使用时,可以先查看不同问题命中的片段,再决定是否设置分数阈值,排除相关程度较低的结果。

上面参数中的 4 也只是返回数量的上限。取回更多片段可能补充证据,也可能带来重复和无关内容,需要结合实际问题选择。

怎样避免查到旧版制度

假设旧制度规定住宿费上限为 400 元,新制度调整为 500 元。两段文字都与住宿费问题相关,仅靠向量距离,很难保证每次只返回新版条款。

这时可以利用 metadata 中的版本字段,将查询范围限定为 version=2026-10。在前面介绍的 ToRetriever 中传入版本过滤条件后,检索器每次查找资料时,都会把这个条件一并交给 store。这样,旧版制度即使在语义上很相关,也不会进入本次回答的资料中。

版本管理也需要和写入方式配合。当前适配器的 AddDocuments 会新增记录,重复导入同一份文件不会自动替换原有片段。制度更新时,应用可以保存新版本并切换查询条件,或删除旧片段后重新写入,避免新旧条款混在一起。

资料增多后,怎样加快搜索

知识库较小时,可以先用上述流程验证能否找对条款。片段数量增长后,如果每次都比较大量向量,查询就会变慢。向量索引的作用,是帮助数据库更快地找到可能相关的候选片段。

这个适配器提供 WithHNSWIndex 选项,用于创建 HNSW 向量索引。它通过近似搜索提高检索速度,可能牺牲一部分召回率,因此需要同时观察查询耗时和正确条款的命中情况。实际配置还需与向量维度、距离计算方式匹配,并确认数据库查询使用了该索引。

索引优化的是查找速度。从正文生成向量仍由 embedding 模型负责,检索到条款后的回答生成则由后面的问答链完成。

RetrievalQA:从相关文档走向回答

检索器找到制度条款后,还需要把条款和用户问题一起交给模型,才能得到回答。RetrievalQA 是 LangChainGo 用来串起这两个步骤的问答链:先调用检索器查资料,再将查到的文档和问题交给后续的回答流程。

这段回答流程保存在 RetrievalQA 的 CombineDocumentsChain 字段中,负责组织文档、准备提示词并调用模型。之所以单独设置这个字段,是因为资料的处理方式可以不同:少量条款可以直接拼接,较多资料则可以先提取要点再汇总。

默认调用链

最直接的回答方式,是把检索到的条款拼成一段资料,再连同问题一起发送给模型。LangChainGo 用 StuffDocuments 完成正文拼接,用 LLMChain 填写提示词模板并调用模型。LoadStuffQA(model) 是一个辅助函数,会将这两个组件和默认问答模板组装好,得到可供 RetrievalQA 使用的回答流程。

创建时,可以调用 chains.NewRetrievalQAFromLLM(model, retriever),传入负责生成回答的模型和负责查资料的检索器。这个构造函数会用 LoadStuffQA 准备上述回答流程,再将它与检索器连接起来,返回一个 RetrievalQA 对象。

用户提问后,通过 chains.Call 执行这个对象。问题先用于检索,检索结果随后进入提示词,最后由模型生成回答:

1
2
3
4
5
6
7
8
9
10
chains.Call(ctx, qa, {"query": 用户问题})
-> 加载记忆变量、检查输入
-> RetrievalQA.Call:安排检索与回答
-> Retriever.GetRelevantDocuments:查找相关条款
-> CombineDocumentsChain:根据条款生成回答
输入 {"question": query, "input_documents": docs}
-> StuffDocuments:将各条款正文拼入 "context"
-> LLMChain:将资料和问题填入模板,调用模型
-> 按配置附加来源文档 "source_documents"
-> 检查输出、保存记忆

普通 RetrievalQA 默认使用不保存内容的 simple memory,因此它本身没有多轮对话历史。业务代码使用包级 chains.Call 可以经过公共的输入输出检查、记忆与链回调流程;直接调用链对象的方法只执行其主体逻辑。

StuffDocuments 怎样构造 context

StuffDocuments 默认使用两个换行连接各文档的 PageContent,然后把结果放入 context。默认 QA 模板要求模型依据这些资料回答,不知道时说明不知道。

LLMChain 随后调用 Prompt.FormatPrompt,使用 promptValue.String() 得到文本,再通过 llms.GenerateFromSinglePrompt 调用模型。当前这条路径将提示词作为单条用户文本发送;即使换成聊天模板,也需要留意这里会先转换为字符串。默认输出解析器只去掉回答首尾的空白。

这里有三个直接影响应用行为的细节:

  1. 只拼正文。 metadata 和 Score 不会自动加入提示词,模型因此未必知道文件名、页码或检索分数。
  2. 不自动控制总长度。 Stuff 不会根据模型上下文窗口删减、压缩或重新检索资料。
  3. 不因空结果自动拒答。 空文档列表会形成空 context,随后仍可能进入模型调用。需要硬性拒答时,应在检索后、生成前增加分支。

输入预算需要同时考虑固定提示词、问题、资料以及为输出预留的空间:

1
T_fixed + T_question + T_context + T_reserved_output <= T_model_window

chunk 限制只控制单个片段。最终预算还受到 Top-K、来源标识、历史内容和模板长度影响。

返回来源与生成引用

设置 qa.ReturnSourceDocuments = true 后,结果会多出 source_documents。这是返回检索文档的开关,不能据此认定模型已经为每句话标注了依据。

如果希望回答中出现 [1]、[2] 这样的引用,可以在自定义 Retriever 或组合链中将检索结果格式化为带编号的证据块:

1
2
3
4
5
[1] 来源:差旅制度.md;版本:2026-10;条款:住宿
境内出差住宿费上限为每晚 500 元。

[2] 来源:差旅制度.md;版本:2026-10;条款:例外审批
超过标准的部分,须提前取得部门负责人审批。

提示词再要求每个结论引用证据编号,应用同时保存编号与原始文档的映射,并检查返回编号是否存在。需要进一步验证“这句话是否确实由该证据支持”时,还要增加证据一致性校验。

文档组合策略与多轮 RAG

四种文档组合方式

RAG 已检索到的资料有时仍然很多。LangChainGo 在 chains/question_answering.go 中提供了几种组合方式,可以传入 NewRetrievalQA:

构造函数 执行方式 主要代价与适用条件
LoadStuffQA 全部正文拼接,一次生成 调用少,适合资料能放入一个上下文窗口的情况
LoadMapReduceQA 每块提取相关信息,再汇总 对 n 块资料通常约 n+1 次调用;提取阶段可能丢失信息
LoadRefineQA 第一块生成初稿,后续逐块修订 对 n 块资料通常约 n 次调用;顺序执行,结果可能受资料顺序影响
LoadMapRerankQA 每块独立回答并评分,返回最高分答案 对 n 块资料通常约 n 次调用;依赖模型自评分与输出格式

MapReduce 的 map 阶段通过 Apply 执行,可配置并发数,但默认 reduce 阶段仍然使用 Stuff。它没有保证汇总后的输入一定落在模型窗口内,必要时仍需应用分级压缩。

MapRerank 在这里表示对各文档生成的答案按模型评分排序。它与“先用 cross-encoder 重排候选文档,再把前几条交给另一个模型综合回答”有不同的执行过程。需要后者时,可以把重排接在自定义 Retriever 内部。

多轮对话

实际问答中,用户往往会接着上一轮继续问:

1
2
3
用户:境内出差住宿费上限是多少?
助手:每晚 500 元。
用户:那超过了怎么办?

人读过前面的对话,知道“超过了”指的是住宿费超过报销上限。但如果直接用“那超过了怎么办”去搜索制度,检索器就缺少了“住宿费”和“报销上限”这些关键信息,容易查不到对应条款。

LangChainGo 的 ConversationalRetrievalQA 为此增加了一个步骤:先让模型结合历史对话,把追问补成一个完整问题。在这个例子中,补全后的问题可以是“境内出差住宿费超过报销上限时怎么办?”

接下来就可以沿用前面的检索与回答流程:用完整问题查找制度,取回超标审批条款,再让模型依据条款回答。

1
2
3
4
5
6
7
8
9
10
11
12
13
[历史对话] + [追问:那超过了怎么办?]
|
v
[模型补全问题]
|
v
[境内出差住宿费超过报销上限时怎么办?]
|
v
[检索超标审批条款]
|
v
[根据条款生成回答]

默认情况下,补全后的问题会同时用于检索和生成回答。历史对话主要用于补全问题,因此模型需要把历史中仍然相关的条件保留下来,例如“境内出差”,避免检索到境外出差的规定。第一轮没有历史时,会直接使用用户原来的问题,省去补全这一步。

接入时,需要为每个会话保存各自的历史,并通过 question 字段传入本轮问题。若追问的检索结果不对,可以开启 ReturnGeneratedQuestion,查看模型补全后的问题,先确认它是否理解了用户在问什么。

第三方库

下面按 RAG 的执行阶段列出直接相关的第三方库。它们分布在不同功能包中,应用根据选择的 loader、模型和存储适配器引入相应依赖。这里区分“库用于什么功能”和“服务端实际执行什么工作”,便于排查问题和替换组件。

文档解析与文本切分

第三方库 对应位置 主要作用
github.com/ledongthuc/pdf documentloaders/pdf.go 读取 PDF 结构、字体与文字层,按页提取文本
github.com/PuerkitoBio/goquery documentloaders/html.go 解析 HTML,选择节点并提取文本
github.com/microcosm-cc/bluemonday documentloaders/html.go 对 HTML loader 提取后的内容执行清理
gitlab.com/golang-commonmark/markdown textsplitter/markdown_splitter.go 解析 Markdown token,辅助按标题、段落、列表等结构切分
github.com/pkoukk/tiktoken-go textsplitter/token_splitter.go 将文本编码为 token,按 token 数切块并解码
github.com/gocolly/colly tools/scraper/scraper.go 为可选的网页抓取工具提供采集、链接遍历与并发抓取能力

Colly 属于可选抓取工具,基础的 HTML loader 不会自动启动爬虫。递归字符切分算法由 LangChainGo 自己实现,主要使用 strings、unicode/utf8 等 Go 标准库。

HTML 清理用于处理页面内容,不能据此认为已经处理了自然语言中的提示词注入。文档里的“忽略之前要求”等句子即使是普通文本,也需要在应用的证据与指令组织中区别对待。

向量生成与模型接入

第三方库 使用场景 主要作用
github.com/nlpodyssey/cybertron embeddings/cybertron 在本机 CPU 上运行文本编码模型,生成 embedding;相应实现还会归一化结果
github.com/aws/aws-sdk-go-v2/service/bedrockruntime embeddings/bedrock 调用 Bedrock 的 embedding 模型,处理 AWS 服务请求
cloud.google.com/go/aiplatform llms/googleai/vertex 使用的内部 palmclient 通过 Vertex AI Prediction API 请求文本向量

这些 SDK 或运行库负责模型服务接入、本地推理等具体能力;embeddings.Embedder 则给上层提供统一的文档与问题向量接口。不同实现的批处理、归一化和输入类型需要分别核对。

当前 OpenAI 适配器通过 llms/openai/internal/openaiclient 和 Go 的 net/http、encoding/json 等标准库发起请求,没有依赖一个外部 OpenAI Go SDK 来完成这条调用链。Ollama 适配器也有自己的内部 client。判断实现依赖时,应查看实际 import 和调用位置。

向量存储适配

第三方库 对应存储 主要作用
github.com/jackc/pgx/v5 pgvector、部分 PostgreSQL 类存储 PostgreSQL 连接、连接池、事务、SQL 与批量写入
github.com/pgvector/pgvector-go pgvector、部分 PostgreSQL 类存储 包装向量值,处理 Go 数据与 PostgreSQL vector 类型之间的表示转换
github.com/amikos-tech/chroma-go Chroma 管理 collection,添加文档、查询向量并接入 embedding function
github.com/milvus-io/milvus-sdk-go/v2 vectorstores/milvus 使用相应版本的 Milvus client 管理 collection、写入与搜索
github.com/milvus-io/milvus/client/v2 vectorstores/milvus/v2 为另一套 Milvus 适配实现提供新版 client、实体及索引配置
github.com/pinecone-io/go-pinecone Pinecone 连接向量索引,执行写入与相似度查询
github.com/weaviate/weaviate-go-client/v5 Weaviate 访问 schema、写入对象并构造向量查询与过滤条件
github.com/redis/rueidis Redis Vector 连接 Redis,执行向量索引和搜索相关命令
go.mongodb.org/mongo-driver/v2 Mongo Vector 访问 MongoDB,通过聚合管道等能力执行向量查询
github.com/opensearch-project/opensearch-go OpenSearch 管理索引、写入数据并执行搜索请求
github.com/google/uuid pgvector、Chroma、Qdrant 等 为文档或集合相关对象生成标识

这些 client 主要负责构造请求、传输数据和解析结果。向量距离计算、持久化与索引搜索通常由对应数据库或服务执行;具体是否使用 HNSW、IVF 等算法,由服务能力和索引配置决定。

Qdrant 在当前仓库中使用 vectorstores/qdrant/rest.go 自行发送 REST 请求,没有在这条适配路径上使用外部 Qdrant Go SDK。

另外,公共接口相同,并不代表过滤、namespace、阈值和分数语义完全相同。例如 pgvector 的当前查询使用余弦距离,Chroma 则使用 collection 配置的距离函数,再由适配器转换返回值。迁移后端时应重新验证检索效果与阈值。

提示词渲染

第三方库 对应位置 主要作用
github.com/Masterminds/sprig/v3 prompts/templates.go 等 为 Go text/template 补充字符串、集合等模板函数
github.com/nikolalohinski/gonja prompts/templates_jinja2.go 提供可选的 Jinja2 风格模板渲染

默认 NewPromptTemplate 使用 Go 模板,核心渲染由标准库 text/template 完成。RetrievalQA、StuffDocuments、MapReduceDocuments 等执行顺序由 LangChainGo 自己编排,没有在运行时启动 Python LangChain。

需要注意,Go 按包编译,某个包的其他源文件仍可能引入额外依赖。例如 documentloaders 将多种 loader 放在同一包,prompts 也包含多种渲染实现;只调用其中一个构造函数,并不保证只下载该构造函数直接使用的库。与此同时,独立存储适配包的存在,也避免了应用必须统一编译所有数据库后端。

完整示例:建立制度知识库,再执行问答

下面使用 OpenAI 兼容的生成与 embedding 接口,以及 PostgreSQL/pgvector。示例分成两个命令模式:-index 加载并写入资料,普通模式复用已经入库的数据回答问题。

示例采用默认 Stuff 路径,返回答案并单独打印检索来源,便于观察各组件的默认行为。它适合验证小规模链路;资料版本切换、幂等更新和严格引用可以在此基础上增加。

准备数据库与资料

在本地测试环境启动已包含 pgvector 的 PostgreSQL。以下凭据仅用于这个本地示例:

1
2
3
4
5
6
docker run --name langchaingo-rag-pg \
-e POSTGRES_USER=rag \
-e POSTGRES_PASSWORD=rag_local \
-e POSTGRES_DB=rag \
-p 127.0.0.1:5432:5432 \
-d pgvector/pgvector:pg16

等待数据库就绪后,配置连接和模型。OPENAI_EMBEDDING_MODEL 是下面示例自行读取的变量;它将被显式传入 WithEmbeddingModel。服务端需要同时提供所选的生成与 embedding 模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export POSTGRES_URL='postgres://rag:rag_local@localhost:5432/rag?sslmode=disable'
export OPENAI_API_KEY='<你的 API key>'
export OPENAI_MODEL='<可用的聊天模型名>'
export OPENAI_EMBEDDING_MODEL='<可用的 embedding 模型名>'
# 使用兼容服务时,按服务商要求设置 API 基础地址:
# export OPENAI_BASE_URL='https://your-provider.example/v1'

cat > policy.txt <<'EOF'
公司差旅报销制度,版本 2026-10。

境内出差住宿费上限为每晚 500 元。报销时须提供住宿发票。

住宿费超过标准的部分,须提前取得部门负责人审批。

出差结束后,员工应在 30 天内提交报销申请。
EOF

程序代码

将下面代码保存为独立示例项目的 main.go。示例使用连接池,并将其交给 pgvector store;程序退出时由连接池释放连接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
package main

import (
"context"
"flag"
"fmt"
"log"
"os"
"time"

"github.com/jackc/pgx/v5/pgxpool"
"github.com/tmc/langchaingo/chains"
"github.com/tmc/langchaingo/documentloaders"
"github.com/tmc/langchaingo/embeddings"
"github.com/tmc/langchaingo/llms/openai"
"github.com/tmc/langchaingo/prompts"
"github.com/tmc/langchaingo/schema"
"github.com/tmc/langchaingo/textsplitter"
"github.com/tmc/langchaingo/vectorstores"
"github.com/tmc/langchaingo/vectorstores/pgvector"
)

func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}

func run() error {
index := flag.Bool("index", false, "加载文件并写入知识库")
file := flag.String("file", "policy.txt", "制度文件路径")
question := flag.String("question", "境内出差住宿费上限是多少?", "查询问题")
flag.Parse()

for _, key := range []string{
"POSTGRES_URL", "OPENAI_API_KEY", "OPENAI_MODEL", "OPENAI_EMBEDDING_MODEL",
} {
if os.Getenv(key) == "" {
return fmt.Errorf("请设置环境变量 %s", key)
}
}

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()

model, err := openai.New(
openai.WithModel(os.Getenv("OPENAI_MODEL")),
openai.WithEmbeddingModel(os.Getenv("OPENAI_EMBEDDING_MODEL")),
)
if err != nil {
return fmt.Errorf("创建模型客户端: %w", err)
}
embedder, err := embeddings.NewEmbedder(model, embeddings.WithBatchSize(32))
if err != nil {
return fmt.Errorf("创建 embedder: %w", err)
}

pool, err := pgxpool.New(ctx, os.Getenv("POSTGRES_URL"))
if err != nil {
return fmt.Errorf("创建连接池: %w", err)
}
defer pool.Close()

store, err := pgvector.New(ctx,
pgvector.WithConn(pool),
pgvector.WithEmbedder(embedder),
pgvector.WithCollectionName("travel_policy_demo_v1"),
)
if err != nil {
return fmt.Errorf("初始化向量存储: %w", err)
}

if *index {
f, err := os.Open(*file)
if err != nil {
return fmt.Errorf("打开资料: %w", err)
}
defer f.Close()

docs, err := documentloaders.NewText(f).Load(ctx)
if err != nil {
return fmt.Errorf("加载资料: %w", err)
}
for i := range docs {
docs[i].Metadata["source"] = *file
docs[i].Metadata["version"] = "2026-10"
}

splitter := textsplitter.NewRecursiveCharacter(
textsplitter.WithChunkSize(500),
textsplitter.WithChunkOverlap(80),
)
chunks, err := textsplitter.SplitDocuments(splitter, docs)
if err != nil {
return fmt.Errorf("切分资料: %w", err)
}
if len(chunks) == 0 {
return fmt.Errorf("资料为空,没有可写入的片段")
}
for i := range chunks {
chunks[i].Metadata["chunk_id"] = fmt.Sprintf("%s#%d", *file, i+1)
}

ids, err := store.AddDocuments(ctx, chunks)
if err != nil {
return fmt.Errorf("写入知识库: %w", err)
}
fmt.Printf("已写入 %d 个片段\n", len(ids))
return nil
}

retriever := vectorstores.ToRetriever(store, 4)
prompt := prompts.NewPromptTemplate(
`请仅依据以下资料,用中文回答问题。
资料不足时,请明确回答“知识库中没有足够的信息”。

资料:
{{.context}}

问题:{{.question}}`,
[]string{"context", "question"},
)
combine := chains.NewStuffDocuments(chains.NewLLMChain(model, prompt))
qa := chains.NewRetrievalQA(combine, retriever)
qa.ReturnSourceDocuments = true

result, err := chains.Call(ctx, qa, map[string]any{"query": *question})
if err != nil {
return fmt.Errorf("执行问答: %w", err)
}
answer, ok := result["text"].(string)
if !ok {
return fmt.Errorf("回答字段类型异常: %T", result["text"])
}
sources, ok := result["source_documents"].([]schema.Document)
if !ok {
return fmt.Errorf("来源字段类型异常: %T", result["source_documents"])
}

fmt.Println(answer)
for i, doc := range sources {
fmt.Printf("来源 %d: %v,片段 %v,得分 %.4f\n",
i+1, doc.Metadata["source"], doc.Metadata["chunk_id"], doc.Score)
}
return nil
}

创建独立项目后再安装依赖。下面固定到本文分析的提交,便于保持 API 一致;该提交要求 Go 1.26.0 或以上,本地验证使用 Go 1.26.10。

1
2
3
4
5
6
7
8
9
10
11
12
mkdir langchaingo-rag-demo
cd langchaingo-rag-demo
go mod init example.com/langchaingo-rag-demo
# 将上面的 main.go 和 policy.txt 放入此目录。
go get github.com/tmc/langchaingo@381d4a36d0f79d03b85c15e3ff303be62916be5a
go mod tidy

# 文档准备:首次写入资料。
go run . -index -file policy.txt

# 在线问答:读取已有索引。
go run . -question '境内出差住宿费上限是多少?超过标准怎么办?'

重复执行 -index 会新增记录;这里写入 metadata 的 chunk_id 只是业务标识,pgvector 适配器仍为记录生成独立 UUID,不会因此自动覆盖同名片段。示例使用同一个客户端配置调用两类模型,查询和入库时应保持 embedding 配置一致。

一次普通查询主要包括一次问题向量请求、一次数据库搜索和一次生成请求。示例没有设置分数阈值,以便先观察返回的资料;正式采用阈值时,需要用真实问题评估,避免直接套用任意数值。

排查与优化

假设现行制度规定住宿费每晚最多报销 500 元,系统却回答 400 元。这个错误可能来自旧版资料,也可能是模型没有按资料回答。排查时,可以顺着“查到了什么、发给模型什么、模型回答了什么”逐步检查。

先看查到了什么

开启 ReturnSourceDocuments,查看本次检索返回的片段。如果其中写的是 400 元,就需要检查是否导入了旧版制度,以及查询是否限定了有效版本。

如果返回的都是交通费、餐补等无关条款,可以先确认住宿费规定是否已经入库,再检查切分后的内容。例如,金额所在的片段如果没有保留“住宿费”这个标题,单独看只剩一句“每晚 500 元”,检索时就可能不容易找到它。此时可以让标题和条款一起保留,再重新生成向量并写入知识库。

如果需要综合多条规定,还要看结果是否齐全。回答“超标怎么办”时,只有住宿费上限还不够,还需要取回超标审批条款。可以适当增加返回片段数量,观察是否能找到缺失的依据。

再看模型收到了什么

检索结果正确后,检查最终发给模型的提示词,确认相关条款确实被放了进去。若资料在拼接或裁剪时丢失,模型仍然无法据此回答。

如果提示词已经包含“每晚 500 元”,模型却回答 400 元,就应检查生成阶段:是否混入了旧版条款,提示词是否明确要求依据资料回答,以及无关内容是否过多。可以先只保留相关条款和问题进行验证,再逐步加入其他资料,观察回答从哪里开始出现偏差。

知识库没有相关规定时,也需要明确处理方式。例如,员工问“家属随行的费用能否报销”,但制度没有说明,应用可以在没有检索结果时直接提示资料不足;即使命中了一般差旅条款,也应要求模型说明其中没有家属费用的规定。

用固定问题验证修改

每次调整切分方式、检索数量或提示词后,都用同一组问题重新检查,才能知道改动是否有效。报销场景可以从这些问题开始:

  • “住宿费每晚能报多少?”——检查是否找到正确金额。
  • “超过标准怎么办?”——结合上一轮对话,检查能否找到审批要求。
  • “家属随行能报销吗?”——检查资料不足时是否会编造规定。

验证时,同时保存检索片段和最终回答。这样既能发现“没找到正确条款”,也能发现“找到了条款却答错了”,避免只凭某一次回答判断效果。

回答慢时,分别看各步耗时

一次问答会经历问题向量生成、数据库检索和模型生成。可以分别记录这些步骤的耗时,再决定优化哪里。

数据库检索慢时,检查查询范围和向量索引;模型生成慢时,先看是否发送了太多重复或无关的资料。开启流式输出可以让用户更早看到回答,但检索仍需先完成,因此也要关注用户发问到第一个字出现之间的等待时间。

小结

LangChainGo 的 RAG 流程分为文档准备和在线问答两部分:先加载、切分文档,生成向量并存入知识库;用户提问后,再检索相关片段,将资料和问题一起交给模型生成回答。多轮对话则先结合历史补全追问,再进行检索。

在这条链路中,文档解析、分词和数据库访问使用相应的第三方库,LangChainGo 通过接口和问答链将各步骤连接起来。本文以 pgvector 为例说明了文档如何保存和检索,并用完整 Go 示例演示了入库与问答。