在这篇博客文章中,我们将探索如何使用 Ent、Atlas 和 pgvector 构建一个 RAG(检索增强生成)系统。
RAG 是一种通过整合检索步骤来增强生成模型能力的技术。我们不再仅仅依赖模型的内部知识,而是可以从外部数据源检索相关文档或数据,并使用这些信息来生成更准确、更具上下文感知的响应。这种方法在构建问答系统、聊天机器人或任何需要最新信息或特定领域知识的场景中特别有用。
设置 Ent Schema
让我们通过初始化项目所需的 Go 模块来开始本教程:
go mod init github.com/rotemtam/entrag # Feel free to replace the module path with your own
在这个项目中,我们将使用 Ent(一个 Go 语言的实体框架)来定义我们的数据库 schema。数据库将存储我们想要检索的文档(分块到固定大小)以及表示每个分块的向量。运行以下命令初始化 Ent 项目:
go run -mod=mod entgo.io/ent/cmd/ent new Embedding Chunk
这个命令会为我们的数据模型创建占位文件。我们的项目结构应该如下所示:
├── ent
│ ├── generate.go
│ └── schema
│ ├── chunk.go
│ └── embedding.go
├── go.mod
└── go.sum
接下来,让我们定义 Chunk 模型的 schema。打开 ent/schema/chunk.go 文件并按如下方式定义 schema:
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
)
// Chunk holds the schema definition for the Chunk entity.
type Chunk struct {
ent.Schema
}
// Fields of the Chunk.
func (Chunk) Fields() []ent.Field {
return []ent.Field{
field.String("path"),
field.Int("nchunk"),
field.Text("data"),
}
}
// Edges of the Chunk.
func (Chunk) Edges() []ent.Edge {
return []ent.Edge{
edge.To("embedding", Embedding.Type).StorageKey(edge.Column("chunk_id")).Unique(),
}
}
这个 schema 定义了一个具有三个字段的 Chunk 实体:path、nchunk 和 data。path 字段存储文档的路径,nchunk 存储分块编号,data 存储分块的文本数据。我们还定义了一个指向 Embedding 实体的边,该实体将存储分块的向量表示。
在继续之前,让我们安装 pgvector 包。pgvector 是一个 PostgreSQL 扩展,提供对向量操作和相似性搜索的支持。我们需要它来存储和检索分块的向量表示。
go get github.com/pgvector/pgvector-go
接下来,让我们定义 Embedding 模型的 schema。打开 ent/schema/embedding.go 文件并按如下方式定义 schema:
package schema
import (
"entgo.io/ent"
"entgo.io/ent/dialect"
"entgo.io/ent/dialect/entsql"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/index"
"github.com/pgvector/pgvector-go"
)
// Embedding holds the schema definition for the Embedding entity.
type Embedding struct {
ent.Schema
}
// Fields of the Embedding.
func (Embedding) Fields() []ent.Field {
return []ent.Field{
field.Other("embedding", pgvector.Vector{}).
SchemaType(map[string]string{
dialect.Postgres: "vector(1536)",
}),
}
}
// Edges of the Embedding.
func (Embedding) Edges() []ent.Edge {
return []ent.Edge{
edge.From("chunk", Chunk.Type).Ref("embedding").Unique().Required(),
}
}
func (Embedding) Indexes() []ent.Index {
return []ent.Index{
index.Fields("embedding").
Annotations(
entsql.IndexType("hnsw"),
entsql.OpClass("vector_l2_ops"),
),
}
}
这个 schema 定义了一个 Embedding 实体,包含一个类型为 pgvector.Vector 的 embedding 字段。embedding 字段存储分块的向量表示。我们还定义了一个指向 Chunk 实体的边,以及一个使用 hnsw 索引类型和 vector_l2_ops 操作符类的 embedding 字段索引。这个索引将使我们能够对嵌入向量执行高效的相似性搜索。
最后,让我们通过运行以下命令生成 Ent 代码:
go mod tidy
go generate ./...
Ent 将根据 schema 定义为我们的模型生成必要的代码。
设置数据库
接下来,让我们设置 PostgreSQL 数据库。我们将使用 Docker 在本地运行 PostgreSQL 实例。由于我们需要 pgvector 扩展,我们将使用 pgvector/pgvector:pg17 Docker 镜像,该镜像已预装了该扩展。
docker run --rm --name postgres -e POSTGRES_PASSWORD=pass -p 5432:5432 -d pgvector/pgvector:pg17
我们将使用 Atlas(一个与 Ent 集成的数据库 schema 即代码工具)来管理我们的数据库 schema。运行以下命令安装 Atlas:
curl -sSfL https://atlasgo.io/install.sh | sh
有关其他安装选项,请参阅 Atlas 安装文档。
由于我们要管理扩展,我们需要一个 Atlas Pro 帐户。您可以通过运行以下命令注册免费试用:
atlas login
如果您想跳过使用 Atlas,可以使用此文件中的语句直接将所需的 schema 应用到数据库
现在,让我们创建基础配置 base.pg.hcl,它为 public schema 提供 vector 扩展:
schema "public" {
}
extension "vector" {
schema = schema.public
}
现在,让我们创建 Atlas 配置,它将 base.pg.hcl 文件与 Ent schema 组合在一起:
data "composite_schema" "schema" {
schema {
url = "file://base.pg.hcl"
}
schema "public" {
url = "ent://ent/schema"
}
}
env "local" {
url = getenv("DB_URL")
schema {
src = data.composite_schema.schema.url
}
dev = "docker://pgvector/pg17/dev"
}
此配置定义了一个包含 base.pg.hcl 文件和 Ent schema 的复合 schema。我们还定义了一个名为 local 的环境,它使用复合 schema,我们将用于本地开发。dev 字段指定开发数据库 URL,Atlas 使用它来规范化 schema 并进行各种计算。
接下来,让我们通过运行以下命令将 schema 应用到数据库:
export DB_URL='postgresql://postgres:pass@localhost:5432/postgres?sslmode=disable'
atlas schema apply --env local
Atlas 将从我们的配置中加载数据库的期望状态,将其与数据库的当前状态进行比较,并创建迁移计划以使数据库达到期望状态:
Planning migration statements (5 in total):
-- create extension "vector":
-> CREATE EXTENSION "vector" WITH SCHEMA "public" VERSION "0.8.0";
-- create "chunks" table:
-> CREATE TABLE "public"."chunks" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
"path" character varying NOT NULL,
"nchunk" bigint NOT NULL,
"data" text NOT NULL,
PRIMARY KEY ("id")
);
-- create "embeddings" table:
-> CREATE TABLE "public"."embeddings" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
"embedding" public.vector(1536) NOT NULL,
"chunk_id" bigint NOT NULL,
PRIMARY KEY ("id"),
CONSTRAINT "embeddings_chunks_embedding" FOREIGN KEY ("chunk_id") REFERENCES "public"."chunks" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION
);
-- create index "embedding_embedding" to table: "embeddings":
-> CREATE INDEX "embedding_embedding" ON "public"."embeddings" USING hnsw ("embedding" vector_l2_ops);
-- create index "embeddings_chunk_id_key" to table: "embeddings":
-> CREATE UNIQUE INDEX "embeddings_chunk_id_key" ON "public"."embeddings" ("chunk_id");
-------------------------------------------
Analyzing planned statements (5 in total):
-- non-optimal columns alignment:
-- L4: Table "chunks" has 8 redundant bytes of padding per row. To reduce disk space,
the optimal order of the columns is as follows: "id", "nchunk", "path",
"data" https://atlasgo.io/lint/analyzers#PG110
-- ok (370.25µs)
-------------------------
-- 114.306667ms
-- 5 schema changes
-- 1 diagnostic
-------------------------------------------
? Approve or abort the plan:
▸ Approve and apply
Abort
除了规划变更之外,Atlas 还会提供诊断和优化 schema 的建议。在这种情况下,它建议重新排列 chunks 表中的列以减少磁盘空间。由于我们在本教程中不关心磁盘空间,我们可以通过选择 Approve and apply 来继续迁移。
最后,我们可以验证 schema 是否成功应用,可以重新运行 atlas schema apply 命令。Atlas 将输出:
Schema is synced, no changes to be made
搭建 CLI 脚手架
现在我们的数据库 schema 已经设置好了,让我们搭建 CLI 应用程序的脚手架。在本教程中,我们将使用 alecthomas/kong 库来构建一个可以加载、索引和查询数据库中文档的小应用程序。
首先,安装 kong 库:
go get github.com/alecthomas/kong
接下来,创建一个名为 cmd/entrag/main.go 的新文件,并按如下方式定义 CLI 应用程序:
package main
import (
"fmt"
"os"
"github.com/alecthomas/kong"
)
// CLI holds global options and subcommands.
type CLI struct {
// DBURL is read from the environment variable DB_URL.
DBURL string `kong:"env='DB_URL',help='Database URL for the application.'"`
OpenAIKey string `kong:"env='OPENAI_KEY',help='OpenAI API key for the application.'"`
// Subcommands
Load *LoadCmd `kong:"cmd,help='Load command that accepts a path.'"`
Index *IndexCmd `kong:"cmd,help='Create embeddings for any chunks that do not have one.'"`
Ask *AskCmd `kong:"cmd,help='Ask a question about the indexed documents'"`
}
func main() {
var cli CLI
app := kong.Parse(&cli,
kong.Name("entrag"),
kong.Description("Ask questions about markdown files."),
kong.UsageOnError(),
)
if err := app.Run(&cli); err != nil {
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
os.Exit(1)
}
}
创建一个名为 cmd/entrag/rag.go 的附加文件,内容如下:
package main
type (
// LoadCmd loads the markdown files into the database.
LoadCmd struct {
Path string `help:"path to dir with markdown files" type:"existingdir" required:""`
}
// IndexCmd creates the embedding index on the database.
IndexCmd struct {
}
// AskCmd is another leaf command.
AskCmd struct {
// Text is the positional argument for the ask command.
Text string `kong:"arg,required,help='Text for the ask command.'"`
}
)
通过运行以下命令验证我们搭建的 CLI 应用程序是否正常工作:
go run ./cmd/entrag --help
如果一切设置正确,您应该会看到 CLI 应用程序的帮助输出:
Usage: entrag <command> [flags]
Ask questions about markdown files.
Flags:
-h, --help Show context-sensitive help.
--dburl=STRING Database URL for the application ($DB_URL).
--open-ai-key=STRING OpenAI API key for the application ($OPENAI_KEY).
Commands:
load --path=STRING [flags]
Load command that accepts a path.
index [flags]
Create embeddings for any chunks that do not have one.
ask <text> [flags]
Ask a question about the indexed documents
Run "entrag <command> --help" for more information on a command.
将文档加载到数据库
接下来,我们需要一些 markdown 文件加载到数据库中。创建一个名为 data 的目录并添加一些 markdown 文件。在这个示例中,我下载了 ent/ent 仓库,并使用 docs 目录作为 markdown 文件的来源。
现在,让我们实现 LoadCmd 命令将 markdown 文件加载到数据库中。打开 cmd/entrag/rag.go 文件并添加以下代码:
const (
tokenEncoding = "cl100k_base"
chunkSize = 1000
)
// Run is the method called when the "load" command is executed.
func (cmd *LoadCmd) Run(ctx *CLI) error {
client, err := ctx.entClient()
if err != nil {
return fmt.Errorf("failed opening connection to postgres: %w", err)
}
tokTotal := 0
return filepath.WalkDir(ctx.Load.Path, func(path string, d fs.DirEntry, err error) error {
if filepath.Ext(path) == ".mdx" || filepath.Ext(path) == ".md" {
chunks := breakToChunks(path)
for i, chunk := range chunks {
tokTotal += len(chunk)
client.Chunk.Create().
SetData(chunk).
SetPath(path).
SetNchunk(i).
SaveX(context.Background())
}
}
return nil
})
}
func (c *CLI) entClient() (*ent.Client, error) {
return ent.Open("postgres", c.DBURL)
}
这段代码为 LoadCmd 命令定义了 Run 方法。该方法从指定路径读取 markdown 文件,将它们分成每个 1000 个 token 的块,并将它们保存到数据库。我们使用 entClient 方法使用 CLI 选项中指定的数据库 URL 创建一个新的 Ent 客户端。
有关 breakToChunks 的实现,请参阅 entrag 仓库中的完整代码,它几乎完全基于 Eli Bendersky 的 Go 语言 RAG 入门。
最后,让我们运行 load 命令将 markdown 文件加载到数据库中:
go run ./cmd/entrag load --path=data
命令完成后,您应该会看到分块已加载到数据库中。要验证,请运行:
docker exec -it postgres psql -U postgres -d postgres -c "SELECT COUNT(*) FROM chunks;"
您应该会看到类似以下的内容:
count
-------
276
(1 row)
索引嵌入向量
现在我们已经将文档加载到数据库中,我们需要为每个分块创建嵌入向量。我们将使用 OpenAI API 为分块生成嵌入向量。为此,我们需要安装 openai 包:
go get github.com/sashabaranov/go-openai
如果您没有 OpenAI API 密钥,可以在 OpenAI 平台上注册帐户并生成 API 密钥。
我们将从环境变量 OPENAI_KEY 读取此密钥,所以让我们设置它:
export OPENAI_KEY=<your OpenAI API key>
接下来,让我们实现 IndexCmd 命令为分块创建嵌入向量。打开 cmd/entrag/rag.go 文件并添加以下代码:
// Run is the method called when the "index" command is executed.
func (cmd *IndexCmd) Run(cli *CLI) error {
client, err := cli.entClient()
if err != nil {
return fmt.Errorf("failed opening connection to postgres: %w", err)
}
ctx := context.Background()
chunks := client.Chunk.Query().
Where(
chunk.Not(
chunk.HasEmbedding(),
),
).
Order(ent.Asc(chunk.FieldID)).
AllX(ctx)
for _, ch := range chunks {
log.Println("Created embedding for chunk", ch.Path, ch.Nchunk)
embedding := getEmbedding(ch.Data)
_, err := client.Embedding.Create().
SetEmbedding(pgvector.NewVector(embedding)).
SetChunk(ch).
Save(ctx)
if err != nil {
return fmt.Errorf("error creating embedding: %v", err)
}
}
return nil
}
// getEmbedding invokes the OpenAI embedding API to calculate the embedding
// for the given string. It returns the embedding.
func getEmbedding(data string) []float32 {
client := openai.NewClient(os.Getenv("OPENAI_KEY"))
queryReq := openai.EmbeddingRequest{
Input: []string{data},
Model: openai.AdaEmbeddingV2,
}
queryResponse, err := client.CreateEmbeddings(context.Background(), queryReq)
if err != nil {
log.Fatalf("Error getting embedding: %v", err)
}
return queryResponse.Data[0].Embedding
}
我们为 IndexCmd 命令定义了 Run 方法。该方法查询数据库中没有嵌入向量的分块,使用 OpenAI API 为每个分块生成嵌入向量,并将嵌入向量保存到数据库。
最后,让我们运行 index 命令为分块创建嵌入向量:
go run ./cmd/entrag index
您应该会看到类似以下的日志:
2025/02/13 13:04:42 Created embedding for chunk /Users/home/entr/data/md/aggregate.md 0
2025/02/13 13:04:43 Created embedding for chunk /Users/home/entr/data/md/ci.mdx 0
2025/02/13 13:04:44 Created embedding for chunk /Users/home/entr/data/md/ci.mdx 1
2025/02/13 13:04:45 Created embedding for chunk /Users/home/entr/data/md/ci.mdx 2
2025/02/13 13:04:46 Created embedding for chunk /Users/home/entr/data/md/code-gen.md 0
2025/02/13 13:04:47 Created embedding for chunk /Users/home/entr/data/md/code-gen.md 1
提问
现在我们已经加载了文档并为分块创建了嵌入向量,我们可以实现 AskCmd 命令来询问有关索引文档的问题。打开 cmd/entrag/rag.go 文件并添加以下代码:
// Run is the method called when the "ask" command is executed.
func (cmd *AskCmd) Run(ctx *CLI) error {
client, err := ctx.entClient()
if err != nil {
return fmt.Errorf("failed opening connection to postgres: %w", err)
}
question := cmd.Text
emb := getEmbedding(question)
embVec := pgvector.NewVector(emb)
embs := client.Embedding.
Query().
Order(func(s *sql.Selector) {
s.OrderExpr(sql.ExprP("embedding <-> $1", embVec))
}).
WithChunk().
Limit(5).
AllX(context.Background())
b := strings.Builder{}
for _, e := range embs {
chnk := e.Edges.Chunk
b.WriteString(fmt.Sprintf("From file: %v\n", chnk.Path))
b.WriteString(chnk.Data)
}
query := fmt.Sprintf(`Use the below information from the ent docs to answer the subsequent question.
Information:
%v
Question: %v`, b.String(), question)
oac := openai.NewClient(ctx.OpenAIKey)
resp, err := oac.CreateChatCompletion(
context.Background(),
openai.ChatCompletionRequest{
Model: openai.GPT4o,
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleUser,
Content: query,
},
},
},
)
if err != nil {
return fmt.Errorf("error creating chat completion: %v", err)
}
choice := resp.Choices[0]
out, err := glamour.Render(choice.Message.Content, "dark")
fmt.Print(out)
return nil
}
这就是所有部分组合在一起的地方。在使用文档及其嵌入向量准备好数据库之后,我们现在可以询问有关它们的问题。让我们分解 AskCmd 命令:
emb := getEmbedding(question)
embVec := pgvector.NewVector(emb)
embs := client.Embedding.
Query().
Order(func(s *sql.Selector) {
s.OrderExpr(sql.ExprP("embedding <-> $1", embVec))
}).
WithChunk().
Limit(5).
AllX(context.Background())
我们首先使用 OpenAI API 将用户的问题转换为向量。使用这个向量,我们希望在数据库中找到最相似的嵌入向量。我们查询数据库中的嵌入向量,使用 pgvector 的 <-> 操作符按相似性排序,并将结果限制为前 5 个。
for _, e := range embs {
chnk := e.Edges.Chunk
b.WriteString(fmt.Sprintf("From file: %v\n", chnk.Path))
b.WriteString(chnk.Data)
}
query := fmt.Sprintf(`Use the below information from the ent docs to answer the subsequent question.
Information:
%v
Question: %v`, b.String(), question)
接下来,我们准备前 5 个分块的信息作为问题的上下文。然后我们将问题和上下文格式化为单个字符串。
oac := openai.NewClient(ctx.OpenAIKey)
resp, err := oac.CreateChatCompletion(
context.Background(),
openai.ChatCompletionRequest{
Model: openai.GPT4o,
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleUser,
Content: query,
},
},
},
)
if err != nil {
return fmt.Errorf("error creating chat completion: %v", err)
}
choice := resp.Choices[0]
out, err := glamour.Render(choice.Message.Content, "dark")
fmt.Print(out)
然后,我们使用 OpenAI API 生成问题的响应。我们将问题和上下文传递给 API 并接收响应。然后我们使用 glamour 包渲染响应以在终端中显示。
在运行 ask 命令之前,让我们安装 glamour 包:
go get github.com/charmbracelet/glamour
最后,让我们运行 ask 命令来询问有关索引文档的问题:
go run ./cmd/entrag ask "tl;dr What is Ent?"
我们的 RAG 系统响应:
Ent is an open-source entity framework (ORM) for the Go programming language. It
allows developers to define data models or graph-structures in Go code. Ent
emphasizes principles such as schema as code, a statically typed and explicit
API generated through codegen, simple queries and graph traversals, statically
typed predicates, and storage agnosticism. It supports various databases,
including MySQL, MariaDB, PostgreSQL, SQLite, and Gremlin-based graph databases,
and aims to enhance productivity in Go development.
太棒了!我们已经成功使用 Ent、Atlas 和 pgvector 构建了一个 RAG 系统。 我们现在可以询问有关加载到数据库中的文档的问题,并获得上下文感知的响应。
以下是我们出色的 RAG 系统的更多问答示例:
go run ./cmd/entrag ask "how to define order field in entgql"
Choose a Comparable Field: Select the field in your schema that you want to be orderable. This could be a text field, a timestamp, an integer, an enum, etc.
Annotate the Field: Use the entgql.OrderField annotation on the chosen field. The order field name should be uppercase and match its corresponding enum value in the GraphQL schema.
Update your Schema: Here’s an example of how to annotate your fields in the ent/schema :
func (Todo) Fields() []ent.Field {
return []ent.Field{
field.Text("text").
NotEmpty().
Annotations(
entgql.OrderField("TEXT"),
),
field.Time("created_at").
Default(time.Now).
Immutable().
Annotations(
entgql.OrderField("CREATED_AT"),
),
field.Enum("status").
NamedValues(
"InProgress", "IN_PROGRESS",
"Completed", "COMPLETED",
).
Default("IN_PROGRESS").
Annotations(
entgql.OrderField("STATUS"),
),
field.Int("priority").
Default(0).
Annotations(
entgql.OrderField("PRIORITY"),
),
}
}Alternatively, Multiple Order Fields: If you want to enable ordering by multiple fields, you can use the entgql.MultiOrder() annotation:
func (Todo) Annotations() []schema.Annotation {
return []schema.Annotation{
entgql.MultiOrder(),
}
}Generate GraphQL Types: Ensure that the GraphQL types match this setup in your GraphQL schema, for example:
enum OrderDirection {
ASC
DESC
}
enum TodoOrderField {
CREATED_AT
PRIORITY
STATUS
TEXT
}
input TodoOrder {
direction: OrderDirection!
field: TodoOrderField
}Add orderBy Argument to Queries: Include the orderBy parameter in your GraphQL queries to allow clients to sort items:
type Query {
todos(
after: Cursor
first: Int
before: Cursor
last: Int
orderBy: TodoOrder
): TodoConnection!
}By following these steps, you ensure that your Ent-based application can support sorting in your GraphQL API based on various fields.
go run ./cmd/entrag ask "what's the difference between privacy rules and interceptors"
- Privacy Rules:
- Purpose: Privacy rules are primarily concerned with enforcing access control policies for data queries and mutations. They determine whether an operation is allowed or denied based on predefined rules.
- Implementation: They are implemented as part of a privacy policy using the ent.Policy interface which includes EvalQuery and EvalMutation methods.
- Operation: Privacy rules evaluate whether specific conditions are met before allowing or denying access to the data. They can return decisions like privacy.Allow , privacy.Deny , or privacy.Skip to control the flow of evaluation.
- Use Case: Ideal for managing access control by ensuring that users can only perform certain operations if they meet the specified criteria.
- Interceptors:
- Purpose: Interceptors act as middleware for Ent queries, allowing modification and customization of query behaviors. They can be used to augment or modify queries during different stages of their lifecycle.
- Implementation: Implemented as interfaces or using the ent.InterceptFunc adapter. They intercept and potentially modify queries by working on the read-path.
- Operation: Interceptors modify or enhance queries, typically without the access control logic inherent in privacy rules. They provide hooks to execute custom logic pre and post query execution.
- Use Case: Suitable for generic transformations or modifications to queries, such as adding default filters, query limitations, or logging operations without focusing on access control.
In summary, while privacy rules focus on access control, interceptors are about managing and modifying the query execution process.
总结
在这篇博客文章中,我们探索了如何使用 Ent、Atlas 和 pgvector 构建 RAG 系统。特别感谢 Eli Bendersky 提供的信息丰富的博客文章以及他多年来出色的 Go 语言写作!







来源:martinfowler.com


